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