diff --git a/.github/workflows/commit_formatting.yml b/.github/workflows/commit_formatting.yml new file mode 100644 index 00000000..a651f8a1 --- /dev/null +++ b/.github/workflows/commit_formatting.yml @@ -0,0 +1,18 @@ +name: Check commit message formatting + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '100' + - uses: actions/setup-python@v4 + - name: Check commit message formatting + run: source tools/ci.sh && ci_commit_formatting_run diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 553b2738..bfce6a24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,12 @@ repos: name: MicroPython codeformat.py for changed files entry: tools/codeformat.py -v -f language: python + - id: verifygitlog + name: MicroPython git commit message format checker + entry: tools/verifygitlog.py --check-file --ignore-rebase + language: python + verbose: true + stages: [commit-msg] - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.1.0 hooks: diff --git a/tools/ci.sh b/tools/ci.sh index 81ec641f..13989478 100755 --- a/tools/ci.sh +++ b/tools/ci.sh @@ -15,6 +15,18 @@ function ci_code_formatting_run { tools/codeformat.py -v } +######################################################################################## +# commit formatting + +function ci_commit_formatting_run { + git remote add upstream https://github.com/micropython/micropython-lib.git + git fetch --depth=100 upstream master + # If the common ancestor commit hasn't been found, fetch more. + git merge-base upstream/master HEAD || git fetch --unshallow upstream master + # For a PR, upstream/master..HEAD ends with a merge commit into master, exclude that one. + tools/verifygitlog.py -v upstream/master..HEAD --no-merges +} + ######################################################################################## # build packages diff --git a/tools/verifygitlog.py b/tools/verifygitlog.py new file mode 100755 index 00000000..20be794f --- /dev/null +++ b/tools/verifygitlog.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + +# This is an exact duplicate of verifygitlog.py from the main repo. + +import re +import subprocess +import sys + +verbosity = 0 # Show what's going on, 0 1 or 2. +suggestions = 1 # Set to 0 to not include lengthy suggestions in error messages. + +ignore_prefixes = [] + + +def verbose(*args): + if verbosity: + print(*args) + + +def very_verbose(*args): + if verbosity > 1: + print(*args) + + +class ErrorCollection: + # Track errors and warnings as the program runs + def __init__(self): + self.has_errors = False + self.has_warnings = False + self.prefix = "" + + def error(self, text): + print("error: {}{}".format(self.prefix, text)) + self.has_errors = True + + def warning(self, text): + print("warning: {}{}".format(self.prefix, text)) + self.has_warnings = True + + +def git_log(pretty_format, *args): + # Delete pretty argument from user args so it doesn't interfere with what we do. + args = ["git", "log"] + [arg for arg in args if "--pretty" not in args] + args.append("--pretty=format:" + pretty_format) + very_verbose("git_log", *args) + # Generator yielding each output line. + for line in subprocess.Popen(args, stdout=subprocess.PIPE).stdout: + yield line.decode().rstrip("\r\n") + + +def diagnose_subject_line(subject_line, subject_line_format, err): + err.error("Subject line: " + subject_line) + if not subject_line.endswith("."): + err.error('* must end with "."') + if not re.match(r"^[^!]+: ", subject_line): + err.error('* must start with "path: "') + if re.match(r"^[^!]+: *$", subject_line): + err.error("* must contain a subject after the path.") + m = re.match(r"^[^!]+: ([a-z][^ ]*)", subject_line) + if m: + err.error('* first word of subject ("{}") must be capitalised.'.format(m.group(1))) + if re.match(r"^[^!]+: [^ ]+$", subject_line): + err.error("* subject must contain more than one word.") + err.error("* must match: " + repr(subject_line_format)) + err.error('* Example: "py/runtime: Add support for foo to bar."') + + +def verify(sha, err): + verbose("verify", sha) + err.prefix = "commit " + sha + ": " + + # Author and committer email. + for line in git_log("%ae%n%ce", sha, "-n1"): + very_verbose("email", line) + if "noreply" in line: + err.error("Unwanted email address: " + line) + + # Message body. + raw_body = list(git_log("%B", sha, "-n1")) + verify_message_body(raw_body, err) + + +def verify_message_body(raw_body, err): + if not raw_body: + err.error("Message is empty") + return + + # Subject line. + subject_line = raw_body[0] + for prefix in ignore_prefixes: + if subject_line.startswith(prefix): + verbose("Skipping ignored commit message") + return + very_verbose("subject_line", subject_line) + subject_line_format = r"^[^!]+: [A-Z]+.+ .+\.$" + if not re.match(subject_line_format, subject_line): + diagnose_subject_line(subject_line, subject_line_format, err) + if len(subject_line) >= 73: + err.error("Subject line must be 72 or fewer characters: " + subject_line) + + # Second one divides subject and body. + if len(raw_body) > 1 and raw_body[1]: + err.error("Second message line must be empty: " + raw_body[1]) + + # Message body lines. + for line in raw_body[2:]: + # Long lines with URLs are exempt from the line length rule. + if len(line) >= 76 and "://" not in line: + err.error("Message lines should be 75 or less characters: " + line) + + if not raw_body[-1].startswith("Signed-off-by: ") or "@" not in raw_body[-1]: + err.error('Message must be signed-off. Use "git commit -s".') + + +def run(args): + verbose("run", *args) + + err = ErrorCollection() + + if "--check-file" in args: + filename = args[-1] + verbose("checking commit message from", filename) + with open(args[-1]) as f: + # Remove comment lines as well as any empty lines at the end. + lines = [line.rstrip("\r\n") for line in f if not line.startswith("#")] + while not lines[-1]: + lines.pop() + verify_message_body(lines, err) + else: # Normal operation, pass arguments to git log + for sha in git_log("%h", *args): + verify(sha, err) + + if err.has_errors or err.has_warnings: + if suggestions: + print("See https://github.com/micropython/micropython/blob/master/CODECONVENTIONS.md") + else: + print("ok") + if err.has_errors: + sys.exit(1) + + +def show_help(): + print("usage: verifygitlog.py [-v -n -h --check-file] ...") + print("-v : increase verbosity, can be specified multiple times") + print("-n : do not print multi-line suggestions") + print("-h : print this help message and exit") + print( + "--check-file : Pass a single argument which is a file containing a candidate commit message" + ) + print( + "--ignore-rebase : Skip checking commits with git rebase autosquash prefixes or WIP as a prefix" + ) + print("... : arguments passed to git log to retrieve commits to verify") + print(" see https://www.git-scm.com/docs/git-log") + print(" passing no arguments at all will verify all commits") + print("examples:") + print("verifygitlog.py -n10 # Check last 10 commits") + print("verifygitlog.py -v master..HEAD # Check commits since master") + + +if __name__ == "__main__": + args = sys.argv[1:] + verbosity = args.count("-v") + suggestions = args.count("-n") == 0 + if "--ignore-rebase" in args: + args.remove("--ignore-rebase") + ignore_prefixes = ["squash!", "fixup!", "amend!", "WIP"] + + if "-h" in args: + show_help() + else: + args = [arg for arg in args if arg not in ["-v", "-n", "-h"]] + run(args)