diff --git a/.githooks/pre-commit b/.githooks/pre-commit index b7a3e7a2..38bca372 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -13,14 +13,109 @@ # Enable: git config core.hooksPath .githooks # Bypass once: git commit --no-verify (reconcile-on-read backfills it) # Opt out: delete CHANGELOG.md and record that decision in CLAUDE.md +# Self-test: .githooks/pre-commit --selftest # set -u +if [ "${1:-}" = "--selftest" ]; then + # Resolve $0 BEFORE anything cds: every probe runs inside a throwaway repo. + self=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") + t=$(mktemp -d); fails=0; n=0 + + # A throwaway repo holding exactly the shape the ledger gate exists to refuse: + # a tracked change staged, the ledger modified but NOT co-staged. `noledger` + # builds one that never tracked a CHANGELOG.md at all. + build() { + n=$((n + 1)); d=$t/r$n + mkdir -p "$d" + ( + cd "$d" || exit 1 + unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE + # --template= : a user's init.templateDir would otherwise install ITS + # hooks into every probe repo, and answer for the hook under test. + git init -q -b main --template= . + git config user.email selftest@example.invalid + git config user.name selftest + printf 'one\n' > tracked.txt + [ "${1:-}" = noledger ] || printf '# Changelog\n' > CHANGELOG.md + git add -A + git commit -q -m init + printf 'two\n' > tracked.txt + [ "${1:-}" = noledger ] || printf '# Changelog\n\n### entry\n' > CHANGELOG.md + git add tracked.txt + ) >/dev/null 2>&1 + # Prove the fixture, not just the guard: a probe run against a repo that + # failed to build answers about nothing, and would do it in green. + git -C "$d" rev-parse --verify -q HEAD >/dev/null 2>&1 \ + && [ -n "$(git -C "$d" diff --cached --name-only)" ] \ + || { echo " FAIL: fixture r$n did not build (no HEAD, or nothing staged)"; fails=$((fails + 1)); } + } + + expect() { # $1 label, $2 wanted exit -- $d is the repo build() just made + label=$1; want=$2 + ( cd "$d" && unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE && sh "$self" ) >/dev/null 2>&1 + got=$? + if [ "$got" -eq "$want" ]; then + echo " PASS: $label" + else + echo " FAIL: $label (exit $got, wanted $want)"; fails=$((fails + 1)) + fi + } + + # The gate itself, with no operation in progress. + build; expect "no markers, ledger not co-staged -> refused" 1 + build; ( cd "$d" && git add CHANGELOG.md >/dev/null 2>&1 ) + expect "ledger co-staged -> pass" 0 + build; ( cd "$d" && git reset -q >/dev/null 2>&1 ) + expect "empty index -> pass" 0 + build noledger; expect "no tracked ledger -> pass (never block a repo without one)" 0 + + # Git LEAVES REBASE_HEAD BEHIND after a rebase that stopped (a conflict, or -i + # at `edit`) completes -- measured on git 2.50.1 -- so a hook that reads it as + # "rebase in progress" disarms itself for the life of the clone. It is also the + # ONLY marker in the list that leaks: MERGE_HEAD, CHERRY_PICK_HEAD, rebase-merge + # and rebase-apply are all removed when their operation ends or is aborted. + build; ( cd "$d" && : > .git/REBASE_HEAD ) + expect "stale REBASE_HEAD -> STILL REFUSED" 1 + + # ...and the skip must still fire for every state that is genuinely in progress. + # REBASE_HEAD is redundant for this: a stopped rebase always carries a DIRECTORY + # too -- rebase-merge on the merge backend, rebase-apply on --apply and `git am`. + build; ( cd "$d" && mkdir -p .git/rebase-merge ) + expect "rebase-merge in progress -> skipped" 0 + build; ( cd "$d" && mkdir -p .git/rebase-apply ) + expect "rebase-apply in progress (rebase --apply, git am) -> skipped" 0 + build; ( cd "$d" && : > .git/MERGE_HEAD ) + expect "MERGE_HEAD in progress -> skipped" 0 + build; ( cd "$d" && : > .git/CHERRY_PICK_HEAD ) + expect "CHERRY_PICK_HEAD in progress -> skipped" 0 + build; ( cd "$d" && mkdir -p .git/rebase-merge && : > .git/REBASE_HEAD ) + expect "stopped rebase (REBASE_HEAD + rebase-merge) -> skipped" 0 + + rm -rf "$t" + [ "$fails" -eq 0 ] && echo "pre-commit selftest: OK (10 checks)" || { echo "pre-commit selftest: $fails FAILED"; exit 1; } + exit 0 +fi + git_dir=$(git rev-parse --git-dir) # Skip merge/rebase/cherry-pick/am: they replay commits already in the ledger's # history; forcing a fresh entry onto a replayed commit would double-log. -for marker in MERGE_HEAD REBASE_HEAD CHERRY_PICK_HEAD rebase-merge rebase-apply; do +# +# REBASE_HEAD IS DELIBERATELY ABSENT FROM THIS LIST. Git leaves it behind when a +# rebase that STOPPED -- a conflict, or `-i` parked at `edit` -- completes, so +# listing it let a single rebase disarm this hook for the life of the clone, +# taking the quality ratchet below it along: both gates read as green because +# neither ran. It is the only marker that leaks -- MERGE_HEAD, CHERRY_PICK_HEAD +# and both directories are removed when their operation ends or is aborted -- and +# it is redundant besides: a rebase in progress always carries a DIRECTORY too, +# rebase-merge on the merge backend, rebase-apply on --apply and `git am`, so +# dropping it costs no in-progress coverage. Measured by running every operation +# in this list to completion on git 2.50.1 and observing the markers at each +# stage; `--selftest` asserts both halves. A CLEAN rebase leaks nothing, which is +# why the trap only arms after a rebase that stopped. Use `-e`, not `-f`/`-d`: +# the list mixes files and directories. +for marker in MERGE_HEAD CHERRY_PICK_HEAD rebase-merge rebase-apply; do if [ -e "$git_dir/$marker" ]; then exit 0 fi diff --git a/.quality-gates.json b/.quality-gates.json index 4327e68d..909f1293 100644 --- a/.quality-gates.json +++ b/.quality-gates.json @@ -76,6 +76,13 @@ "threshold": 0, "command": ".githooks/commit-msg --selftest", "why": "exit code: the disclosure gate's own checks" + }, + { + "name": "pre-commit-selftest", + "direction": "max", + "threshold": 0, + "command": ".githooks/pre-commit --selftest", + "why": "exit code: the ledger gate's own checks. A hook fails OPEN, so nothing else reports when it stops running -- this one skipped every commit in a clone for as long as a stale REBASE_HEAD sat in the git dir, while every other gate stayed green." } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ecc181..6d7421dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,34 @@ Reverse-chronological, newest on top; prepend-only. Promote to `## YYYY-MM` sect --- +### 2026-09-20 · [ad hoc] The ledger gate stopped running after any stopped rebase — the marker git leaves behind, dropped + +- **Action:** `.githooks/pre-commit` skipped replayed commits by testing five markers under the git + dir. One of them, `REBASE_HEAD`, is **left behind by git** when a rebase that *stopped* — a + conflict, or `-i` parked at `edit` — runs to completion. From that moment the hook exited 0 on + every commit in that clone. The marker loop runs **before** both gates the hook chains, so the + casualty was not only the failure-mode-#27 ledger gate but `quality_ratchet.py --precommit`, + whose whole job is refusing a loosened threshold. Both then read green because neither ran. +- **The shape was chosen from a measurement, not from plausibility.** Every operation in the marker + list was run to completion on git 2.50.1 and its markers observed at each stage. Two facts decided + it: `REBASE_HEAD` is the **only** marker that survives its operation (the other four are removed + when the operation ends or is aborted, so none needs the same treatment), and it is **redundant** — + every rebase in progress carries `rebase-merge` or `rebase-apply` alongside it, so dropping it + costs no in-progress coverage. A third measured fact explains how it goes unnoticed: a **clean** + rebase leaks nothing, so only a stopped rebase arms the trap. +- **A hook is the one gate nothing else watches, and it fails open** — its failure mode is silence, + not red. So the fix ships with `.githooks/pre-commit --selftest` (10 checks, on the + `.githooks/commit-msg --selftest` precedent) and a `pre-commit-selftest` gate in + `.quality-gates.json`, modelled on the existing `commit-msg-selftest` entry. Adding a gate always + passes the ratchet; no threshold moved. +- **Evidence:** RED-first — the same ten checks against the unfixed marker list go red on exactly + one, green on the other nine, so the test isolates the defect rather than being vacuously red; + 0 red after the fix. The selftest builds each probe repo with `git init --template=`, so a user's + `init.templateDir` cannot install *its* hooks into the probe and answer for the hook under test, + and it asserts each fixture actually built (a probe against a repo that failed to build answers + about nothing, in green). `bin/tests.sh` unchanged at 139 passed / 0 failed; + `quality_ratchet.py --run` 11/11 with the new gate. + ### 2026-09-16 · [ad hoc] PR #82 merged — post-merge verification on main and the first tightening - **Action:** the operator merged [PR #82](https://github.com/KJ5HST/methodology/pull/82) (quality ratchet,