Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions .quality-gates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down