#!/usr/bin/env python3 """ Check commit messages against Penpot's commit guidelines. Validates commit messages using the rules defined in: - .github/workflows/commit-checker.yml (regex pattern) - CONTRIBUTING.md (formatting rules, subject length, DCO) By default, checks HEAD. Use --commit to specify a different commit. Usage: ./scripts/check-commit ./scripts/check-commit --commit HEAD~1 ./scripts/check-commit -c abc1234 """ import argparse import re import subprocess import sys # ── Emoji list ─────────────────────────────────────────────────────────────── # Combined from commit-checker.yml AND CONTRIBUTING.md VALID_EMOJIS = ( "lipstick|globe_with_meridians|wrench|books|" "arrow_up|arrow_down|zap|ambulance|construction|" "boom|fire|whale|bug|sparkles|paperclip|tada|" "recycle|rewind|construction_worker|rocket" ) # ── Regex from .github/workflows/commit-checker.yml ────────────────────────── # Matches: # 1) ":emoji: " # 2) "Merge|Revert|Reapply ... without trailing dot" COMMIT_PATTERN = re.compile( r"^((:(" + VALID_EMOJIS + r"):\s[A-Z].*[^.]))$" ) MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$") # ═══════════════════════════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════════════════════════ def run_git(args): """Run a git command and return (returncode, stdout, stderr).""" try: result = subprocess.run( ["git"] + args, capture_output=True, text=True, check=False, ) return result.returncode, result.stdout, result.stderr except FileNotFoundError: print("ERROR: git not found. Is it installed?", file=sys.stderr) sys.exit(1) def get_commit_message(commit_ref): """Return the full commit message for *commit_ref*.""" rc, out, err = run_git(["log", "--format=%B", "-n", "1", commit_ref]) if rc != 0: print(f"ERROR: could not read commit {commit_ref}: {err.strip()}", file=sys.stderr) sys.exit(1) if not out.strip(): print(f"ERROR: commit {commit_ref} has no message", file=sys.stderr) sys.exit(1) return out.rstrip("\n") # ═══════════════════════════════════════════════════════════════════════════════ # Validators # ═══════════════════════════════════════════════════════════════════════════════ def check_regex(message): """Check the commit message against the CI regex pattern.""" # Normalise: strip trailing newlines for single-line matching first_line = message.split("\n")[0] if MERGE_PATTERN.match(first_line): return True, None if COMMIT_PATTERN.match(first_line): return True, None return False, ( "Commit subject must match one of:\n" " :emoji: \n" " Merge|Revert|Reapply \n" f"Got: {first_line!r}" ) def check_subject_length(message): """Subject line must be ≤ 90 characters.""" first_line = message.split("\n")[0] if len(first_line) > 90: return False, ( f"Subject line exceeds 90 characters ({len(first_line)} chars):\n" f" {first_line}" ) return True, None def check_subject_no_trailing_dot(message): """Subject line must not end with a period ('.').""" first_line = message.split("\n")[0] if first_line.endswith("."): return False, ( "Subject line must not end with a period:\n" f" {first_line}" ) return True, None def check_subject_capitalized(message): """Subject must be capitalized, but only if it's a regular commit (not Merge/Revert/Reapply).""" first_line = message.split("\n")[0] # Skip check for Merge/Revert/Reapply commits if MERGE_PATTERN.match(first_line): return True, None # Strip emoji prefix before checking capitalization emoji_match = re.match(r"^:([a-z_]+):\s+(.*)", first_line) if emoji_match: rest = emoji_match.group(2) else: rest = first_line if rest and not rest[0].isupper(): return False, ( "Subject line must start with a capital letter " "(after the emoji prefix):\n" f" {first_line}" ) return True, None def check_body_blank_line(message): """If a body exists, there must be a blank line between subject and body.""" lines = message.split("\n") if len(lines) >= 3 and lines[1] != "": return False, ( "A blank line must separate the subject from the body." ) return True, None def check_signed_off_by(message): """Check for the DCO Signed-off-by line (required for code changes).""" if "Signed-off-by:" not in message: return False, ( "Missing 'Signed-off-by:' line in the commit footer.\n" " Add it with 'git commit -s' or append it manually:\n" " Signed-off-by: Your Real Name " ) return True, None # ═══════════════════════════════════════════════════════════════════════════════ def main(): parser = argparse.ArgumentParser( description="Check a commit message against Penpot commit guidelines." ) parser.add_argument( "-c", "--commit", default="HEAD", help="Commit to check (default: HEAD)", ) args = parser.parse_args() commit_ref = args.commit message = get_commit_message(commit_ref) print(f"Checking commit {commit_ref} ...\n") validators = [ ("Regex pattern", check_regex), ("Subject ≤ 90 chars", check_subject_length), ("No trailing period in subject", check_subject_no_trailing_dot), ("Subject capitalized", check_subject_capitalized), ("Blank line after subject", check_body_blank_line), ] all_ok = True for name, validator in validators: ok, error_msg = validator(message) status = "✓" if ok else "✗" print(f" [{status}] {name}") if not ok: all_ok = False print(f" {error_msg}", file=sys.stderr) print() if all_ok: print("All checks passed.") sys.exit(0) else: print("Some checks FAILED. See messages above.", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()