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>
100 lines
4.8 KiB
Bash
Executable File
100 lines
4.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# PreToolUse / Bash — deny `git commit` / `git push` when a .cs file this branch touches carries a
|
|
# UTF-8 BOM. `.editorconfig` sets charset=utf-8 (no BOM), and the #311 fix-as-you-touch gate
|
|
# ("Formatting (changed .cs conform to .editorconfig)") FAILS THE PR for any touched file that has one.
|
|
#
|
|
# Why a hook and not a note: the ~2500 legacy .cs files carry a BOM, so it becomes *your* problem the
|
|
# moment you touch one — and the usual ways of touching them re-add it silently. Python
|
|
# `io.open(..., encoding='utf-8-sig')` WRITES a BOM back; perl/sed round-trips preserve it. On
|
|
# 2026-07-17 this cost two separate sessions a red CI job on the same day (PR #405 x6 files;
|
|
# #70/PR #402 x19), and a memory describing the trap did not prevent either — the second session
|
|
# re-added a BOM an hour after writing that memory down. A check that runs is worth more than one you
|
|
# have to remember.
|
|
#
|
|
# Generated files are excluded: dotnet format skips *.Designer.cs and TvContextModelSnapshot.cs as
|
|
# generated code, and so does the CI verify, so `dotnet ef` may leave its BOM there.
|
|
#
|
|
# Fail-open by design: any parse/lookup trouble → allow (exit 0, no output). This gate must never be
|
|
# the reason a commit can't happen; CI is still the backstop.
|
|
set -uo pipefail
|
|
|
|
input=$(cat)
|
|
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
|
[ -n "$cmd" ] || exit 0
|
|
|
|
# Only gate real `git commit` / `git push` invocations (allowing global flags like `git -c x=y commit`).
|
|
# Matched in command position so the words inside a commit message or an echo never false-trip.
|
|
printf '%s' "$cmd" \
|
|
| grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+(commit|push)([[:space:]]|$)' \
|
|
|| exit 0
|
|
|
|
# Which tree does this act on? Commits here are typically `cd <worktree>` followed by git, and the
|
|
# harness resets the shell cwd between calls, so an in-command `cd` is the most reliable signal.
|
|
# Fall back to the payload cwd, then the project dir.
|
|
dir=$(printf '%s' "$cmd" \
|
|
| grep -oE '(^|[;&|(]|&&|\|\|)[[:space:]]*cd[[:space:]]+[^;&|)]+' \
|
|
| tail -1 | sed -E 's/.*cd[[:space:]]+//; s/[[:space:]]+$//' | tr -d "\"'" || true)
|
|
if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then
|
|
dir=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)
|
|
fi
|
|
if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then
|
|
dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
fi
|
|
|
|
root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
|
|
# Scoped to this repo — the .editorconfig rule it enforces is ours.
|
|
case "$root" in
|
|
*ersatztv*) ;;
|
|
*) exit 0 ;;
|
|
esac
|
|
|
|
# The touched set: what this branch changes vs origin/main, plus anything staged or dirty right now
|
|
# (a commit can introduce a BOM that isn't in the pushed diff yet).
|
|
base=$(git -C "$root" rev-parse --verify --quiet origin/main 2>/dev/null || true)
|
|
{
|
|
[ -n "$base" ] && git -C "$root" diff --name-only --diff-filter=ACM "$base"...HEAD -- '*.cs' 2>/dev/null
|
|
git -C "$root" diff --name-only --diff-filter=ACM --cached -- '*.cs' 2>/dev/null
|
|
git -C "$root" diff --name-only --diff-filter=ACM -- '*.cs' 2>/dev/null
|
|
} | sort -u > /tmp/.bom-guard-files.$$ 2>/dev/null || { rm -f /tmp/.bom-guard-files.$$; exit 0; }
|
|
|
|
bad=""
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
case "$f" in
|
|
*.Designer.cs|*TvContextModelSnapshot.cs) continue ;;
|
|
esac
|
|
p="$root/$f"
|
|
[ -f "$p" ] || continue
|
|
if [ "$(head -c3 "$p" 2>/dev/null | xxd -p 2>/dev/null)" = "efbbbf" ]; then
|
|
bad="${bad} ${f}"$'\n'
|
|
fi
|
|
done < /tmp/.bom-guard-files.$$
|
|
rm -f /tmp/.bom-guard-files.$$
|
|
|
|
[ -n "$bad" ] || exit 0
|
|
|
|
reason="Blocked: these .cs files carry a UTF-8 BOM, which .editorconfig forbids (charset=utf-8). The #311 Formatting CI job fails the PR for any file this branch touches that has one:
|
|
|
|
${bad}
|
|
Strip it, then re-run this command:
|
|
|
|
python3 - <<'EOF'
|
|
import subprocess
|
|
def g(*a): return subprocess.run(['git','diff','--name-only',*a,'--','*.cs'],
|
|
capture_output=True, text=True).stdout.split()
|
|
# same detection set as the guard: branch diff + staged + dirty (a brand-new staged
|
|
# file is exactly what fires the deny and is absent from origin/main...HEAD)
|
|
fs = set(g('origin/main...HEAD')) | set(g('--cached')) | set(g())
|
|
for f in sorted(fs):
|
|
try: b = open(f,'rb').read()
|
|
except OSError: continue
|
|
if b[:3] == b'\xef\xbb\xbf':
|
|
open(f,'wb').write(b[3:]); print('stripped', f)
|
|
EOF
|
|
|
|
Usual cause: an edit that rewrote a legacy file preserved its BOM — Python io.open(..., encoding='utf-8-sig') WRITES one back; sed/perl round-trips keep it. Touching a legacy file makes its inherited BOM yours to remove (docs/contributing.md; ersatztv#311). Generated *.Designer.cs / TvContextModelSnapshot.cs are exempt and not listed here."
|
|
|
|
jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
|
|
exit 0
|