diff --git a/scripts/autorun.py b/scripts/autorun.py index 9ee2cfac7..9632f4f61 100644 --- a/scripts/autorun.py +++ b/scripts/autorun.py @@ -43,6 +43,7 @@ import shlex import shutil import subprocess import sys +import tempfile import time from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass, field @@ -73,11 +74,6 @@ HOST_MISREAD_MARKERS = ("SystemError", "unknown opcode") VOLATILE_KEYS = frozenset({"generated_at", "generated", "imported_at"}) SUBJECT_TYPES = re.compile(r"^(feat|refactor|chore|docs|fix): [a-z0-9]") -FORBIDDEN_WORDS = re.compile( - r"\b(bug|regression|revert|dead code|broken|wrong|hack|workaround|" - r"comprehensive|implement)", - re.IGNORECASE, -) CI_RED = frozenset({"failure", "timed_out", "startup_failure"}) CI_GREEN = frozenset({"success", "skipped", "neutral"}) @@ -190,16 +186,32 @@ class Context: def check_subject(subject: str, body: str = "") -> list[str]: - """What the repository's commit-msg hook would refuse in this message.""" + """What the repository's commit-msg hook would refuse in this message. + + The hook holds the vocabulary rules, so they live in one place: a job + list is checked against it when it loads, not two hours into the job. + Without a hook (another clone) only the shape is checked. + """ problems = [] if len(subject) > 50: problems.append(f"subject is {len(subject)} characters, 50 at most") if not SUBJECT_TYPES.match(subject): problems.append("subject must read 'type: description'") - if FORBIDDEN_WORDS.search(f"{subject}\n{body}"): - problems.append("message uses a word the history does not carry") if re.search(r"^co-authored-by", body, re.IGNORECASE | re.MULTILINE): problems.append("no Co-Authored-By trailer") + hook = subprocess.run(["git", "rev-parse", "--git-path", "hooks/commit-msg"], + capture_output=True, text=True, cwd=REPO_ROOT, check=False).stdout.strip() + hook_path = (REPO_ROOT / hook) if hook else None + if hook_path and hook_path.is_file() and os.access(hook_path, os.X_OK): + with tempfile.NamedTemporaryFile("w", suffix=".msg", delete=False) as message: + message.write(f"{subject}\n\n{body}\n" if body else f"{subject}\n") + try: + refused = subprocess.run([str(hook_path), message.name], capture_output=True, + text=True, check=False) + finally: + os.unlink(message.name) + if refused.returncode: + problems.append((refused.stderr or refused.stdout).strip() or "refused by commit-msg") return problems diff --git a/tests/test_autorun.py b/tests/test_autorun.py index 72bd0687e..0edaf2388 100644 --- a/tests/test_autorun.py +++ b/tests/test_autorun.py @@ -17,6 +17,7 @@ import sys import tempfile import time import unittest +import unittest.mock from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -834,5 +835,29 @@ class BiosIntegrity(Scratch): return str(self.tmp / "database.json") +class SubjectsFollowTheRepositoryHook(unittest.TestCase): + """The vocabulary rules live in the commit-msg hook alone: a copy in the + runner put the very words the history refuses into a tracked file.""" + + def test_a_refusal_comes_from_the_hook(self): + with tempfile.TemporaryDirectory() as tmp: + hook = Path(tmp) / "commit-msg" + hook.write_text('#!/bin/sh\ngrep -q forbiddenword "$1" && echo refused && exit 1\nexit 0\n') + hook.chmod(0o755) + real = subprocess.run + def fake(argv, *args, **kwargs): + if argv[:3] == ["git", "rev-parse", "--git-path"]: + return subprocess.CompletedProcess(argv, 0, stdout=str(hook) + "\n", stderr="") + return real(argv, *args, **kwargs) + with unittest.mock.patch.object(ar.subprocess, "run", side_effect=fake): + self.assertEqual(ar.check_subject("chore: tidy the cache"), []) + self.assertEqual(ar.check_subject("chore: add forbiddenword"), ["refused"]) + + def test_the_shape_is_checked_without_a_hook(self): + problems = ar.check_subject("not a subject that follows the type rule at all, too long") + self.assertTrue(any("50 at most" in p for p in problems)) + self.assertTrue(any("type: description" in p for p in problems)) + + if __name__ == "__main__": unittest.main()