Cold review (MERGEABLE-WITH-NITS) flagged that the printed fix snippet and the
docs by-hand check used `git diff origin/main...HEAD`, which excludes
staged-but-uncommitted files. A brand-new staged .cs is exactly what fires the
pre-commit deny yet is absent from that diff, so the copy-paste remedy would strip
nothing → deny again → loop. (The deny still names the file, so manual stripping
always worked; this just makes the printed remedy match the guard's own detection
set: branch diff ∪ staged ∪ dirty.)
Verified on a fresh fixture: a brand-new staged BOM file is denied, and the
widened snippet strips it.
Left as accepted (fail-open, Medium, CI backstops; shared with sibling hooks):
`git -C <path>` isn't parsed for the target tree (only `cd` is — matches the
`cd <wt> && git` usage here), and the heredoc-body false-deny vector that
bash-guard/worktree-guard also carry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #311 fix-as-you-touch rule is already enforced by a pre-commit hook and a
blocking CI job, and both missed the same mistake twice on 2026-07-17: PR #405
shipped 6 BOM'd files, PR #402 shipped 19. The gap is that the pre-commit hook is
routinely skipped — worktree hook friction makes `git commit --no-verify` the
normal path here — which leaves CI, a ~10-minute round trip, as the first thing
that tells you.
This guard fires on Bash before git runs, so --no-verify can't skip it. It denies
`git commit`/`git push` when a .cs the branch touches still carries a BOM, names
the files, and gives the strip command.
Why a hook rather than a note: a memory describing this exact trap did not prevent
either failure — the PR #402 session re-added a BOM an hour after writing that
memory down, because the usual ways of touching a legacy file re-add it silently
(Python io.open(..., encoding='utf-8-sig') WRITES a BOM back; sed/perl round-trips
keep it). A check that runs beats one you have to remember.
Follows the existing gate pattern: a fail-open script in .claude/hooks/ registered
on the PreToolUse/Bash matcher alongside bash-guard and worktree-guard. Any parse
or lookup trouble → allow; this must never be the reason a commit can't happen, and
CI remains the backstop. Generated *.Designer.cs / TvContextModelSnapshot.cs are
exempt, matching what dotnet format itself skips. Scoped to this repo, and it
resolves the target tree from an in-command `cd` because commits here run as
`cd <worktree> && git ...` and the harness resets the shell cwd between calls.
Verified against a throwaway fixture: denies commit and push for a BOM'd touched
file (naming it); allows a clean tree, a BOM in generated files only, `git status`
with a BOM present, and `echo 'run git push later'` (no false-trip on the words).
Proved live via a sentinel — it fires on the Bash matcher — sentinel removed.
docs/contributing.md updated: three enforcement layers, why the third exists, the
utf-8-sig re-add trap, a by-hand check, and the bash-not-zsh caveat for
`dotnet format --include` (mapfile is bash-only; under zsh the file list is empty
and the tool looks like it silently did nothing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>