#!/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 ` 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