#!/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 # # Wireshark - Network traffic analyzer # By Gerald Combs # 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)