xref: /llvm-project/clang/tools/clang-format/git-clang-format (revision e25c556abeb9ae5f82da42cd26b9dae8462a7197)
1c94a02e0SNico Weber#!/usr/bin/env python3
2313b6074SWalter Erquinigo#
3*e25c556aSOwen Pan# ===- git-clang-format - ClangFormat Git Integration -------*- python -*--=== #
4313b6074SWalter Erquinigo#
5313b6074SWalter Erquinigo# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
6313b6074SWalter Erquinigo# See https://llvm.org/LICENSE.txt for license information.
7313b6074SWalter Erquinigo# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
8313b6074SWalter Erquinigo#
9*e25c556aSOwen Pan# ===----------------------------------------------------------------------=== #
10313b6074SWalter Erquinigo
11313b6074SWalter Erquinigor"""
12313b6074SWalter Erquinigoclang-format git integration
13313b6074SWalter Erquinigo============================
14313b6074SWalter Erquinigo
15313b6074SWalter ErquinigoThis file provides a clang-format integration for git. Put it somewhere in your
16a8fbc16eSOwen Panpath and ensure that it is executable. Then, "git clang-format" will invoke
17313b6074SWalter Erquinigoclang-format on the changes in current files or a specific commit.
18313b6074SWalter Erquinigo
19313b6074SWalter ErquinigoFor further details, run:
20a8fbc16eSOwen Pangit clang-format -h
21313b6074SWalter Erquinigo
22313b6074SWalter ErquinigoRequires Python 2.7 or Python 3
23313b6074SWalter Erquinigo"""
24313b6074SWalter Erquinigo
25313b6074SWalter Erquinigofrom __future__ import absolute_import, division, print_function
26313b6074SWalter Erquinigoimport argparse
27313b6074SWalter Erquinigoimport collections
28313b6074SWalter Erquinigoimport contextlib
29313b6074SWalter Erquinigoimport errno
30313b6074SWalter Erquinigoimport os
31313b6074SWalter Erquinigoimport re
32313b6074SWalter Erquinigoimport subprocess
33313b6074SWalter Erquinigoimport sys
34313b6074SWalter Erquinigo
35*e25c556aSOwen Panusage = (
36*e25c556aSOwen Pan    "git clang-format [OPTIONS] [<commit>] [<commit>|--staged] [--] [<file>...]"
37*e25c556aSOwen Pan)
38313b6074SWalter Erquinigo
39fb2cbc00SOwen Pandesc = """
40313b6074SWalter ErquinigoIf zero or one commits are given, run clang-format on all lines that differ
41313b6074SWalter Erquinigobetween the working directory and <commit>, which defaults to HEAD.  Changes are
42bee61aa7SErik Larssononly applied to the working directory, or in the stage/index.
43313b6074SWalter Erquinigo
44a45764f2SNico WeberExamples:
45a45764f2SNico Weber  To format staged changes, i.e everything that's been `git add`ed:
46a8fbc16eSOwen Pan    git clang-format
47a45764f2SNico Weber
48a45764f2SNico Weber  To also format everything touched in the most recent commit:
49a8fbc16eSOwen Pan    git clang-format HEAD~1
50a45764f2SNico Weber
51a45764f2SNico Weber  If you're on a branch off main, to format everything touched on your branch:
52a8fbc16eSOwen Pan    git clang-format main
53a45764f2SNico Weber
54313b6074SWalter ErquinigoIf two commits are given (requires --diff), run clang-format on all lines in the
55313b6074SWalter Erquinigosecond <commit> that differ from the first <commit>.
56313b6074SWalter Erquinigo
57313b6074SWalter ErquinigoThe following git-config settings set the default of the corresponding option:
58313b6074SWalter Erquinigo  clangFormat.binary
59313b6074SWalter Erquinigo  clangFormat.commit
60313b6074SWalter Erquinigo  clangFormat.extensions
61313b6074SWalter Erquinigo  clangFormat.style
62fb2cbc00SOwen Pan"""
63313b6074SWalter Erquinigo
64313b6074SWalter Erquinigo# Name of the temporary index file in which save the output of clang-format.
65313b6074SWalter Erquinigo# This file is created within the .git directory.
66fb2cbc00SOwen Pantemp_index_basename = "clang-format-index"
67313b6074SWalter Erquinigo
68313b6074SWalter Erquinigo
69fb2cbc00SOwen PanRange = collections.namedtuple("Range", "start, count")
70313b6074SWalter Erquinigo
71313b6074SWalter Erquinigo
72313b6074SWalter Erquinigodef main():
73313b6074SWalter Erquinigo    config = load_git_config()
74313b6074SWalter Erquinigo
75313b6074SWalter Erquinigo    # In order to keep '--' yet allow options after positionals, we need to
76313b6074SWalter Erquinigo    # check for '--' ourselves.  (Setting nargs='*' throws away the '--', while
77313b6074SWalter Erquinigo    # nargs=argparse.REMAINDER disallows options after positionals.)
78313b6074SWalter Erquinigo    argv = sys.argv[1:]
79313b6074SWalter Erquinigo    try:
80fb2cbc00SOwen Pan        idx = argv.index("--")
81313b6074SWalter Erquinigo    except ValueError:
82313b6074SWalter Erquinigo        dash_dash = []
83313b6074SWalter Erquinigo    else:
84313b6074SWalter Erquinigo        dash_dash = argv[idx:]
85313b6074SWalter Erquinigo        argv = argv[:idx]
86313b6074SWalter Erquinigo
87fb2cbc00SOwen Pan    default_extensions = ",".join(
88fb2cbc00SOwen Pan        [
89313b6074SWalter Erquinigo            # From clang/lib/Frontend/FrontendOptions.cpp, all lower case
90fb2cbc00SOwen Pan            "c",
91fb2cbc00SOwen Pan            "h",  # C
92fb2cbc00SOwen Pan            "m",  # ObjC
93fb2cbc00SOwen Pan            "mm",  # ObjC++
94fb2cbc00SOwen Pan            "cc",
95fb2cbc00SOwen Pan            "cp",
96fb2cbc00SOwen Pan            "cpp",
97fb2cbc00SOwen Pan            "c++",
98fb2cbc00SOwen Pan            "cxx",
99fb2cbc00SOwen Pan            "hh",
100fb2cbc00SOwen Pan            "hpp",
101fb2cbc00SOwen Pan            "hxx",
102fb2cbc00SOwen Pan            "inc",  # C++
103fb2cbc00SOwen Pan            "ccm",
104fb2cbc00SOwen Pan            "cppm",
105fb2cbc00SOwen Pan            "cxxm",
106fb2cbc00SOwen Pan            "c++m",  # C++ Modules
107fb2cbc00SOwen Pan            "cu",
108fb2cbc00SOwen Pan            "cuh",  # CUDA
109313b6074SWalter Erquinigo            # Other languages that clang-format supports
110fb2cbc00SOwen Pan            "proto",
111fb2cbc00SOwen Pan            "protodevel",  # Protocol Buffers
112fb2cbc00SOwen Pan            "java",  # Java
113fb2cbc00SOwen Pan            "js",
114fb2cbc00SOwen Pan            "mjs",
115fb2cbc00SOwen Pan            "cjs",  # JavaScript
116fb2cbc00SOwen Pan            "ts",  # TypeScript
117fb2cbc00SOwen Pan            "cs",  # C Sharp
118fb2cbc00SOwen Pan            "json",  # Json
119fb2cbc00SOwen Pan            "sv",
120fb2cbc00SOwen Pan            "svh",
121fb2cbc00SOwen Pan            "v",
122fb2cbc00SOwen Pan            "vh",  # Verilog
123fb2cbc00SOwen Pan            "td",  # TableGen
124fb2cbc00SOwen Pan            "txtpb",
125fb2cbc00SOwen Pan            "textpb",
126fb2cbc00SOwen Pan            "pb.txt",
127fb2cbc00SOwen Pan            "textproto",
128fb2cbc00SOwen Pan            "asciipb",  # TextProto
129fb2cbc00SOwen Pan        ]
130fb2cbc00SOwen Pan    )
131313b6074SWalter Erquinigo
132313b6074SWalter Erquinigo    p = argparse.ArgumentParser(
133fb2cbc00SOwen Pan        usage=usage,
134fb2cbc00SOwen Pan        formatter_class=argparse.RawDescriptionHelpFormatter,
135fb2cbc00SOwen Pan        description=desc,
136fb2cbc00SOwen Pan    )
137fb2cbc00SOwen Pan    p.add_argument(
138fb2cbc00SOwen Pan        "--binary",
139fb2cbc00SOwen Pan        default=config.get("clangformat.binary", "clang-format"),
140fb2cbc00SOwen Pan        help="path to clang-format",
141fb2cbc00SOwen Pan    ),
142fb2cbc00SOwen Pan    p.add_argument(
143fb2cbc00SOwen Pan        "--commit",
144fb2cbc00SOwen Pan        default=config.get("clangformat.commit", "HEAD"),
145fb2cbc00SOwen Pan        help="default commit to use if none is specified",
146fb2cbc00SOwen Pan    ),
147fb2cbc00SOwen Pan    p.add_argument(
148fb2cbc00SOwen Pan        "--diff",
149fb2cbc00SOwen Pan        action="store_true",
150fb2cbc00SOwen Pan        help="print a diff instead of applying the changes",
151fb2cbc00SOwen Pan    )
152fb2cbc00SOwen Pan    p.add_argument(
153fb2cbc00SOwen Pan        "--diffstat",
154fb2cbc00SOwen Pan        action="store_true",
155fb2cbc00SOwen Pan        help="print a diffstat instead of applying the changes",
156fb2cbc00SOwen Pan    )
157fb2cbc00SOwen Pan    p.add_argument(
158fb2cbc00SOwen Pan        "--extensions",
159fb2cbc00SOwen Pan        default=config.get("clangformat.extensions", default_extensions),
160fb2cbc00SOwen Pan        help=(
161fb2cbc00SOwen Pan            "comma-separated list of file extensions to format, "
162fb2cbc00SOwen Pan            "excluding the period and case-insensitive"
163fb2cbc00SOwen Pan        ),
164fb2cbc00SOwen Pan    ),
165fb2cbc00SOwen Pan    p.add_argument(
166*e25c556aSOwen Pan        "-f",
167*e25c556aSOwen Pan        "--force",
168*e25c556aSOwen Pan        action="store_true",
169*e25c556aSOwen Pan        help="allow changes to unstaged files",
170fb2cbc00SOwen Pan    )
171fb2cbc00SOwen Pan    p.add_argument(
172fb2cbc00SOwen Pan        "-p", "--patch", action="store_true", help="select hunks interactively"
173fb2cbc00SOwen Pan    )
174fb2cbc00SOwen Pan    p.add_argument(
175*e25c556aSOwen Pan        "-q",
176*e25c556aSOwen Pan        "--quiet",
177*e25c556aSOwen Pan        action="count",
178*e25c556aSOwen Pan        default=0,
179*e25c556aSOwen Pan        help="print less information",
180fb2cbc00SOwen Pan    )
181fb2cbc00SOwen Pan    p.add_argument(
182fb2cbc00SOwen Pan        "--staged",
183fb2cbc00SOwen Pan        "--cached",
184fb2cbc00SOwen Pan        action="store_true",
185fb2cbc00SOwen Pan        help="format lines in the stage instead of the working dir",
186fb2cbc00SOwen Pan    )
187fb2cbc00SOwen Pan    p.add_argument(
188fb2cbc00SOwen Pan        "--style",
189fb2cbc00SOwen Pan        default=config.get("clangformat.style", None),
190fb2cbc00SOwen Pan        help="passed to clang-format",
191fb2cbc00SOwen Pan    ),
192fb2cbc00SOwen Pan    p.add_argument(
193*e25c556aSOwen Pan        "-v",
194*e25c556aSOwen Pan        "--verbose",
195*e25c556aSOwen Pan        action="count",
196*e25c556aSOwen Pan        default=0,
197*e25c556aSOwen Pan        help="print extra information",
198fb2cbc00SOwen Pan    )
199fb2cbc00SOwen Pan    p.add_argument(
200fb2cbc00SOwen Pan        "--diff_from_common_commit",
201fb2cbc00SOwen Pan        action="store_true",
202fb2cbc00SOwen Pan        help=(
203fb2cbc00SOwen Pan            "diff from the last common commit for commits in "
204fb2cbc00SOwen Pan            "separate branches rather than the exact point of the "
205fb2cbc00SOwen Pan            "commits"
206fb2cbc00SOwen Pan        ),
207fb2cbc00SOwen Pan    )
208313b6074SWalter Erquinigo    # We gather all the remaining positional arguments into 'args' since we need
209313b6074SWalter Erquinigo    # to use some heuristics to determine whether or not <commit> was present.
210313b6074SWalter Erquinigo    # However, to print pretty messages, we make use of metavar and help.
211fb2cbc00SOwen Pan    p.add_argument(
212fb2cbc00SOwen Pan        "args",
213fb2cbc00SOwen Pan        nargs="*",
214fb2cbc00SOwen Pan        metavar="<commit>",
215fb2cbc00SOwen Pan        help="revision from which to compute the diff",
216fb2cbc00SOwen Pan    )
217fb2cbc00SOwen Pan    p.add_argument(
218fb2cbc00SOwen Pan        "ignored",
219fb2cbc00SOwen Pan        nargs="*",
220fb2cbc00SOwen Pan        metavar="<file>...",
221fb2cbc00SOwen Pan        help="if specified, only consider differences in these files",
222fb2cbc00SOwen Pan    )
223313b6074SWalter Erquinigo    opts = p.parse_args(argv)
224313b6074SWalter Erquinigo
225313b6074SWalter Erquinigo    opts.verbose -= opts.quiet
226313b6074SWalter Erquinigo    del opts.quiet
227313b6074SWalter Erquinigo
228313b6074SWalter Erquinigo    commits, files = interpret_args(opts.args, dash_dash, opts.commit)
229aeaae531SAiden Grossman    if len(commits) > 2:
230fb2cbc00SOwen Pan        die("at most two commits allowed; %d given" % len(commits))
231aeaae531SAiden Grossman    if len(commits) == 2:
232bee61aa7SErik Larsson        if opts.staged:
233fb2cbc00SOwen Pan            die("--staged is not allowed when two commits are given")
234313b6074SWalter Erquinigo        if not opts.diff:
235fb2cbc00SOwen Pan            die("--diff is required when two commits are given")
236aeaae531SAiden Grossman    elif opts.diff_from_common_commit:
237*e25c556aSOwen Pan        die(
238*e25c556aSOwen Pan            "--diff_from_common_commit is only allowed when two commits are "
239*e25c556aSOwen Pan            "given"
240*e25c556aSOwen Pan        )
24178940a4eSOwen Pan
24278940a4eSOwen Pan    if os.path.dirname(opts.binary):
2432a3f1195SOwen Pan        opts.binary = os.path.abspath(opts.binary)
24478940a4eSOwen Pan
245fb2cbc00SOwen Pan    changed_lines = compute_diff_and_extract_lines(
246fb2cbc00SOwen Pan        commits, files, opts.staged, opts.diff_from_common_commit
247fb2cbc00SOwen Pan    )
248313b6074SWalter Erquinigo    if opts.verbose >= 1:
249313b6074SWalter Erquinigo        ignored_files = set(changed_lines)
250fb2cbc00SOwen Pan    filter_by_extension(changed_lines, opts.extensions.lower().split(","))
2510fd0a010SPirama Arumuga Nainar    # The computed diff outputs absolute paths, so we must cd before accessing
2520fd0a010SPirama Arumuga Nainar    # those files.
2530fd0a010SPirama Arumuga Nainar    cd_to_toplevel()
2540fd0a010SPirama Arumuga Nainar    filter_symlinks(changed_lines)
255986bc3d0SOwen Pan    filter_ignored_files(changed_lines, binary=opts.binary)
256313b6074SWalter Erquinigo    if opts.verbose >= 1:
257313b6074SWalter Erquinigo        ignored_files.difference_update(changed_lines)
258313b6074SWalter Erquinigo        if ignored_files:
259fb2cbc00SOwen Pan            print(
260fb2cbc00SOwen Pan                "Ignoring the following files (wrong extension, symlink, or "
261fb2cbc00SOwen Pan                "ignored by clang-format):"
262fb2cbc00SOwen Pan            )
263313b6074SWalter Erquinigo            for filename in ignored_files:
264fb2cbc00SOwen Pan                print("    %s" % filename)
265313b6074SWalter Erquinigo        if changed_lines:
266fb2cbc00SOwen Pan            print("Running clang-format on the following files:")
267313b6074SWalter Erquinigo            for filename in changed_lines:
268fb2cbc00SOwen Pan                print("    %s" % filename)
269edbb8a84Sowenca
270313b6074SWalter Erquinigo    if not changed_lines:
271294e1900SGi Vald        if opts.verbose >= 0:
272fb2cbc00SOwen Pan            print("no modified files to format")
273edbb8a84Sowenca        return 0
274edbb8a84Sowenca
275313b6074SWalter Erquinigo    if len(commits) > 1:
276313b6074SWalter Erquinigo        old_tree = commits[1]
2773f801e07SGergely Meszaros        revision = old_tree
2783f801e07SGergely Meszaros    elif opts.staged:
2793f801e07SGergely Meszaros        old_tree = create_tree_from_index(changed_lines)
280fb2cbc00SOwen Pan        revision = ""
281313b6074SWalter Erquinigo    else:
282313b6074SWalter Erquinigo        old_tree = create_tree_from_workdir(changed_lines)
2833f801e07SGergely Meszaros        revision = None
284fb2cbc00SOwen Pan    new_tree = run_clang_format_and_save_to_tree(
285fb2cbc00SOwen Pan        changed_lines, revision, binary=opts.binary, style=opts.style
286fb2cbc00SOwen Pan    )
287313b6074SWalter Erquinigo    if opts.verbose >= 1:
288fb2cbc00SOwen Pan        print("old tree: %s" % old_tree)
289fb2cbc00SOwen Pan        print("new tree: %s" % new_tree)
290edbb8a84Sowenca
291313b6074SWalter Erquinigo    if old_tree == new_tree:
292313b6074SWalter Erquinigo        if opts.verbose >= 0:
293fb2cbc00SOwen Pan            print("clang-format did not modify any files")
294edbb8a84Sowenca        return 0
295edbb8a84Sowenca
296edbb8a84Sowenca    if opts.diff:
297f9a2f6b6SSridhar Gopinath        return print_diff(old_tree, new_tree)
298f9a2f6b6SSridhar Gopinath    if opts.diffstat:
299f9a2f6b6SSridhar Gopinath        return print_diffstat(old_tree, new_tree)
300f9a2f6b6SSridhar Gopinath
301fb2cbc00SOwen Pan    changed_files = apply_changes(
302fb2cbc00SOwen Pan        old_tree, new_tree, force=opts.force, patch_mode=opts.patch
303fb2cbc00SOwen Pan    )
304313b6074SWalter Erquinigo    if (opts.verbose >= 0 and not opts.patch) or opts.verbose >= 1:
305fb2cbc00SOwen Pan        print("changed files:")
306313b6074SWalter Erquinigo        for filename in changed_files:
307fb2cbc00SOwen Pan            print("    %s" % filename)
308313b6074SWalter Erquinigo
309edbb8a84Sowenca    return 1
310edbb8a84Sowenca
311313b6074SWalter Erquinigo
312313b6074SWalter Erquinigodef load_git_config(non_string_options=None):
313313b6074SWalter Erquinigo    """Return the git configuration as a dictionary.
314313b6074SWalter Erquinigo
315*e25c556aSOwen Pan    All options are assumed to be strings unless in `non_string_options`, in
316*e25c556aSOwen Pan    which is a dictionary mapping option name (in lower case) to either "--bool"
317*e25c556aSOwen Pan    or "--int"."""
318313b6074SWalter Erquinigo    if non_string_options is None:
319313b6074SWalter Erquinigo        non_string_options = {}
320313b6074SWalter Erquinigo    out = {}
321fb2cbc00SOwen Pan    for entry in run("git", "config", "--list", "--null").split("\0"):
322313b6074SWalter Erquinigo        if entry:
323fb2cbc00SOwen Pan            if "\n" in entry:
324fb2cbc00SOwen Pan                name, value = entry.split("\n", 1)
325313b6074SWalter Erquinigo            else:
326313b6074SWalter Erquinigo                # A setting with no '=' ('\n' with --null) is implicitly 'true'
327313b6074SWalter Erquinigo                name = entry
328fb2cbc00SOwen Pan                value = "true"
329313b6074SWalter Erquinigo            if name in non_string_options:
330fb2cbc00SOwen Pan                value = run("git", "config", non_string_options[name], name)
331313b6074SWalter Erquinigo            out[name] = value
332313b6074SWalter Erquinigo    return out
333313b6074SWalter Erquinigo
334313b6074SWalter Erquinigo
335313b6074SWalter Erquinigodef interpret_args(args, dash_dash, default_commit):
336313b6074SWalter Erquinigo    """Interpret `args` as "[commits] [--] [files]" and return (commits, files).
337313b6074SWalter Erquinigo
338313b6074SWalter Erquinigo    It is assumed that "--" and everything that follows has been removed from
339313b6074SWalter Erquinigo    args and placed in `dash_dash`.
340313b6074SWalter Erquinigo
341313b6074SWalter Erquinigo    If "--" is present (i.e., `dash_dash` is non-empty), the arguments to its
342*e25c556aSOwen Pan    left (if present) are taken as commits.  Otherwise, the arguments are
343*e25c556aSOwen Pan    checked from left to right if they are commits or files.  If commits are not
344*e25c556aSOwen Pan    given, a list with `default_commit` is used."""
345313b6074SWalter Erquinigo    if dash_dash:
346313b6074SWalter Erquinigo        if len(args) == 0:
347313b6074SWalter Erquinigo            commits = [default_commit]
348313b6074SWalter Erquinigo        else:
349313b6074SWalter Erquinigo            commits = args
350313b6074SWalter Erquinigo        for commit in commits:
351313b6074SWalter Erquinigo            object_type = get_object_type(commit)
352fb2cbc00SOwen Pan            if object_type not in ("commit", "tag"):
353313b6074SWalter Erquinigo                if object_type is None:
354313b6074SWalter Erquinigo                    die("'%s' is not a commit" % commit)
355313b6074SWalter Erquinigo                else:
356fb2cbc00SOwen Pan                    die(
357fb2cbc00SOwen Pan                        "'%s' is a %s, but a commit was expected"
358fb2cbc00SOwen Pan                        % (commit, object_type)
359fb2cbc00SOwen Pan                    )
360313b6074SWalter Erquinigo        files = dash_dash[1:]
361313b6074SWalter Erquinigo    elif args:
362313b6074SWalter Erquinigo        commits = []
363313b6074SWalter Erquinigo        while args:
364313b6074SWalter Erquinigo            if not disambiguate_revision(args[0]):
365313b6074SWalter Erquinigo                break
366313b6074SWalter Erquinigo            commits.append(args.pop(0))
367313b6074SWalter Erquinigo        if not commits:
368313b6074SWalter Erquinigo            commits = [default_commit]
369313b6074SWalter Erquinigo        files = args
370313b6074SWalter Erquinigo    else:
371313b6074SWalter Erquinigo        commits = [default_commit]
372313b6074SWalter Erquinigo        files = []
373313b6074SWalter Erquinigo    return commits, files
374313b6074SWalter Erquinigo
375313b6074SWalter Erquinigo
376313b6074SWalter Erquinigodef disambiguate_revision(value):
377313b6074SWalter Erquinigo    """Returns True if `value` is a revision, False if it is a file, or dies."""
378313b6074SWalter Erquinigo    # If `value` is ambiguous (neither a commit nor a file), the following
379313b6074SWalter Erquinigo    # command will die with an appropriate error message.
380fb2cbc00SOwen Pan    run("git", "rev-parse", value, verbose=False)
381313b6074SWalter Erquinigo    object_type = get_object_type(value)
382313b6074SWalter Erquinigo    if object_type is None:
383313b6074SWalter Erquinigo        return False
384fb2cbc00SOwen Pan    if object_type in ("commit", "tag"):
385313b6074SWalter Erquinigo        return True
386*e25c556aSOwen Pan    die(
387*e25c556aSOwen Pan        "`%s` is a %s, but a commit or filename was expected"
388*e25c556aSOwen Pan        % (value, object_type)
389*e25c556aSOwen Pan    )
390313b6074SWalter Erquinigo
391313b6074SWalter Erquinigo
392313b6074SWalter Erquinigodef get_object_type(value):
393313b6074SWalter Erquinigo    """Returns a string description of an object's type, or None if it is not
394313b6074SWalter Erquinigo    a valid git object."""
395fb2cbc00SOwen Pan    cmd = ["git", "cat-file", "-t", value]
396313b6074SWalter Erquinigo    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
397313b6074SWalter Erquinigo    stdout, stderr = p.communicate()
398313b6074SWalter Erquinigo    if p.returncode != 0:
399313b6074SWalter Erquinigo        return None
400313b6074SWalter Erquinigo    return convert_string(stdout.strip())
401313b6074SWalter Erquinigo
402313b6074SWalter Erquinigo
403aeaae531SAiden Grossmandef compute_diff_and_extract_lines(commits, files, staged, diff_common_commit):
404313b6074SWalter Erquinigo    """Calls compute_diff() followed by extract_lines()."""
405aeaae531SAiden Grossman    diff_process = compute_diff(commits, files, staged, diff_common_commit)
406313b6074SWalter Erquinigo    changed_lines = extract_lines(diff_process.stdout)
407313b6074SWalter Erquinigo    diff_process.stdout.close()
408313b6074SWalter Erquinigo    diff_process.wait()
409313b6074SWalter Erquinigo    if diff_process.returncode != 0:
410313b6074SWalter Erquinigo        # Assume error was already printed to stderr.
411313b6074SWalter Erquinigo        sys.exit(2)
412313b6074SWalter Erquinigo    return changed_lines
413313b6074SWalter Erquinigo
414313b6074SWalter Erquinigo
415aeaae531SAiden Grossmandef compute_diff(commits, files, staged, diff_common_commit):
416313b6074SWalter Erquinigo    """Return a subprocess object producing the diff from `commits`.
417313b6074SWalter Erquinigo
418313b6074SWalter Erquinigo    The return value's `stdin` file object will produce a patch with the
419bee61aa7SErik Larsson    differences between the working directory (or stage if --staged is used) and
420bee61aa7SErik Larsson    the first commit if a single one was specified, or the difference between
421bee61aa7SErik Larsson    both specified commits, filtered on `files` (if non-empty).
422bee61aa7SErik Larsson    Zero context lines are used in the patch."""
423fb2cbc00SOwen Pan    git_tool = "diff-index"
424bee61aa7SErik Larsson    extra_args = []
425aeaae531SAiden Grossman    if len(commits) == 2:
426fb2cbc00SOwen Pan        git_tool = "diff-tree"
427aeaae531SAiden Grossman        if diff_common_commit:
428fb2cbc00SOwen Pan            commits = [f"{commits[0]}...{commits[1]}"]
429bee61aa7SErik Larsson    elif staged:
430fb2cbc00SOwen Pan        extra_args += ["--cached"]
431aeaae531SAiden Grossman
432fb2cbc00SOwen Pan    cmd = ["git", git_tool, "-p", "-U0"] + extra_args + commits + ["--"]
433313b6074SWalter Erquinigo    cmd.extend(files)
434313b6074SWalter Erquinigo    p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
435313b6074SWalter Erquinigo    p.stdin.close()
436313b6074SWalter Erquinigo    return p
437313b6074SWalter Erquinigo
438313b6074SWalter Erquinigo
439313b6074SWalter Erquinigodef extract_lines(patch_file):
440313b6074SWalter Erquinigo    """Extract the changed lines in `patch_file`.
441313b6074SWalter Erquinigo
442313b6074SWalter Erquinigo    The return value is a dictionary mapping filename to a list of (start_line,
443313b6074SWalter Erquinigo    line_count) pairs.
444313b6074SWalter Erquinigo
445313b6074SWalter Erquinigo    The input must have been produced with ``-U0``, meaning unidiff format with
446313b6074SWalter Erquinigo    zero lines of context.  The return value is a dict mapping filename to a
447313b6074SWalter Erquinigo    list of line `Range`s."""
448313b6074SWalter Erquinigo    matches = {}
449313b6074SWalter Erquinigo    for line in patch_file:
450313b6074SWalter Erquinigo        line = convert_string(line)
451fb2cbc00SOwen Pan        match = re.search(r"^\+\+\+\ [^/]+/(.*)", line)
452313b6074SWalter Erquinigo        if match:
453fb2cbc00SOwen Pan            filename = match.group(1).rstrip("\r\n\t")
454fb2cbc00SOwen Pan        match = re.search(r"^@@ -[0-9,]+ \+(\d+)(,(\d+))?", line)
455313b6074SWalter Erquinigo        if match:
456313b6074SWalter Erquinigo            start_line = int(match.group(1))
457313b6074SWalter Erquinigo            line_count = 1
458313b6074SWalter Erquinigo            if match.group(3):
459313b6074SWalter Erquinigo                line_count = int(match.group(3))
460f9316922SZequan Wu            if line_count == 0:
461f9316922SZequan Wu                line_count = 1
4625e969125Smydeveloperday            if start_line == 0:
4635e969125Smydeveloperday                continue
464*e25c556aSOwen Pan            matches.setdefault(filename, []).append(
465*e25c556aSOwen Pan                Range(start_line, line_count)
466*e25c556aSOwen Pan            )
467313b6074SWalter Erquinigo    return matches
468313b6074SWalter Erquinigo
469313b6074SWalter Erquinigo
470313b6074SWalter Erquinigodef filter_by_extension(dictionary, allowed_extensions):
471313b6074SWalter Erquinigo    """Delete every key in `dictionary` that doesn't have an allowed extension.
472313b6074SWalter Erquinigo
473313b6074SWalter Erquinigo    `allowed_extensions` must be a collection of lowercase file extensions,
474313b6074SWalter Erquinigo    excluding the period."""
475313b6074SWalter Erquinigo    allowed_extensions = frozenset(allowed_extensions)
476313b6074SWalter Erquinigo    for filename in list(dictionary.keys()):
477fb2cbc00SOwen Pan        base_ext = filename.rsplit(".", 1)
478fb2cbc00SOwen Pan        if len(base_ext) == 1 and "" in allowed_extensions:
479313b6074SWalter Erquinigo            continue
480313b6074SWalter Erquinigo        if len(base_ext) == 1 or base_ext[1].lower() not in allowed_extensions:
481313b6074SWalter Erquinigo            del dictionary[filename]
482313b6074SWalter Erquinigo
483313b6074SWalter Erquinigo
4840fd0a010SPirama Arumuga Nainardef filter_symlinks(dictionary):
4850fd0a010SPirama Arumuga Nainar    """Delete every key in `dictionary` that is a symlink."""
4860fd0a010SPirama Arumuga Nainar    for filename in list(dictionary.keys()):
4870fd0a010SPirama Arumuga Nainar        if os.path.islink(filename):
4880fd0a010SPirama Arumuga Nainar            del dictionary[filename]
4890fd0a010SPirama Arumuga Nainar
4900fd0a010SPirama Arumuga Nainar
491986bc3d0SOwen Pandef filter_ignored_files(dictionary, binary):
492986bc3d0SOwen Pan    """Delete every key in `dictionary` that is ignored by clang-format."""
493fb2cbc00SOwen Pan    ignored_files = run(binary, "-list-ignored", *dictionary.keys())
494986bc3d0SOwen Pan    if not ignored_files:
495986bc3d0SOwen Pan        return
496fb2cbc00SOwen Pan    ignored_files = ignored_files.split("\n")
497986bc3d0SOwen Pan    for filename in ignored_files:
498986bc3d0SOwen Pan        del dictionary[filename]
499986bc3d0SOwen Pan
500986bc3d0SOwen Pan
501313b6074SWalter Erquinigodef cd_to_toplevel():
502313b6074SWalter Erquinigo    """Change to the top level of the git repository."""
503fb2cbc00SOwen Pan    toplevel = run("git", "rev-parse", "--show-toplevel")
504313b6074SWalter Erquinigo    os.chdir(toplevel)
505313b6074SWalter Erquinigo
506313b6074SWalter Erquinigo
507313b6074SWalter Erquinigodef create_tree_from_workdir(filenames):
508313b6074SWalter Erquinigo    """Create a new git tree with the given files from the working directory.
509313b6074SWalter Erquinigo
510313b6074SWalter Erquinigo    Returns the object ID (SHA-1) of the created tree."""
511fb2cbc00SOwen Pan    return create_tree(filenames, "--stdin")
512313b6074SWalter Erquinigo
513313b6074SWalter Erquinigo
5143f801e07SGergely Meszarosdef create_tree_from_index(filenames):
5153f801e07SGergely Meszaros    # Copy the environment, because the files have to be read from the original
5163f801e07SGergely Meszaros    # index.
5173f801e07SGergely Meszaros    env = os.environ.copy()
518fb2cbc00SOwen Pan
5193f801e07SGergely Meszaros    def index_contents_generator():
5203f801e07SGergely Meszaros        for filename in filenames:
521*e25c556aSOwen Pan            git_ls_files_cmd = [
522*e25c556aSOwen Pan                "git",
523*e25c556aSOwen Pan                "ls-files",
524*e25c556aSOwen Pan                "--stage",
525*e25c556aSOwen Pan                "-z",
526*e25c556aSOwen Pan                "--",
527*e25c556aSOwen Pan                filename,
528*e25c556aSOwen Pan            ]
529fb2cbc00SOwen Pan            git_ls_files = subprocess.Popen(
530*e25c556aSOwen Pan                git_ls_files_cmd,
531*e25c556aSOwen Pan                env=env,
532*e25c556aSOwen Pan                stdin=subprocess.PIPE,
533*e25c556aSOwen Pan                stdout=subprocess.PIPE,
534fb2cbc00SOwen Pan            )
5353f801e07SGergely Meszaros            stdout = git_ls_files.communicate()[0]
536fb2cbc00SOwen Pan            yield convert_string(stdout.split(b"\0")[0])
537fb2cbc00SOwen Pan
538fb2cbc00SOwen Pan    return create_tree(index_contents_generator(), "--index-info")
5393f801e07SGergely Meszaros
5403f801e07SGergely Meszaros
541fb2cbc00SOwen Pandef run_clang_format_and_save_to_tree(
542fb2cbc00SOwen Pan    changed_lines, revision=None, binary="clang-format", style=None
543fb2cbc00SOwen Pan):
544313b6074SWalter Erquinigo    """Run clang-format on each file and save the result to a git tree.
545313b6074SWalter Erquinigo
546313b6074SWalter Erquinigo    Returns the object ID (SHA-1) of the created tree."""
5473f801e07SGergely Meszaros    # Copy the environment when formatting the files in the index, because the
5483f801e07SGergely Meszaros    # files have to be read from the original index.
549fb2cbc00SOwen Pan    env = os.environ.copy() if revision == "" else None
550fb2cbc00SOwen Pan
551313b6074SWalter Erquinigo    def iteritems(container):
552313b6074SWalter Erquinigo        try:
553313b6074SWalter Erquinigo            return container.iteritems()  # Python 2
554313b6074SWalter Erquinigo        except AttributeError:
555313b6074SWalter Erquinigo            return container.items()  # Python 3
556fb2cbc00SOwen Pan
557313b6074SWalter Erquinigo    def index_info_generator():
558313b6074SWalter Erquinigo        for filename, line_ranges in iteritems(changed_lines):
5593f801e07SGergely Meszaros            if revision is not None:
5603f801e07SGergely Meszaros                if len(revision) > 0:
561fb2cbc00SOwen Pan                    git_metadata_cmd = [
562fb2cbc00SOwen Pan                        "git",
563fb2cbc00SOwen Pan                        "ls-tree",
564fb2cbc00SOwen Pan                        "%s:%s" % (revision, os.path.dirname(filename)),
565fb2cbc00SOwen Pan                        os.path.basename(filename),
566fb2cbc00SOwen Pan                    ]
5673f801e07SGergely Meszaros                else:
568*e25c556aSOwen Pan                    git_metadata_cmd = [
569*e25c556aSOwen Pan                        "git",
570*e25c556aSOwen Pan                        "ls-files",
571*e25c556aSOwen Pan                        "--stage",
572*e25c556aSOwen Pan                        "--",
573*e25c556aSOwen Pan                        filename,
574*e25c556aSOwen Pan                    ]
575fb2cbc00SOwen Pan                git_metadata = subprocess.Popen(
576fb2cbc00SOwen Pan                    git_metadata_cmd,
577fb2cbc00SOwen Pan                    env=env,
5783f801e07SGergely Meszaros                    stdin=subprocess.PIPE,
579fb2cbc00SOwen Pan                    stdout=subprocess.PIPE,
580fb2cbc00SOwen Pan                )
581313b6074SWalter Erquinigo                stdout = git_metadata.communicate()[0]
582313b6074SWalter Erquinigo                mode = oct(int(stdout.split()[0], 8))
583313b6074SWalter Erquinigo            else:
584313b6074SWalter Erquinigo                mode = oct(os.stat(filename).st_mode)
585313b6074SWalter Erquinigo            # Adjust python3 octal format so that it matches what git expects
586fb2cbc00SOwen Pan            if mode.startswith("0o"):
587fb2cbc00SOwen Pan                mode = "0" + mode[2:]
588fb2cbc00SOwen Pan            blob_id = clang_format_to_blob(
589fb2cbc00SOwen Pan                filename,
590fb2cbc00SOwen Pan                line_ranges,
591313b6074SWalter Erquinigo                revision=revision,
592313b6074SWalter Erquinigo                binary=binary,
5933f801e07SGergely Meszaros                style=style,
594fb2cbc00SOwen Pan                env=env,
595fb2cbc00SOwen Pan            )
596fb2cbc00SOwen Pan            yield "%s %s\t%s" % (mode, blob_id, filename)
597fb2cbc00SOwen Pan
598fb2cbc00SOwen Pan    return create_tree(index_info_generator(), "--index-info")
599313b6074SWalter Erquinigo
600313b6074SWalter Erquinigo
601313b6074SWalter Erquinigodef create_tree(input_lines, mode):
602313b6074SWalter Erquinigo    """Create a tree object from the given input.
603313b6074SWalter Erquinigo
604313b6074SWalter Erquinigo    If mode is '--stdin', it must be a list of filenames.  If mode is
605313b6074SWalter Erquinigo    '--index-info' is must be a list of values suitable for "git update-index
606*e25c556aSOwen Pan    --index-info", such as "<mode> <SP> <sha1> <TAB> <filename>".  Any other
607*e25c556aSOwen Pan    mode is invalid."""
608fb2cbc00SOwen Pan    assert mode in ("--stdin", "--index-info")
609fb2cbc00SOwen Pan    cmd = ["git", "update-index", "--add", "-z", mode]
610313b6074SWalter Erquinigo    with temporary_index_file():
611313b6074SWalter Erquinigo        p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
612313b6074SWalter Erquinigo        for line in input_lines:
613fb2cbc00SOwen Pan            p.stdin.write(to_bytes("%s\0" % line))
614313b6074SWalter Erquinigo        p.stdin.close()
615313b6074SWalter Erquinigo        if p.wait() != 0:
616fb2cbc00SOwen Pan            die("`%s` failed" % " ".join(cmd))
617fb2cbc00SOwen Pan        tree_id = run("git", "write-tree")
618313b6074SWalter Erquinigo        return tree_id
619313b6074SWalter Erquinigo
620313b6074SWalter Erquinigo
621fb2cbc00SOwen Pandef clang_format_to_blob(
622*e25c556aSOwen Pan    filename,
623*e25c556aSOwen Pan    line_ranges,
624*e25c556aSOwen Pan    revision=None,
625*e25c556aSOwen Pan    binary="clang-format",
626*e25c556aSOwen Pan    style=None,
627*e25c556aSOwen Pan    env=None,
628fb2cbc00SOwen Pan):
629313b6074SWalter Erquinigo    """Run clang-format on the given file and save the result to a git blob.
630313b6074SWalter Erquinigo
631313b6074SWalter Erquinigo    Runs on the file in `revision` if not None, or on the file in the working
632*e25c556aSOwen Pan    directory if `revision` is None. Revision can be set to an empty string to
633*e25c556aSOwen Pan    run clang-format on the file in the index.
634313b6074SWalter Erquinigo
635313b6074SWalter Erquinigo    Returns the object ID (SHA-1) of the created blob."""
636313b6074SWalter Erquinigo    clang_format_cmd = [binary]
637313b6074SWalter Erquinigo    if style:
638fb2cbc00SOwen Pan        clang_format_cmd.extend(["--style=" + style])
639fb2cbc00SOwen Pan    clang_format_cmd.extend(
640fb2cbc00SOwen Pan        [
641fb2cbc00SOwen Pan            "--lines=%s:%s" % (start_line, start_line + line_count - 1)
642fb2cbc00SOwen Pan            for start_line, line_count in line_ranges
643fb2cbc00SOwen Pan        ]
644fb2cbc00SOwen Pan    )
6453f801e07SGergely Meszaros    if revision is not None:
646fb2cbc00SOwen Pan        clang_format_cmd.extend(["--assume-filename=" + filename])
647*e25c556aSOwen Pan        git_show_cmd = [
648*e25c556aSOwen Pan            "git",
649*e25c556aSOwen Pan            "cat-file",
650*e25c556aSOwen Pan            "blob",
651*e25c556aSOwen Pan            "%s:%s" % (revision, filename),
652*e25c556aSOwen Pan        ]
653fb2cbc00SOwen Pan        git_show = subprocess.Popen(
654fb2cbc00SOwen Pan            git_show_cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
655fb2cbc00SOwen Pan        )
656313b6074SWalter Erquinigo        git_show.stdin.close()
657313b6074SWalter Erquinigo        clang_format_stdin = git_show.stdout
658313b6074SWalter Erquinigo    else:
659313b6074SWalter Erquinigo        clang_format_cmd.extend([filename])
660313b6074SWalter Erquinigo        git_show = None
661313b6074SWalter Erquinigo        clang_format_stdin = subprocess.PIPE
662313b6074SWalter Erquinigo    try:
663fb2cbc00SOwen Pan        clang_format = subprocess.Popen(
664fb2cbc00SOwen Pan            clang_format_cmd, stdin=clang_format_stdin, stdout=subprocess.PIPE
665fb2cbc00SOwen Pan        )
666313b6074SWalter Erquinigo        if clang_format_stdin == subprocess.PIPE:
667313b6074SWalter Erquinigo            clang_format_stdin = clang_format.stdin
668313b6074SWalter Erquinigo    except OSError as e:
669313b6074SWalter Erquinigo        if e.errno == errno.ENOENT:
670313b6074SWalter Erquinigo            die('cannot find executable "%s"' % binary)
671313b6074SWalter Erquinigo        else:
672313b6074SWalter Erquinigo            raise
673313b6074SWalter Erquinigo    clang_format_stdin.close()
674*e25c556aSOwen Pan    hash_object_cmd = [
675*e25c556aSOwen Pan        "git",
676*e25c556aSOwen Pan        "hash-object",
677*e25c556aSOwen Pan        "-w",
678*e25c556aSOwen Pan        "--path=" + filename,
679*e25c556aSOwen Pan        "--stdin",
680*e25c556aSOwen Pan    ]
681fb2cbc00SOwen Pan    hash_object = subprocess.Popen(
682fb2cbc00SOwen Pan        hash_object_cmd, stdin=clang_format.stdout, stdout=subprocess.PIPE
683fb2cbc00SOwen Pan    )
684313b6074SWalter Erquinigo    clang_format.stdout.close()
685313b6074SWalter Erquinigo    stdout = hash_object.communicate()[0]
686313b6074SWalter Erquinigo    if hash_object.returncode != 0:
687fb2cbc00SOwen Pan        die("`%s` failed" % " ".join(hash_object_cmd))
688313b6074SWalter Erquinigo    if clang_format.wait() != 0:
689fb2cbc00SOwen Pan        die("`%s` failed" % " ".join(clang_format_cmd))
690313b6074SWalter Erquinigo    if git_show and git_show.wait() != 0:
691fb2cbc00SOwen Pan        die("`%s` failed" % " ".join(git_show_cmd))
692fb2cbc00SOwen Pan    return convert_string(stdout).rstrip("\r\n")
693313b6074SWalter Erquinigo
694313b6074SWalter Erquinigo
695313b6074SWalter Erquinigo@contextlib.contextmanager
696313b6074SWalter Erquinigodef temporary_index_file(tree=None):
697*e25c556aSOwen Pan    """Context manager for setting GIT_INDEX_FILE to a temporary file and
698*e25c556aSOwen Pan    deleting the file afterward."""
699313b6074SWalter Erquinigo    index_path = create_temporary_index(tree)
700fb2cbc00SOwen Pan    old_index_path = os.environ.get("GIT_INDEX_FILE")
701fb2cbc00SOwen Pan    os.environ["GIT_INDEX_FILE"] = index_path
702313b6074SWalter Erquinigo    try:
703313b6074SWalter Erquinigo        yield
704313b6074SWalter Erquinigo    finally:
705313b6074SWalter Erquinigo        if old_index_path is None:
706fb2cbc00SOwen Pan            del os.environ["GIT_INDEX_FILE"]
707313b6074SWalter Erquinigo        else:
708fb2cbc00SOwen Pan            os.environ["GIT_INDEX_FILE"] = old_index_path
709313b6074SWalter Erquinigo        os.remove(index_path)
710313b6074SWalter Erquinigo
711313b6074SWalter Erquinigo
712313b6074SWalter Erquinigodef create_temporary_index(tree=None):
713313b6074SWalter Erquinigo    """Create a temporary index file and return the created file's path.
714313b6074SWalter Erquinigo
715313b6074SWalter Erquinigo    If `tree` is not None, use that as the tree to read in.  Otherwise, an
716313b6074SWalter Erquinigo    empty index is created."""
717fb2cbc00SOwen Pan    gitdir = run("git", "rev-parse", "--git-dir")
718313b6074SWalter Erquinigo    path = os.path.join(gitdir, temp_index_basename)
719313b6074SWalter Erquinigo    if tree is None:
720fb2cbc00SOwen Pan        tree = "--empty"
721fb2cbc00SOwen Pan    run("git", "read-tree", "--index-output=" + path, tree)
722313b6074SWalter Erquinigo    return path
723313b6074SWalter Erquinigo
724313b6074SWalter Erquinigo
725313b6074SWalter Erquinigodef print_diff(old_tree, new_tree):
726313b6074SWalter Erquinigo    """Print the diff between the two trees to stdout."""
727*e25c556aSOwen Pan    # We use the porcelain 'diff' and not plumbing 'diff-tree' because the
728*e25c556aSOwen Pan    # output is expected to be viewed by the user, and only the former does nice
729*e25c556aSOwen Pan    # things like color and pagination.
730313b6074SWalter Erquinigo    #
731313b6074SWalter Erquinigo    # We also only print modified files since `new_tree` only contains the files
732313b6074SWalter Erquinigo    # that were modified, so unmodified files would show as deleted without the
733313b6074SWalter Erquinigo    # filter.
734fb2cbc00SOwen Pan    return subprocess.run(
735fb2cbc00SOwen Pan        ["git", "diff", "--diff-filter=M", "--exit-code", old_tree, new_tree]
736fb2cbc00SOwen Pan    ).returncode
737fb2cbc00SOwen Pan
738313b6074SWalter Erquinigo
739191a3953SRoland Fischerdef print_diffstat(old_tree, new_tree):
740191a3953SRoland Fischer    """Print the diffstat between the two trees to stdout."""
741*e25c556aSOwen Pan    # We use the porcelain 'diff' and not plumbing 'diff-tree' because the
742*e25c556aSOwen Pan    # output is expected to be viewed by the user, and only the former does nice
743*e25c556aSOwen Pan    # things like color and pagination.
744191a3953SRoland Fischer    #
745191a3953SRoland Fischer    # We also only print modified files since `new_tree` only contains the files
746191a3953SRoland Fischer    # that were modified, so unmodified files would show as deleted without the
747191a3953SRoland Fischer    # filter.
748fb2cbc00SOwen Pan    return subprocess.run(
749*e25c556aSOwen Pan        [
750*e25c556aSOwen Pan            "git",
751*e25c556aSOwen Pan            "diff",
752*e25c556aSOwen Pan            "--diff-filter=M",
753*e25c556aSOwen Pan            "--exit-code",
754*e25c556aSOwen Pan            "--stat",
755*e25c556aSOwen Pan            old_tree,
756*e25c556aSOwen Pan            new_tree,
757*e25c556aSOwen Pan        ]
758fb2cbc00SOwen Pan    ).returncode
759fb2cbc00SOwen Pan
760313b6074SWalter Erquinigo
761313b6074SWalter Erquinigodef apply_changes(old_tree, new_tree, force=False, patch_mode=False):
762313b6074SWalter Erquinigo    """Apply the changes in `new_tree` to the working directory.
763313b6074SWalter Erquinigo
764313b6074SWalter Erquinigo    Bails if there are local changes in those files and not `force`.  If
765313b6074SWalter Erquinigo    `patch_mode`, runs `git checkout --patch` to select hunks interactively."""
766fb2cbc00SOwen Pan    changed_files = (
767fb2cbc00SOwen Pan        run(
768fb2cbc00SOwen Pan            "git",
769fb2cbc00SOwen Pan            "diff-tree",
770fb2cbc00SOwen Pan            "--diff-filter=M",
771fb2cbc00SOwen Pan            "-r",
772fb2cbc00SOwen Pan            "-z",
773fb2cbc00SOwen Pan            "--name-only",
774fb2cbc00SOwen Pan            old_tree,
775fb2cbc00SOwen Pan            new_tree,
776fb2cbc00SOwen Pan        )
777fb2cbc00SOwen Pan        .rstrip("\0")
778fb2cbc00SOwen Pan        .split("\0")
779fb2cbc00SOwen Pan    )
780313b6074SWalter Erquinigo    if not force:
781*e25c556aSOwen Pan        unstaged_files = run(
782*e25c556aSOwen Pan            "git", "diff-files", "--name-status", *changed_files
783*e25c556aSOwen Pan        )
784313b6074SWalter Erquinigo        if unstaged_files:
785fb2cbc00SOwen Pan            print(
786*e25c556aSOwen Pan                "The following files would be modified but have unstaged "
787*e25c556aSOwen Pan                "changes:",
788fb2cbc00SOwen Pan                file=sys.stderr,
789fb2cbc00SOwen Pan            )
790313b6074SWalter Erquinigo            print(unstaged_files, file=sys.stderr)
791fb2cbc00SOwen Pan            print("Please commit, stage, or stash them first.", file=sys.stderr)
792313b6074SWalter Erquinigo            sys.exit(2)
793313b6074SWalter Erquinigo    if patch_mode:
794313b6074SWalter Erquinigo        # In patch mode, we could just as well create an index from the new tree
795313b6074SWalter Erquinigo        # and checkout from that, but then the user will be presented with a
796313b6074SWalter Erquinigo        # message saying "Discard ... from worktree".  Instead, we use the old
797313b6074SWalter Erquinigo        # tree as the index and checkout from new_tree, which gives the slightly
798313b6074SWalter Erquinigo        # better message, "Apply ... to index and worktree".  This is not quite
799313b6074SWalter Erquinigo        # right, since it won't be applied to the user's index, but oh well.
800313b6074SWalter Erquinigo        with temporary_index_file(old_tree):
801fb2cbc00SOwen Pan            subprocess.run(["git", "checkout", "--patch", new_tree], check=True)
802313b6074SWalter Erquinigo        index_tree = old_tree
803313b6074SWalter Erquinigo    else:
804313b6074SWalter Erquinigo        with temporary_index_file(new_tree):
805fb2cbc00SOwen Pan            run("git", "checkout-index", "-f", "--", *changed_files)
806313b6074SWalter Erquinigo    return changed_files
807313b6074SWalter Erquinigo
808313b6074SWalter Erquinigo
809313b6074SWalter Erquinigodef run(*args, **kwargs):
810fb2cbc00SOwen Pan    stdin = kwargs.pop("stdin", "")
811fb2cbc00SOwen Pan    verbose = kwargs.pop("verbose", True)
812fb2cbc00SOwen Pan    strip = kwargs.pop("strip", True)
813313b6074SWalter Erquinigo    for name in kwargs:
814313b6074SWalter Erquinigo        raise TypeError("run() got an unexpected keyword argument '%s'" % name)
815fb2cbc00SOwen Pan    p = subprocess.Popen(
816*e25c556aSOwen Pan        args,
817*e25c556aSOwen Pan        stdout=subprocess.PIPE,
818*e25c556aSOwen Pan        stderr=subprocess.PIPE,
819*e25c556aSOwen Pan        stdin=subprocess.PIPE,
820fb2cbc00SOwen Pan    )
821313b6074SWalter Erquinigo    stdout, stderr = p.communicate(input=stdin)
822313b6074SWalter Erquinigo
823313b6074SWalter Erquinigo    stdout = convert_string(stdout)
824313b6074SWalter Erquinigo    stderr = convert_string(stderr)
825313b6074SWalter Erquinigo
826313b6074SWalter Erquinigo    if p.returncode == 0:
827313b6074SWalter Erquinigo        if stderr:
828313b6074SWalter Erquinigo            if verbose:
829*e25c556aSOwen Pan                print(
830*e25c556aSOwen Pan                    "`%s` printed to stderr:" % " ".join(args), file=sys.stderr
831*e25c556aSOwen Pan                )
832313b6074SWalter Erquinigo            print(stderr.rstrip(), file=sys.stderr)
833313b6074SWalter Erquinigo        if strip:
834fb2cbc00SOwen Pan            stdout = stdout.rstrip("\r\n")
835313b6074SWalter Erquinigo        return stdout
836313b6074SWalter Erquinigo    if verbose:
837*e25c556aSOwen Pan        print(
838*e25c556aSOwen Pan            "`%s` returned %s" % (" ".join(args), p.returncode), file=sys.stderr
839*e25c556aSOwen Pan        )
840313b6074SWalter Erquinigo    if stderr:
841313b6074SWalter Erquinigo        print(stderr.rstrip(), file=sys.stderr)
842313b6074SWalter Erquinigo    sys.exit(2)
843313b6074SWalter Erquinigo
844313b6074SWalter Erquinigo
845313b6074SWalter Erquinigodef die(message):
846fb2cbc00SOwen Pan    print("error:", message, file=sys.stderr)
847313b6074SWalter Erquinigo    sys.exit(2)
848313b6074SWalter Erquinigo
849313b6074SWalter Erquinigo
850313b6074SWalter Erquinigodef to_bytes(str_input):
851313b6074SWalter Erquinigo    # Encode to UTF-8 to get binary data.
852313b6074SWalter Erquinigo    if isinstance(str_input, bytes):
853313b6074SWalter Erquinigo        return str_input
854fb2cbc00SOwen Pan    return str_input.encode("utf-8")
855313b6074SWalter Erquinigo
856313b6074SWalter Erquinigo
857313b6074SWalter Erquinigodef to_string(bytes_input):
858313b6074SWalter Erquinigo    if isinstance(bytes_input, str):
859313b6074SWalter Erquinigo        return bytes_input
860fb2cbc00SOwen Pan    return bytes_input.encode("utf-8")
861313b6074SWalter Erquinigo
862313b6074SWalter Erquinigo
863313b6074SWalter Erquinigodef convert_string(bytes_input):
864313b6074SWalter Erquinigo    try:
865fb2cbc00SOwen Pan        return to_string(bytes_input.decode("utf-8"))
866313b6074SWalter Erquinigo    except AttributeError:  # 'str' object has no attribute 'decode'.
867313b6074SWalter Erquinigo        return str(bytes_input)
868313b6074SWalter Erquinigo    except UnicodeError:
869313b6074SWalter Erquinigo        return str(bytes_input)
870313b6074SWalter Erquinigo
871fb2cbc00SOwen Pan
872fb2cbc00SOwen Panif __name__ == "__main__":
873357afd95Sowenca    sys.exit(main())
874