fix(git): push-guard — resolve re-review blockers; mutation table now generated
ci/woodpecker/pr/ci Pipeline was successful

Authored by installer-7; committed by mos-claude (no credential for this remote).

BLOCKER 1 — verify-clean-clone.sh had B1's own defect: it copied WORKING-TREE files into a
  scratch repo and asserted the SCRATCH index, so a stale local exec bit was laundered in and it
  reported success while the committed artifact was still 100644. v2 reads mode from 'git ls-tree'
  of the SOURCE COMMIT and runs from 'git clone --no-local --no-hardlinks' of that commit; there is
  no 'cp' in the file. Proven by A/B against one laundered repo: v1 EXIT=0, v2 EXIT=1 (both observed,
  not asserted). New test-verify-clean-clone.sh 6/6, incl. a fixture that first proves it really is
  git=100644 disk=755 before testing it.
BLOCKER 2 — empty-merge needle + non-empty-merge control, both asserting on OUTPUT; the fixture
  first proves it IS a merge with a tree identical to both parents.

The 'non-blocking' README item was not documentation: regenerating its mutation table (now generated
by mutate-push-guard.sh, not hand-numbered) found TWO genuinely surviving mutants against a 46/46
green suite — staged-but-uncommitted opt-out honoured, and unparseable committed config ignored.
Both still exit 6 under mutation because control falls to a SIBLING refusal, so an exit-code-only
assertion would have been satisfied by the wrong branch. It also found a live instance of the
substring-anchor defect already in the suite: three branches print 'OPT-OUT IS NOT REVIEWABLE', so
deleting one let the needle RE-POINT to a sibling instead of failing. Re-anchored.
Suite 44 -> 46. Verifier 6/6. 13 mutants, 0 survived.

Also: README reformatted to satisfy 'pnpm format:check' (prettier), which was failing CI at both
prior heads — a gate neither author nor reviewer had exercised.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YKj59Qadrb2WBLaePvkM7H
This commit is contained in:
installer-7
2026-07-30 21:06:55 -05:00
co-authored by Claude Opus 5
parent b0fb208b89
commit 8310075d33
5 changed files with 497 additions and 107 deletions
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# mutate-push-guard.sh -- regenerate the README's mutation table from MEASUREMENT.
#
# WHY THIS EXISTS. The README carried a hand-written mutation table quoting
# "32/32" style results. Those numbers were true when typed and went stale in
# silence as the suite grew. A README is what a reader trusts when the tool
# misbehaves, so a confidently-wrong one is worse than none. Every number the
# README prints about mutation now comes out of this script.
#
# TWO WAYS A MUTATION RUN LIES, AND THE GUARD FOR EACH:
#
# 1. THE ANCHOR NO LONGER MATCHES. The mutant is never applied, the suite is
# green, and the report says SURVIVED -- which is the same word a real
# coverage gap gets. Guarded by grep-before-mutate (ANCHOR MISSING).
#
# 2. THE ANCHOR MATCHES PROSE. This one bit me on the first run: my
# "revert refuse-on-missing-config" mutant landed inside the usage()
# heredoc, edited a help string, changed no behaviour, and duly reported
# SURVIVED. I nearly recorded a documentation edit as an uncovered branch.
# A MUTATION THAT CANNOT CHANGE BEHAVIOUR IS NOT A SURVIVING MUTANT, IT IS
# A NON-MEASUREMENT -- and a non-measurement reported as a result is the
# same defect as the vacuous test it is supposed to be hunting.
# Guarded by prose_range(): anchors inside usage() are a loud refusal.
#
# 3. THE ANCHOR IS AMBIGUOUS. replace(...,1) silently picks the first match,
# which may not be the branch named in the label. Guarded by an exact
# occurrence count.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$HERE/push-guard.sh"
SUITE="$HERE/test-push-guard.sh"
BAK="$(mktemp)"; cp "$TARGET" "$BAK"
trap 'cp "$BAK" "$TARGET"; rm -f "$BAK"' EXIT
# --- where the prose lives: usage() { ... EOF ---------------------------------
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
exit 1
fi
printf 'prose (usage heredoc) is lines %s-%s -- anchors there are refused, not scored\n\n' \
"$PROSE_LO" "$PROSE_HI"
rc_all=0
KILLED=0; SURVIVED=0
declare -a ROWS=()
mutate() {
local name="$1" find="$2" repl="$3"
# Count and locate in python so MULTI-LINE anchors work. They are required:
# two unrelated branches in this file are both the single line
# `if (( status != 0 )); then`, and a one-line anchor cannot say which one a
# result belongs to. Guessing would attribute a kill to the wrong branch.
local loc; loc="$(python3 - "$BAK" "$find" <<'LOCPY'
import sys
s = open(sys.argv[1]).read(); find = sys.argv[2]
n = s.count(find)
print(n, (s[:s.index(find)].count("\n") + 1) if n else 0)
LOCPY
)"
local n="${loc%% *}" ln="${loc##* }"
if (( n == 0 )); then
printf ' !! ANCHOR MISSING %-46s NOT APPLIED -- result would be meaningless\n' "$name"
rc_all=1; return
fi
if (( n != 1 )); then
printf ' !! ANCHOR AMBIGUOUS %-46s %d matches -- refusing to guess which branch\n' "$name" "$n"
rc_all=1; return
fi
if (( ln >= PROSE_LO && ln <= PROSE_HI )); then
printf ' !! ANCHOR IS PROSE %-46s line %d is inside usage() -- not a branch\n' "$name" "$ln"
rc_all=1; return
fi
python3 - "$BAK" "$TARGET" "$find" "$repl" <<'PY'
import sys
src, dst, find, repl = sys.argv[1:5]
open(dst, "w").write(open(src).read().replace(find, repl, 1))
PY
local out line failed passed total
out="$("$SUITE" 2>&1)"
line="$(printf '%s\n' "$out" | grep -E 'needles: [0-9]+ passed' | tail -1)"
failed="$(printf '%s\n' "$line" | sed -n 's/.*, \([0-9]*\) failed.*/\1/p')"
passed="$(printf '%s\n' "$line" | sed -n 's/.*: \([0-9]*\) passed.*/\1/p')"
if [[ -z "$failed" || -z "$passed" ]]; then
printf ' !! NO TALLY %-46s suite produced no needle count\n' "$name"
rc_all=1; cp "$BAK" "$TARGET"; return
fi
total=$(( passed + failed ))
if (( failed > 0 )); then
printf ' KILLED L%-5s %-46s %s/%s fail\n' "$ln" "$name" "$failed" "$total"
ROWS+=("| \`$name\` (L$ln) | $failed/$total fail | killed |")
KILLED=$(( KILLED + 1 ))
else
printf ' SURVIVED L%-5s %-46s 0/%s fail <-- UNCOVERED BRANCH\n' "$ln" "$name" "$total"
ROWS+=("| \`$name\` (L$ln) | 0/$total fail | **SURVIVED** |")
SURVIVED=$(( SURVIVED + 1 )); rc_all=1
fi
cp "$BAK" "$TARGET"
}
echo "=== push-guard mutation run ==="
mutate "json decision requirement bypassed" \
' if (( ${#JSON_PATHS[@]} == 0 )); then' ' if false; then'
mutate "opt-out accepted with no written reason" \
'if not isinstance(reason, str) or not reason.strip():' 'if False:'
mutate "committed re-read of the opt-out skipped" \
' [[ "$CFG_MODE" == "none" ]] || return 0' ' return 0'
mutate "untracked config honoured as an opt-out" \
' if [[ -z "$rel" ]]; then' ' if false; then'
mutate "staged-but-uncommitted opt-out honoured" \
' if [[ -z "$cmode" ]]; then' ' if false; then'
mutate "committed SYMLINK config honoured" \
' if [[ "$cmode" == "120000" ]]; then' ' if false; then'
mutate "unparseable committed config ignored" \
' if (( cstatus != 0 )); then' ' if false; then'
mutate "local-only opt-out (HEAD says ON) honoured" \
' if [[ "$cmode_val" != "none" ]]; then' ' if false; then'
mutate "empty MERGE exempted" \
' if [[ "$all_same" == yes ]]; then' ' if false; then'
mutate "empty ROOT exempted" \
'if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then' \
'if false; then'
mutate "--since-head ancestry check removed" \
'if ! git merge-base --is-ancestor "$since_head" "$head"; then' 'if false; then'
mutate "staged-file enumeration ignores git failure" \
"$(printf 'if (( status != 0 )); then\n local msg')" \
"$(printf 'if false; then\n local msg')"
mutate "malformed config degrades to absent instead of refusing" \
"$(printf 'if (( status != 0 )); then\n fail "$EX_CONFIG"')" \
"$(printf 'if false; then\n fail "$EX_CONFIG"')"
BASE="$("$SUITE" 2>&1 | grep -E 'needles: [0-9]+ passed' | tail -1)"
printf '\nunmodified: %s\n' "$BASE"
printf '%d killed, %d survived\n' "$KILLED" "$SURVIVED"
printf '\n--- README TABLE (paste verbatim) ---\n'
printf '| mutation | suite result | verdict |\n|---|---|---|\n'
printf '| *unmodified* | %s | baseline |\n' \
"$(printf '%s' "$BASE" | sed 's/push-guard needles: //')"
printf '%s\n' "${ROWS[@]}"
exit "$rc_all"
@@ -4,27 +4,27 @@ Mechanical closure of one defect: **a verification that passes when the thing it
Three incidents in one evening, three agents, no coordination, same shape:
| # | Incident | Why the check passed |
|---|---|---|
| 1 | `PUSH VERIFIED` reported after nothing was pushed | Commit had aborted, so local HEAD trivially equalled the remote ref |
| 2 | An **empty commit** carrying another commit's message was pushed | A push was chained after a *failed* commit and ran on stale state |
| 3 | Conflict markers + invalid JSON committed into 70 generated files | Nothing asserted the generated output still parsed |
| # | Incident | Why the check passed |
| --- | ----------------------------------------------------------------- | ------------------------------------------------------------------- |
| 1 | `PUSH VERIFIED` reported after nothing was pushed | Commit had aborted, so local HEAD trivially equalled the remote ref |
| 2 | An **empty commit** carrying another commit's message was pushed | A push was chained after a _failed_ commit and ran on stale state |
| 3 | Conflict markers + invalid JSON committed into 70 generated files | Nothing asserted the generated output still parsed |
Each is an assertion satisfied by the null case. `push-guard.sh` asserts the **positive** fact instead: a new object exists, the remote *moved*, the content *parses*.
Each is an assertion satisfied by the null case. `push-guard.sh` asserts the **positive** fact instead: a new object exists, the remote _moved_, the content _parses_.
## Checks
| Sub-command | Asserts | Exit on failure |
|---|---|---|
| `check-staged` | index has no unmerged paths | `2` |
| | no conflict markers in staged content | `2` |
| | the conflict scan actually *completed* | `2` |
| | staged JSON under the nominated paths parses | `3` |
| | **something is actually staged** | `5` |
| | **an explicit JSON decision exists** | `6` |
| `push` | HEAD advanced past `--since-head` | `5` |
| | HEAD is a non-empty commit | `5` |
| | remote **moved**, and now equals local HEAD | `4` |
| Sub-command | Asserts | Exit on failure |
| -------------- | -------------------------------------------- | --------------- |
| `check-staged` | index has no unmerged paths | `2` |
| | no conflict markers in staged content | `2` |
| | the conflict scan actually _completed_ | `2` |
| | staged JSON under the nominated paths parses | `3` |
| | **something is actually staged** | `5` |
| | **an explicit JSON decision exists** | `6` |
| `push` | HEAD advanced past `--since-head` | `5` |
| | HEAD is a non-empty commit | `5` |
| | remote **moved**, and now equals local HEAD | `4` |
## Usage
@@ -36,7 +36,7 @@ git commit -m "..."
push-guard.sh push --remote origin --branch main --since-head "$BEFORE"
```
`--since-head` is what distinguishes "committed nothing" from "committed something", and capturing the remote ref *before* pushing is what makes `remote == local` mean anything. Both were missing from the guard that produced incident 1.
`--since-head` is what distinguishes "committed nothing" from "committed something", and capturing the remote ref _before_ pushing is what makes `remote == local` mean anything. Both were missing from the guard that produced incident 1.
## Design decisions that measurement forced
@@ -44,9 +44,9 @@ These are the interesting part; each one was wrong in the first draft.
**A bare `=======` is deliberately NOT treated as a conflict marker.** It collides with reStructuredText underlines and ASCII rules. Every genuine conflict git writes also contains `<<<<<<<` and `>>>>>>>`, so requiring those loses no real detection. Measured against a real repository: **zero** false positives across the entire tracked tree.
**The JSON check is opt-in by path, not on-by-default.** The first draft checked every staged `*.json`, on the reasoning that broader is strictly stronger. Measured against the same repository: **17 of 119** tracked `.json` files fail a strict parse, *all legitimately* — every `tsconfig*.json` is JSONC (comments are legal there) and the CA templates are Go templates that merely carry a `.json` suffix. An on-by-default check fires on ~14% of the repo's JSON, and a guard that cries wolf gets routed around until the bypass is habitual — at which point the bypass covers the true positives too. Broader was **weaker**.
**The JSON check is opt-in by path, not on-by-default.** The first draft checked every staged `*.json`, on the reasoning that broader is strictly stronger. Measured against the same repository: **17 of 119** tracked `.json` files fail a strict parse, _all legitimately_ — every `tsconfig*.json` is JSONC (comments are legal there) and the CA templates are Go templates that merely carry a `.json` suffix. An on-by-default check fires on ~14% of the repo's JSON, and a guard that cries wolf gets routed around until the bypass is habitual — at which point the bypass covers the true positives too. Broader was **weaker**.
**`--json-path` values are normalized to `:(glob)` magic.** Git's default pathspec matching makes `data/**/*.json` require at least one intermediate directory: it matches `data/sub/b.json` and *silently skips* `data/a.json`. The obvious spelling would have delivered partial coverage with no warning.
**`--json-path` values are normalized to `:(glob)` magic.** Git's default pathspec matching makes `data/**/*.json` require at least one intermediate directory: it matches `data/sub/b.json` and _silently skips_ `data/a.json`. The obvious spelling would have delivered partial coverage with no warning.
## Found by independent review, after the needles were already green
@@ -54,9 +54,9 @@ An adversarial review (author ≠ reviewer) found two genuine fail-opens that 17
**Renamed files were invisible to every content check.** Git detects renames by default (`diff.renames=true` since 2.9), so `git mv` plus a small edit is reported as a single `R` entry — which `--diff-filter=ACM` does not match. Reproduced at 97% similarity: the guard printed `staged content OK` and exited 0 with conflict markers in the staged blob. Fixed with `--no-renames`.
There is a trap inside this one worth recording. With *only* the renamed file staged, the guard exits 5 — the nothing-staged check fires because the file list came back empty. That looks like a catch and is pure accident; co-stage one ordinary file and the fail-open is total. The needle deliberately co-stages a clean file so it cannot pass for the wrong reason.
There is a trap inside this one worth recording. With _only_ the renamed file staged, the guard exits 5 — the nothing-staged check fires because the file list came back empty. That looks like a catch and is pure accident; co-stage one ordinary file and the fail-open is total. The needle deliberately co-stages a clean file so it cannot pass for the wrong reason.
**`-I` skipped files marked binary in `.gitattributes`,** while the summary still counted them as scanned — a clean pass *and* a false count. Fixed with `-a`.
**`-I` skipped files marked binary in `.gitattributes`,** while the summary still counted them as scanned — a clean pass _and_ a false count. Fixed with `-a`.
The review also correctly identified one **vacuous control**: the positive `--since-head` case asserted only exit 0, which the push produces anyway, so deleting the entire `--since-head` block left it passing. It now asserts on output.
@@ -64,7 +64,7 @@ Two reported findings did **not** reproduce and were not acted on: `:(glob)` doe
## The JSON decision is mandatory (`.push-guard.json`)
The first release announced `JSON check NOT REQUESTED` and continued. That was *visible* rather than silent, which is better — but it is still an **absence**, and an absence is not reviewable. Nobody reads a line that has printed correctly ten thousand times. A repo that needed the check and never wired it up stayed unprotected forever, and nothing ever failed.
The first release announced `JSON check NOT REQUESTED` and continued. That was _visible_ rather than silent, which is better — but it is still an **absence**, and an absence is not reviewable. Nobody reads a line that has printed correctly ten thousand times. A repo that needed the check and never wired it up stayed unprotected forever, and nothing ever failed.
The decision is now required, from one of exactly two places:
@@ -78,61 +78,87 @@ The decision is now required, from one of exactly two places:
`--json-path` on the command line also satisfies it. Saying nothing is refused (exit `6`).
**The asymmetry is deliberate.** Turning the check *on* is safe from anywhere, so a CLI flag suffices. Turning it *off* is confined to a committed file, because that is the only form a human can review: you can read a reason in a diff, and you cannot review the fact that nobody typed a flag. An opt-out living in an ad-hoc command line is the old fail-open with extra steps.
**The asymmetry is deliberate.** Turning the check _on_ is safe from anywhere, so a CLI flag suffices. Turning it _off_ is confined to a committed file, because that is the only form a human can review: you can read a reason in a diff, and you cannot review the fact that nobody typed a flag. An opt-out living in an ad-hoc command line is the old fail-open with extra steps.
A **malformed** config is refused outright rather than treated as absent — that fallback would mean a typo silently disables the check the file was written to enable. A config that parses but *states nothing* (`{}`) is refused too: valid JSON that says nothing is the original defect wearing a config file as a disguise.
A **malformed** config is refused outright rather than treated as absent — that fallback would mean a typo silently disables the check the file was written to enable. A config that parses but _states nothing_ (`{}`) is refused too: valid JSON that says nothing is the original defect wearing a config file as a disguise.
**Migration is a hard cutover, on purpose.** The tempting path is "warn for one release, then enforce" — but that warning phase *is* the degrade-to-a-warning this tool forbids, and it leaves the fail-open open for exactly as long as the warning is ignored, which is indefinitely. Consumers add a config or the guard refuses. It breaks loudly, once. Two pre-existing cases in this repo's own harness were the first to pay that cost, which is the correct place to feel it.
**Migration is a hard cutover, on purpose.** The tempting path is "warn for one release, then enforce" — but that warning phase _is_ the degrade-to-a-warning this tool forbids, and it leaves the fail-open open for exactly as long as the warning is ignored, which is indefinitely. Consumers add a config or the guard refuses. It breaks loudly, once. Two pre-existing cases in this repo's own harness were the first to pay that cost, which is the correct place to feel it.
## Known limitations — stated, not hidden
- `check-staged` inspects the index. Content added to the working tree *after* it runs is not covered — it belongs in a `pre-commit` hook, where the window is smallest.
- `check-staged` inspects the index. Content added to the working tree _after_ it runs is not covered — it belongs in a `pre-commit` hook, where the window is smallest.
- `push` hardcodes `HEAD:refs/heads/$branch`, so it cannot verify a commit built via plumbing on a different base. A `--sha` option would close this; it is deliberately not added without a needle.
## Test harness
`test-push-guard.sh`32 cases, each check in **both polarities**:
`test-push-guard.sh`46 cases, each check in **both polarities**:
- **NEEDLE** — a deliberately broken fixture that must trip the guard.
- **CONTROL** — a clean fixture that must pass.
Controls are not decoration. A guard that failed unconditionally would satisfy every needle and look fully covered. Several controls additionally assert on the guard's *output* (`--out`), because exit 0 cannot distinguish "checked the files and they were fine" from "matched no files and had nothing to check" — two controls in an earlier revision were passing vacuously for exactly that reason.
Controls are not decoration. A guard that failed unconditionally would satisfy every needle and look fully covered. Several controls additionally assert on the guard's _output_ (`--out`), because exit 0 cannot distinguish "checked the files and they were fine" from "matched no files and had nothing to check" — two controls in an earlier revision were passing vacuously for exactly that reason.
### Mutation results — the harness was itself tested by breaking the guard
| Mutant | Result |
|---|---|
| fail-open (every failure downgraded to exit 0) | 9/16 fail — exactly the needles |
| always-fail | 16/16 fail — controls catch it |
| revert `:(glob)` normalization | 3/16 fail — caught **only** by the `--out` assertions |
| drop the remote-did-not-move assertion | 1/16 fail — the incident-1 needle |
| restore blanket `\|\| true` on the scan | 1/17 fail — the fault-injection needle |
| revert `--no-renames` | 1/19 fail — the rename needle |
| revert `-a` to `-I` | 1/19 fail — the binary-attribute needle |
| delete the `--since-head` block | 2/19 fail — incl. the control that *was* vacuous |
| revert refuse-on-missing-config | 1/32 fail — the closed fail-open |
| malformed config degrades to "absent" | 4/32 fail — **all four by `--out` alone**, exit code was correct every time |
| drop the mandatory `reason` | 1/32 fail — the unexplained-opt-out needle |
| make the guard emit a stray warning | 1/32 fail — the stray-output control |
| unmodified | 32/32 pass |
**Everything in this section is regenerated by `./mutate-push-guard.sh`, not typed.** The previous version of this table was hand-maintained: it was true when written, went stale as the suite grew, and ended up asserting `unmodified | 32/32 pass` against a 46-case suite. A README is what a reader consults when the tool misbehaves, so a confidently-wrong one is worse than none. Re-run the script and paste; do not edit the numbers.
A guard nobody has watched fail is not a guard. The same applies to the harness: mutant C would have passed silently without the output assertions, so the assertions are load-bearing rather than ornamental.
| mutation | suite result | verdict |
| ---------------------------------------------------------------- | ------------------- | -------- |
| _unmodified_ | 46 passed, 0 failed | baseline |
| `json decision requirement bypassed` (L482) | 3/46 fail | killed |
| `opt-out accepted with no written reason` (L334) | 1/46 fail | killed |
| `committed re-read of the opt-out skipped` (L405) | 5/46 fail | killed |
| `untracked config honoured as an opt-out` (L409) | 1/46 fail | killed |
| `staged-but-uncommitted opt-out honoured` (L425) | 1/46 fail | killed |
| `committed SYMLINK config honoured` (L433) | 1/46 fail | killed |
| `unparseable committed config ignored` (L444) | 1/46 fail | killed |
| `local-only opt-out (HEAD says ON) honoured` (L459) | 1/46 fail | killed |
| `empty MERGE exempted` (L704) | 1/46 fail | killed |
| `empty ROOT exempted` (L715) | 1/46 fail | killed |
| `--since-head ancestry check removed` (L665) | 1/46 fail | killed |
| `staged-file enumeration ignores git failure` (L173) | 1/46 fail | killed |
| `malformed config degrades to absent instead of refusing` (L375) | 4/46 fail | killed |
13 mutants, 0 survived.
Earlier mutants, run at the suite size of the day and kept as history rather than as a live claim: fail-open (9/16), always-fail (16/16 — controls catch it), revert `:(glob)` normalization (3/16, caught **only** by the `--out` assertions), drop the remote-did-not-move assertion (1/16), restore blanket `|| true` on the scan (1/17), revert `--no-renames` (1/19), revert `-a` to `-I` (1/19), delete the `--since-head` block (2/19, incl. the control that _was_ vacuous), stray-warning emission (1/32).
A guard nobody has watched fail is not a guard. The same applies to the harness: the `:(glob)` mutant would have passed silently without the output assertions, so the assertions are load-bearing rather than ornamental.
### Two branches were uncovered, and writing this table is what found them
Building the generator turned up two mutants that survived a **46-case suite reporting 46/46 green**: `staged-but-uncommitted opt-out honoured` and `unparseable committed config ignored`. Neither branch had any case at all. Both are now needled, which is why they read _killed_ above.
Both survivors share a shape worth naming: the mutant still exits `6`, because control falls through to a _sibling_ refusal that rejects for a different reason. **An exit-code-only assertion would have been satisfied by the wrong branch.** The new cases anchor on the distinguishing clause instead.
The same audit found a live instance of that defect already in the suite. Three separate branches print the headline `OPT-OUT IS NOT REVIEWABLE` — untracked, staged-not-committed, and symlink — and the untracked needle was anchored on the shared headline. Delete the untracked branch and control reaches a sibling printing those same words, so **the needle would not fail; it would re-point.** A substring anchor does not fail when its subject is removed. It is now anchored on `is not tracked in git`.
### The generator refuses three ways a mutation run can lie
1. **The anchor no longer matches.** The mutant is never applied, the suite is green, and a naive report says _survived_ — the same word a real coverage gap gets. `ANCHOR MISSING` is a loud failure instead.
2. **The anchor matches prose.** This one landed on the first run: a mutant aimed at the refuse-on-missing-config branch matched inside the `usage()` heredoc, edited a help string, changed no behaviour, and duly reported _survived_. A documentation edit was one step from being recorded as an uncovered branch. **A mutation that cannot change behaviour is not a surviving mutant, it is a non-measurement** — and a non-measurement reported as a result is the same defect as the vacuous test the harness exists to hunt. Anchors resolving inside `usage()` are now refused, and the heredoc's bounds are located at runtime rather than hardcoded.
3. **The anchor is ambiguous.** Two unrelated branches here are both the single line `if (( status != 0 )); then`; a first-match replace would silently attribute the kill to whichever came first. Multi-line anchors are supported and a match count `!= 1` refuses rather than guesses.
**A blanket `|| true` on the scan was a fail-open in the guard's own error handling.** `git grep` exits `1` for "no match" but `>=2` for a real failure. `|| true` collapsed the two, so a malformed pathspec or unreadable index would have been reported as "ok, no conflict markers" — a clean pass from a scan that never ran. The status is now discriminated, and a PATH-shim fault-injection needle proves the refusal.
**A `<<'PY'` heredoc inside `$( )` made bash print a warning on every run — and 30 needles plus a clean linter all missed it.** The config parser started life as an inline heredoc inside a command substitution, so every invocation emitted `warning: command substitution: 1 unterminated here-document` to stderr. It executed correctly, every needle stayed green, and shellcheck reported nothing. The harness missed it because `expect` asserts *substrings it was told to look for* — and nobody tells you to look for output you did not know existed. It surfaced only when the guard was run against a real repository.
**A `<<'PY'` heredoc inside `$( )` made bash print a warning on every run — and 30 needles plus a clean linter all missed it.** The config parser started life as an inline heredoc inside a command substitution, so every invocation emitted `warning: command substitution: 1 unterminated here-document` to stderr. It executed correctly, every needle stayed green, and shellcheck reported nothing. The harness missed it because `expect` asserts _substrings it was told to look for_ — and nobody tells you to look for output you did not know existed. It surfaced only when the guard was run against a real repository.
The fix moves the parser to a top-level constant. The lesson is encoded as case `z1`, which asserts the **absence of a whole output class** rather than the presence of an expected string, and is paired with a needle proving the detector can fire. A tool built to refuse quiet failures was quietly polluting stderr for its entire existence.
**The `-E` flag on `git grep` is load-bearing and was caught by a needle, not by review.** `git grep` defaults to *basic* regex, in which `(`, `|` and `{7}` are literal characters. Without `-E` the patterns match nothing and the check reports a clean pass over a file full of conflict markers — the exact defect this tool exists to prevent, shipped inside the tool itself.
**The `-E` flag on `git grep` is load-bearing and was caught by a needle, not by review.** `git grep` defaults to _basic_ regex, in which `(`, `|` and `{7}` are literal characters. Without `-E` the patterns match nothing and the check reports a clean pass over a file full of conflict markers — the exact defect this tool exists to prevent, shipped inside the tool itself.
## Proposed framework path
```
framework/tools/git/push-guard.sh
framework/tools/git/test-push-guard.sh
framework/tools/git/push-guard.sh # the guard
framework/tools/git/test-push-guard.sh # 46 needles and controls
framework/tools/git/mutate-push-guard.sh # regenerates the mutation table above
framework/tools/git/verify-clean-clone.sh # proves the COMMITTED artifact runs
framework/tools/git/test-verify-clean-clone.sh # 6 needles for the verifier itself
```
Matches the existing `tools/git/test-*.sh` convention. Dependencies: bash 4.4+, git, python3 — `python3` is already an accepted dependency of `ci-queue-wait.sh`.
**All five must be committed mode `100755`.** They were once delivered `100644`, so a clone exited `126 Permission denied` for everyone who was not the author; `verify-clean-clone.sh` exists to make that unshippable and asserts the mode from `git ls-tree` of the source commit, never from the filesystem.
Operator-agnostic: no hostnames, credentials, remotes, or operator-specific paths. Clean under the framework-PR firewall.
@@ -508,8 +508,44 @@ R="$(new_repo e2)"
write_config_untracked "$R" '{"json_check": "none", "reason": "local unreviewed bypass"}'
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
git -C "$R" add -f data/bad.json
# The anchor is the DISTINGUISHING clause, not the shared headline. Three
# separate branches print "OPT-OUT IS NOT REVIEWABLE" — untracked, staged-not-
# committed, and symlink. Anchoring on the headline means this needle stays green
# if the untracked branch is deleted and control falls through to a SIBLING that
# prints the same words: a substring anchor does not fail when its subject is
# removed, IT RE-POINTS. Anchor on the clause only this branch can produce.
expect NEEDLE 6 "an UNTRACKED opt-out is refused as unreviewable" \
--out "OPT-OUT IS NOT REVIEWABLE" -- \
--out "is not tracked in git" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# STAGED BUT NOT COMMITTED. Found by mutation, not by review: `if [[ -z "$cmode" ]]`
# survived `if false` against a 44/44 green suite, because no case ever built this
# state. Note the mutant still exits 6 — control falls to the LOCAL-ONLY branch
# below and refuses for a different reason. An exit-code-only assertion here would
# be satisfied by the wrong branch, which is why --out carries the distinguishing
# clause. `git add` puts the file in the index, so ls-files matches while HEAD
# does not: staged review is not review.
R="$(new_repo e2b)"
write_config_untracked "$R" '{"json_check": "none", "reason": "staged, never committed"}'
git -C "$R" add .push-guard.json
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
git -C "$R" add -f data/bad.json
expect NEEDLE 6 "a STAGED-but-uncommitted opt-out is refused" \
--out "staged but not yet in HEAD" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# COMMITTED CONFIG DOES NOT PARSE while the working tree opts out cleanly. Also a
# mutation survivor. The working-tree config is valid, so the early config check
# passes and we reach the re-read; the committed object is what fails. Without
# this branch an opt-out could be honoured on the strength of a HEAD blob nobody
# can actually read a decision out of.
R="$(new_repo e2c)"
write_config "$R" '{ this is not json'
printf '%s\n' '{"json_check": "none", "reason": "valid here, broken in HEAD"}' > "$R/.push-guard.json"
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
git -C "$R" add -f data/bad.json
expect NEEDLE 6 "an UNPARSEABLE committed config cannot authorise an opt-out" \
--out "is INVALID" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# A committed ON silently flipped OFF in the working tree is the same bypass.
@@ -580,6 +616,44 @@ expect CONTROL 0 "a NON-empty root commit still pushes" \
--out "non-empty root commit" -- \
bash -c "cd '$R' && '$GUARD' push --remote origin --branch fresh2"
# THE MERGE BRANCH HAD NO NEEDLE AT ALL. It was defined, hand-verified, and
# shipped -- and deleting the refusal left the suite at 41/41 green. Defining
# semantics is not testing them, and an untested branch is indistinguishable
# from an absent one to everyone downstream.
# EMPTY MERGE: two branches that each change nothing, merged. The merge tree is
# then identical to EVERY parent -- it integrates nothing and introduces nothing.
R="$(new_repo e9)"
git -C "$R" checkout -q -b mx
git -C "$R" commit -q --allow-empty -m "x: no tree change"
git -C "$R" checkout -q -b my HEAD~1 2>/dev/null || git -C "$R" checkout -q -b my
git -C "$R" commit -q --allow-empty -m "y: no tree change"
git -C "$R" checkout -q mx
git -C "$R" merge -q --no-ff --no-edit my
# PROVE THE FIXTURE IS ACTUALLY AN EMPTY MERGE before asserting on it, or the
# needle passes for the wrong reason on a repo that never made a merge at all.
np="$(git -C "$R" rev-list --parents -n 1 HEAD | wc -w)"
if (( np > 2 )) && git -C "$R" diff --quiet HEAD^1 HEAD && git -C "$R" diff --quiet HEAD^2 HEAD; then
printf ' ok [%-14s] fixture is a merge (%d fields) with tree identical to both parents\n' "e9-fixture" "$np"; PASS=$(( PASS + 1 ))
else
printf ' FAIL [%-14s] fixture is not an empty merge -- needle would be vacuous\n' "e9-fixture"; FAIL=$(( FAIL + 1 ))
fi
expect NEEDLE 5 "an EMPTY MERGE is refused, not exempted" \
--out "EMPTY MERGE" -- \
bash -c "cd '$R' && '$GUARD' push --remote origin --branch mx"
# CONTROL: a merge that really integrates must still push. Both sides add a
# distinct file, so the merge tree differs from BOTH parents.
R="$(new_repo e10)"
git -C "$R" checkout -q -b nx
printf 'from x\n' > "$R/x.txt"; git -C "$R" add x.txt; git -C "$R" commit -q -m "x adds a file"
git -C "$R" checkout -q -b ny HEAD~1
printf 'from y\n' > "$R/y.txt"; git -C "$R" add y.txt; git -C "$R" commit -q -m "y adds a file"
git -C "$R" checkout -q nx
git -C "$R" merge -q --no-ff --no-edit ny
expect CONTROL 0 "a NON-empty merge commit still pushes" \
--out "non-empty merge commit" -- \
bash -c "cd '$R' && '$GUARD' push --remote origin --branch nx"
echo
printf 'push-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL"
(( FAIL == 0 )) || exit 1
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# test-verify-clean-clone.sh -- needles for the verifier itself.
#
# v1 of the verifier passed while the real repository recorded 100644, because it
# asserted a SCRATCH repo built by cp. There was no needle that could have caught
# that: every case ran against a tree whose modes came off my filesystem.
# So the load-bearing needle here is w2 -- SOURCE GIT MODE 100644, DISK MODE 755.
# That is the exact contaminated state, and v1 exits 0 on it while v2 must refuse.
# A verifier without this needle is the same shape as the defect it verifies.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFY="$HERE/verify-clean-clone.sh"
ARTIFACTS=(push-guard.sh test-push-guard.sh verify-clean-clone.sh mutate-push-guard.sh)
PASS=0; FAIL=0
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# Build a source repo whose COMMITTED modes are 100755 and whose disk modes are
# executable -- the honest state.
mkrepo() {
local d="$1"
mkdir -p "$d"
local f
for f in "${ARTIFACTS[@]}"; do
install -m 755 "$HERE/$f" "$d/$f"
done
git -C "$d" init -q .
git -C "$d" config user.email "[email protected]"
git -C "$d" config user.name "Verifier Needles"
git -C "$d" add -A
git -C "$d" commit -q -m "artifacts"
}
run_case() {
local name="$1" want_rc="$2" want_txt="$3" repo="$4"
local out rc
out="$("$VERIFY" --repo "$repo" 2>&1)"; rc=$?
if (( rc == want_rc )) && [[ "$out" == *"$want_txt"* ]]; then
printf ' ok [%-14s]\n' "$name"; PASS=$(( PASS + 1 ))
else
printf ' FAIL [%-14s] wanted rc=%d containing %q; got rc=%d\n%s\n' \
"$name" "$want_rc" "$want_txt" "$rc" "$out"; FAIL=$(( FAIL + 1 ))
fi
}
echo "== POSITIVE CONTROL: an honestly-committed artifact must PASS =="
# Without this, every case below could be passing because the verifier always
# refuses -- a wall, not a gate.
mkrepo "$TMP/good"
run_case w1-honest 0 "the COMMITTED artifact ran and passed" "$TMP/good"
echo
echo "== THE B1 NEEDLE: source git mode 100644, DISK MODE STILL 755 =="
# This is the reviewer's exact reproduction. v1 of the verifier exits 0 here.
mkrepo "$TMP/laundered"
git -C "$TMP/laundered" update-index --chmod=-x push-guard.sh test-push-guard.sh
git -C "$TMP/laundered" commit -q -m "drop exec bit in git only"
# PROVE THE FIXTURE IS THE CONTAMINATED STATE, not merely a broken repo: git must
# say 100644 while the filesystem still says executable. If the disk bit were
# gone too, the needle would be testing something easier than the real defect.
src_mode="$(git -C "$TMP/laundered" ls-tree HEAD -- push-guard.sh | awk '{print $1}')"
disk_mode="$(stat -c '%a' "$TMP/laundered/push-guard.sh")"
if [[ "$src_mode" == "100644" && "$disk_mode" == "755" ]]; then
printf ' ok [%-14s] fixture is git=%s disk=%s\n' "w2-fixture" "$src_mode" "$disk_mode"; PASS=$(( PASS + 1 ))
else
printf ' FAIL [%-14s] fixture wrong: git=%s disk=%s -- needle would not test the defect\n' \
"w2-fixture" "$src_mode" "$disk_mode"; FAIL=$(( FAIL + 1 ))
fi
run_case w2-laundered 1 "committed 100644, needs 100755" "$TMP/laundered"
echo
echo "== the artifact must be COMMITTED, or there is no mode to verify =="
mkrepo "$TMP/untracked"
git -C "$TMP/untracked" rm -q --cached push-guard.sh
git -C "$TMP/untracked" commit -q -m "untrack the guard"
run_case w3-untracked 1 "NOT TRACKED" "$TMP/untracked"
echo
echo "== a committed SYMLINK is a path, not the reviewed code =="
mkrepo "$TMP/symlink"
( cd "$TMP/symlink" && rm -f push-guard.sh && ln -s /dev/null push-guard.sh \
&& git add push-guard.sh && git commit -q -m "symlink the guard" )
run_case w4-symlink 1 "SYMLINK" "$TMP/symlink"
echo
echo "== not a repository at all: REFUSE, never pass =="
mkdir -p "$TMP/bare"
for f in "${ARTIFACTS[@]}"; do install -m 755 "$HERE/$f" "$TMP/bare/$f"; done
run_case w5-norepo 1 "not inside a git repository" "$TMP/bare"
echo
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
(( FAIL == 0 ))
@@ -1,75 +1,127 @@
#!/usr/bin/env bash
# verify-clean-clone.sh -- run the suite from a FRESH CLONE, never the working copy.
# verify-clean-clone.sh -- prove the COMMITTED artifact runs, from a clean clone.
#
# BLOCKER 1 EXISTED BECAUSE EVERY RUN HAPPENED WHERE THE EXEC BIT WAS ALREADY SET.
# Two people ran 32/32 on files whose local mode had been set by hand at creation
# time; the COMMITTED mode was 100644, so the delivered artifact returned 126
# Permission denied and aborted before printing a summary. The artifact had never
# run for anyone.
# ================== THIS FILE IS THE SECOND VERSION. THE FIRST HAD B1. ========
# B1 was: the delivered scripts were committed mode 100644, so the artifact
# returned 126 Permission denied for anyone who cloned it. Two of us ran 32/32
# because our local working copies had the exec bit set by hand at creation.
#
# "Run it somewhere real" was not enough. The missing word is CLEAN: run it from a
# checkout that carries only what the repository actually stores. Local state you
# did not commit is invisible to you precisely because it is yours.
# I wrote v1 of this file to prevent exactly that. V1 COPIED THE WORKING-TREE
# FILES INTO A SCRATCH REPOSITORY AND ASSERTED THE SCRATCH REPOSITORY'S INDEX.
# cp preserves the local exec bit, so `git add` in the scratch repo recorded
# 100755 NO MATTER WHAT THE REAL REPOSITORY STORED. Reviewer reproduction:
# git update-index --chmod=-x push-guard.sh test-push-guard.sh
# ./verify-clean-clone.sh -> EXIT 0, "CLEAN CLONE: suite ran and passed"
# while the real index read 100644 -- the precise contaminated state B1 was.
#
# This script commits the artifacts into a scratch repo, clones that repo, and
# runs the suite from the clone -- so the only modes in play are the ones git
# recorded. It also asserts the mode positively, so a future commit that drops the
# bit fails here instead of at the next reviewer.
# THE CONTROL FOR THE DEFECT INHERITED THE DEFECT, BECAUSE IT MEASURED A COPY
# INSTEAD OF THE SUBJECT. cp LAUNDERS MODE.
#
# Both of v1's mechanisms were right -- assert the INDEX not the disk, execute
# DIRECTLY not via `bash script`. They were applied to the wrong repository.
# So v2 changes what is measured, not how:
# * mode is read from the SOURCE repository's COMMITTED TREE (git ls-tree),
# which no local chmod can influence;
# * the tree under test is produced by CLONING THAT COMMIT, so every mode
# comes out of the object store rather than off my filesystem.
# There is no cp anywhere in this file, and that is deliberate.
#
# It also REFUSES rather than passes when the artifacts are untracked: an
# artifact that is not committed has no mode to verify, and a verifier that
# silently succeeds on nothing is the vacuous-absence failure all over again.
set -euo pipefail
readonly EX_FAIL=1
readonly EX_USAGE=64
REPO=""
REV="HEAD"
die() { printf 'usage error: %s\n' "$1" >&2; exit "$EX_USAGE"; }
while (( $# )); do
case "$1" in
--repo) REPO="${2:-}"; shift 2 ;;
--rev) REV="${2:-}"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[[ -n "$REPO" ]] || REPO="$HERE"
ARTIFACTS=(push-guard.sh test-push-guard.sh verify-clean-clone.sh mutate-push-guard.sh)
SUITE="test-push-guard.sh"
# --- the subject must be a real repository, or there is nothing to verify -----
if ! ROOT="$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null)"; then
printf 'REFUSING: %s is not inside a git repository.\n' "$REPO" >&2
printf 'This verifier checks the COMMITTED mode of the artifact. An uncommitted\n' >&2
printf 'artifact has no committed mode, and passing here would prove nothing.\n' >&2
exit "$EX_FAIL"
fi
if ! REV_SHA="$(git -C "$ROOT" rev-parse --verify --quiet "${REV}^{commit}")"; then
printf 'REFUSING: %s does not resolve to a commit in %s\n' "$REV" "$ROOT" >&2
exit "$EX_FAIL"
fi
printf '=== subject ===\n'
printf ' repo %s\n rev %s (%s)\n\n' "$ROOT" "$REV" "$REV_SHA"
# --- 1. MODE, FROM THE COMMITTED TREE OF THE SUBJECT -------------------------
# git ls-tree reports what the COMMIT records. Nothing on my filesystem can
# move this number -- which is the whole point, and what v1 got wrong.
printf '=== committed modes (git ls-tree %s) ===\n' "$REV"
rc=0
for f in "${ARTIFACTS[@]}"; do
entry="$(git -C "$ROOT" ls-tree "$REV_SHA" -- "$f")"
if [[ -z "$entry" ]]; then
printf ' FAIL %s is NOT TRACKED at %s -- nothing committed to verify\n' "$f" "$REV"
rc=1; continue
fi
mode="${entry%% *}"; rest="${entry#* }"; type="${rest%% *}"
if [[ "$type" != blob ]]; then
printf ' FAIL %s is a %s at %s, not a regular file\n' "$f" "$type" "$REV"
rc=1; continue
fi
case "$mode" in
100755) printf ' ok %s committed %s\n' "$f" "$mode" ;;
120000) printf ' FAIL %s is a SYMLINK (%s) -- the reviewed blob is a path, not the code\n' "$f" "$mode"; rc=1 ;;
*) printf ' FAIL %s committed %s, needs 100755\n' "$f" "$mode"
printf ' fix with: git update-index --chmod=+x %s && commit\n' "$f"
rc=1 ;;
esac
done
if (( rc != 0 )); then
printf '\nREFUSING TO CONTINUE: the COMMITTED modes are wrong, whatever the disk says.\n'
printf 'A clone of this commit would exit 126 for the next person.\n'
exit "$EX_FAIL"
fi
# --- 2. RUN FROM A CLONE OF THAT COMMIT --------------------------------------
# Cloning materialises every file from the object store, so the modes on disk
# are the COMMITTED modes by construction. No cp, no local state, nothing this
# machine can contribute.
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
ARTIFACTS=(push-guard.sh test-push-guard.sh)
printf '=== packaging from %s ===\n' "$HERE"
mkdir -p "$WORK/src"
for f in "${ARTIFACTS[@]}"; do
cp "$HERE/$f" "$WORK/src/$f"
done
[[ -f "$HERE/README.md" ]] && cp "$HERE/README.md" "$WORK/src/README.md"
cd "$WORK/src"
git init -q .
git config user.email "[email protected]"
git config user.name "Clean Clone"
git add -A
# THE ASSERTION THAT WOULD HAVE CAUGHT BLOCKER 1.
# Check the mode in the INDEX -- what git will actually store -- not the mode on
# disk, which is what misled us. These differ exactly when it matters.
rc=0
for f in "${ARTIFACTS[@]}"; do
mode="$(git ls-files -s -- "$f" | awk '{print $1}')"
if [[ "$mode" != "100755" ]]; then
printf ' FAIL %s is mode %s in the index (needs 100755)\n' "$f" "$mode"
printf ' fix with: git update-index --chmod=+x %s\n' "$f"
rc=1
else
printf ' ok %s is mode %s in the index\n' "$f" "$mode"
fi
done
(( rc == 0 )) || { printf '\nREFUSING TO CONTINUE: the committed modes are wrong.\n'; exit 1; }
git commit -q -m "artifacts under test"
printf '\n=== cloning (no local mode bits survive this) ===\n'
cd "$WORK"
git clone -q src clone
cd clone
printf '\n=== cloning %s (no local mode bits survive this) ===\n' "$REV_SHA"
git clone -q --no-local --no-hardlinks "$ROOT" "$WORK/clone"
git -C "$WORK/clone" -c advice.detachedHead=false checkout -q "$REV_SHA"
for f in "${ARTIFACTS[@]}"; do
printf ' clone mode: %s %s\n' "$(stat -c '%a' "$f")" "$f"
printf ' clone disk mode: %s %s\n' "$(stat -c '%a' "$WORK/clone/$f")" "$f"
done
printf '\n=== executing DIRECTLY (./script), not via `bash script` ===\n'
# Running `bash script` masks a missing exec bit completely -- it is how the
# original 32/32 was obtained. Direct execution is the thing under test.
if ! ./test-push-guard.sh; then
printf '\n=== executing DIRECTLY (./%s), not via "bash %s" ===\n' "$SUITE" "$SUITE"
# Invoking the interpreter explicitly masks a missing exec bit completely -- it
# is how the original green was obtained. Direct execution is the thing under
# test, so the mode has to be real for this line to succeed.
cd "$WORK/clone"
if ! "./$SUITE"; then
printf '\nCLEAN-CLONE RUN FAILED.\n'
exit 1
exit "$EX_FAIL"
fi
printf '\n=== CLEAN CLONE: suite ran and passed from a fresh checkout ===\n'
printf '\n=== CLEAN CLONE: the COMMITTED artifact ran and passed ===\n'