| # Copyright 2026 The Fuchsia Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Checks for Git commit messages.""" | |
| load("./common.star", "cipd_platform_name", "get_fuchsia_dir", "os_exec") | |
| _FOOTERS = [ | |
| # keep-sorted start | |
| "Build-Only", | |
| "Bypass-Vuln-Scanning", | |
| "Change-Id", | |
| "Cq-Do-Not-Cancel-Tryjobs", | |
| "Cq-Exclude-Trybots", | |
| "Cq-Include-Trybots", | |
| "Depends-On", | |
| "Depends-on", | |
| "Multiply", | |
| "Run-All-Tests", | |
| "Want-Review", | |
| # keep-sorted end | |
| ] | |
| _FOOTER_RE = r"^(" + "|".join(_FOOTERS) + r")\s*:" | |
| def _check_commit_message(ctx): | |
| """Lints Git commit messages on the current branch against style guidelines.""" | |
| commits = ctx.scm.commits() | |
| if not commits: | |
| return | |
| fuchsia_dir = get_fuchsia_dir(ctx) | |
| python_bin = "%s/prebuilt/third_party/python3/%s/bin/python3" % ( | |
| fuchsia_dir, | |
| cipd_platform_name(ctx), | |
| ) | |
| checker_path = "%s/scripts/shac/commit_msg_checker.py" % fuchsia_dir | |
| for commit in commits: | |
| msg_file = ctx.io.tempfile(commit.message, name = "commit_message.txt") | |
| res = os_exec( | |
| ctx, | |
| [python_bin, checker_path, "--message-file", msg_file, "--json"], | |
| raise_on_failure = False, | |
| ).wait() | |
| if not res.stdout or not res.stdout.strip().startswith("["): | |
| continue | |
| findings = json.decode(res.stdout) | |
| for finding in findings: | |
| msg = finding.get("message", "") | |
| kwargs = { | |
| "level": finding.get("level", "warning"), | |
| "commit": commit, | |
| } | |
| if finding.get("line"): | |
| kwargs["line"] = finding["line"] | |
| ctx.emit.commit_message_finding(message = msg, **kwargs) | |
| def _commit_msg_footers(ctx): | |
| """Enforces that machine-parsed Git footers are in the last paragraph.""" | |
| for commit in ctx.scm.commits(): | |
| lines = commit.message.rstrip().splitlines() | |
| # Find the start of the last non-empty paragraph. If there are no blank | |
| # lines, the entire message is the subject paragraph (not a footer | |
| # block), so check all lines. | |
| last_para_start = len(lines) | |
| for i in range(len(lines) - 1, -1, -1): | |
| if not lines[i].strip(): | |
| last_para_start = i + 1 | |
| break | |
| # Skip commits that have already been submitted by Gerrit (e.g. in | |
| # postsubmit CI or on a detached HEAD at origin/main) or generated by | |
| # rollers (which place Multiply: above the footer block). | |
| if any([ | |
| line.startswith("Reviewed-on:") or line.startswith("Roller-URL:") | |
| for line in lines[last_para_start:] | |
| ]): | |
| continue | |
| for i in range(last_para_start): | |
| match = ctx.re.match(_FOOTER_RE, lines[i]) | |
| if match: | |
| ctx.emit.commit_message_finding( | |
| level = "error", | |
| commit = commit, | |
| line = i + 1, | |
| message = 'Footer "%s" must be in the last paragraph of the commit message to take effect.' % match.groups[1], | |
| ) | |
| def register_commit_msg_checks(): | |
| shac.register_check(shac.check(_check_commit_message, name = "commit_msg")) | |
| shac.register_check(_commit_msg_footers) |