Files
dotfiles/git/hooks/pre-commit
T
David F GliddenandClaude Opus 5 ecee76b862 [FIX] pre-commit: the size check word-split on paths and skipped them entirely
for file in $(git diff --cached --name-only) is unquoted, so a staged path
containing whitespace split into tokens, every token failed the [ -f ] guard, and
the file was never measured. A 17MB "my big file.dat" passed the 5MB ceiling
without the check ever running — REVIEWED-105's class (a check that passes
because it could not run), in the guard rather than in a declared check.

Now null-delimited (-z / read -d ''), fed by process substitution rather than a
pipe so `exit 1` still refuses the commit from inside the loop body.

Controls, run before committing:
  space-in-name 17MB  -> REFUSED  (the fix; previously committed)
  plain 17MB          -> REFUSED  (unchanged)
  small file          -> COMMITTED (unchanged)
  staged deletion     -> COMMITTED, no crash (the [ -f ] guard is intact)
  newline+unicode name-> REFUSED  (impossible under the old loop)

Narrows nothing and widens nothing: it makes the check do what it already said.
The 5MB ceiling and the LFS advice line are UNTOUCHED — that is the policy
question in PENDING-163, and it is the steward's.

Also files PENDING-163 AMENDMENT 1 (joins, replaces nothing), raised by the jurist
reading the item against REVIEWED-100/105 and verified empirically here:

  - CONFIRMED: option (ii) does NOT widen permissions generally. git cat-file -s
    reads the staged blob: an LFS-tracked 17MB file stages at 133 bytes, a plain
    one stages at 17825792 and is still refused. The item's "widens what may be
    committed everywhere" is withdrawn as false. That error is why the fork went
    to the steward as a policy question at all.
  - ACCEPTED: .gitattributes already is the per-repo versioned declaration that
    option (iii) proposed to build. (iii) WITHDRAWN.
  - CONFIRMED, and worse than visible from outside: (iii) inverts REVIEWED-100's
    polarity, and the parser would refuse an exemption line as malformed.
  - The jurist's fourth point does NOT hold — line 46's [ -f "$file" ] guard is
    present, so staged deletions never reach wc -c. Flagged by them as inferred,
    and it was. But the class they predicted is real, at line 45, by a different
    mechanism. The inference was wrong; the instinct was not.

Recommendation changes from "(i) now, (iii) later" to "(ii)". Still the steward's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvZAKSf9aqratbqHbU9LK5
2026-08-26 17:49:55 +02:00

188 lines
9.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Global pre-commit hook
# Runs checks before allowing a commit
#
# Scans the staged DIFF (added lines only), not whole files, so that
# pre-existing content in modified files does not trigger false positives.
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo "Running pre-commit checks..."
# Collect added lines from the staged diff, once, for subsequent content checks.
# -U0: zero context lines (only the +/- lines)
# ^\+ : lines that start with + (added)
# ^\+\+\+ : excluded (these are file headers like "+++ b/foo.txt")
added_lines="$(git diff --cached -U0 | grep -E '^\+' | grep -vE '^\+\+\+' || true)"
# Check for debugging keywords or TODOs in ADDED lines only
if echo "$added_lines" | grep -qE "(console\.log|debugger|binding\.pry|TODO:|FIXME:|XXX:)"; then
echo -e "${YELLOW}Warning: Found debugging keywords or TODOs in added lines:${NC}"
echo "$added_lines" | grep -nE "(console\.log|debugger|binding\.pry|TODO:|FIXME:|XXX:)" || true
echo
# Probe by OPENING /dev/tty, not testing existence — on macOS /dev/tty always
# exists ([ -e ] passes) but open() fails with ENXIO when there is no controlling
# terminal (agent harnesses, CI), which used to kill the read and block the commit.
if ( : < /dev/tty ) 2>/dev/null; then
printf "Continue anyway? (y/n) "
read -n 1 -r REPLY < /dev/tty
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
else
echo -e "${YELLOW}(non-interactive; proceeding — rerun in a TTY to enforce)${NC}"
fi
fi
# Check for large files (>5MB) — operates on file size, not diff content
#
# -z / read -d '': the path list MUST be null-delimited. Unquoted $(git diff
# --cached --name-only) word-splits, so a staged path containing whitespace broke
# into tokens, every token failed the [ -f ] test below, and the file was skipped
# ENTIRELY — a 17 MB "my big file.dat" passed this check without it ever running.
# That is REVIEWED-105's class again (a check that passes because it could not
# run), and it is why the loop is fed by process substitution rather than a pipe:
# a pipe would put the body in a subshell where `exit 1` cannot refuse the commit.
# Measured 2026-08-26 (PENDING-163 AMENDMENT 1). Narrows nothing, widens nothing —
# it makes this check do what it already claimed to do.
while IFS= read -r -d '' file; do
if [ -f "$file" ]; then
size=$(wc -c < "$file")
if [ "$size" -gt 5242880 ]; then
echo -e "${RED}Error: $file is larger than 5MB${NC}"
echo "Consider using Git LFS for large files"
exit 1
fi
fi
done < <(git diff --cached --name-only -z)
# Check for secrets in ADDED lines only (basic check)
if echo "$added_lines" | grep -qE "(password|secret|token|api_key)[[:space:]]*=[[:space:]]*[\"'][^\"']+[\"']"; then
echo -e "${RED}Warning: Possible secrets detected in added lines:${NC}"
echo "$added_lines" | grep -nE "(password|secret|token|api_key)[[:space:]]*=[[:space:]]*[\"'][^\"']+[\"']" || true
echo "Please review your changes carefully."
echo
# Same open()-probe as above. Note the deliberate asymmetry: secrets BLOCK when
# non-interactive (fail closed); debug keywords proceed with a warning.
if ( : < /dev/tty ) 2>/dev/null; then
printf "Continue anyway? (y/n) "
read -n 1 -r REPLY < /dev/tty
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
else
echo -e "${RED}(non-interactive; blocking — rerun in a TTY to confirm or use --no-verify with explicit justification)${NC}"
exit 1
fi
fi
# ── Repo-declared checks ──────────────────────────────────────── (REVIEWED-100)
# This hook is global to every repo (core.hooksPath), so it must hold no repo
# knowledge. A repo opts in by declaring `.precommit-triggers` at its root:
#
# <git pathspec> [more pathspecs] | <command>
#
# If the staged diff touches a declared pathspec, the command runs and a non-zero
# exit refuses the commit. Path matching is delegated to git's own pathspec
# engine rather than reimplemented here.
#
# Deliberately dependency-free — no yq, no python. A global convention that needs
# a toolchain silently fails to travel to the next machine or repo, and a check
# that silently does not run is worse than no check, because its absence reads as
# a pass. That is why this departs from the YAML used by data python tools read.
# A DISARMED HOOK MUST NOT LOOK LIKE AN ARMED ONE (REVIEWED-105 / PENDING-123).
# Measured 2026-08-08: five distinct disarming faults — pathspec typo, missing '|',
# empty command, comments-only file, empty file — every one silent at exit 0, and
# indistinguishable both from each other and from the legitimate "docs-only commit"
# case. A malformed declaration therefore REFUSES (never skips), and when a triggers
# file exists but nothing matched, that fact is PRINTED rather than left to silence.
refuse_declaration() { # $1 line number, $2 fault, $3 the offending line
echo -e "${RED}Malformed .precommit-triggers — commit refused.${NC}"
echo " file: ${triggers_file}"
echo " line: $1"
echo " fault: $2"
echo " text: $3"
echo " expected: <git pathspec> [more pathspecs] | <command>"
echo " --no-verify bypasses this; it is a tripwire, not a boundary."
exit 1
}
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
triggers_file="${repo_root:-.}/.precommit-triggers"
if [ -n "$repo_root" ] && [ -f "$triggers_file" ]; then
declared=0; matched=0; lineno=0; declared_paths=""; rule_detail=""
# fd 3, so a check that reads stdin cannot swallow the rest of this file
while IFS= read -r rawline <&3 || [ -n "${rawline:-}" ]; do
lineno=$((lineno + 1))
trimmed="$(printf '%s' "$rawline" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
case "$trimmed" in ''|\#*) continue ;; esac
declared=$((declared + 1))
# ── (b) validate the declaration ────────────────────────────────────────
case "$rawline" in
*"|"*) ;;
*) refuse_declaration "$lineno" "no '|' separator between pathspec and command" "$trimmed" ;;
esac
# split exactly as `IFS='|' read paths cmd` did, so matched-rule output stays
# byte-identical to what REVIEWED-100's acceptance test proved
paths="${rawline%%|*}"
cmd="$(printf '%s' "${rawline#*|}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
paths_trimmed="$(printf '%s' "$paths" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
[ -n "$paths_trimmed" ] || refuse_declaration "$lineno" "pathspec is empty" "$trimmed"
[ -n "$cmd" ] || refuse_declaration "$lineno" "command is empty" "$trimmed"
declared_paths="${declared_paths}${declared_paths:+, }${paths_trimmed}"
# shellcheck disable=SC2086 — word splitting is intended: multiple pathspecs
if ! staged="$(git diff --cached --name-only -- $paths 2>/dev/null)"; then
refuse_declaration "$lineno" "git cannot resolve this pathspec" "$trimmed"
fi
if [ -z "$staged" ]; then
rule_detail="${rule_detail} line ${lineno}: [${paths_trimmed}] — declared, no staged match
"
continue
fi
matched=$((matched + 1))
echo -e "${YELLOW}Staged change touches [${paths# }] — running declared check:${NC} $cmd"
if ! ( cd "$repo_root" && eval "$cmd" ) < /dev/null; then
echo -e "${RED}Declared check FAILED — commit refused.${NC}"
echo " declared in: .precommit-triggers"
echo " command: $cmd"
echo " --no-verify bypasses this; it is a tripwire, not a boundary."
exit 1
fi
echo -e "${GREEN}Declared check passed.${NC}"
done 3< "$triggers_file"
# ── (e) speak in exactly the ambiguous case ─────────────────────────────────
# A rule ran: the lines above already said so — add nothing. No triggers file:
# this block never runs, so no other repo gains noise. Rules declared and none
# matched is the ONLY case a reader cannot otherwise distinguish from a broken
# hook, so it is the only case that gets a line.
if [ "$declared" -eq 0 ]; then
# A triggers file that declares nothing is a DISARMED state wearing an armed
# face: the file is present, so the repo looks opted-in, and every commit
# sails through. Refusing would block a legitimately-emptied file, so this
# reports rather than refuses — but it must not be silent.
echo -e "${YELLOW}.precommit-triggers exists but declares no rules — this repo is opted in and unguarded.${NC}"
elif [ "$matched" -eq 0 ]; then
echo -e "${YELLOW}${declared} rule(s) declared in .precommit-triggers, none matched staged paths (${declared_paths}).${NC}"
# A rule that has NEVER matched is honestly unknown, not passing and not
# failing — the hook holds no history and must not imply one. PRECOMMIT_VERBOSE
# prints the per-rule detail for the reader who wants to check a suspect pathspec.
if [ -n "${PRECOMMIT_VERBOSE:-}" ]; then
printf '%s' "$rule_detail"
fi
fi
fi
echo -e "${GREEN}Pre-commit checks passed!${NC}"