| #!/usr/bin/env fuchsia-vendored-python |
| # 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 Fuchsia Git commit messages for style guidelines. |
| |
| Based on //docs/contribute/commit-message-style-guide.md: |
| - First line summary length (recommended <= 50, warning > 65). |
| - Body lines wrapped at 72 characters (excluding URLs, footers, and code blocks). |
| """ |
| |
| import argparse |
| import json |
| import re |
| import subprocess |
| import sys |
| from typing import List, Literal, Optional, TypedDict |
| |
| SUBJECT_REC_MAX_LEN = 50 |
| SUBJECT_WARN_MAX_LEN = 65 |
| BODY_MAX_LEN = 72 |
| |
| URL_REGEX = re.compile(r"https?://[^\s]+") |
| # Footers that are exempt from line length checks |
| FOOTER_REGEX = re.compile( |
| r"^(Bug|Fixed|Test|Change-Id|Cq-Include-Trybots|Fuchsia-Auto-Submit|Multiply|Depends-on|Run-All-Tests|Exempt|No-Tree-Checks|No-Presubmit|No-Try|TAG|CONV)[:=]" |
| ) |
| # Reverts and relands take the original subject and prepend "Revert " or "Reland ". |
| # These are exempt from the length check to avoid manual rewrites of auto-generated subjects. |
| REVERT_RELAND_REGEX = re.compile(r"^(?:Revert(?:\^\d+)?|Reland) ") |
| |
| |
| class Finding(TypedDict, total=False): |
| level: Literal["warning", "error"] |
| message: str |
| filepath: str |
| line: int |
| |
| |
| class CommitInfo(TypedDict): |
| hash: str |
| message: str |
| |
| |
| def check_commit_message( |
| message: str, filepath: str = ".git/COMMIT_MSG" |
| ) -> List[Finding]: |
| findings: List[Finding] = [] |
| lines = message.splitlines() |
| if not lines: |
| return findings |
| |
| subject = lines[0] |
| |
| # Check subject line length |
| if len(subject) > SUBJECT_WARN_MAX_LEN and not REVERT_RELAND_REGEX.match( |
| subject |
| ): |
| findings.append( |
| { |
| "level": "warning", |
| "message": f"Subject line exceeds {SUBJECT_WARN_MAX_LEN} characters ({len(subject)} chars). Limit first line to {SUBJECT_REC_MAX_LEN}-{SUBJECT_WARN_MAX_LEN} characters.", |
| "filepath": filepath, |
| "line": 1, |
| } |
| ) |
| |
| # Check body line length (wrap at 72 chars) starting from line 3 (if body exists) |
| for i, line in enumerate(lines[2:], start=3): |
| if len(line) <= BODY_MAX_LEN: |
| continue |
| # Allow lines containing URLs |
| if URL_REGEX.search(line): |
| continue |
| # Allow standard footers (e.g. Change-Id, Bug, etc.) |
| if FOOTER_REGEX.match(line): |
| continue |
| # Allow indented code block / stack trace lines or quoted revert lines |
| if line.startswith((" ", "\t", ">")): |
| continue |
| findings.append( |
| { |
| "level": "warning", |
| "message": f"Line {i} exceeds {BODY_MAX_LEN} characters ({len(line)} chars). Wrap body text at {BODY_MAX_LEN} characters.", |
| "filepath": filepath, |
| "line": i, |
| } |
| ) |
| |
| return findings |
| |
| |
| def get_git_commit_messages( |
| rev: str, cwd: Optional[str] = None |
| ) -> List[CommitInfo]: |
| """Returns a list of commit messages for the specified git revision range.""" |
| cmd = ["git", "log", "--format=%H%x00%B%x00", rev] |
| try: |
| proc = subprocess.run( |
| cmd, |
| cwd=cwd, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| text=True, |
| check=True, |
| ) |
| except subprocess.CalledProcessError as e: |
| sys.stderr.write(f"Failed to read git commit message: {e.stderr}\n") |
| return [] |
| |
| output = proc.stdout |
| commits: List[CommitInfo] = [] |
| parts = output.split("\x00") |
| for i in range(0, len(parts) - 1, 2): |
| commits.append(CommitInfo(hash=parts[i].strip(), message=parts[i + 1])) |
| return commits |
| |
| |
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument( |
| "--message", help="Commit message string to check literally." |
| ) |
| parser.add_argument( |
| "--message-file", help="Path to file containing commit message." |
| ) |
| parser.add_argument( |
| "--rev", |
| default="-1", |
| help="Git revision or range to check (default: -1 for latest commit).", |
| ) |
| parser.add_argument( |
| "--json", |
| action="store_true", |
| help="Output findings formatted as JSON array for Shac.", |
| ) |
| parser.add_argument( |
| "--strict", |
| action="store_true", |
| help="Return non-zero exit code if any warnings or errors are found.", |
| ) |
| args = parser.parse_args() |
| |
| findings: List[Finding] = [] |
| |
| if args.message is not None: |
| findings.extend(check_commit_message(args.message)) |
| elif args.message_file is not None: |
| with open( |
| args.message_file, "r", encoding="utf-8", errors="replace" |
| ) as f: |
| findings.extend( |
| check_commit_message(f.read(), filepath=args.message_file) |
| ) |
| else: |
| commits = get_git_commit_messages(args.rev) |
| for commit in commits: |
| findings.extend(check_commit_message(commit["message"])) |
| |
| if args.json: |
| print(json.dumps(findings, indent=2)) |
| else: |
| for finding in findings: |
| level = finding["level"].upper() |
| msg = finding["message"] |
| line_info = ( |
| f" (line {finding['line']})" if "line" in finding else "" |
| ) |
| print(f"[{level}]{line_info}: {msg}") |
| |
| has_errors = any(f["level"] == "error" for f in findings) |
| has_warnings = any(f["level"] == "warning" for f in findings) |
| |
| if has_errors or (args.strict and has_warnings): |
| return 1 |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |