1#!/usr/bin/env python3 2# Verifies whether commit messages adhere to the standards. 3# Checks the author name and email and invokes the tools/commit-msg script. 4# Copy this into .git/hooks/post-commit 5# 6# Copyright (c) 2018 Peter Wu <peter@lekensteyn.nl> 7# 8# Wireshark - Network traffic analyzer 9# By Gerald Combs <gerald@wireshark.org> 10# Copyright 1998 Gerald Combs 11# 12# SPDX-License-Identifier: GPL-2.0-or-later 13 14from __future__ import print_function 15 16import argparse 17import difflib 18import json 19import os 20import subprocess 21import sys 22import tempfile 23import urllib.request 24import re 25 26 27parser = argparse.ArgumentParser() 28parser.add_argument('commit', nargs='?', default='HEAD', 29 help='Commit ID to be checked (default %(default)s)') 30parser.add_argument('--commitmsg', help='commit-msg check', action='store') 31 32 33def print_git_user_instructions(): 34 print('To configure your name and email for git, run:') 35 print('') 36 print(' git config --global user.name "Your Name"') 37 print(' git config --global user.email "you@example.com"') 38 print('') 39 print('After that update the author of your latest commit with:') 40 print('') 41 print(' git commit --amend --reset-author --no-edit') 42 print('') 43 44 45def verify_name(name): 46 name = name.lower().strip() 47 forbidden_names = ('unknown', 'root', 'user', 'your name') 48 if name in forbidden_names: 49 return False 50 # Warn about names without spaces. Sometimes it is a mistake where the 51 # developer accidentally committed using the system username. 52 if ' ' not in name: 53 print("WARNING: name '%s' does not contain a space." % (name,)) 54 print_git_user_instructions() 55 return True 56 57 58def verify_email(email): 59 email = email.lower().strip() 60 try: 61 user, host = email.split('@') 62 except ValueError: 63 # Lacks a '@' (e.g. a plain domain or "foo[AT]example.com") 64 return False 65 tld = host.split('.')[-1] 66 67 # localhost, localhost.localdomain, my.local etc. 68 if 'local' in tld: 69 return False 70 71 # Possibly an IP address 72 if tld.isdigit(): 73 return False 74 75 # forbid code.wireshark.org. Submissions could be submitted by other 76 # addresses if one would like to remain anonymous. 77 if host.endswith('.wireshark.org'): 78 return False 79 80 # For documentation purposes only. 81 if host == 'example.com': 82 return False 83 84 # 'peter-ubuntu32.(none)' 85 if '(none)' in host: 86 return False 87 88 return True 89 90 91def tools_dir(): 92 if __file__.endswith('.py'): 93 # Assume direct invocation from tools directory 94 return os.path.dirname(__file__) 95 # Otherwise it is a git hook. To support git worktrees, do not manually look 96 # for the .git directory, but query the actual top level instead. 97 cmd = ['git', 'rev-parse', '--show-toplevel'] 98 srcdir = subprocess.check_output(cmd, universal_newlines=True).strip() 99 return os.path.join(srcdir, 'tools') 100 101 102def extract_subject(subject): 103 '''Extracts the original subject (ignoring the Revert prefix).''' 104 subject = subject.rstrip('\r\n') 105 prefix = 'Revert "' 106 suffix = '"' 107 while subject.startswith(prefix) and subject.endswith(suffix): 108 subject = subject[len(prefix):-len(suffix)] 109 return subject 110 111 112def verify_body(body): 113 bodynocomments = re.sub('^#.*$', '', body, flags=re.MULTILINE) 114 old_lines = bodynocomments.splitlines(True) 115 is_good = True 116 if len(old_lines) >= 2 and old_lines[1].strip(): 117 print('ERROR: missing blank line after the first subject line.') 118 is_good = False 119 cleaned_subject = extract_subject(old_lines[0]) 120 if len(cleaned_subject) > 80: 121 # Note that this check is also invoked by the commit-msg hook. 122 print('Warning: keep lines in the commit message under 80 characters.') 123 is_good = False 124 if not is_good: 125 print(''' 126Please rewrite your commit message to our standards, matching this format: 127 128 component: a very brief summary of the change 129 130 A commit message should start with a brief summary, followed by a single 131 blank line and an optional longer description. If the change is specific to 132 a single protocol, start the summary line with the abbreviated name of the 133 protocol and a colon. 134 135 Use paragraphs to improve readability. Limit each line to 80 characters. 136 137''') 138 if any(line.startswith('Bug:') or line.startswith('Ping-Bug:') for line in old_lines): 139 sys.stderr.write(''' 140To close an issue, use "Closes #1234" or "Fixes #1234" instead of "Bug: 1234". 141To reference an issue, use "related to #1234" instead of "Ping-Bug: 1234". See 142https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically 143for details. 144''') 145 return False 146 147 # Cherry-picking can add an extra newline, which we'll allow. 148 cp_line = '\n(cherry picked from commit' 149 body = body.replace('\n' + cp_line, cp_line) 150 151 try: 152 cmd = ['git', 'stripspace'] 153 newbody = subprocess.check_output(cmd, input=body, universal_newlines=True) 154 except OSError as ex: 155 print('Warning: unable to invoke git stripspace: %s' % (ex,)) 156 return is_good 157 if newbody != body: 158 new_lines = newbody.splitlines(True) 159 diff = difflib.unified_diff(old_lines, new_lines, 160 fromfile='OLD/.git/COMMIT_EDITMSG', 161 tofile='NEW/.git/COMMIT_EDITMSG') 162 # Clearly mark trailing whitespace (GNU patch supports such comments). 163 diff = [ 164 '# NOTE: trailing space on the next line\n%s' % (line,) 165 if len(line) > 2 and line[-2].isspace() else line 166 for line in diff 167 ] 168 print('The commit message does not follow our standards.') 169 print('Please rewrite it (there are likely whitespace issues):') 170 print('') 171 print(''.join(diff)) 172 return False 173 return is_good 174 175 176 177def verify_merge_request(): 178 # Not needed if/when https://gitlab.com/gitlab-org/gitlab/-/issues/23308 is fixed. 179 gitlab_api_pfx = "https://gitlab.com/api/v4" 180 # gitlab.com/wireshark/wireshark = 7898047 181 project_id = os.getenv('CI_MERGE_REQUEST_PROJECT_ID') 182 ansi_csi = '\x1b[' 183 ansi_codes = { 184 'black_white': ansi_csi + '30;47m', 185 'bold_red': ansi_csi + '31;1m', # gitlab-runner errors 186 'reset': ansi_csi + '0m' 187 } 188 m_r_iid = os.getenv('CI_MERGE_REQUEST_IID') 189 if project_id is None or m_r_iid is None: 190 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)) 191 return True 192 193 m_r_url = '{}/projects/{}/merge_requests/{}'.format(gitlab_api_pfx, project_id, m_r_iid) 194 req = urllib.request.Request(m_r_url) 195 # print('req', repr(req), m_r_url) 196 with urllib.request.urlopen(req) as resp: 197 resp_json = resp.read().decode('utf-8') 198 # print('resp', resp_json) 199 m_r_attrs = json.loads(resp_json) 200 try: 201 if not m_r_attrs['allow_collaboration']: 202 print('''\ 203{bold_red}ERROR:{reset} Please edit your merge request and make sure the setting 204 {black_white}✅ Allow commits from members who can merge to the target branch{reset} 205is checked so that maintainers can rebase your change and make minor edits.\ 206'''.format(**ansi_codes)) 207 return False 208 except KeyError: 209 sys.stderr.write('This appears to be a merge request, but we were not able to fetch the "Allow commits" status\n') 210 return True 211 212 213def main(): 214 args = parser.parse_args() 215 commit = args.commit 216 217 # If called from commit-msg script, just validate that part and return. 218 if args.commitmsg: 219 try: 220 with open(args.commitmsg) as f: 221 return 0 if verify_body(f.read()) else 1 222 except: 223 print("Couldn't verify body of message from file '", + args.commitmsg + "'"); 224 return 1 225 226 227 if(os.getenv('CI_MERGE_REQUEST_EVENT_TYPE') == 'merge_train'): 228 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. ") 229 return 0 230 231 cmd = ['git', 'show', '--no-patch', 232 '--format=%h%n%an%n%ae%n%B', commit, '--'] 233 output = subprocess.check_output(cmd, universal_newlines=True) 234 # For some reason there is always an additional LF in the output, drop it. 235 if output.endswith('\n\n'): 236 output = output[:-1] 237 abbrev, author_name, author_email, body = output.split('\n', 3) 238 subject = body.split('\n', 1)[0] 239 240 # If called directly (from the tools directory), print the commit that was 241 # being validated. If called from a git hook (without .py extension), try to 242 # remain silent unless there are issues. 243 if __file__.endswith('.py'): 244 print('Checking commit: %s %s' % (abbrev, subject)) 245 246 exit_code = 0 247 if not verify_name(author_name): 248 print('Disallowed author name: {}'.format(author_name)) 249 exit_code = 1 250 251 if not verify_email(author_email): 252 print('Disallowed author email address: {}'.format(author_email)) 253 exit_code = 1 254 255 if exit_code: 256 print_git_user_instructions() 257 258 if not verify_body(body): 259 exit_code = 1 260 261 if not verify_merge_request(): 262 exit_code = 1 263 264 return exit_code 265 266 267if __name__ == '__main__': 268 try: 269 sys.exit(main()) 270 except subprocess.CalledProcessError as ex: 271 print('\n%s' % ex) 272 sys.exit(ex.returncode) 273 except KeyboardInterrupt: 274 sys.exit(130) 275