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
116 lines
5.1 KiB
Bash
Executable File
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}"
|