xref: /llvm-project/llvm/utils/git/pre-push.py (revision c49770c60f26e449379447109f7d915bd8de0384)
1#!/usr/bin/env python3
2#
3# ======- pre-push - LLVM Git Help 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
11"""
12pre-push git hook integration
13=============================
14
15This script is intended to be setup as a pre-push hook, from the root of the
16repo run:
17
18   ln -sf ../../llvm/utils/git/pre-push.py .git/hooks/pre-push
19
20From the git doc:
21
22  The pre-push hook runs during git push, after the remote refs have been
23  updated but before any objects have been transferred. It receives the name
24  and location of the remote as parameters, and a list of to-be-updated refs
25  through stdin. You can use it to validate a set of ref updates before a push
26  occurs (a non-zero exit code will abort the push).
27"""
28
29import argparse
30import shutil
31import subprocess
32import sys
33import time
34from shlex import quote
35
36VERBOSE = False
37QUIET = False
38dev_null_fd = None
39z40 = "0000000000000000000000000000000000000000"
40
41
42def eprint(*args, **kwargs):
43    print(*args, file=sys.stderr, **kwargs)
44
45
46def log(*args, **kwargs):
47    if QUIET:
48        return
49    print(*args, **kwargs)
50
51
52def log_verbose(*args, **kwargs):
53    if not VERBOSE:
54        return
55    print(*args, **kwargs)
56
57
58def die(msg):
59    eprint(msg)
60    sys.exit(1)
61
62
63def ask_confirm(prompt):
64    while True:
65        query = input("%s (y/N): " % (prompt))
66        if query.lower() not in ["y", "n", ""]:
67            print("Expect y or n!")
68            continue
69        return query.lower() == "y"
70
71
72def shell(
73    cmd,
74    strip=True,
75    cwd=None,
76    stdin=None,
77    die_on_failure=True,
78    ignore_errors=False,
79    text=True,
80    print_raw_stderr=False,
81):
82    # Escape args when logging for easy repro.
83    quoted_cmd = [quote(arg) for arg in cmd]
84    cwd_msg = ""
85    if cwd:
86        cwd_msg = " in %s" % cwd
87    log_verbose("Running%s: %s" % (cwd_msg, " ".join(quoted_cmd)))
88
89    # Silence errors if requested.
90    err_pipe = subprocess.DEVNULL if ignore_errors else subprocess.PIPE
91
92    start = time.time()
93    p = subprocess.Popen(
94        cmd,
95        cwd=cwd,
96        stdout=subprocess.PIPE,
97        stderr=err_pipe,
98        stdin=subprocess.PIPE,
99        universal_newlines=text,
100    )
101    stdout, stderr = p.communicate(input=stdin)
102    elapsed = time.time() - start
103
104    log_verbose("Command took %0.1fs" % elapsed)
105
106    if p.returncode == 0 or ignore_errors:
107        if stderr and not ignore_errors:
108            if not print_raw_stderr:
109                eprint("`%s` printed to stderr:" % " ".join(quoted_cmd))
110            eprint(stderr.rstrip())
111        if strip:
112            if text:
113                stdout = stdout.rstrip("\r\n")
114            else:
115                stdout = stdout.rstrip(b"\r\n")
116        if VERBOSE:
117            for l in stdout.splitlines():
118                log_verbose("STDOUT: %s" % l)
119        return stdout
120    err_msg = "`%s` returned %s" % (" ".join(quoted_cmd), p.returncode)
121    eprint(err_msg)
122    if stderr:
123        eprint(stderr.rstrip())
124    if die_on_failure:
125        sys.exit(2)
126    raise RuntimeError(err_msg)
127
128
129def git(*cmd, **kwargs):
130    return shell(["git"] + list(cmd), **kwargs)
131
132
133def get_revs_to_push(range):
134    commits = git("rev-list", range).splitlines()
135    # Reverse the order so we print the oldest commit first
136    commits.reverse()
137    return commits
138
139
140def handle_push(args, local_ref, local_sha, remote_ref, remote_sha):
141    """Check a single push request (which can include multiple revisions)"""
142    log_verbose(
143        "Handle push, reproduce with "
144        "`echo %s %s %s %s | pre-push.py %s %s"
145        % (local_ref, local_sha, remote_ref, remote_sha, args.remote, args.url)
146    )
147    # Handle request to delete
148    if local_sha == z40:
149        if not ask_confirm(
150            'Are you sure you want to delete "%s" on remote "%s"?'
151            % (remote_ref, args.url)
152        ):
153            die("Aborting")
154        return
155
156    # Push a new branch
157    if remote_sha == z40:
158        if not ask_confirm(
159            'Are you sure you want to push a new branch/tag "%s" on remote "%s"?'
160            % (remote_ref, args.url)
161        ):
162            die("Aborting")
163        range = local_sha
164        return
165    else:
166        # Update to existing branch, examine new commits
167        range = "%s..%s" % (remote_sha, local_sha)
168        # Check that the remote commit exists, otherwise let git proceed
169        if "commit" not in git("cat-file", "-t", remote_sha, ignore_errors=True):
170            return
171
172    revs = get_revs_to_push(range)
173    if not revs:
174        # This can happen if someone is force pushing an older revision to a branch
175        return
176
177    # Print the revision about to be pushed commits
178    print('Pushing to "%s" on remote "%s"' % (remote_ref, args.url))
179    for sha in revs:
180        print(" - " + git("show", "--oneline", "--quiet", sha))
181
182    if len(revs) > 1:
183        if not ask_confirm("Are you sure you want to push %d commits?" % len(revs)):
184            die("Aborting")
185
186    for sha in revs:
187        msg = git("log", "--format=%B", "-n1", sha)
188        if "Differential Revision" not in msg:
189            continue
190        for line in msg.splitlines():
191            for tag in ["Summary", "Reviewers", "Subscribers", "Tags"]:
192                if line.startswith(tag + ":"):
193                    eprint(
194                        'Please remove arcanist tags from the commit message (found "%s" tag in %s)'
195                        % (tag, sha[:12])
196                    )
197                    if len(revs) == 1:
198                        eprint("Try running: llvm/utils/git/arcfilter.sh")
199                    die('Aborting (force push by adding "--no-verify")')
200
201    return
202
203
204if __name__ == "__main__":
205    if not shutil.which("git"):
206        die("error: cannot find git command")
207
208    argv = sys.argv[1:]
209    p = argparse.ArgumentParser(
210        prog="pre-push",
211        formatter_class=argparse.RawDescriptionHelpFormatter,
212        description=__doc__,
213    )
214    verbosity_group = p.add_mutually_exclusive_group()
215    verbosity_group.add_argument(
216        "-q", "--quiet", action="store_true", help="print less information"
217    )
218    verbosity_group.add_argument(
219        "-v", "--verbose", action="store_true", help="print more information"
220    )
221
222    p.add_argument("remote", type=str, help="Name of the remote")
223    p.add_argument("url", type=str, help="URL for the remote")
224
225    args = p.parse_args(argv)
226    VERBOSE = args.verbose
227    QUIET = args.quiet
228
229    lines = sys.stdin.readlines()
230    sys.stdin = open("/dev/tty", "r")
231    for line in lines:
232        local_ref, local_sha, remote_ref, remote_sha = line.split()
233        handle_push(args, local_ref, local_sha, remote_ref, remote_sha)
234