diff --git a/.githooks/post-commit b/.githooks/post-commit index 96a00dc..ad605ea 100755 --- a/.githooks/post-commit +++ b/.githooks/post-commit @@ -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 diff --git a/.githooks/post-merge b/.githooks/post-merge index 66325f3..0456217 100755 --- a/.githooks/post-merge +++ b/.githooks/post-merge @@ -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 diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1efe956..ef3abef 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 946cabe..45b774a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.global.md b/CLAUDE.global.md index 90b550e..924782c 100644 --- a/CLAUDE.global.md +++ b/CLAUDE.global.md @@ -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 diff --git a/Makefile b/Makefile index 7002ce1..18fa55e 100644 --- a/Makefile +++ b/Makefile @@ -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 ;; \ diff --git a/doctor.sh b/doctor.sh index ae12d13..3c5afa5 100644 --- a/doctor.sh +++ b/doctor.sh @@ -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 # ──────────────────────────────────────────────────────────── diff --git a/githooks/post-commit b/githooks/post-commit new file mode 100755 index 0000000..ad605ea --- /dev/null +++ b/githooks/post-commit @@ -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 diff --git a/githooks/post-merge b/githooks/post-merge new file mode 100755 index 0000000..0456217 --- /dev/null +++ b/githooks/post-merge @@ -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 diff --git a/githooks/pre-commit b/githooks/pre-commit new file mode 100755 index 0000000..ef3abef --- /dev/null +++ b/githooks/pre-commit @@ -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 diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 36b9584..029fafc 100644 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -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. diff --git a/lib/gitflow-test.sh b/lib/gitflow-test.sh index cb452b8..d9b3dde 100644 --- a/lib/gitflow-test.sh +++ b/lib/gitflow-test.sh @@ -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 ====" diff --git a/lib/gitflow.sh b/lib/gitflow.sh index 445066b..59955b5 100644 --- a/lib/gitflow.sh +++ b/lib/gitflow.sh @@ -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() { # + 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: " 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 [config-value] → write the three hooks into +# and point git's GLOBAL core.hooksPath at it (value defaults to ; 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 " >&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 [value]|emit-hook [pre-commit|post-commit|post-merge]}" >&2; exit 2 ;; esac fi diff --git a/lib/tests/run-doc-commit.sh b/lib/tests/run-doc-commit.sh index f337d9c..5d16d22 100755 --- a/lib/tests/run-doc-commit.sh +++ b/lib/tests/run-doc-commit.sh @@ -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" diff --git a/lib/tests/run-release-candidate.sh b/lib/tests/run-release-candidate.sh index 8875016..3bfe4ae 100644 --- a/lib/tests/run-release-candidate.sh +++ b/lib/tests/run-release-candidate.sh @@ -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/ diff --git a/link.sh b/link.sh index ec942ff..d260640 100644 --- a/link.sh +++ b/link.sh @@ -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 diff --git a/settings.json b/settings.json index 2c577cb..d9b984f 100644 --- a/settings.json +++ b/settings.json @@ -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 *)", diff --git a/templates/settings/SETTINGS.md b/templates/settings/SETTINGS.md index c062174..335303b 100644 --- a/templates/settings/SETTINGS.md +++ b/templates/settings/SETTINGS.md @@ -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)