diff options
Diffstat (limited to 'hooks/git-commit-confirm.py')
| -rwxr-xr-x | hooks/git-commit-confirm.py | 57 |
1 files changed, 57 insertions, 0 deletions
diff --git a/hooks/git-commit-confirm.py b/hooks/git-commit-confirm.py index 618ac20..cf01948 100755 --- a/hooks/git-commit-confirm.py +++ b/hooks/git-commit-confirm.py @@ -46,6 +46,7 @@ from _common import ( read_payload, read_referenced_file, respond_ask, + respond_deny, scan_attribution, ) @@ -53,6 +54,28 @@ from _common import ( MAX_FILES_SHOWN = 25 MAX_MESSAGE_LINES = 30 +# Recognized full-suite test runners across languages. +TEST_RUNNER_RE = re.compile( + r"\b(" + r"make\s+(?:test|check)" + r"|pytest" + r"|go\s+test" + r"|cargo\s+test" + r"|npm\s+(?:run\s+)?test" + r"|yarn\s+test" + r"|pnpm\s+(?:run\s+)?test" + r"|jest|vitest|bats|tox|rspec|phpunit|ctest" + r")\b" +) + +GIT_COMMIT_RE = re.compile(r"(?:^|[\s;&|()\n])git\s+(?:-[^\s]+\s+)*commit\b") + +BUNDLED_TEST_REASON = ( + "Blocked: a test run is bundled with `git commit` in one command, where a " + "red suite won't stop the commit. Run the full test suite as its own step, " + "read the result, and commit only on zero failures (see verification.md)." +) + UNPARSEABLE_MESSAGE = ( "(commit message not parseable from command line; " "will be edited interactively)" @@ -68,6 +91,12 @@ def main() -> int: if not is_git_commit(cmd): return 0 + # Hard gate: a test run bundled into the commit command is denied outright, + # because an ungated chain lets a red suite commit anyway. + if detect_bundled_test_run(cmd): + respond_deny(BUNDLED_TEST_REASON) + return 0 + message = extract_commit_message(cmd) staged = get_staged_files() stats = get_diff_stats() @@ -117,6 +146,34 @@ def collect_issues(message: str, staged: list[str], author: str) -> list[str]: return issues +def detect_bundled_test_run(cmd: str): + """Return a truthy reason if a `git commit` is chained after a test run in + one command via an ungated connector (a red suite wouldn't stop the commit). + + `&&` between the test run and the commit is the one safe connector — the + commit runs only on a green suite — and is allowed. Any other chaining (`;`, + `&`, `|`, `||`, newline, or a pipe that masks the suite's exit) is flagged. + The test runner is matched only in the command *prefix* before `git commit`, + so a runner name inside the commit message never trips the detector. + """ + if not cmd: + return None + commit = GIT_COMMIT_RE.search(cmd) + if not commit: + return None + prefix = cmd[: commit.start()] + runs = list(TEST_RUNNER_RE.finditer(prefix)) + if not runs: + return None + # Everything between the last test run and the commit. Strip the safe `&&` + # connectors; anything left that chains commands means the commit isn't + # gated on the suite's success. + segment = prefix[runs[-1].end():].replace("&&", "") + if re.search(r"[;|\n&]", segment): + return BUNDLED_TEST_REASON + return None + + def is_git_commit(cmd: str) -> bool: """True if the command invokes `git commit` (possibly with env/cd prefix).""" # Strip leading assignments and subshells; find a `git commit` word boundary |
