Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 30s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m3s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
Every hook under `.claude/hooks/` assigned `ETV_HOOK_FIRE_LIB` from `${CLAUDE_PROJECT_DIR:-<self>}`
and then `. `-SOURCED it. Sourcing is execution, so a file of that name in an env-designated tree ran
as code inside the hook before stdin was read and before it could decide anything. Measured on the
merge gate before #858 fixed that one hook: a decoy tree's copy printed an `allow` and exited 0.
Reachable without an attacker, because husky is a different launcher: `.husky/pre-push` invokes
`./.claude/hooks/…` relative to the PUSHED tree, independent of the variable, so a push from one
worktree while the environment names another sources the other tree's code into a gate.
Sweeps the remaining twelve hooks together (population derived from `git ls-files`), reconciles the
second resolution inside `scripts/hook-fire-log.sh` itself, and requires the root to OWN the sink
(`-ef`, not `-e`). The static guard pins the preamble BYTE-FOR-BYTE — a withdrawal, after a lexical
rule was defeated by five successive shapes.
Also pins two arms of the checker that were unsubsumed AND unpinned: the begin call's presence and
its missing stdout-mode token. `…_LOSES_its_instrumentation_…` looked like their proof and was not —
it asserts only that the fault list is non-empty, and a stripped hook trips four arms, so deleting
either left the suite green.
fixes #891
refs #858, #859, #776
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYNbVwgVszv6Pum7ZuGd75
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
51 lines
3.1 KiB
Bash
Executable File
51 lines
3.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# H13 (ersatztv#416 session) — refuse to push when a file in the pushed diff still has UNCOMMITTED
|
|
# changes in the working tree or index. That is the "I left part of my intended change behind"
|
|
# failure: a fix edited into the working file but never committed (e.g. after a `git reset --soft`
|
|
# that re-staged a stale index) gets pushed WITHOUT the fix — while local tests and a working-tree
|
|
# review both see the fix that never shipped. This bit the #416 session: a `--no-renames` review fix
|
|
# lived only in the working tree, so the pushed commit, CI, and the first re-review each saw a
|
|
# different tree, and a PR went out still carrying the bug the review had "confirmed" fixed.
|
|
#
|
|
# Scope is deliberately PRECISE to keep false positives near zero: it blocks only when a dirty
|
|
# tracked file is ALSO part of this branch's diff vs origin/main. Unrelated uncommitted scratch in a
|
|
# file the push doesn't touch is fine; untracked files are ignored.
|
|
#
|
|
# Fail-OPEN on anything we can't decide (a git pre-push hook has no "ask"): not a git repo, offline /
|
|
# no origin/main, HEAD unresolved -> allow. Deliberate escape: ETV_ALLOW_DIRTY_PUSH=1.
|
|
set -uo pipefail
|
|
|
|
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
|
# git hook: decides by exit code, and its stdout is live progress text.
|
|
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
|
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
|
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
|
etv_hook_fire_begin prepush-clean-worktree-check "" stream || true
|
|
|
|
[ "${ETV_ALLOW_DIRTY_PUSH:-}" = "1" ] && exit 0
|
|
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
|
|
# Files with uncommitted changes vs HEAD — unstaged AND staged-but-uncommitted, tracked only.
|
|
dirty="$( { git diff --name-only; git diff --cached --name-only; } 2>/dev/null | sort -u )"
|
|
[ -z "$dirty" ] && exit 0 # clean tree -> nothing to guard
|
|
|
|
# The set of files this branch introduces vs origin/main (the "pushed diff"). Best-effort fetch;
|
|
# if origin/main is unavailable we cannot scope precisely -> fail open rather than over-block.
|
|
git fetch origin main --quiet 2>/dev/null || exit 0
|
|
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
|
|
pushed="$( git diff --name-only "origin/main...HEAD" 2>/dev/null | sort -u )"
|
|
[ -z "$pushed" ] && exit 0
|
|
|
|
# Intersection: dirty files that are part of the pushed diff.
|
|
both="$( comm -12 <(printf '%s\n' "$dirty") <(printf '%s\n' "$pushed") )"
|
|
[ -z "$both" ] && exit 0
|
|
|
|
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
|
|
echo "husky - push blocked (H13): '$branch' has UNCOMMITTED changes to file(s) that are part of"
|
|
echo " what you're pushing — the pushed commit does NOT match your working tree, so a local fix"
|
|
echo " or review may be shipping without its change (the #416 index/worktree trap):"
|
|
printf '%s\n' "$both" | sed 's/^/ /'
|
|
echo " Commit them (or 'git checkout --' to discard), then push. If the difference is intentional"
|
|
echo " and unrelated, bypass with: ETV_ALLOW_DIRTY_PUSH=1 git push"
|
|
exit 1
|