| #!/usr/bin/env python3 | |
| # Copyright lowRISC contributors. | |
| # Licensed under the Apache License, Version 2.0, see LICENSE for details. | |
| # SPDX-License-Identifier: Apache-2.0 | |
| import argparse | |
| import re | |
| import sys | |
| from git import Repo | |
| error_msg_prefix = 'ERROR: ' | |
| warning_msg_prefix = 'WARNING: ' | |
| # Maximum length of the summary line in the commit message (the first line) | |
| # There is no hard limit, but a typical convention is to keep this line at or | |
| # below 50 characters, with occasional outliers. | |
| COMMIT_MSG_MAX_SUMMARY_LEN = 100 | |
| def error(msg, commit=None): | |
| full_msg = msg | |
| if commit: | |
| full_msg = "Commit %s: %s" % (commit.hexsha, msg) | |
| print(error_msg_prefix + full_msg, file=sys.stderr) | |
| def warning(msg, commit=None): | |
| full_msg = msg | |
| if commit: | |
| full_msg = "Commit %s: %s" % (commit.hexsha, msg) | |
| print(warning_msg_prefix + full_msg, file=sys.stderr) | |
| def lint_commit_author(commit): | |
| success = True | |
| if commit.author.email.endswith('users.noreply.github.com'): | |
| error( | |
| f'Commit author has no valid email address set: ' | |
| '{commit.author.email!r}. ' | |
| 'Use "git config user.email user@example.com" to ' | |
| 'set a valid email address, then update the commit ' | |
| 'with "git rebase -i" and/or ' | |
| '"git commit --amend --signoff --reset-author". ' | |
| 'Also check your GitHub settings at ' | |
| 'https://github.com/settings/emails: your email address ' | |
| 'must be verified, and the option "Keep my email address ' | |
| 'private" must be disabled. ' | |
| 'This command will also sign off your commit indicating agreement ' | |
| 'to the Contributor License Agreement. See CONTRIBUTING.md for ' | |
| 'more details.', commit) | |
| success = False | |
| if ' ' not in commit.author.name: | |
| warning( | |
| f'The commit author name {commit.author.name!r} contains no space. ' | |
| 'Use "git config user.name \'Johnny English\'" to ' | |
| 'set your real name, and update the commit with "git rebase -i " ' | |
| 'and/or "git commit --amend --signoff --reset-author". ' | |
| 'This command will also sign off your commit indicating agreement ' | |
| 'to the Contributor License Agreement. See CONTRIBUTING.md for ' | |
| 'more details.', commit) | |
| # A warning doesn't fail lint. | |
| return success | |
| def lint_commit_message(commit): | |
| success = True | |
| lines = commit.message.splitlines() | |
| # Check length of summary line. | |
| summary_line_len = len(lines[0]) | |
| if summary_line_len > COMMIT_MSG_MAX_SUMMARY_LEN: | |
| error( | |
| "The summary line in the commit message is %d characters long; " | |
| "only %d characters are allowed." % | |
| (summary_line_len, COMMIT_MSG_MAX_SUMMARY_LEN), commit) | |
| success = False | |
| # Check for an empty line separating the summary line from the long | |
| # description. | |
| if len(lines) > 1 and lines[1] != "": | |
| error( | |
| "The second line of a commit message must be empty, as it " | |
| "separates the summary from the long description.", commit) | |
| success = False | |
| # Check that the commit message contains at least one Signed-off-by line | |
| # that matches the author name and email. There might be other signoffs (if | |
| # there are multiple authors). We don't have any requirements about those | |
| # at the moment and just pass them through. | |
| signoff_lines = [] | |
| signoff_pfx = 'Signed-off-by: ' | |
| for idx, line in enumerate(lines): | |
| if not line.startswith(signoff_pfx): | |
| continue | |
| signoff_body = line[len(signoff_pfx):] | |
| match = re.match(r'[^<]+ <[^>]*>$', signoff_body) | |
| if match is None: | |
| error('Commit has Signed-off-by line {!r}, but the second part ' | |
| 'is not of the required form. It should be of the form ' | |
| '"Signed-off-by: NAME <EMAIL>".' | |
| .format(line)) | |
| success = False | |
| signoff_lines.append(line) | |
| expected_signoff_line = ("Signed-off-by: {} <{}>" | |
| .format(commit.author.name, | |
| commit.author.email)) | |
| signoff_req_msg = ('The commit message must contain a Signed-off-by line ' | |
| 'that matches the commit author name and email, ' | |
| 'indicating agreement to the Contributor License ' | |
| 'Agreement. See CONTRIBUTING.md for more details. ' | |
| 'You can use "git commit --signoff" to ask git to add ' | |
| 'this line for you.') | |
| if not signoff_lines: | |
| error('Commit has no Signed-off-by line. ' + signoff_req_msg) | |
| success = False | |
| elif expected_signoff_line not in signoff_lines: | |
| error(('Commit has one or more Signed-off-by lines, but not the one ' | |
| 'we expect. We expected to find "{}". ' | |
| .format(expected_signoff_line)) + | |
| signoff_req_msg) | |
| success = False | |
| return success | |
| def lint_commit(commit): | |
| success = True | |
| if not lint_commit_author(commit): | |
| success = False | |
| if not lint_commit_message(commit): | |
| success = False | |
| return success | |
| def main(): | |
| global error_msg_prefix | |
| global warning_msg_prefix | |
| parser = argparse.ArgumentParser( | |
| description='Check commit metadata for common mistakes') | |
| parser.add_argument('--error-msg-prefix', | |
| default=error_msg_prefix, | |
| required=False, | |
| help='string to prepend to all error messages') | |
| parser.add_argument('--warning-msg-prefix', | |
| default=warning_msg_prefix, | |
| required=False, | |
| help='string to prepend to all warning messages') | |
| parser.add_argument('--no-merges', | |
| required=False, | |
| action="store_true", | |
| help='do not check commits with more than one parent') | |
| parser.add_argument('commit_range', | |
| metavar='commit-range', | |
| help=('commit range to check ' | |
| '(must be understood by git log)')) | |
| args = parser.parse_args() | |
| error_msg_prefix = args.error_msg_prefix | |
| warning_msg_prefix = args.warning_msg_prefix | |
| lint_successful = True | |
| repo = Repo() | |
| commits = repo.iter_commits(args.commit_range) | |
| for commit in commits: | |
| print("Checking commit %s" % commit.hexsha) | |
| is_merge = len(commit.parents) > 1 | |
| if is_merge and args.no_merges: | |
| print("Skipping merge commit.") | |
| continue | |
| if not lint_commit(commit): | |
| lint_successful = False | |
| if not lint_successful: | |
| error('Commit lint failed.') | |
| sys.exit(1) | |
| if __name__ == '__main__': | |
| main() |