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. # holds. Never fails the commit: no origin / offline / refused → warning only.
# Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests). # Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests).
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0 [ "${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 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 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 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. # holds. Never fails the commit: no origin / offline / refused → warning only.
# Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests). # Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests).
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0 [ "${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 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 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 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 echo "gitflow pre-commit: gitleaks not installed — secret scan skipped (https://github.com/gitleaks/gitleaks)." >&2
fi 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 case "$br" in
main|develop) ;; # protected — keep checking main|develop) ;; # protected — keep checking
*) exit 0 ;; # working branch — allow *) exit 0 ;; # working branch — allow
esac esac
# whitelist: all-staged-under-.claude/ (memory/doc/deploy helpers) — allow # whitelist: all-staged-under-.claude/ (memory/doc/deploy helpers) or
if [ -z "$(git diff --cached --name-only | grep -v '^\.claude/' | head -1)" ]; then # .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 exit 0
fi fi
echo "gitflow pre-commit: BLOCKED — direct commit on '$br'." >&2 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 " 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 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 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 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 opt out in throwaway repos). A failed push warns loudly and never blocks
the commit. Existing projects get the hooks by re-running the commit. Nothing to run per project: `make link` generates `githooks/`
`bash ~/.claude/lib/gitflow.sh install-hook`. Covered by `gitflow-test.sh` from the lib and sets git's global `core.hooksPath` to
T18 (bare origin: start, commit, opt-out, unreachable origin, finish) and `~/.claude/githooks`, so every repo on the machine runs the three hooks,
T19 (installed hooks equal the emitted ones, LRN-114 drift gate). 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 - `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 branch is ahead of its upstream, has no upstream, or has no `origin`; at
session start also the count of uncommitted changes. Non-blocking. 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 - `lib/tests/guard-bash.test.sh`: the executable spec of a PreToolUse Bash
guard (transfer tools, sync deletes, recursive `rm` outside the project, guard (transfer tools, sync deletes, recursive `rm` outside the project,
bulk permissions, privilege escalation, disk tools, docker privileges and 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`, docker socket and `-v /:`, and git history destruction (`push --delete`,
`--mirror`, `:ref`, `--force-with-lease`, `branch -D`, `filter-branch`, `--mirror`, `:ref`, `--force-with-lease`, `branch -D`, `filter-branch`,
`reflog expire`, `stash clear`/`drop`, `clean -f`, `--no-verify`, `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 unreliable under auto mode. New `autoMode.hard_deny`: a destructive tool
aimed at a path built from a variable, `~`, `..`, a wildcard, or outside 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 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, 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 never finishing — so those skills branch to `chore/*` via the aiguillage, not
the `.claude/**` exemption. New/onboarded projects get the model + the the `.claude/**` exemption. New/onboarded projects get the model + the
versioned pre-commit hook via `gitflow init`. Advisory, so two deterministic versioned hooks via `gitflow init`. Advisory, so deterministic backstops
backstops apply: the per-repo pre-commit hook (blocks code commits on apply: the pre-commit hook (blocks code commits on main/develop, exempts
main/develop, exempts `.claude/**` + merges + the root commit) and Gitea branch `.claude/**` + `.githooks/**` + merges + the root commit) and Gitea branch
protection on `main`/`develop`. Don't lean on `--no-verify` to bypass them. 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` Every branch is pushed at `start` and every commit as it lands by the
/ `install-hook` write post-commit and post-merge hooks that push to `origin` post-commit and post-merge hooks (warn, never block, on failure). The three
(warn, never block, when it fails); `GITFLOW_NO_PUSH=1` is for throwaway test hooks run in EVERY repo on the machine: `make link` generates `githooks/`
repos only. A branch ahead of its upstream is a defect, not a state. 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 ## 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"' bash lib/seo-data/connect.sh --label "$$label"'
test: ## Run deterministic tests (lib/tests/*.test.sh + lib/gitflow-test.sh + lib/tests/run-*.sh) 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"; \ echo "== $$t"; \
case "$$(basename "$$t")" in \ case "$$(basename "$$t")" in \
run-release-candidate.sh) RC_WORK=$$(mktemp -d) RC_TAG=1 bash "$$t" || fail=1 ;; \ run-release-candidate.sh) RC_WORK=$$(mktemp -d) RC_TAG=1 bash "$$t" || fail=1 ;; \
+41
View File
@@ -314,6 +314,47 @@ fi
echo "" 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 # 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 fi
unset _lib 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 plugin detection ──
TOGGLE_ACTIVE=() TOGGLE_ACTIVE=()
@@ -199,6 +210,11 @@ unset _active_count _inactive_count
printf "│ 🖥️ CLI : %-40s│\n" "$GSD_STATUS" printf "│ 🖥️ CLI : %-40s│\n" "$GSD_STATUS"
[ -n "$TOKEN_WARN" ] && printf "│ 💰 %-44s│\n" "${TOKEN_WARN:0:44}" [ -n "$TOKEN_WARN" ] && printf "│ 💰 %-44s│\n" "${TOKEN_WARN:0:44}"
printf "│ 📦 v%-45s│\n" "$CONFIG_VERSION" 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 # CLAUDE.global.md line-count guard (anti-regression). BDR-062 supersedes
# BDR-031's 275 target: 305 is the assumed reality (extraction done at # BDR-031's 275 target: 305 is the assumed reality (extraction done at
# job1; further compression costs clarity > token gain) — warn past 320. # 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)" ]' 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 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)" ]' 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 git remote set-url origin /nonexistent/x.git
echo w3>>w; git add w echo w3>>w; git add w
# shellcheck disable=SC2034 # ap_out/ap_rc are read by the deferred chk evals # shellcheck disable=SC2034 # ap_out/ap_rc are read by the deferred chk evals
@@ -337,6 +341,40 @@ if [ -d "$HERE/../.githooks" ]; then
else else
ok "T19 skipped (no .githooks next to the lib)" ok "T19 skipped (no .githooks next to the lib)"
fi 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
echo "==== RESULT: $PASS passed, $FAIL failed ====" 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 echo "gitflow pre-commit: gitleaks not installed — secret scan skipped (https://github.com/gitleaks/gitleaks)." >&2
fi 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 case "\$br" in
$GITFLOW_MAIN|$GITFLOW_DEVELOP) ;; # protected — keep checking $GITFLOW_MAIN|$GITFLOW_DEVELOP) ;; # protected — keep checking
*) exit 0 ;; # working branch — allow *) exit 0 ;; # working branch — allow
esac esac
# whitelist: all-staged-under-.claude/ (memory/doc/deploy helpers) — allow # whitelist: all-staged-under-.claude/ (memory/doc/deploy helpers) or
if [ -z "\$(git diff --cached --name-only | grep -v '^\.claude/' | head -1)" ]; then # .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 exit 0
fi fi
echo "gitflow pre-commit: BLOCKED — direct commit on '\$br'." >&2 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 " 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 exit 1
HOOK HOOK
} }
@@ -335,6 +340,8 @@ cat <<'HOOK'
# holds. Never fails the commit: no origin / offline / refused → warning only. # holds. Never fails the commit: no origin / offline / refused → warning only.
# Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests). # Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests).
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0 [ "${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 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 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 command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi
@@ -345,9 +352,18 @@ exit 0
HOOK 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() { _gitflow_write_hook() {
local hd=".githooks" local hd="${1:-.githooks}"
mkdir -p "$hd" mkdir -p "$hd"
_gitflow_emit_pre_commit > "$hd/pre-commit" _gitflow_emit_pre_commit > "$hd/pre-commit"
_gitflow_emit_push_hook post-commit > "$hd/post-commit" _gitflow_emit_push_hook post-commit > "$hd/post-commit"
@@ -366,6 +382,41 @@ gitflow_install_hook() {
_gitflow_write_hook && gitflow_activate_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) ─────────────────────────── # ── CLI dispatch (only when executed, not sourced) ───────────────────────────
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
set -uo pipefail set -uo pipefail
@@ -381,11 +432,10 @@ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
reconcile) gitflow_reconcile_gitignore "$@" ;; reconcile) gitflow_reconcile_gitignore "$@" ;;
purge-transient) _gitflow_purge_transient ;; purge-transient) _gitflow_purge_transient ;;
install-hook) gitflow_install_hook "$@" ;; install-hook) gitflow_install_hook "$@" ;;
emit-hook) case "${1:-pre-commit}" in reconcile-hooks) gitflow_reconcile_hooks ;;
pre-commit) _gitflow_emit_pre_commit ;; global-hooks) gitflow_global_hooks "$@" ;;
post-commit|post-merge) _gitflow_emit_push_hook "$1" ;; emit-hook) _gitflow_emit_hook "${1:-pre-commit}" \
*) echo "gitflow.sh emit-hook {pre-commit|post-commit|post-merge}" >&2; exit 2 ;; || { 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|reconcile-hooks|global-hooks <dir> [value]|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|emit-hook [pre-commit|post-commit|post-merge]}" >&2; exit 2 ;;
esac esac
fi fi
+2
View File
@@ -18,6 +18,8 @@
# #
# No -e: run every test and report, even after a failure. # No -e: run every test and report, even after a failure.
set -uo pipefail 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)" HERE="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HELPER="$HERE/../doc-commit.sh" HELPER="$HERE/../doc-commit.sh"
+2
View File
@@ -9,6 +9,8 @@
# assertion REDS, proving gitflow fans out main+develop but never tags. # 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. # GREEN(RC_TAG=1): the skill's flow adds `git tag` → tag present on main's merge commit.
set -uo pipefail 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 GREP=/usr/bin/grep # LRN-074: pin grep
LIBDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # repo lib/ 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/CLAUDE.global.md" "$CLAUDE/CLAUDE.md"
link_file "$REPO/settings.json" "$CLAUDE/settings.json" 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" target="$CLAUDE/$item"
if [ -L "$target" ]; then if [ -L "$target" ]; then
if [ "$(readlink "$target")" = "$REPO/$item" ]; then if [ "$(readlink "$target")" = "$REPO/$item" ]; then
+11 -1
View File
@@ -292,7 +292,17 @@
"Bash(* | sh)", "Bash(* | sh)",
"Bash(* | sh -*)", "Bash(* | sh -*)",
"Bash(* | sudo *)", "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": [ "ask": [
"Bash(bash -c *)", "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, Push discipline lives in `lib/gitflow.sh`: `start` pushes the branch,
`finish` pushes each merge target, and the post-commit / post-merge hooks `finish` pushes each merge target, and the post-commit / post-merge hooks
written by `gitflow init` / `install-hook` push every commit as it lands push every commit as it lands (warn, never block, on failure). The hooks
(warn, never block, on failure; `GITFLOW_NO_PUSH=1` for throwaway repos). reach every repo two ways: `make link` generates `githooks/` from the lib
`hooks/unpushed-guard.sh` reports a branch ahead of its upstream at session and sets git's global `core.hooksPath` to `~/.claude/githooks` (a repo's own
start and at each turn end. 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) ## managed-settings.json (enterprise)