-
Notifications
You must be signed in to change notification settings - Fork 17
/
githook.py
executable file
·221 lines (182 loc) · 9.54 KB
/
githook.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
#!/usr/bin/env python3
"""Hooks for git that perform linting.
This file contains two git hooks:
1. A commit-msg hook, to lint the commit message. If lint fails, the commit
is aborted, and the message is saved in a temporary file.
2. A pre-push hook, to lint files that will remotely change as a result of the
push. If lint fails, the push is aborted.
These hooks are automatically installed in linted KA repositories, by the
`ka-clone` tool.
This script accepts an optional argument "--hook=<hook>", which specifies which
hook to run (commit-msg or pre-push). If omitted, defaults to "commit-msg", to
preserve legacy behavior (from back when that was the only hook this script
contained).
The remaining positional arguments are assumed to be provided by git, and are
forwarded to the hook's handler function as the argument list. (For example,
the commit-msg git hook has a single positional argument: the path to the
commit message file.) Note, too, that some git hooks provide additional input
on STDIN.
The user can choose to skip this hook, by running `git push --no-verify` (in
which case, git won't call this script at all), or specifying FORCE_COMMIT=1
in the environment (in which case, this script will be a no-op).
NOTE(mdr): If you're here to remove a hook, be aware that existing ka-clone'd
repositories on developers' machines might still attempt to call the hook.
Instead of deleting the hook outright and triggering a crash, consider
replacing the hook's handler with a message explaining how to upgrade.
"""
import argparse
import os
import subprocess
import sys
import hook_lib
def _normalized_commit_message(text):
"""Remove lines starting with '#' and other stuff that git ignores."""
lines = text.splitlines(True)
lines = [l for l in lines if not l.startswith('#')]
return ''.join(lines).strip()
def commit_msg_hook(commit_message_file):
"""Run a git pre-commit lint-check."""
git_root = subprocess.check_output(
['git', 'rev-parse', '--git-dir']).decode('utf-8')
# read the commit message contents from the file specified
commit_message = open(commit_message_file).read()
# Get rid of the comment lines, and leading and trailing whitespace.
commit_message = _normalized_commit_message(commit_message)
# If the commit message is empty or unchanged from the template, abort.
if not commit_message:
print("Aborting commit, empty commit message")
return 1
try:
with open(os.path.join(git_root.strip(), 'commit_template')) as f:
template = f.read()
except (IOError, OSError): # user doesn't have a commit template
pass
else:
if commit_message == _normalized_commit_message(template):
print("Aborting commit, commit message unchanged")
return 1
# If we're a merge, don't try to do a lint-check.
is_merge_commit = os.path.exists(os.path.join(
git_root.strip(), 'MERGE_HEAD'))
# Lint the commit message itself!
# For the phabricator workflow, some people always have the git
# commit message be 'WIP', and put in the actual message at 'arc
# diff' time. We don't require a 'real' commit message in that
# case.
num_errors = 0
if not is_merge_commit and not commit_message.lower().startswith('wip'):
num_errors += hook_lib.lint_commit_message(commit_message)
# Report what we found, and exit with the proper status code.
hook_lib.report_errors_and_exit(num_errors, commit_message,
os.path.join('.git', 'commit.save'))
def pre_push_hook(_unused_arg_remote_name, _unused_arg_remote_location):
"""Run a git pre-push lint-check.
The pre-push hook has a test script: test_githook_pre_push.sh. If you're
making significant changes to this function, consider running the test!
"""
# You can disable linting by setting GIT_LINT to "no".
if os.getenv("GIT_LINT") == "no":
return
for line in sys.stdin:
# Skip blank lines - though we only expect this to happen in the case
# of STDIN being empty, and the only input being a single blank line.
if not line.strip():
continue
# For each branch we're trying to push, the git hook will tell us the
# local branch name, local sha, remote branch name, and remote sha.
# For our purposes, we only care about the local sha.
(_, local_sha, _, _) = line.split()
if local_sha == '0000000000000000000000000000000000000000':
continue # means we're deleting this branch
# To find files that have been changed locally, we'll use `git log` to
# find commits that are present in the branch state we intend to push,
# but aren't present on any remote-tracking branch. We'll then format
# the list of files changed in each such commit.
#
# This filtering is based on the assumption that any change already on
# the remote server, and therefore in a remote-tracking branch, has
# been linted by a previous `git push` hook.
#
# This filtering is just an optimization, and it's okay if we re-lint
# a few extra files... but we should try not to lint *too* many extra
# files, or else the process will be annoyingly slow.
#
# NOTE(mdr): This filtering won't recognize changes that are already on
# on remote, but were *copied* to the local branch, for example by
# `git cherry-pick`. The `--cherry-pick` filtering option for
# `git log` claims to be able to detect this, but only works for
# symmetric difference filtering, which isn't what we're using.
# Thankfully, this isn't a big deal in standard workflows for
# merging or rebasing master's work into a feature branch. Merging
# will use the original commits from both branches. Rebasing is
# liable to copy the feature branch's commits, but those are likely
# to have a much smaller surface area, so re-linting them shouldn't
# take very long.
#
# NOTE(mdr): A simpler filtering strategy might be to just compare the
# local and remote state of the current branch. However, this would
# lead to unnecessarily slow linting for merge commits: e.g., you
# merge origin/master into local branch foobar, then push to
# origin/foobar. This push might contain many commits from
# origin/master which aren't yet in origin/foobar, but that *have*
# been linted already.
files_to_lint = subprocess.check_output([
'git', 'log',
# We "format" each commit by hiding its commit details, and listing
# the name of each added/modified/removed file, separated by NUL.
'--pretty=format:', '--name-only', '--diff-filter=AMR', '-z',
# I'm not sure I totally understand it, but this flag
# lets us see resolved conflicts in merge commits.
'--cc',
# Ignore submodules, because they're likely to have lint rules that
# are different than the repository we're currently linting.
'--ignore-submodules',
# Include commits that are reachable from the local state we intend
# to push.
local_sha,
# Do not include commits that are reachable from any
# remote-tracking branch's state. (The `--remotes` flag is
# equivalent to manually listing `origin/master`, `origin/foobar`,
# etc. The `--not` flag negates all subsequent references.)
'--not', '--remotes',
]).decode('utf-8')
# Parse files to lint: split at NUL characters, remove blank entries,
# and remove duplicates.
files_to_lint = list({f for f in files_to_lint.split('\0')
if f and os.path.exists(f)})
# Lint the files, if any. If there are any errors, print a helpful
# message, and return a nonzero status code to abort the push.
if files_to_lint:
print("khan-linter: linting {} files with unpushed "
"changes...".format(len(files_to_lint)))
num_errors = hook_lib.lint_files(files_to_lint)
if num_errors > 0:
print(
'\n--- %s lint errors. Push aborted. ---' % num_errors,
file=sys.stderr)
print(
'Running `make fixc` may help to autofix the errors.',
file=sys.stderr)
return 1
# Yay, everything went okay! Return a zero status code, in order to
# allow the push.
print('khan-linter: all lint checks passed', file=sys.stderr)
return 0
if __name__ == '__main__':
suppress_lint = os.getenv('FORCE_COMMIT', '')
if suppress_lint.lower() in ('1', 'true'):
sys.exit(0)
parser = argparse.ArgumentParser(description='Perform lint git hooks.')
parser.add_argument('--hook', type=str, choices=['commit-msg', 'pre-push'],
default='commit-msg', help='which hook to perform')
parser.add_argument('hook_args', nargs='*',
help='The arguments provided by the git hook.')
args = parser.parse_args()
if args.hook == 'commit-msg':
status_code = commit_msg_hook(*args.hook_args)
elif args.hook == 'pre-push':
status_code = pre_push_hook(*args.hook_args)
else:
raise AssertionError("unrecognized hook name - should've been caught "
"by argparse?")
sys.exit(status_code)