![Joerg Mayer](/assets/img/avatar_default.png)
Fixes the problem that a one line commit message followed by the default comment lines was rejected.
275 lines
9.4 KiB
Python
Executable file
275 lines
9.4 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# Verifies whether commit messages adhere to the standards.
|
|
# Checks the author name and email and invokes the tools/commit-msg script.
|
|
# Copy this into .git/hooks/post-commit
|
|
#
|
|
# Copyright (c) 2018 Peter Wu <peter@lekensteyn.nl>
|
|
#
|
|
# Wireshark - Network traffic analyzer
|
|
# By Gerald Combs <gerald@wireshark.org>
|
|
# Copyright 1998 Gerald Combs
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
from __future__ import print_function
|
|
|
|
import argparse
|
|
import difflib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.request
|
|
import re
|
|
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('commit', nargs='?', default='HEAD',
|
|
help='Commit ID to be checked (default %(default)s)')
|
|
parser.add_argument('--commitmsg', help='commit-msg check', action='store')
|
|
|
|
|
|
def print_git_user_instructions():
|
|
print('To configure your name and email for git, run:')
|
|
print('')
|
|
print(' git config --global user.name "Your Name"')
|
|
print(' git config --global user.email "you@example.com"')
|
|
print('')
|
|
print('After that update the author of your latest commit with:')
|
|
print('')
|
|
print(' git commit --amend --reset-author --no-edit')
|
|
print('')
|
|
|
|
|
|
def verify_name(name):
|
|
name = name.lower().strip()
|
|
forbidden_names = ('unknown', 'root', 'user', 'your name')
|
|
if name in forbidden_names:
|
|
return False
|
|
# Warn about names without spaces. Sometimes it is a mistake where the
|
|
# developer accidentally committed using the system username.
|
|
if ' ' not in name:
|
|
print("WARNING: name '%s' does not contain a space." % (name,))
|
|
print_git_user_instructions()
|
|
return True
|
|
|
|
|
|
def verify_email(email):
|
|
email = email.lower().strip()
|
|
try:
|
|
user, host = email.split('@')
|
|
except ValueError:
|
|
# Lacks a '@' (e.g. a plain domain or "foo[AT]example.com")
|
|
return False
|
|
tld = host.split('.')[-1]
|
|
|
|
# localhost, localhost.localdomain, my.local etc.
|
|
if 'local' in tld:
|
|
return False
|
|
|
|
# Possibly an IP address
|
|
if tld.isdigit():
|
|
return False
|
|
|
|
# forbid code.wireshark.org. Submissions could be submitted by other
|
|
# addresses if one would like to remain anonymous.
|
|
if host.endswith('.wireshark.org'):
|
|
return False
|
|
|
|
# For documentation purposes only.
|
|
if host == 'example.com':
|
|
return False
|
|
|
|
# 'peter-ubuntu32.(none)'
|
|
if '(none)' in host:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def tools_dir():
|
|
if __file__.endswith('.py'):
|
|
# Assume direct invocation from tools directory
|
|
return os.path.dirname(__file__)
|
|
# Otherwise it is a git hook. To support git worktrees, do not manually look
|
|
# for the .git directory, but query the actual top level instead.
|
|
cmd = ['git', 'rev-parse', '--show-toplevel']
|
|
srcdir = subprocess.check_output(cmd, universal_newlines=True).strip()
|
|
return os.path.join(srcdir, 'tools')
|
|
|
|
|
|
def extract_subject(subject):
|
|
'''Extracts the original subject (ignoring the Revert prefix).'''
|
|
subject = subject.rstrip('\r\n')
|
|
prefix = 'Revert "'
|
|
suffix = '"'
|
|
while subject.startswith(prefix) and subject.endswith(suffix):
|
|
subject = subject[len(prefix):-len(suffix)]
|
|
return subject
|
|
|
|
|
|
def verify_body(body):
|
|
bodynocomments = re.sub('^#.*$', '', body, flags=re.MULTILINE)
|
|
old_lines = bodynocomments.splitlines(True)
|
|
is_good = True
|
|
if len(old_lines) >= 2 and old_lines[1].strip():
|
|
print('ERROR: missing blank line after the first subject line.')
|
|
is_good = False
|
|
cleaned_subject = extract_subject(old_lines[0])
|
|
if len(cleaned_subject) > 80:
|
|
# Note that this check is also invoked by the commit-msg hook.
|
|
print('Warning: keep lines in the commit message under 80 characters.')
|
|
is_good = False
|
|
if not is_good:
|
|
print('''
|
|
Please rewrite your commit message to our standards, matching this format:
|
|
|
|
component: a very brief summary of the change
|
|
|
|
A commit message should start with a brief summary, followed by a single
|
|
blank line and an optional longer description. If the change is specific to
|
|
a single protocol, start the summary line with the abbreviated name of the
|
|
protocol and a colon.
|
|
|
|
Use paragraphs to improve readability. Limit each line to 80 characters.
|
|
|
|
''')
|
|
if any(line.startswith('Bug:') or line.startswith('Ping-Bug:') for line in old_lines):
|
|
sys.stderr.write('''
|
|
To close an issue, use "Closes #1234" or "Fixes #1234" instead of "Bug: 1234".
|
|
To reference an issue, use "related to #1234" instead of "Ping-Bug: 1234". See
|
|
https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically
|
|
for details.
|
|
''')
|
|
return False
|
|
|
|
# Cherry-picking can add an extra newline, which we'll allow.
|
|
cp_line = '\n(cherry picked from commit'
|
|
body = body.replace('\n' + cp_line, cp_line)
|
|
|
|
try:
|
|
cmd = ['git', 'stripspace']
|
|
newbody = subprocess.check_output(cmd, input=body, universal_newlines=True)
|
|
except OSError as ex:
|
|
print('Warning: unable to invoke git stripspace: %s' % (ex,))
|
|
return is_good
|
|
if newbody != body:
|
|
new_lines = newbody.splitlines(True)
|
|
diff = difflib.unified_diff(old_lines, new_lines,
|
|
fromfile='OLD/.git/COMMIT_EDITMSG',
|
|
tofile='NEW/.git/COMMIT_EDITMSG')
|
|
# Clearly mark trailing whitespace (GNU patch supports such comments).
|
|
diff = [
|
|
'# NOTE: trailing space on the next line\n%s' % (line,)
|
|
if len(line) > 2 and line[-2].isspace() else line
|
|
for line in diff
|
|
]
|
|
print('The commit message does not follow our standards.')
|
|
print('Please rewrite it (there are likely whitespace issues):')
|
|
print('')
|
|
print(''.join(diff))
|
|
return False
|
|
return is_good
|
|
|
|
|
|
|
|
def verify_merge_request():
|
|
# Not needed if/when https://gitlab.com/gitlab-org/gitlab/-/issues/23308 is fixed.
|
|
gitlab_api_pfx = "https://gitlab.com/api/v4"
|
|
# gitlab.com/wireshark/wireshark = 7898047
|
|
project_id = os.getenv('CI_MERGE_REQUEST_PROJECT_ID')
|
|
ansi_csi = '\x1b['
|
|
ansi_codes = {
|
|
'black_white': ansi_csi + '30;47m',
|
|
'bold_red': ansi_csi + '31;1m', # gitlab-runner errors
|
|
'reset': ansi_csi + '0m'
|
|
}
|
|
m_r_iid = os.getenv('CI_MERGE_REQUEST_IID')
|
|
if project_id is None or m_r_iid is None:
|
|
print("This doesn't appear to be a merge request. CI_MERGE_REQUEST_PROJECT_ID={}, CI_MERGE_REQUEST_IID={}".format(project_id, m_r_iid))
|
|
return True
|
|
|
|
m_r_url = '{}/projects/{}/merge_requests/{}'.format(gitlab_api_pfx, project_id, m_r_iid)
|
|
req = urllib.request.Request(m_r_url)
|
|
# print('req', repr(req), m_r_url)
|
|
with urllib.request.urlopen(req) as resp:
|
|
resp_json = resp.read().decode('utf-8')
|
|
# print('resp', resp_json)
|
|
m_r_attrs = json.loads(resp_json)
|
|
try:
|
|
if not m_r_attrs['allow_collaboration']:
|
|
print('''\
|
|
{bold_red}ERROR:{reset} Please edit your merge request and make sure the setting
|
|
{black_white}✅ Allow commits from members who can merge to the target branch{reset}
|
|
is checked so that maintainers can rebase your change and make minor edits.\
|
|
'''.format(**ansi_codes))
|
|
return False
|
|
except KeyError:
|
|
sys.stderr.write('This appears to be a merge request, but we were not able to fetch the "Allow commits" status\n')
|
|
return True
|
|
|
|
|
|
def main():
|
|
args = parser.parse_args()
|
|
commit = args.commit
|
|
|
|
# If called from commit-msg script, just validate that part and return.
|
|
if args.commitmsg:
|
|
try:
|
|
with open(args.commitmsg) as f:
|
|
return 0 if verify_body(f.read()) else 1
|
|
except:
|
|
print("Couldn't verify body of message from file '", + args.commitmsg + "'");
|
|
return 1
|
|
|
|
|
|
if(os.getenv('CI_MERGE_REQUEST_EVENT_TYPE') == 'merge_train'):
|
|
print("If we were on the love train, people all over the world would be joining hands for this merge request.\nInstead, we're on a merge train so we're skipping commit validation checks. ")
|
|
return 0
|
|
|
|
cmd = ['git', 'show', '--no-patch',
|
|
'--format=%h%n%an%n%ae%n%B', commit, '--']
|
|
output = subprocess.check_output(cmd, universal_newlines=True)
|
|
# For some reason there is always an additional LF in the output, drop it.
|
|
if output.endswith('\n\n'):
|
|
output = output[:-1]
|
|
abbrev, author_name, author_email, body = output.split('\n', 3)
|
|
subject = body.split('\n', 1)[0]
|
|
|
|
# If called directly (from the tools directory), print the commit that was
|
|
# being validated. If called from a git hook (without .py extension), try to
|
|
# remain silent unless there are issues.
|
|
if __file__.endswith('.py'):
|
|
print('Checking commit: %s %s' % (abbrev, subject))
|
|
|
|
exit_code = 0
|
|
if not verify_name(author_name):
|
|
print('Disallowed author name: {}'.format(author_name))
|
|
exit_code = 1
|
|
|
|
if not verify_email(author_email):
|
|
print('Disallowed author email address: {}'.format(author_email))
|
|
exit_code = 1
|
|
|
|
if exit_code:
|
|
print_git_user_instructions()
|
|
|
|
if not verify_body(body):
|
|
exit_code = 1
|
|
|
|
if not verify_merge_request():
|
|
exit_code = 1
|
|
|
|
return exit_code
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
sys.exit(main())
|
|
except subprocess.CalledProcessError as ex:
|
|
print('\n%s' % ex)
|
|
sys.exit(ex.returncode)
|
|
except KeyboardInterrupt:
|
|
sys.exit(130)
|