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