The message told people to use Git LFS. Wrong twice: LFS cannot satisfy this check
(it measures the working-tree file), and LFS was tried in this system and retired.
Message drafted by the jurist, with two corrections to my draft that I would have
shipped as written:
(a) My version asserted "git.skemantix.com serves no LFS endpoint" — a
PRESENT-TENSE substrate claim, inside a script global to 37 repos, that the
hook cannot verify, that is repo-dependent across those remotes, and that
nothing checks. PENDING-144's open class exactly. Now stated historically:
a dated fact about what was tried does not go stale.
(b) My version led the remedy with `git config --local core.hooksPath .githooks`.
That does not exempt large files — it stops the global hook running in that
repo AT ALL, taking every other check with it. Advertising it as the routine
response to a routine refusal is REVIEWED-105's failure mode returning:
someone runs the config line without copying the hook and now has an
unguarded repo that looks governed. The remedy now leads with copying
400c054's hook and adding the exemption there; the config line is last.
Controls re-run after the change: whitespace-named 6MB REFUSED (and printing the
new message), plain 6MB REFUSED, small file COMMITTED.
Disposition of PENDING-163's option (ii), recorded here because the ground matters
more than the verdict: 0677e8a gives two reasons for retiring LFS with different
lifespans. The endpoint reason is contingent — a repo pointing elsewhere changes
it. The merits reason ("git delta-compresses text natively") is not, and it is
now measured rather than quoted: eight commits of an append-only 4MB JSONL cost
6MB in plain git and 18MB under LFS, because LFS stores a whole opaque blob per
version and cannot delta. THREE TIMES WORSE, on precisely the corpus that started
this. (ii) therefore REJECTED on the merits, not deferred on the endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvZAKSf9aqratbqHbU9LK5
192 lines
9.8 KiB
Bash
Executable File
192 lines
9.8 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 5 MiB (5,242,880 bytes)${NC}"
|
|
echo "This ceiling measures the working-tree file. Git LFS does not exempt it,"
|
|
echo "and LFS was tried in this system and retired (chamber-library 0677e8a, 2026-06-05)."
|
|
echo "If this repo legitimately holds large files it needs its own hook:"
|
|
echo "copy .githooks from chamber-library (400c054), add the path exemption there,"
|
|
echo "then: git config --local core.hooksPath .githooks"
|
|
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}"
|