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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 ;; \
|
||||
|
||||
@@ -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
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
Executable
+15
@@ -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
|
||||
Executable
+15
@@ -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
|
||||
Executable
+41
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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
@@ -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 *)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user