#!/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: # # [more pathspecs] | # # 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: [more pathspecs] | " 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}"