Files
dotfiles/git/hooks/pre-commit
T
David F GliddenandClaude Opus 5 c86b82512b [PROPOSAL] Global pre-commit hook: repo-declared checks (REVIEWED-100)
core.hooksPath makes this hook global to every repo, which is why it is tracked
and travels — and why it must hold no repo knowledge. A repo opts in by
declaring `.precommit-triggers` at its root: staged pathspecs on the left, a
command on the right. If the staged diff touches a declared pathspec the command
runs, and a non-zero exit refuses the commit.

Three decisions worth stating rather than leaving to be rediscovered:

Path matching is delegated to `git diff --cached --name-only -- <pathspec>`
rather than reimplemented, so declarations use the pathspec syntax the repo's
users already know and globs behave as they do everywhere else in git.

The declaration file is read on fd 3, so a declared check that reads stdin
cannot swallow the remainder of the rules.

It is dependency-free by design — no yq, no python. A global convention that
needs a toolchain silently fails to travel to the next machine, and a check that
silently does not run is worse than no check, because its absence reads as a
pass. This is a deliberate departure from the YAML used by data that python
tools consume.

Scope: this is a tripwire, not an enforcement boundary. --no-verify steps over
it, and the message says so. It is worth having because the failure mode it
addresses is forgetting, not evading.

First consumer: studium-engine, where a corpus edit invalidates engine fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A35wiD55yRHj5U1ECZAX4t
2026-08-08 13:47:18 +02:00

116 lines
5.1 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
for file in $(git diff --cached --name-only); 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
# 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.
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
# fd 3, so a check that reads stdin cannot swallow the rest of this file
while IFS='|' read -r paths cmd <&3 || [ -n "${paths:-}" ]; do
case "${paths%%[![:space:]]*}${paths#"${paths%%[![:space:]]*}"}" in \#*) continue ;; esac
cmd="$(printf '%s' "${cmd:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
[ -n "$cmd" ] || continue
# shellcheck disable=SC2086 — word splitting is intended: multiple pathspecs
if [ -z "$(git diff --cached --name-only -- $paths 2>/dev/null)" ]; then
continue
fi
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"
fi
echo -e "${GREEN}Pre-commit checks passed!${NC}"