[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
This commit is contained in:
David F Glidden
2026-08-08 13:47:18 +02:00
co-authored by Claude Opus 5
parent 08fe08eed5
commit c86b82512b
+38
View File
@@ -74,4 +74,42 @@ if echo "$added_lines" | grep -qE "(password|secret|token|api_key)[[:space:]]*=[
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}"