diff --git a/git/hooks/pre-commit b/git/hooks/pre-commit index fb7bf0b..3760511 100755 --- a/git/hooks/pre-commit +++ b/git/hooks/pre-commit @@ -88,18 +88,59 @@ fi # a toolchain silently fails to travel to the next machine or repo, and a check # that silently does not run is worse than no check, because its absence reads as # a pass. That is why this departs from the YAML used by data python tools read. +# A DISARMED HOOK MUST NOT LOOK LIKE AN ARMED ONE (REVIEWED-105 / PENDING-123). +# Measured 2026-08-08: five distinct disarming faults — pathspec typo, missing '|', +# empty command, comments-only file, empty file — every one silent at exit 0, and +# indistinguishable both from each other and from the legitimate "docs-only commit" +# case. A malformed declaration therefore REFUSES (never skips), and when a triggers +# file exists but nothing matched, that fact is PRINTED rather than left to silence. +refuse_declaration() { # $1 line number, $2 fault, $3 the offending line + echo -e "${RED}Malformed .precommit-triggers — commit refused.${NC}" + echo " file: ${triggers_file}" + echo " line: $1" + echo " fault: $2" + echo " text: $3" + echo " expected: [more pathspecs] | " + echo " --no-verify bypasses this; it is a tripwire, not a boundary." + exit 1 +} + repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" triggers_file="${repo_root:-.}/.precommit-triggers" if [ -n "$repo_root" ] && [ -f "$triggers_file" ]; then + declared=0; matched=0; lineno=0; declared_paths=""; rule_detail="" # fd 3, so a check that reads stdin cannot swallow the rest of this file - while IFS='|' read -r paths cmd <&3 || [ -n "${paths:-}" ]; do - case "${paths%%[![:space:]]*}${paths#"${paths%%[![:space:]]*}"}" in \#*) continue ;; esac - cmd="$(printf '%s' "${cmd:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" - [ -n "$cmd" ] || continue + while IFS= read -r rawline <&3 || [ -n "${rawline:-}" ]; do + lineno=$((lineno + 1)) + trimmed="$(printf '%s' "$rawline" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + case "$trimmed" in ''|\#*) continue ;; esac + declared=$((declared + 1)) + + # ── (b) validate the declaration ──────────────────────────────────────── + case "$rawline" in + *"|"*) ;; + *) refuse_declaration "$lineno" "no '|' separator between pathspec and command" "$trimmed" ;; + esac + # split exactly as `IFS='|' read paths cmd` did, so matched-rule output stays + # byte-identical to what REVIEWED-100's acceptance test proved + paths="${rawline%%|*}" + cmd="$(printf '%s' "${rawline#*|}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + paths_trimmed="$(printf '%s' "$paths" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + [ -n "$paths_trimmed" ] || refuse_declaration "$lineno" "pathspec is empty" "$trimmed" + [ -n "$cmd" ] || refuse_declaration "$lineno" "command is empty" "$trimmed" + declared_paths="${declared_paths}${declared_paths:+, }${paths_trimmed}" + # shellcheck disable=SC2086 — word splitting is intended: multiple pathspecs - if [ -z "$(git diff --cached --name-only -- $paths 2>/dev/null)" ]; then + if ! staged="$(git diff --cached --name-only -- $paths 2>/dev/null)"; then + refuse_declaration "$lineno" "git cannot resolve this pathspec" "$trimmed" + fi + if [ -z "$staged" ]; then + rule_detail="${rule_detail} line ${lineno}: [${paths_trimmed}] — declared, no staged match +" continue fi + matched=$((matched + 1)) + echo -e "${YELLOW}Staged change touches [${paths# }] — running declared check:${NC} $cmd" if ! ( cd "$repo_root" && eval "$cmd" ) < /dev/null; then echo -e "${RED}Declared check FAILED — commit refused.${NC}" @@ -110,6 +151,27 @@ if [ -n "$repo_root" ] && [ -f "$triggers_file" ]; then fi echo -e "${GREEN}Declared check passed.${NC}" done 3< "$triggers_file" + + # ── (e) speak in exactly the ambiguous case ───────────────────────────────── + # A rule ran: the lines above already said so — add nothing. No triggers file: + # this block never runs, so no other repo gains noise. Rules declared and none + # matched is the ONLY case a reader cannot otherwise distinguish from a broken + # hook, so it is the only case that gets a line. + if [ "$declared" -eq 0 ]; then + # A triggers file that declares nothing is a DISARMED state wearing an armed + # face: the file is present, so the repo looks opted-in, and every commit + # sails through. Refusing would block a legitimately-emptied file, so this + # reports rather than refuses — but it must not be silent. + echo -e "${YELLOW}.precommit-triggers exists but declares no rules — this repo is opted in and unguarded.${NC}" + elif [ "$matched" -eq 0 ]; then + echo -e "${YELLOW}${declared} rule(s) declared in .precommit-triggers, none matched staged paths (${declared_paths}).${NC}" + # A rule that has NEVER matched is honestly unknown, not passing and not + # failing — the hook holds no history and must not imply one. PRECOMMIT_VERBOSE + # prints the per-rule detail for the reader who wants to check a suspect pathspec. + if [ -n "${PRECOMMIT_VERBOSE:-}" ]; then + printf '%s' "$rule_detail" + fi + fi fi echo -e "${GREEN}Pre-commit checks passed!${NC}"