#!/usr/bin/env bash # test-push-guard.sh - Needle harness for push-guard.sh. # # Every check gets BOTH polarities: # NEEDLE a deliberately-broken fixture that MUST trip the guard. # CONTROL a clean fixture that MUST pass. # # The controls are not decoration. A guard that failed unconditionally would # satisfy every needle and look fully covered — which is the same class of defect # (an assertion satisfied by the null case) that push-guard.sh exists to prevent. # A needle set without controls cannot tell "working guard" from "broken guard". set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" GUARD="$SCRIPT_DIR/push-guard.sh" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$SCRIPT_DIR/.work}" PASS=0 FAIL=0 # Fresh repo + bare "remote" for each case, so no case can inherit another's state. new_repo() { local name="$1" local dir="$WORK_DIR/$name" rm -rf "$dir" mkdir -p "$dir" git init -q -b main "$dir/repo" git init -q --bare "$dir/remote.git" git -C "$dir/repo" remote add origin "$dir/remote.git" git -C "$dir/repo" config user.name "Needle Harness" git -C "$dir/repo" config user.email "needle@example.invalid" printf 'seed\n' > "$dir/repo/seed.txt" git -C "$dir/repo" add seed.txt git -C "$dir/repo" commit -q -m "seed" printf '%s' "$dir/repo" } # write_config # The guard reads .push-guard.json from the WORKING TREE at the repo root, so it # does not need to be staged — which also keeps the "N staged file(s)" counts in # the older controls unchanged. write_config() { printf '%s\n' "$2" > "$1/.push-guard.json" } # expect [--out ] -- # # --out asserts a substring of the guard's own output. It exists because exit 0 # alone cannot distinguish "checked the files and they were fine" from "matched # no files and had nothing to check". Two controls in an earlier revision of this # harness were passing vacuously for exactly that reason — the pathspec silently # matched nothing. A control that cannot fail is not a control. expect() { local kind="$1" want="$2" desc="$3"; shift 3 local need_out="" while (( $# )); do case "$1" in --out) need_out="$2"; shift 2 ;; --) shift; break ;; *) break ;; esac done local got=0 out out="$("$@" 2>&1)" || got=$? local why="" [[ "$got" == "$want" ]] || why="wanted exit $want, got $got" if [[ -z "$why" && -n "$need_out" && "$out" != *"$need_out"* ]]; then why="exit $got as expected, but output never said: $need_out" fi if [[ -z "$why" ]]; then printf ' PASS [%-7s] %s (exit %s)\n' "$kind" "$desc" "$got" PASS=$((PASS + 1)) else printf ' FAIL [%-7s] %s — %s\n' "$kind" "$desc" "$why" printf '%s\n' "$out" | sed 's/^/ | /' FAIL=$((FAIL + 1)) fi } rm -rf "$WORK_DIR" mkdir -p "$WORK_DIR" echo "=== (a) conflict markers / unmerged index ===" # NEEDLE: staged content carrying real conflict markers. R="$(new_repo a1)" cat > "$R/conflicted.txt" <<'EOF' intro line <<<<<<< HEAD ours ======= theirs >>>>>>> feature/x tail line EOF git -C "$R" add conflicted.txt expect NEEDLE 2 "staged conflict markers are refused" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # NEEDLE: a genuine unmerged index (merge in progress). R="$(new_repo a2)" git -C "$R" checkout -q -b other printf 'from-other\n' > "$R/clash.txt" git -C "$R" add clash.txt && git -C "$R" commit -q -m other git -C "$R" checkout -q main printf 'from-main\n' > "$R/clash.txt" git -C "$R" add clash.txt && git -C "$R" commit -q -m main git -C "$R" merge other -q >/dev/null 2>&1 || true expect NEEDLE 2 "unmerged index paths are refused" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # CONTROL: ordinary staged content passes. # The config is REQUIRED as of the decision gate: this fixture has no generated # JSON, so it records that fact with a reason. This is the migration cost of the # hard cutover, paid here first — these two controls were the only pre-existing # cases that reached the JSON stage without nominating a path, and they are # exactly the "repo that never wired it up" the gate now refuses. R="$(new_repo a3)" write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}' printf 'just some code\n' > "$R/clean.txt" git -C "$R" add clean.txt expect CONTROL 0 "clean staged content passes (1 file actually scanned)" \ --out "no conflict markers in 1 staged file(s)" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # CONTROL (false-positive guard): prose using ======= as an underline must NOT trip. # This is why the marker pattern deliberately ignores a bare '======='. R="$(new_repo a4)" write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}' cat > "$R/README.rst" <<'EOF' Section Title ============= Body text. Another Heading ======= EOF git -C "$R" add README.rst expect CONTROL 0 "prose using ======= underlines does not trip the guard" \ --out "no conflict markers in 1 staged file(s)" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # NEEDLE (fault injection): if the scan itself ERRORS, the guard must refuse # rather than report a clean result. git grep exits 1 for "no match" but >=2 for # a real failure; a blanket '|| true' would collapse the two. We inject the # failure with a PATH shim rather than trying to corrupt an index. R="$(new_repo a5)" SHIM="$WORK_DIR/a5/bin" mkdir -p "$SHIM" REAL_GIT="$(command -v git)" cat > "$SHIM/git" < "$R/thing.txt" git -C "$R" add thing.txt expect NEEDLE 2 "a conflict scan that ERRORS is refused, not reported clean" \ --out "did not run" -- \ bash -c "cd '$R' && export PATH=\"$SHIM:\$PATH\" && '$GUARD' check-staged" # NEEDLE: RENAME + MODIFY. Git reports a `git mv` plus a small edit as a single # 'R' entry, which --diff-filter=ACM does not match, making the file invisible to # every content check. A clean file is co-staged deliberately: without it the # file list is empty and the nothing-staged guard fires by ACCIDENT, which would # make this needle pass for the wrong reason and hide the real defect. R="$(new_repo a6)" mkdir -p "$R/data" seq 1 200 | sed 's/^/line /' > "$R/data/canon.txt" printf 'original\n' > "$R/other.txt" git -C "$R" add -A && git -C "$R" commit -q -m seed git -C "$R" mv data/canon.txt data/canon2.txt printf '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\n' >> "$R/data/canon2.txt" printf 'an ordinary innocent change\n' > "$R/other.txt" git -C "$R" add -A expect NEEDLE 2 "conflict markers in a RENAMED file are refused (rename+modify)" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # NEEDLE: a path marked binary in .gitattributes. `git grep -I` honours that # classification and skips the blob entirely while the summary still counts the # file as scanned — a clean pass AND a false count. Hence -a, not -I. R="$(new_repo a7)" printf 'notes.txt binary\n' > "$R/.gitattributes" printf 'x\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\ny\n' > "$R/notes.txt" git -C "$R" add -A expect NEEDLE 2 "conflict markers in a .gitattributes-binary file are refused" -- \ bash -c "cd '$R' && '$GUARD' check-staged" echo "=== (c) staged JSON parses ===" # NEEDLE: malformed JSON, the shape a broken generator emits. R="$(new_repo c1)" mkdir -p "$R/data" printf '{"a": 1,\n' > "$R/data/broken.json" git -C "$R" add data/broken.json expect NEEDLE 3 "staged unparseable JSON under --json-path is refused" -- \ bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'" # NEEDLE: conflict markers inside a generated JSON file — the 70-file incident. R="$(new_repo c2)" mkdir -p "$R/data" cat > "$R/data/canon.json" <<'EOF' { <<<<<<< HEAD "v": 1 ======= "v": 2 >>>>>>> main } EOF git -C "$R" add data/canon.json expect NEEDLE 2 "conflict markers in generated JSON are refused" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # CONTROL: valid JSON passes. R="$(new_repo c3)" mkdir -p "$R/data" printf '{"a": 1, "b": [2, 3]}\n' > "$R/data/good.json" git -C "$R" add data/good.json expect CONTROL 0 "valid staged JSON passes (and was actually parsed)" \ --out "1 staged .json file(s)" -- \ bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'" # CONTROL: an explicit exemption works (and is reported, never silent). R="$(new_repo c4)" mkdir -p "$R/fixtures" printf 'not json at all' > "$R/fixtures/malformed.json" git -C "$R" add fixtures/malformed.json expect CONTROL 0 "deliberate malformed fixture can be exempted (exemption announced)" \ --out "EXEMPTED by --allow-invalid-json" -- \ bash -c "cd '$R' && '$GUARD' check-staged --json-path 'fixtures/*' --allow-invalid-json 'fixtures/*'" # CONTROL (false-positive regression): JSONC is the common real-world case. # tsconfig.json legally carries comments and does NOT parse strictly. Measured on # a real repo: 17 of 119 tracked .json files are like this. An on-by-default # check would fire on all of them; scoping by --json-path is what prevents it. R="$(new_repo c5)" cat > "$R/tsconfig.json" <<'EOF' { // JSONC: comments are legal here and strict json.load() rejects them. "compilerOptions": { "strict": true } } EOF mkdir -p "$R/data" printf '{"generated": true}\n' > "$R/data/view.json" git -C "$R" add tsconfig.json data/view.json expect CONTROL 0 "JSONC outside --json-path does not trip the JSON check" \ --out "1 staged .json file(s)" -- \ bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'" # NEEDLE: the same tsconfig DOES fail if someone wrongly nominates it, proving # the check is genuinely running and the control above is not a vacuous pass. expect NEEDLE 3 "the same JSONC file fails when explicitly nominated" -- \ bash -c "cd '$R' && '$GUARD' check-staged --json-path 'tsconfig.json'" echo "=== (d) the JSON decision is MANDATORY (.push-guard.json) ===" # NEEDLE: the fail-open this gate closes. No --json-path, no config: the previous # release printed "JSON check NOT REQUESTED" and exited 0, leaving the repo # unprotected forever with nothing ever failing. R="$(new_repo d1)" printf '{"a": 1}\n' > "$R/thing.json" git -C "$R" add thing.json expect NEEDLE 6 "no --json-path and no config is REFUSED (the closed fail-open)" \ --out "NO JSON DECISION" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # NEEDLE: config nominates paths, and the check genuinely runs off it. R="$(new_repo d2)" write_config "$R" '{"json_paths": ["data/**/*.json"]}' mkdir -p "$R/data" printf '{"a": 1,\n' > "$R/data/broken.json" git -C "$R" add data/broken.json expect NEEDLE 3 "broken JSON is caught via config-supplied json_paths" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # NEEDLE: opt-out WITHOUT a reason. An unexplained opt-out is the absence again, # merely relocated into a file. R="$(new_repo d3)" write_config "$R" '{"json_check": "none"}' printf 'code\n' > "$R/x.txt" git -C "$R" add x.txt expect NEEDLE 6 "json_check:none without a reason is refused" \ --out "REQUIRES a non-empty" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # NEEDLE: malformed config must REFUSE, never fall back to "treat as absent". # That fallback would mean a typo silently disables the check the file enables. R="$(new_repo d4)" write_config "$R" '{"json_paths": ["data/**/*.json",' printf 'code\n' > "$R/x.txt" git -C "$R" add x.txt expect NEEDLE 6 "an unparseable config is refused, not treated as absent" \ --out "INVALID" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # NEEDLE: a config that parses but states NO decision. Distinct from malformed — # this one is valid JSON and still says nothing, which is the original defect # wearing a config file as a disguise. R="$(new_repo d5)" write_config "$R" '{}' printf 'code\n' > "$R/x.txt" git -C "$R" add x.txt expect NEEDLE 6 "a valid config that states no decision is refused" \ --out "states no decision" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # NEEDLE: a bogus json_check value must not be read as an opt-out. R="$(new_repo d6)" write_config "$R" '{"json_check": "off", "reason": "typo for none"}' printf 'code\n' > "$R/x.txt" git -C "$R" add x.txt expect NEEDLE 6 'json_check with an unrecognised value is refused' \ --out 'must be "none"' -- \ bash -c "cd '$R' && '$GUARD' check-staged" # CONTROL: config nominates paths, JSON is clean. # --out is REQUIRED. Exit 0 alone cannot distinguish "read the config, resolved # the paths, parsed the files" from "matched nothing and had nothing to do" — # the vacuous-control trap that already caught this harness once. R="$(new_repo d7)" write_config "$R" '{"json_paths": ["data/**/*.json"]}' mkdir -p "$R/data" printf '{"generated": true}\n' > "$R/data/view.json" git -C "$R" add data/view.json expect CONTROL 0 "config-supplied json_paths pass and are reported as parsed" \ --out "1 staged .json file(s)" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # CONTROL: a recorded opt-out passes AND the reason is echoed. Asserting on the # reason is the whole point — an opt-out nobody can see is what we just removed. R="$(new_repo d8)" write_config "$R" '{"json_check": "none", "reason": "no generated JSON in this repo"}' printf 'code\n' > "$R/x.txt" git -C "$R" add x.txt expect CONTROL 0 "a recorded opt-out passes and PRINTS its reason" \ --out "recorded reason: no generated JSON in this repo" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # CONTROL: config-supplied exemptions still work through the config path. R="$(new_repo d9)" write_config "$R" '{"json_paths": ["fixtures/*"], "allow_invalid_json": ["fixtures/*"]}' mkdir -p "$R/fixtures" printf 'not json at all' > "$R/fixtures/malformed.json" git -C "$R" add fixtures/malformed.json expect CONTROL 0 "config-supplied allow_invalid_json exempts (and announces it)" \ --out "EXEMPTED by --allow-invalid-json" -- \ bash -c "cd '$R' && '$GUARD' check-staged" # CONTROL: --json-path alone still satisfies the decision, with no config at all. # This is what keeps every pre-existing caller working: nominating a path IS an # explicit decision. Only saying nothing is refused. R="$(new_repo d10)" mkdir -p "$R/data" printf '{"a": 1}\n' > "$R/data/good.json" git -C "$R" add data/good.json expect CONTROL 0 "--json-path alone satisfies the decision with no config present" \ --out "1 staged .json file(s)" -- \ bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'" # NEEDLE: a CLI nomination must WIN over a config opt-out, loudly. Resolving a # contradiction toward more checking is the only safe direction, but doing it # silently would hide a genuine disagreement between caller and repo. R="$(new_repo d11)" write_config "$R" '{"json_check": "none", "reason": "claims no generated JSON"}' mkdir -p "$R/data" printf '{"a": 1,\n' > "$R/data/broken.json" git -C "$R" add data/broken.json expect NEEDLE 3 "--json-path overrides a config opt-out and still catches bad JSON" \ --out "honouring the nomination" -- \ bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'" echo "=== (z) the guard itself emits no stray output ===" # CONTROL: the guard must not print interpreter warnings. # # This case exists because a real one shipped: the config parser was an inline # `<<'PY'` heredoc inside a $( ) command substitution, so bash printed # warning: command substitution: 1 unterminated here-document # on EVERY run. Thirty needles and a clean shellcheck all missed it — the # warning went to stderr, and no assertion in this harness looked at output it # had not already been told to expect. It was found only by running the guard # against a real repository. # # The lesson is narrow and worth encoding: asserting on what you EXPECT to see # cannot detect what you never thought to look for. This asserts on the absence # of a whole output class instead. R="$(new_repo z1)" write_config "$R" '{"json_paths": ["data/**/*.json"]}' mkdir -p "$R/data" printf '{"a": 1}\n' > "$R/data/view.json" git -C "$R" add data/view.json z_out="$(cd "$R" && "$GUARD" check-staged 2>&1)" if grep -qiE 'warning:|unterminated|command substitution' <<<"$z_out"; then printf ' FAIL [%-7s] %s\n' "CONTROL" "guard emits interpreter warnings" printf '%s\n' "$z_out" | sed 's/^/ | /' FAIL=$((FAIL + 1)) else printf ' PASS [%-7s] %s\n' "CONTROL" "guard runs without emitting any interpreter warning" PASS=$((PASS + 1)) fi # NEEDLE for the case above: prove the detector can actually fire, otherwise it # is one more assertion that passes because it looked at nothing. if grep -qiE 'warning:|unterminated|command substitution' \ <<<"bash: warning: command substitution: 1 unterminated here-document"; then printf ' PASS [%-7s] %s\n' "NEEDLE" "the warning detector fires on a known warning string" PASS=$((PASS + 1)) else printf ' FAIL [%-7s] %s\n' "NEEDLE" "the warning detector is dead — it cannot fire" FAIL=$((FAIL + 1)) fi echo "=== null case: nothing staged ===" # NEEDLE: the index equals HEAD. Committing here produces the empty commit. R="$(new_repo n1)" expect NEEDLE 5 "empty index is refused before a commit happens" -- \ bash -c "cd '$R' && '$GUARD' check-staged" echo "=== (b) push actually happened ===" # CONTROL: a real push that moves the remote. R="$(new_repo b1)" printf 'work\n' > "$R/work.txt" git -C "$R" add work.txt && git -C "$R" commit -q -m "real work" expect CONTROL 0 "a push that moves the remote is confirmed" \ --out "PUSH CONFIRMED" -- \ bash -c "cd '$R' && '$GUARD' push --remote origin --branch main" # NEEDLE: THE ORIGINAL FALSE POSITIVE. Remote already equals local HEAD, so the # naive 'remote == local' assertion succeeds while nothing is transferred. R="$(new_repo b2)" printf 'work\n' > "$R/work.txt" git -C "$R" add work.txt && git -C "$R" commit -q -m "real work" git -C "$R" push -q origin HEAD:refs/heads/main expect NEEDLE 4 "second push transferring nothing is refused (remote did not move)" -- \ bash -c "cd '$R' && '$GUARD' push --remote origin --branch main" # NEEDLE: the commit step aborted, so HEAD never advanced. R="$(new_repo b3)" HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)" expect NEEDLE 5 "push after an aborted commit is refused (HEAD did not advance)" -- \ bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'" # NEEDLE: an empty commit carrying a real message. R="$(new_repo b4)" git -C "$R" commit -q --allow-empty -m "feat: important-sounding message" expect NEEDLE 5 "pushing an empty commit is refused" -- \ bash -c "cd '$R' && '$GUARD' push --remote origin --branch main" # CONTROL: --since-head is satisfied when a real commit was made. R="$(new_repo b5)" HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)" printf 'work\n' > "$R/work.txt" git -C "$R" add work.txt && git -C "$R" commit -q -m "real work" # --out is required here. Without it this control is VACUOUS: deleting the whole # --since-head validation block would still yield exit 0, because the empty-commit # and remote-moved checks independently succeed. It would prove nothing about the # code path it names. expect CONTROL 0 "--since-head passes when a real commit was created" \ --out "HEAD advanced" -- \ bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'" echo printf 'push-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL" (( FAIL == 0 )) || exit 1