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
+2
View File
@@ -4,6 +4,8 @@
# 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
+2
View File
@@ -4,6 +4,8 @@
# 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
+8 -3
View File
@@ -20,17 +20,22 @@ 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
main|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
+24 -5
View File
@@ -56,13 +56,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
hooks next to `pre-commit` that push the current branch after every
commit and merge (`--follow-tags`, 30 s timeout, `GITFLOW_NO_PUSH=1` to
opt out in throwaway repos). A failed push warns loudly and never blocks
the commit. Existing projects get the hooks by re-running
`bash ~/.claude/lib/gitflow.sh install-hook`. Covered by `gitflow-test.sh`
T18 (bare origin: start, commit, opt-out, unreachable origin, finish) and
T19 (installed hooks equal the emitted ones, LRN-114 drift gate).
the commit. Nothing to run per project: `make link` generates `githooks/`
from the lib and sets git's global `core.hooksPath` to
`~/.claude/githooks`, so every repo on the machine runs the three hooks,
and `hooks/session-start.sh` refreshes a repo's own `.githooks/` when it
lags the lib (`gitflow reconcile-hooks`). Per-repo opt-outs for a foreign
clone: `git config gitflow.protect false`, `git config gitflow.autopush
false`. `make doctor` checks both. The pre-commit exemption now covers
`.githooks/**` next to `.claude/**`. `make test` and the suites that
commit on `main` run with `GIT_CONFIG_GLOBAL=/dev/null`, so the global
hooks never fire in throwaway repos. Covered by `gitflow-test.sh` T18
(bare origin: start, commit, opt-outs, unreachable origin, finish), T19
(installed and generated hooks equal the emitted ones, LRN-114 drift
gate), T20 (reconcile) and T21 (whitelist and protect opt-out).
- `hooks/unpushed-guard.sh` on `SessionStart` and `Stop`: a warning when the
branch is ahead of its upstream, has no upstream, or has no `origin`; at
session start also the count of uncommitted changes. Non-blocking.
- `make doctor` gains two sections: "Git hooks" (global `core.hooksPath`
set, generated `githooks/` equal to the emitters) and "Scratchpad": a
warning when `TMPDIR` sits on a tmpfs mounted with `usrquota`. systemd
mounts `/tmp` that way by default and caps each user at 80 % of its
size, so Claude's tool outputs share one quota across every session and
sub-agent, and one fat probe directory kills every shell at once (this
happened twice on 2026-09-22, BLK-021). Fix: launch claude with
`TMPDIR=$HOME/.cache/claude-tmp`.
- `lib/tests/guard-bash.test.sh`: the executable spec of a PreToolUse Bash
guard (transfer tools, sync deletes, recursive `rm` outside the project,
bulk permissions, privilege escalation, disk tools, docker privileges and
@@ -179,7 +196,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
docker socket and `-v /:`, and git history destruction (`push --delete`,
`--mirror`, `:ref`, `--force-with-lease`, `branch -D`, `filter-branch`,
`reflog expire`, `stash clear`/`drop`, `clean -f`, `--no-verify`,
`core.hooksPath`). The pipe-to-shell and stash entries left `ask`, which is
`core.hooksPath`, the `GIT_CONFIG_GLOBAL=` / `GIT_CONFIG=` env prefixes
and the per-repo `gitflow.*` opt-outs, which belong to the human). The
pipe-to-shell and stash entries left `ask`, which is
unreliable under auto mode. New `autoMode.hard_deny`: a destructive tool
aimed at a path built from a variable, `~`, `..`, a wildcard, or outside
the project and the temp dir, including as a trace or a rehearsal that a
+12 -7
View File
@@ -194,14 +194,19 @@ flows (`/feat` `/bugfix` `/hotfix`) and the standalone memory/doc `chore`
skills auto-branch on a protected base but commit in place on a working branch,
never finishing — so those skills branch to `chore/*` via the aiguillage, not
the `.claude/**` exemption. New/onboarded projects get the model + the
versioned pre-commit hook via `gitflow init`. Advisory, so two deterministic
backstops apply: the per-repo pre-commit hook (blocks code commits on
main/develop, exempts `.claude/**` + merges + the root commit) and Gitea branch
versioned hooks via `gitflow init`. Advisory, so deterministic backstops
apply: the pre-commit hook (blocks code commits on main/develop, exempts
`.claude/**` + `.githooks/**` + merges + the root commit) and Gitea branch
protection on `main`/`develop`. Don't lean on `--no-verify` to bypass them.
Every branch is pushed at `start` and every commit as it lands: `gitflow init`
/ `install-hook` write post-commit and post-merge hooks that push to `origin`
(warn, never block, when it fails); `GITFLOW_NO_PUSH=1` is for throwaway test
repos only. A branch ahead of its upstream is a defect, not a state.
Every branch is pushed at `start` and every commit as it lands by the
post-commit and post-merge hooks (warn, never block, on failure). The three
hooks run in EVERY repo on the machine: `make link` generates `githooks/`
from the lib and sets git's global `core.hooksPath` to `~/.claude/githooks`;
a repo that ran `gitflow init` keeps its own `.githooks/`, refreshed at
session start when it lags the lib. Foreign clone: `git config
gitflow.protect false` / `gitflow.autopush false`. `GITFLOW_NO_PUSH=1` is
for throwaway test repos only. A branch ahead of its upstream is a defect,
not a state.
## Security — non-negotiable defaults
+4 -1
View File
@@ -29,7 +29,10 @@ seo-connect: ## Connect a Google account for /seo FULL (creates venv, OAuth cons
bash lib/seo-data/connect.sh --label "$$label"'
test: ## Run deterministic tests (lib/tests/*.test.sh + lib/gitflow-test.sh + lib/tests/run-*.sh)
@fail=0; for t in lib/tests/*.test.sh lib/seo-data/*.test.sh lib/gitflow-test.sh lib/tests/run-*.sh; do \
@# Hermetic git: the machine's global core.hooksPath (BDR-095) must not
@# fire inside the throwaway repos the suites build.
@export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null; \
fail=0; for t in lib/tests/*.test.sh lib/seo-data/*.test.sh lib/gitflow-test.sh lib/tests/run-*.sh; do \
echo "== $$t"; \
case "$$(basename "$$t")" in \
run-release-candidate.sh) RC_WORK=$$(mktemp -d) RC_TAG=1 bash "$$t" || fail=1 ;; \
+41
View File
@@ -314,6 +314,47 @@ fi
echo ""
# ────────────────────────────────────────────────────────────
# 5b. Git hooks (BDR-095): global core.hooksPath + generated githooks/
# ────────────────────────────────────────────────────────────
echo "── Git hooks ──"
_gh_cfg=$(git config --global core.hooksPath 2>/dev/null || true)
# literal tilde accepted: git expands it itself (see link.sh)
# shellcheck disable=SC2088
if [ "$_gh_cfg" = '~/.claude/githooks' ] || [ "$_gh_cfg" = "$HOME/.claude/githooks" ]; then
pass "global core.hooksPath → $_gh_cfg (every repo protected + auto-pushed)"
else
warn "global core.hooksPath is '${_gh_cfg:-unset}' — expected ~/.claude/githooks (run: make link)"
fi
for _h in pre-commit post-commit post-merge; do
if [ ! -f "$REPO/githooks/$_h" ]; then
warn "githooks/$_h missing (run: make link)"
elif ! diff -q <(bash "$REPO/lib/gitflow.sh" emit-hook "$_h" 2>/dev/null) "$REPO/githooks/$_h" >/dev/null 2>&1; then
warn "githooks/$_h lags lib/gitflow.sh (run: make link)"
else
pass "githooks/$_h matches lib/gitflow.sh"
fi
done
unset _gh_cfg _h
echo ""
# ────────────────────────────────────────────────────────────
# 5c. Scratchpad (BLK-021): Claude's tool outputs live under $TMPDIR; on a
# tmpfs with a per-user quota (systemd mounts /tmp with usrquota and caps
# each user at 80% of its size) one fat probe kills every session's shell.
# ────────────────────────────────────────────────────────────
echo "── Scratchpad ──"
_sp="${TMPDIR:-/tmp}"
_sp_fs=$(findmnt -no FSTYPE -T "$_sp" 2>/dev/null || echo "?")
_sp_opts=$(findmnt -no OPTIONS -T "$_sp" 2>/dev/null || true)
if [ "$_sp_fs" = tmpfs ] && printf '%s' "$_sp_opts" | grep -q usrquota; then
warn "TMPDIR=$_sp is a tmpfs with a per-user quota — every session's shell dies when it fills (BLK-021). Launch claude with TMPDIR=\$HOME/.cache/claude-tmp"
else
pass "TMPDIR=$_sp on $_sp_fs (no per-user tmpfs quota in the way)"
fi
unset _sp _sp_fs _sp_opts
echo ""
# ────────────────────────────────────────────────────────────
# 6. Token budget estimate
# ────────────────────────────────────────────────────────────
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# gitflow post-commit — generated by gitflow_init. Do not hand-edit.
# Pushes every commit as it lands (BDR-095): a remote only backs up what it
# 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
if $t git push -q -u --follow-tags origin "$br" >/dev/null 2>&1; then exit 0; fi
echo "gitflow post-commit: push of '$br' FAILED — this commit exists only on this disk." >&2
echo " Push by hand: git push -u origin $br (rejected as non-fast-forward? never force-push; ask first)" >&2
exit 0
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# gitflow post-merge — generated by gitflow_init. Do not hand-edit.
# Pushes every commit as it lands (BDR-095): a remote only backs up what it
# 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
if $t git push -q -u --follow-tags origin "$br" >/dev/null 2>&1; then exit 0; fi
echo "gitflow post-commit: push of '$br' FAILED — this commit exists only on this disk." >&2
echo " Push by hand: git push -u origin $br (rejected as non-fast-forward? never force-push; ask first)" >&2
exit 0
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# gitflow pre-commit — generated by gitflow_init. Do not hand-edit.
# Mirrors gitflow_protected_base (lib/gitflow.sh). Drift caught by T10.
gd=$(git rev-parse --git-dir)
br=$(git symbolic-ref --short -q HEAD 2>/dev/null)
git rev-parse --verify -q HEAD >/dev/null 2>&1 || exit 0 # root commit — allow
[ -f "$gd/MERGE_HEAD" ] && exit 0 # merge in progress — allow
# Secret backstop (job7) — any branch, not just protected ones. Non-blocking
# if gitleaks isn't installed; auto-discovers ./.gitleaks.toml (repo root).
if command -v gitleaks >/dev/null 2>&1; then
if ! gitleaks git --staged --no-banner >/dev/null 2>&1; then
echo "gitflow pre-commit: BLOCKED — gitleaks found a secret in staged changes." >&2
echo " Details: gitleaks git --staged --no-banner" >&2
echo " Genuine false-positive? add an allowlist rule to .gitleaks.toml — never bypass with --no-verify." >&2
exit 1
fi
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
main|develop) ;; # protected — keep checking
*) exit 0 ;; # working branch — allow
esac
# 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/** and .githooks/** commits are exempt; foreign clone? git config gitflow.protect false)" >&2
exit 1
+16
View File
@@ -44,6 +44,17 @@ else
fi
unset _lib
# ── gitflow hooks reconcile (BDR-095) ──
# A repo's .githooks/ lags lib/gitflow.sh until someone re-runs install-hook
# (LRN-114). Do it here, once per session, silently when current; the lib
# prints the refreshed names, shown in the banner with a commit reminder.
GF_REFRESHED=""
_gf_lib="$(dirname "${BASH_SOURCE[0]}")/../lib/gitflow.sh"
if [ -f "$_gf_lib" ] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
GF_REFRESHED=$(bash "$_gf_lib" reconcile-hooks 2>/dev/null | sed -n 's/^gitflow hooks refreshed: *//p')
fi
unset _gf_lib
# ── Toggle plugin detection ──
TOGGLE_ACTIVE=()
@@ -199,6 +210,11 @@ unset _active_count _inactive_count
printf "│ 🖥️ CLI : %-40s│\n" "$GSD_STATUS"
[ -n "$TOKEN_WARN" ] && printf "│ 💰 %-44s│\n" "${TOKEN_WARN:0:44}"
printf "│ 📦 v%-45s│\n" "$CONFIG_VERSION"
if [ -n "$GF_REFRESHED" ]; then
_gf_line="hooks refreshed: $GF_REFRESHED → commit .githooks/"
printf "│ 🪝 %-44s│\n" "${_gf_line:0:44}"
unset _gf_line
fi
# CLAUDE.global.md line-count guard (anti-regression). BDR-062 supersedes
# BDR-031's 275 target: 305 is the assumed reality (extraction done at
# job1; further compression costs clarity > token gain) — warn past 320.
+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/
+20 -1
View File
@@ -20,7 +20,26 @@ link_file() {
link_file "$REPO/CLAUDE.global.md" "$CLAUDE/CLAUDE.md"
link_file "$REPO/settings.json" "$CLAUDE/settings.json"
for item in hooks agents skills lib templates rules; do
# Global git hooks (BDR-095): githooks/ is generated from lib/gitflow.sh so it
# never drifts from the per-repo .githooks/ the lib writes, and git's GLOBAL
# core.hooksPath points at ~/.claude/githooks → every repo on this machine is
# protected and auto-pushed, even one that never ran gitflow init. A repo's
# own local core.hooksPath still wins (git precedence), which is what the
# session-start reconcile is for.
# The tilde is stored literally on purpose: git expands `~` in core.hooksPath
# itself, so the setting stays valid on any machine and for any HOME.
# shellcheck disable=SC2088
_gh_before=$(git config --global core.hooksPath 2>/dev/null || true)
# shellcheck disable=SC2088
bash "$REPO/lib/gitflow.sh" global-hooks "$REPO/githooks" '~/.claude/githooks'
# shellcheck disable=SC2088
if [ "$_gh_before" != '~/.claude/githooks' ]; then
echo "🪝 git config --global core.hooksPath ~/.claude/githooks (was: ${_gh_before:-unset})"
CHANGED=$((CHANGED + 1))
fi
unset _gh_before
for item in hooks githooks agents skills lib templates rules; do
target="$CLAUDE/$item"
if [ -L "$target" ]; then
if [ "$(readlink "$target")" = "$REPO/$item" ]; then
+11 -1
View File
@@ -292,7 +292,17 @@
"Bash(* | sh)",
"Bash(* | sh -*)",
"Bash(* | sudo *)",
"Bash(chattr *)"
"Bash(chattr *)",
"Bash(GIT_CONFIG_GLOBAL=*)",
"Bash(GIT_CONFIG_SYSTEM=*)",
"Bash(GIT_CONFIG=*)",
"Bash(env GIT_CONFIG*)",
"Bash(git config --unset core.hooksPath*)",
"Bash(git config --unset-all core.hooksPath*)",
"Bash(git config --local core.hooksPath *)",
"Bash(git config gitflow.*)",
"Bash(git config --global gitflow.*)",
"Bash(git config --local gitflow.*)"
],
"ask": [
"Bash(bash -c *)",
+10 -4
View File
@@ -152,10 +152,16 @@ is not shipped yet (BLK-022).
Push discipline lives in `lib/gitflow.sh`: `start` pushes the branch,
`finish` pushes each merge target, and the post-commit / post-merge hooks
written by `gitflow init` / `install-hook` push every commit as it lands
(warn, never block, on failure; `GITFLOW_NO_PUSH=1` for throwaway repos).
`hooks/unpushed-guard.sh` reports a branch ahead of its upstream at session
start and at each turn end.
push every commit as it lands (warn, never block, on failure). The hooks
reach every repo two ways: `make link` generates `githooks/` from the lib
and sets git's global `core.hooksPath` to `~/.claude/githooks` (a repo's own
local `core.hooksPath` wins, by git's rules), and `hooks/session-start.sh`
refreshes a repo's `.githooks/` when it lags the lib. Per-repo opt-outs for
a foreign clone: `git config gitflow.protect false` (branch model) and
`git config gitflow.autopush false` (push); `GITFLOW_NO_PUSH=1` for one
command in a throwaway repo. `make doctor` checks the global setting and
the generated dir. `hooks/unpushed-guard.sh` reports a branch ahead of its
upstream at session start and at each turn end.
## managed-settings.json (enterprise)