xref: /llvm-project/clang/tools/clang-format/git-clang-format (revision fb2cbc00e0b27bc25afd8c831151333a41820bc0)
1#!/usr/bin/env python3
2#
3# ===- git-clang-format - ClangFormat Git Integration ---------*- python -*--===#
4#
5# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
6# See https://llvm.org/LICENSE.txt for license information.
7# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
8#
9# ===------------------------------------------------------------------------===#
10
11r"""
12clang-format git integration
13============================
14
15This file provides a clang-format integration for git. Put it somewhere in your
16path and ensure that it is executable. Then, "git clang-format" will invoke
17clang-format on the changes in current files or a specific commit.
18
19For further details, run:
20git clang-format -h
21
22Requires Python 2.7 or Python 3
23"""
24
25from __future__ import absolute_import, division, print_function
26import argparse
27import collections
28import contextlib
29import errno
30import os
31import re
32import subprocess
33import sys
34
35usage = "git clang-format [OPTIONS] [<commit>] [<commit>|--staged] " "[--] [<file>...]"
36
37desc = """
38If zero or one commits are given, run clang-format on all lines that differ
39between the working directory and <commit>, which defaults to HEAD.  Changes are
40only applied to the working directory, or in the stage/index.
41
42Examples:
43  To format staged changes, i.e everything that's been `git add`ed:
44    git clang-format
45
46  To also format everything touched in the most recent commit:
47    git clang-format HEAD~1
48
49  If you're on a branch off main, to format everything touched on your branch:
50    git clang-format main
51
52If two commits are given (requires --diff), run clang-format on all lines in the
53second <commit> that differ from the first <commit>.
54
55The following git-config settings set the default of the corresponding option:
56  clangFormat.binary
57  clangFormat.commit
58  clangFormat.extensions
59  clangFormat.style
60"""
61
62# Name of the temporary index file in which save the output of clang-format.
63# This file is created within the .git directory.
64temp_index_basename = "clang-format-index"
65
66
67Range = collections.namedtuple("Range", "start, count")
68
69
70def main():
71    config = load_git_config()
72
73    # In order to keep '--' yet allow options after positionals, we need to
74    # check for '--' ourselves.  (Setting nargs='*' throws away the '--', while
75    # nargs=argparse.REMAINDER disallows options after positionals.)
76    argv = sys.argv[1:]
77    try:
78        idx = argv.index("--")
79    except ValueError:
80        dash_dash = []
81    else:
82        dash_dash = argv[idx:]
83        argv = argv[:idx]
84
85    default_extensions = ",".join(
86        [
87            # From clang/lib/Frontend/FrontendOptions.cpp, all lower case
88            "c",
89            "h",  # C
90            "m",  # ObjC
91            "mm",  # ObjC++
92            "cc",
93            "cp",
94            "cpp",
95            "c++",
96            "cxx",
97            "hh",
98            "hpp",
99            "hxx",
100            "inc",  # C++
101            "ccm",
102            "cppm",
103            "cxxm",
104            "c++m",  # C++ Modules
105            "cu",
106            "cuh",  # CUDA
107            # Other languages that clang-format supports
108            "proto",
109            "protodevel",  # Protocol Buffers
110            "java",  # Java
111            "js",
112            "mjs",
113            "cjs",  # JavaScript
114            "ts",  # TypeScript
115            "cs",  # C Sharp
116            "json",  # Json
117            "sv",
118            "svh",
119            "v",
120            "vh",  # Verilog
121            "td",  # TableGen
122            "txtpb",
123            "textpb",
124            "pb.txt",
125            "textproto",
126            "asciipb",  # TextProto
127        ]
128    )
129
130    p = argparse.ArgumentParser(
131        usage=usage,
132        formatter_class=argparse.RawDescriptionHelpFormatter,
133        description=desc,
134    )
135    p.add_argument(
136        "--binary",
137        default=config.get("clangformat.binary", "clang-format"),
138        help="path to clang-format",
139    ),
140    p.add_argument(
141        "--commit",
142        default=config.get("clangformat.commit", "HEAD"),
143        help="default commit to use if none is specified",
144    ),
145    p.add_argument(
146        "--diff",
147        action="store_true",
148        help="print a diff instead of applying the changes",
149    )
150    p.add_argument(
151        "--diffstat",
152        action="store_true",
153        help="print a diffstat instead of applying the changes",
154    )
155    p.add_argument(
156        "--extensions",
157        default=config.get("clangformat.extensions", default_extensions),
158        help=(
159            "comma-separated list of file extensions to format, "
160            "excluding the period and case-insensitive"
161        ),
162    ),
163    p.add_argument(
164        "-f", "--force", action="store_true", help="allow changes to unstaged files"
165    )
166    p.add_argument(
167        "-p", "--patch", action="store_true", help="select hunks interactively"
168    )
169    p.add_argument(
170        "-q", "--quiet", action="count", default=0, help="print less information"
171    )
172    p.add_argument(
173        "--staged",
174        "--cached",
175        action="store_true",
176        help="format lines in the stage instead of the working dir",
177    )
178    p.add_argument(
179        "--style",
180        default=config.get("clangformat.style", None),
181        help="passed to clang-format",
182    ),
183    p.add_argument(
184        "-v", "--verbose", action="count", default=0, help="print extra information"
185    )
186    p.add_argument(
187        "--diff_from_common_commit",
188        action="store_true",
189        help=(
190            "diff from the last common commit for commits in "
191            "separate branches rather than the exact point of the "
192            "commits"
193        ),
194    )
195    # We gather all the remaining positional arguments into 'args' since we need
196    # to use some heuristics to determine whether or not <commit> was present.
197    # However, to print pretty messages, we make use of metavar and help.
198    p.add_argument(
199        "args",
200        nargs="*",
201        metavar="<commit>",
202        help="revision from which to compute the diff",
203    )
204    p.add_argument(
205        "ignored",
206        nargs="*",
207        metavar="<file>...",
208        help="if specified, only consider differences in these files",
209    )
210    opts = p.parse_args(argv)
211
212    opts.verbose -= opts.quiet
213    del opts.quiet
214
215    commits, files = interpret_args(opts.args, dash_dash, opts.commit)
216    if len(commits) > 2:
217        die("at most two commits allowed; %d given" % len(commits))
218    if len(commits) == 2:
219        if opts.staged:
220            die("--staged is not allowed when two commits are given")
221        if not opts.diff:
222            die("--diff is required when two commits are given")
223    elif opts.diff_from_common_commit:
224        die("--diff_from_common_commit is only allowed when two commits are given")
225
226    if os.path.dirname(opts.binary):
227        opts.binary = os.path.abspath(opts.binary)
228
229    changed_lines = compute_diff_and_extract_lines(
230        commits, files, opts.staged, opts.diff_from_common_commit
231    )
232    if opts.verbose >= 1:
233        ignored_files = set(changed_lines)
234    filter_by_extension(changed_lines, opts.extensions.lower().split(","))
235    # The computed diff outputs absolute paths, so we must cd before accessing
236    # those files.
237    cd_to_toplevel()
238    filter_symlinks(changed_lines)
239    filter_ignored_files(changed_lines, binary=opts.binary)
240    if opts.verbose >= 1:
241        ignored_files.difference_update(changed_lines)
242        if ignored_files:
243            print(
244                "Ignoring the following files (wrong extension, symlink, or "
245                "ignored by clang-format):"
246            )
247            for filename in ignored_files:
248                print("    %s" % filename)
249        if changed_lines:
250            print("Running clang-format on the following files:")
251            for filename in changed_lines:
252                print("    %s" % filename)
253
254    if not changed_lines:
255        if opts.verbose >= 0:
256            print("no modified files to format")
257        return 0
258
259    if len(commits) > 1:
260        old_tree = commits[1]
261        revision = old_tree
262    elif opts.staged:
263        old_tree = create_tree_from_index(changed_lines)
264        revision = ""
265    else:
266        old_tree = create_tree_from_workdir(changed_lines)
267        revision = None
268    new_tree = run_clang_format_and_save_to_tree(
269        changed_lines, revision, binary=opts.binary, style=opts.style
270    )
271    if opts.verbose >= 1:
272        print("old tree: %s" % old_tree)
273        print("new tree: %s" % new_tree)
274
275    if old_tree == new_tree:
276        if opts.verbose >= 0:
277            print("clang-format did not modify any files")
278        return 0
279
280    if opts.diff:
281        return print_diff(old_tree, new_tree)
282    if opts.diffstat:
283        return print_diffstat(old_tree, new_tree)
284
285    changed_files = apply_changes(
286        old_tree, new_tree, force=opts.force, patch_mode=opts.patch
287    )
288    if (opts.verbose >= 0 and not opts.patch) or opts.verbose >= 1:
289        print("changed files:")
290        for filename in changed_files:
291            print("    %s" % filename)
292
293    return 1
294
295
296def load_git_config(non_string_options=None):
297    """Return the git configuration as a dictionary.
298
299    All options are assumed to be strings unless in `non_string_options`, in which
300    is a dictionary mapping option name (in lower case) to either "--bool" or
301    "--int"."""
302    if non_string_options is None:
303        non_string_options = {}
304    out = {}
305    for entry in run("git", "config", "--list", "--null").split("\0"):
306        if entry:
307            if "\n" in entry:
308                name, value = entry.split("\n", 1)
309            else:
310                # A setting with no '=' ('\n' with --null) is implicitly 'true'
311                name = entry
312                value = "true"
313            if name in non_string_options:
314                value = run("git", "config", non_string_options[name], name)
315            out[name] = value
316    return out
317
318
319def interpret_args(args, dash_dash, default_commit):
320    """Interpret `args` as "[commits] [--] [files]" and return (commits, files).
321
322    It is assumed that "--" and everything that follows has been removed from
323    args and placed in `dash_dash`.
324
325    If "--" is present (i.e., `dash_dash` is non-empty), the arguments to its
326    left (if present) are taken as commits.  Otherwise, the arguments are checked
327    from left to right if they are commits or files.  If commits are not given,
328    a list with `default_commit` is used."""
329    if dash_dash:
330        if len(args) == 0:
331            commits = [default_commit]
332        else:
333            commits = args
334        for commit in commits:
335            object_type = get_object_type(commit)
336            if object_type not in ("commit", "tag"):
337                if object_type is None:
338                    die("'%s' is not a commit" % commit)
339                else:
340                    die(
341                        "'%s' is a %s, but a commit was expected"
342                        % (commit, object_type)
343                    )
344        files = dash_dash[1:]
345    elif args:
346        commits = []
347        while args:
348            if not disambiguate_revision(args[0]):
349                break
350            commits.append(args.pop(0))
351        if not commits:
352            commits = [default_commit]
353        files = args
354    else:
355        commits = [default_commit]
356        files = []
357    return commits, files
358
359
360def disambiguate_revision(value):
361    """Returns True if `value` is a revision, False if it is a file, or dies."""
362    # If `value` is ambiguous (neither a commit nor a file), the following
363    # command will die with an appropriate error message.
364    run("git", "rev-parse", value, verbose=False)
365    object_type = get_object_type(value)
366    if object_type is None:
367        return False
368    if object_type in ("commit", "tag"):
369        return True
370    die("`%s` is a %s, but a commit or filename was expected" % (value, object_type))
371
372
373def get_object_type(value):
374    """Returns a string description of an object's type, or None if it is not
375    a valid git object."""
376    cmd = ["git", "cat-file", "-t", value]
377    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
378    stdout, stderr = p.communicate()
379    if p.returncode != 0:
380        return None
381    return convert_string(stdout.strip())
382
383
384def compute_diff_and_extract_lines(commits, files, staged, diff_common_commit):
385    """Calls compute_diff() followed by extract_lines()."""
386    diff_process = compute_diff(commits, files, staged, diff_common_commit)
387    changed_lines = extract_lines(diff_process.stdout)
388    diff_process.stdout.close()
389    diff_process.wait()
390    if diff_process.returncode != 0:
391        # Assume error was already printed to stderr.
392        sys.exit(2)
393    return changed_lines
394
395
396def compute_diff(commits, files, staged, diff_common_commit):
397    """Return a subprocess object producing the diff from `commits`.
398
399    The return value's `stdin` file object will produce a patch with the
400    differences between the working directory (or stage if --staged is used) and
401    the first commit if a single one was specified, or the difference between
402    both specified commits, filtered on `files` (if non-empty).
403    Zero context lines are used in the patch."""
404    git_tool = "diff-index"
405    extra_args = []
406    if len(commits) == 2:
407        git_tool = "diff-tree"
408        if diff_common_commit:
409            commits = [f"{commits[0]}...{commits[1]}"]
410    elif staged:
411        extra_args += ["--cached"]
412
413    cmd = ["git", git_tool, "-p", "-U0"] + extra_args + commits + ["--"]
414    cmd.extend(files)
415    p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
416    p.stdin.close()
417    return p
418
419
420def extract_lines(patch_file):
421    """Extract the changed lines in `patch_file`.
422
423    The return value is a dictionary mapping filename to a list of (start_line,
424    line_count) pairs.
425
426    The input must have been produced with ``-U0``, meaning unidiff format with
427    zero lines of context.  The return value is a dict mapping filename to a
428    list of line `Range`s."""
429    matches = {}
430    for line in patch_file:
431        line = convert_string(line)
432        match = re.search(r"^\+\+\+\ [^/]+/(.*)", line)
433        if match:
434            filename = match.group(1).rstrip("\r\n\t")
435        match = re.search(r"^@@ -[0-9,]+ \+(\d+)(,(\d+))?", line)
436        if match:
437            start_line = int(match.group(1))
438            line_count = 1
439            if match.group(3):
440                line_count = int(match.group(3))
441            if line_count == 0:
442                line_count = 1
443            if start_line == 0:
444                continue
445            matches.setdefault(filename, []).append(Range(start_line, line_count))
446    return matches
447
448
449def filter_by_extension(dictionary, allowed_extensions):
450    """Delete every key in `dictionary` that doesn't have an allowed extension.
451
452    `allowed_extensions` must be a collection of lowercase file extensions,
453    excluding the period."""
454    allowed_extensions = frozenset(allowed_extensions)
455    for filename in list(dictionary.keys()):
456        base_ext = filename.rsplit(".", 1)
457        if len(base_ext) == 1 and "" in allowed_extensions:
458            continue
459        if len(base_ext) == 1 or base_ext[1].lower() not in allowed_extensions:
460            del dictionary[filename]
461
462
463def filter_symlinks(dictionary):
464    """Delete every key in `dictionary` that is a symlink."""
465    for filename in list(dictionary.keys()):
466        if os.path.islink(filename):
467            del dictionary[filename]
468
469
470def filter_ignored_files(dictionary, binary):
471    """Delete every key in `dictionary` that is ignored by clang-format."""
472    ignored_files = run(binary, "-list-ignored", *dictionary.keys())
473    if not ignored_files:
474        return
475    ignored_files = ignored_files.split("\n")
476    for filename in ignored_files:
477        del dictionary[filename]
478
479
480def cd_to_toplevel():
481    """Change to the top level of the git repository."""
482    toplevel = run("git", "rev-parse", "--show-toplevel")
483    os.chdir(toplevel)
484
485
486def create_tree_from_workdir(filenames):
487    """Create a new git tree with the given files from the working directory.
488
489    Returns the object ID (SHA-1) of the created tree."""
490    return create_tree(filenames, "--stdin")
491
492
493def create_tree_from_index(filenames):
494    # Copy the environment, because the files have to be read from the original
495    # index.
496    env = os.environ.copy()
497
498    def index_contents_generator():
499        for filename in filenames:
500            git_ls_files_cmd = ["git", "ls-files", "--stage", "-z", "--", filename]
501            git_ls_files = subprocess.Popen(
502                git_ls_files_cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
503            )
504            stdout = git_ls_files.communicate()[0]
505            yield convert_string(stdout.split(b"\0")[0])
506
507    return create_tree(index_contents_generator(), "--index-info")
508
509
510def run_clang_format_and_save_to_tree(
511    changed_lines, revision=None, binary="clang-format", style=None
512):
513    """Run clang-format on each file and save the result to a git tree.
514
515    Returns the object ID (SHA-1) of the created tree."""
516    # Copy the environment when formatting the files in the index, because the
517    # files have to be read from the original index.
518    env = os.environ.copy() if revision == "" else None
519
520    def iteritems(container):
521        try:
522            return container.iteritems()  # Python 2
523        except AttributeError:
524            return container.items()  # Python 3
525
526    def index_info_generator():
527        for filename, line_ranges in iteritems(changed_lines):
528            if revision is not None:
529                if len(revision) > 0:
530                    git_metadata_cmd = [
531                        "git",
532                        "ls-tree",
533                        "%s:%s" % (revision, os.path.dirname(filename)),
534                        os.path.basename(filename),
535                    ]
536                else:
537                    git_metadata_cmd = ["git", "ls-files", "--stage", "--", filename]
538                git_metadata = subprocess.Popen(
539                    git_metadata_cmd,
540                    env=env,
541                    stdin=subprocess.PIPE,
542                    stdout=subprocess.PIPE,
543                )
544                stdout = git_metadata.communicate()[0]
545                mode = oct(int(stdout.split()[0], 8))
546            else:
547                mode = oct(os.stat(filename).st_mode)
548            # Adjust python3 octal format so that it matches what git expects
549            if mode.startswith("0o"):
550                mode = "0" + mode[2:]
551            blob_id = clang_format_to_blob(
552                filename,
553                line_ranges,
554                revision=revision,
555                binary=binary,
556                style=style,
557                env=env,
558            )
559            yield "%s %s\t%s" % (mode, blob_id, filename)
560
561    return create_tree(index_info_generator(), "--index-info")
562
563
564def create_tree(input_lines, mode):
565    """Create a tree object from the given input.
566
567    If mode is '--stdin', it must be a list of filenames.  If mode is
568    '--index-info' is must be a list of values suitable for "git update-index
569    --index-info", such as "<mode> <SP> <sha1> <TAB> <filename>".  Any other mode
570    is invalid."""
571    assert mode in ("--stdin", "--index-info")
572    cmd = ["git", "update-index", "--add", "-z", mode]
573    with temporary_index_file():
574        p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
575        for line in input_lines:
576            p.stdin.write(to_bytes("%s\0" % line))
577        p.stdin.close()
578        if p.wait() != 0:
579            die("`%s` failed" % " ".join(cmd))
580        tree_id = run("git", "write-tree")
581        return tree_id
582
583
584def clang_format_to_blob(
585    filename, line_ranges, revision=None, binary="clang-format", style=None, env=None
586):
587    """Run clang-format on the given file and save the result to a git blob.
588
589    Runs on the file in `revision` if not None, or on the file in the working
590    directory if `revision` is None. Revision can be set to an empty string to run
591    clang-format on the file in the index.
592
593    Returns the object ID (SHA-1) of the created blob."""
594    clang_format_cmd = [binary]
595    if style:
596        clang_format_cmd.extend(["--style=" + style])
597    clang_format_cmd.extend(
598        [
599            "--lines=%s:%s" % (start_line, start_line + line_count - 1)
600            for start_line, line_count in line_ranges
601        ]
602    )
603    if revision is not None:
604        clang_format_cmd.extend(["--assume-filename=" + filename])
605        git_show_cmd = ["git", "cat-file", "blob", "%s:%s" % (revision, filename)]
606        git_show = subprocess.Popen(
607            git_show_cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
608        )
609        git_show.stdin.close()
610        clang_format_stdin = git_show.stdout
611    else:
612        clang_format_cmd.extend([filename])
613        git_show = None
614        clang_format_stdin = subprocess.PIPE
615    try:
616        clang_format = subprocess.Popen(
617            clang_format_cmd, stdin=clang_format_stdin, stdout=subprocess.PIPE
618        )
619        if clang_format_stdin == subprocess.PIPE:
620            clang_format_stdin = clang_format.stdin
621    except OSError as e:
622        if e.errno == errno.ENOENT:
623            die('cannot find executable "%s"' % binary)
624        else:
625            raise
626    clang_format_stdin.close()
627    hash_object_cmd = ["git", "hash-object", "-w", "--path=" + filename, "--stdin"]
628    hash_object = subprocess.Popen(
629        hash_object_cmd, stdin=clang_format.stdout, stdout=subprocess.PIPE
630    )
631    clang_format.stdout.close()
632    stdout = hash_object.communicate()[0]
633    if hash_object.returncode != 0:
634        die("`%s` failed" % " ".join(hash_object_cmd))
635    if clang_format.wait() != 0:
636        die("`%s` failed" % " ".join(clang_format_cmd))
637    if git_show and git_show.wait() != 0:
638        die("`%s` failed" % " ".join(git_show_cmd))
639    return convert_string(stdout).rstrip("\r\n")
640
641
642@contextlib.contextmanager
643def temporary_index_file(tree=None):
644    """Context manager for setting GIT_INDEX_FILE to a temporary file and deleting
645    the file afterward."""
646    index_path = create_temporary_index(tree)
647    old_index_path = os.environ.get("GIT_INDEX_FILE")
648    os.environ["GIT_INDEX_FILE"] = index_path
649    try:
650        yield
651    finally:
652        if old_index_path is None:
653            del os.environ["GIT_INDEX_FILE"]
654        else:
655            os.environ["GIT_INDEX_FILE"] = old_index_path
656        os.remove(index_path)
657
658
659def create_temporary_index(tree=None):
660    """Create a temporary index file and return the created file's path.
661
662    If `tree` is not None, use that as the tree to read in.  Otherwise, an
663    empty index is created."""
664    gitdir = run("git", "rev-parse", "--git-dir")
665    path = os.path.join(gitdir, temp_index_basename)
666    if tree is None:
667        tree = "--empty"
668    run("git", "read-tree", "--index-output=" + path, tree)
669    return path
670
671
672def print_diff(old_tree, new_tree):
673    """Print the diff between the two trees to stdout."""
674    # We use the porcelain 'diff' and not plumbing 'diff-tree' because the output
675    # is expected to be viewed by the user, and only the former does nice things
676    # like color and pagination.
677    #
678    # We also only print modified files since `new_tree` only contains the files
679    # that were modified, so unmodified files would show as deleted without the
680    # filter.
681    return subprocess.run(
682        ["git", "diff", "--diff-filter=M", "--exit-code", old_tree, new_tree]
683    ).returncode
684
685
686def print_diffstat(old_tree, new_tree):
687    """Print the diffstat between the two trees to stdout."""
688    # We use the porcelain 'diff' and not plumbing 'diff-tree' because the output
689    # is expected to be viewed by the user, and only the former does nice things
690    # like color and pagination.
691    #
692    # We also only print modified files since `new_tree` only contains the files
693    # that were modified, so unmodified files would show as deleted without the
694    # filter.
695    return subprocess.run(
696        ["git", "diff", "--diff-filter=M", "--exit-code", "--stat", old_tree, new_tree]
697    ).returncode
698
699
700def apply_changes(old_tree, new_tree, force=False, patch_mode=False):
701    """Apply the changes in `new_tree` to the working directory.
702
703    Bails if there are local changes in those files and not `force`.  If
704    `patch_mode`, runs `git checkout --patch` to select hunks interactively."""
705    changed_files = (
706        run(
707            "git",
708            "diff-tree",
709            "--diff-filter=M",
710            "-r",
711            "-z",
712            "--name-only",
713            old_tree,
714            new_tree,
715        )
716        .rstrip("\0")
717        .split("\0")
718    )
719    if not force:
720        unstaged_files = run("git", "diff-files", "--name-status", *changed_files)
721        if unstaged_files:
722            print(
723                "The following files would be modified but " "have unstaged changes:",
724                file=sys.stderr,
725            )
726            print(unstaged_files, file=sys.stderr)
727            print("Please commit, stage, or stash them first.", file=sys.stderr)
728            sys.exit(2)
729    if patch_mode:
730        # In patch mode, we could just as well create an index from the new tree
731        # and checkout from that, but then the user will be presented with a
732        # message saying "Discard ... from worktree".  Instead, we use the old
733        # tree as the index and checkout from new_tree, which gives the slightly
734        # better message, "Apply ... to index and worktree".  This is not quite
735        # right, since it won't be applied to the user's index, but oh well.
736        with temporary_index_file(old_tree):
737            subprocess.run(["git", "checkout", "--patch", new_tree], check=True)
738        index_tree = old_tree
739    else:
740        with temporary_index_file(new_tree):
741            run("git", "checkout-index", "-f", "--", *changed_files)
742    return changed_files
743
744
745def run(*args, **kwargs):
746    stdin = kwargs.pop("stdin", "")
747    verbose = kwargs.pop("verbose", True)
748    strip = kwargs.pop("strip", True)
749    for name in kwargs:
750        raise TypeError("run() got an unexpected keyword argument '%s'" % name)
751    p = subprocess.Popen(
752        args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE
753    )
754    stdout, stderr = p.communicate(input=stdin)
755
756    stdout = convert_string(stdout)
757    stderr = convert_string(stderr)
758
759    if p.returncode == 0:
760        if stderr:
761            if verbose:
762                print("`%s` printed to stderr:" % " ".join(args), file=sys.stderr)
763            print(stderr.rstrip(), file=sys.stderr)
764        if strip:
765            stdout = stdout.rstrip("\r\n")
766        return stdout
767    if verbose:
768        print("`%s` returned %s" % (" ".join(args), p.returncode), file=sys.stderr)
769    if stderr:
770        print(stderr.rstrip(), file=sys.stderr)
771    sys.exit(2)
772
773
774def die(message):
775    print("error:", message, file=sys.stderr)
776    sys.exit(2)
777
778
779def to_bytes(str_input):
780    # Encode to UTF-8 to get binary data.
781    if isinstance(str_input, bytes):
782        return str_input
783    return str_input.encode("utf-8")
784
785
786def to_string(bytes_input):
787    if isinstance(bytes_input, str):
788        return bytes_input
789    return bytes_input.encode("utf-8")
790
791
792def convert_string(bytes_input):
793    try:
794        return to_string(bytes_input.decode("utf-8"))
795    except AttributeError:  # 'str' object has no attribute 'decode'.
796        return str(bytes_input)
797    except UnicodeError:
798        return str(bytes_input)
799
800
801if __name__ == "__main__":
802    sys.exit(main())
803