From e2501c533ddcb2a6641ab6b0cc5a36f0cd0f97d7 Mon Sep 17 00:00:00 2001 From: "R. Mark Sharp" Date: Sun, 20 Sep 2026 15:35:16 -0500 Subject: [PATCH] fix(hooks): drop the rebase marker git leaves behind, and gate the hook on its own selftest .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 exits 0 on every commit in that clone. The marker loop runs before both gates the hook chains, so the casualty is 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 rather than from plausibility: every operation in the list was run to completion on git 2.50.1 and its markers observed at each stage. REBASE_HEAD is the ONLY marker that survives its operation -- the other four are removed when the operation ends or is aborted -- and it is REDUNDANT, since a rebase in progress always carries rebase-merge or rebase-apply alongside it. Dropping it therefore costs no in-progress coverage. A clean rebase leaks nothing, which is why 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 a --selftest (10 checks, on the .githooks/commit-msg --selftest precedent) and a pre-commit-selftest gate modelled on the existing commit-msg-selftest entry. Adding a gate always passes the ratchet; no threshold moved and bin/tests.sh is unchanged. RED-first: the same ten checks against the unfixed marker list go red on exactly one and green on the other nine, so the test isolates the defect rather than being vacuously red; 0 red after. Each probe repo is built with `git init --template=` so a user's init.templateDir cannot install its own hooks into the probe and answer for the hook under test, and each fixture is asserted to have built -- a probe against a repo that failed to build answers about nothing, in green. Co-Authored-By: Claude Opus 5 --- .githooks/pre-commit | 97 +++++++++++++++++++++++++++++++++++++++++++- .quality-gates.json | 7 ++++ CHANGELOG.md | 28 +++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) 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,