feat(gitflow): hooks in every repo, no per-project step

Global: `make link` generates githooks/ from lib/gitflow.sh and sets git's
global core.hooksPath to ~/.claude/githooks, so every repo on the machine
runs the pre-commit protection and the post-commit / post-merge push, even
one that never ran gitflow init. A repo's own local core.hooksPath still
wins, so hooks/session-start.sh calls `gitflow reconcile-hooks` once per
session and rewrites a .githooks/ that lags the lib (LRN-114 automated);
the pre-commit exemption now covers .githooks/** next to .claude/**.

Per-repo opt-outs for a foreign clone: `git config gitflow.protect false`
(branch model) and `git config gitflow.autopush false` (push). Both, and
the GIT_CONFIG_GLOBAL= / GIT_CONFIG= env bypass, are static deny rules.

`make test` and the two suites that commit on main export
GIT_CONFIG_GLOBAL=/dev/null so the machine's global hooks never fire in
throwaway repos. doctor gains "Git hooks" (global setting, githooks/ equal
to the emitters) and "Scratchpad" (warn when TMPDIR sits on a tmpfs with
usrquota: systemd caps each user at 80% of it, which killed two shells
today, BLK-021). Tests: T18h, T19d, T20 (reconcile), T21 (whitelist and
protect opt-out); this repo's own stale .githooks/ refreshed.
This commit is contained in:
bastien
2026-09-22 16:34:27 +02:00
parent e600394acc
commit f608d34c3e
18 changed files with 324 additions and 33 deletions
+38
View File
@@ -314,6 +314,10 @@ echo w>w; git add w; git commit -q -m w 2>/dev/null
chk "T18b commit pushed by post-commit" '[ "$(git rev-parse HEAD)" = "$(git -C "$bare" rev-parse feature/ap)" ]'
echo w2>>w; git add w; GITFLOW_NO_PUSH=1 git commit -q -m w2 2>/dev/null
chk "T18c GITFLOW_NO_PUSH=1 → not pushed" '[ "$(git rev-parse HEAD)" != "$(git -C "$bare" rev-parse feature/ap)" ]'
git config gitflow.autopush false
echo w2b>>w; git add w; git commit -q -m w2b 2>/dev/null
chk "T18h gitflow.autopush=false → not pushed" '[ "$(git rev-parse HEAD)" != "$(git -C "$bare" rev-parse feature/ap)" ]'
git config --unset gitflow.autopush
git remote set-url origin /nonexistent/x.git
echo w3>>w; git add w
# shellcheck disable=SC2034 # ap_out/ap_rc are read by the deferred chk evals
@@ -337,6 +341,40 @@ if [ -d "$HERE/../.githooks" ]; then
else
ok "T19 skipped (no .githooks next to the lib)"
fi
if [ -d "$HERE/../githooks" ]; then
for h in pre-commit post-commit post-merge; do
chk "T19d global githooks/$h == emitted" "diff -q <(_gitflow_emit_hook $h) \"$HERE/../githooks/$h\" >/dev/null"
done
else
ok "T19d skipped (no githooks/ next to the lib — run make link)"
fi
echo "T20 — reconcile-hooks: a stale .githooks/ is refreshed, a current one is left alone"
newrepo rec; echo a>a; hookon; gitflow_init >/dev/null 2>&1
rm -f .githooks/post-commit; echo "# stale" >> .githooks/pre-commit
# shellcheck disable=SC2034
rec_out="$(gitflow_reconcile_hooks 2>/dev/null)"
chk "T20a names the refreshed hooks" 'printf "%s" "$rec_out" | grep -q "pre-commit" && printf "%s" "$rec_out" | grep -q "post-commit"'
chk "T20b pre-commit rewritten == emitted" 'diff -q <(_gitflow_emit_pre_commit) .githooks/pre-commit >/dev/null'
chk "T20c post-commit restored" '[ -x .githooks/post-commit ]'
chk "T20d second run is silent" '[ -z "$(gitflow_reconcile_hooks 2>/dev/null)" ]'
mkdir -p sub; cd sub || exit 1; echo "# stale" >> ../.githooks/post-merge
chk "T20e works from a subdirectory" 'gitflow_reconcile_hooks 2>/dev/null | grep -q post-merge'
cd .. || exit 1
newrepo plain; echo a>a; git add a; git commit -q -m a
chk "T20f non-gitflow repo → silent, no .githooks created" '[ -z "$(gitflow_reconcile_hooks 2>/dev/null)" ] && [ ! -d .githooks ]'
echo "T21 — pre-commit whitelist + per-repo protect opt-out"
newrepo wl; echo a>a; hookon; gitflow_init >/dev/null 2>&1
git checkout -q develop
echo "# tweak" >> .githooks/post-merge; git add .githooks/post-merge
chk "T21a .githooks/-only commit on develop → allowed" '.githooks/pre-commit 2>/dev/null'
echo code>code.txt; git add code.txt
chk "T21b .githooks/ + code on develop → blocked" '! .githooks/pre-commit 2>/dev/null'
git config gitflow.protect false
chk "T21c gitflow.protect=false → allowed" '.githooks/pre-commit 2>/dev/null'
git config --unset gitflow.protect
git restore --staged code.txt .githooks/post-merge 2>/dev/null || true
echo
echo "==== RESULT: $PASS passed, $FAIL failed ===="
+61 -11
View File
@@ -306,19 +306,24 @@ else
echo "gitflow pre-commit: gitleaks not installed — secret scan skipped (https://github.com/gitleaks/gitleaks)." >&2
fi
# Per-repo opt-out of the branch model (a clone of a foreign project):
# git config gitflow.protect false
[ "\$(git config --bool --default true gitflow.protect)" = false ] && exit 0
case "\$br" in
$GITFLOW_MAIN|$GITFLOW_DEVELOP) ;; # protected — keep checking
*) exit 0 ;; # working branch — allow
esac
# whitelist: all-staged-under-.claude/ (memory/doc/deploy helpers) — allow
if [ -z "\$(git diff --cached --name-only | grep -v '^\.claude/' | head -1)" ]; then
# whitelist: all-staged-under-.claude/ (memory/doc/deploy helpers) or
# .githooks/ (the hooks themselves, refreshed by the lib) — allow
if [ -z "\$(git diff --cached --name-only | grep -vE '^\.(claude|githooks)/' | head -1)" ]; then
exit 0
fi
echo "gitflow pre-commit: BLOCKED — direct commit on '\$br'." >&2
echo " Branch from the right base (feature/bugfix->develop, hotfix->main), or merge." >&2
echo " (.claude/** memory commits are exempt; --no-verify bypasses locally.)" >&2
echo " (.claude/** and .githooks/** commits are exempt; foreign clone? git config gitflow.protect false)" >&2
exit 1
HOOK
}
@@ -335,6 +340,8 @@ cat <<'HOOK'
# holds. Never fails the commit: no origin / offline / refused → warning only.
# Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests).
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0
# Per-repo opt-out (no push rights on a foreign clone): git config gitflow.autopush false
[ "$(git config --bool --default true gitflow.autopush)" = false ] && exit 0
git remote get-url origin >/dev/null 2>&1 || exit 0
br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0 # detached HEAD — nothing to track
if command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi
@@ -345,9 +352,18 @@ exit 0
HOOK
}
# write the versioned hook files — does NOT activate (see gitflow_activate_hook).
_gitflow_emit_hook() { # <pre-commit|post-commit|post-merge>
case "$1" in
pre-commit) _gitflow_emit_pre_commit ;;
post-commit|post-merge) _gitflow_emit_push_hook "$1" ;;
*) return 2 ;;
esac
}
# write the versioned hook files into $1 (default .githooks) — does NOT
# activate (see gitflow_activate_hook / gitflow_global_hooks).
_gitflow_write_hook() {
local hd=".githooks"
local hd="${1:-.githooks}"
mkdir -p "$hd"
_gitflow_emit_pre_commit > "$hd/pre-commit"
_gitflow_emit_push_hook post-commit > "$hd/post-commit"
@@ -366,6 +382,41 @@ gitflow_install_hook() {
_gitflow_write_hook && gitflow_activate_hook
}
# gitflow_reconcile_hooks → refresh a repo's .githooks/ when it lags the lib
# (LRN-114: a generator edit never reaches installed hooks by itself; the
# session-start hook calls this once per session). Only for repos that opted
# into the per-repo layout (.githooks/pre-commit present, or local
# core.hooksPath = .githooks); others are covered by the global hooks dir.
# Prints "gitflow hooks refreshed: <names>" when it wrote something, nothing
# when current. Never fails the caller.
gitflow_reconcile_hooks() {
local root hd name stale=""
root=$(git rev-parse --show-toplevel 2>/dev/null) || return 0
hd="$root/.githooks"
[ -f "$hd/pre-commit" ] \
|| [ "$(git config --local core.hooksPath 2>/dev/null)" = ".githooks" ] \
|| return 0
for name in pre-commit post-commit post-merge; do
diff -q <(_gitflow_emit_hook "$name") "$hd/$name" >/dev/null 2>&1 || stale="$stale $name"
done
[ -n "$stale" ] || return 0
(cd "$root" && gitflow_install_hook) || return 0
echo "gitflow hooks refreshed:$stale"
}
# gitflow_global_hooks <dir> [config-value] → write the three hooks into <dir>
# and point git's GLOBAL core.hooksPath at it (value defaults to <dir>; link.sh
# passes '~/.claude/githooks' so the setting is machine-agnostic). Every repo
# on the machine is then protected and auto-pushed, whether or not it ever ran
# gitflow init; a repo's own local core.hooksPath still wins, by git's rules.
gitflow_global_hooks() {
local dir="${1:-}" value="${2:-${1:-}}"
[ -n "$dir" ] || { echo "gitflow_global_hooks: missing <dir>" >&2; return 2; }
_gitflow_write_hook "$dir" || return 1
[ "$(git config --global core.hooksPath 2>/dev/null)" = "$value" ] && return 0
git config --global core.hooksPath "$value"
}
# ── CLI dispatch (only when executed, not sourced) ───────────────────────────
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
set -uo pipefail
@@ -381,11 +432,10 @@ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
reconcile) gitflow_reconcile_gitignore "$@" ;;
purge-transient) _gitflow_purge_transient ;;
install-hook) gitflow_install_hook "$@" ;;
emit-hook) case "${1:-pre-commit}" in
pre-commit) _gitflow_emit_pre_commit ;;
post-commit|post-merge) _gitflow_emit_push_hook "$1" ;;
*) echo "gitflow.sh emit-hook {pre-commit|post-commit|post-merge}" >&2; exit 2 ;;
esac ;;
*) echo "usage: gitflow.sh {type|protected-base|base-for|release-open|start|finish|init|reconcile|purge-transient|install-hook|emit-hook [pre-commit|post-commit|post-merge]}" >&2; exit 2 ;;
reconcile-hooks) gitflow_reconcile_hooks ;;
global-hooks) gitflow_global_hooks "$@" ;;
emit-hook) _gitflow_emit_hook "${1:-pre-commit}" \
|| { echo "gitflow.sh emit-hook {pre-commit|post-commit|post-merge}" >&2; exit 2; } ;;
*) echo "usage: gitflow.sh {type|protected-base|base-for|release-open|start|finish|init|reconcile|purge-transient|install-hook|reconcile-hooks|global-hooks <dir> [value]|emit-hook [pre-commit|post-commit|post-merge]}" >&2; exit 2 ;;
esac
fi
+2
View File
@@ -18,6 +18,8 @@
#
# No -e: run every test and report, even after a failure.
set -uo pipefail
# Hermetic git: the global hooks dir (BDR-095) must not fire in throwaway repos.
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
HERE="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HELPER="$HERE/../doc-commit.sh"
+2
View File
@@ -9,6 +9,8 @@
# assertion REDS, proving gitflow fans out main+develop but never tags.
# GREEN(RC_TAG=1): the skill's flow adds `git tag` → tag present on main's merge commit.
set -uo pipefail
# Hermetic git: the global hooks dir (BDR-095) must not fire in throwaway repos.
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
GREP=/usr/bin/grep # LRN-074: pin grep
LIBDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # repo lib/