feat(gitflow): delete a branch only after a verified merge, main/develop undeletable

Since BDR-095 `start` sets an auto-pushed upstream, so `git branch -d`
checked "merged into origin/<branch>" (always true, the post-commit hook
keeps it in sync) instead of "merged into develop". T22a proves it: an
unmerged feature with its upstream in sync is deleted by `-d` alone.

- `gitflow_delete` is the single delete path (finish + CLI `delete`):
  refuses main/develop (rc 6) and any branch that is not an ancestor of
  develop or main (rc 5, `gitflow_merged_into_base`, fail closed when
  neither base exists), then `-d` as a second layer. CLI `merged`, `hooks`.
- Fourth generated hook `reference-transaction`: in the `prepared` call,
  a deletion of refs/heads/main or refs/heads/develop exits 1, whatever
  issued it (branch -d/-D, update-ref -d, rename, script, sub-agent).
  `git config gitflow.protect false` opts a foreign clone out.
- `GITFLOW_HOOKS` is the one hook list: write/emit/reconcile, T19d and
  doctor.sh (`gitflow.sh hooks`) read it. `.githooks/` and `githooks/`
  regenerated with the fourth hook.
- settings.json: static deny on hand `git branch -d/--delete/-dr/-rd` and
  on renames of main/develop; hard_deny "Branch deletion by hand"; the
  Disarming entry covers all four hooks and `gitflow.*` config; the
  protected-branches environment line states the rule.
- Doctrine (CLAUDE.global.md gitflow section), gitflow SKILL (`delete`
  op, rc 5/6 rows, common mistake), guard-bash spec T8w flips to deny,
  SETTINGS.md, README, CHANGELOG.
- Tests: T22 (12) lib guard incl. the premise proof, T23 (11) hook;
  T19 covers the fourth hook. 152/154, the 2 failures are the
  pre-existing T16a (gitleaks absent on this host).
This commit is contained in:
bastien
2026-09-24 11:35:01 +02:00
parent 72d4662289
commit 32d8f981df
12 changed files with 228 additions and 34 deletions
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# gitflow reference-transaction — generated by gitflow_init. Do not hand-edit.
# Refuses deleting (or renaming) main / develop, whatever the
# command. Mirrors gitflow_protected_base (lib/gitflow.sh).
[ "$1" = prepared ] || exit 0
while read -r _old new ref; do
case "$ref" in refs/heads/main|refs/heads/develop) ;; *) continue ;; esac
case "$new" in *[!0]*) continue ;; esac # new value not all-zeros → an update, not a deletion
# Per-repo opt-out (a foreign clone): git config gitflow.protect false
[ "$(git config --bool --default true gitflow.protect)" = false ] && exit 0
echo "gitflow reference-transaction: BLOCKED — deleting '$ref', a protected base." >&2
echo " main and develop are never deleted or renamed. A merged working branch: gitflow.sh delete <branch>" >&2
exit 1
done
exit 0
+12
View File
@@ -7,6 +7,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- **Branch deletion guard** — `gitflow_delete` (also `gitflow.sh delete
<branch>`) is the only path that deletes a branch: it refuses `main` and
`develop` (rc 6) and any branch not merged into develop or main (rc 5),
with an explicit ancestor check, and keeps the branch. Motivation, proven
by `gitflow-test.sh` T22a: since `start` sets an auto-pushed upstream,
`git branch -d` checks "merged into origin/<branch>", which the post-commit
hook keeps trivially true. A fourth generated hook, `reference-transaction`,
vetoes any deletion or rename of `main`/`develop` at the ref layer in every
repo (`git config gitflow.protect false` opts a foreign clone out). Static
deny on hand deletion (`git branch -d`/`--delete`, renames of the bases),
a `hard_deny` entry for the nested forms; `gitflow.sh hooks` lists the hook
set, read by `doctor.sh` and the tests.
- **`make doctor` reports the Playwright browser cache** — a read-only
`Playwright browsers` section listing cache size, which registered
Playwright install requires each cached browser revision, and counts of
+12 -8
View File
@@ -199,14 +199,18 @@ 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 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.
post-commit and post-merge hooks (warn, never block, on failure). A branch
is deleted only by `finish` or `gitflow.sh delete <br>`: never `main` or
`develop`, never a branch not merged into develop or main (explicit
ancestor check; `git branch -d` proves nothing once the branch has an
auto-pushed upstream, T22a). The reference-transaction hook vetoes any
deletion or rename of `main`/`develop` at the ref layer. The four 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
+2 -1
View File
@@ -17,7 +17,8 @@ Not a collection of prompts — an operating layer on top of Claude Code:
opus judges, the session model only reflects).
- **Hooks and permissions** are deterministic guardrails: gitflow enforced
by a pre-commit hook, every commit pushed by post-commit and post-merge
hooks, deny-first permission rules, secrets kept in `~/.claude/.env` and
hooks, `main`/`develop` undeletable by a reference-transaction hook,
deny-first permission rules, secrets kept in `~/.claude/.env` and
never in config files.
- **Templates and memory** seed every project with persistent registries
(decisions, learnings, blockers) — what a session learns, the next
+2 -2
View File
@@ -326,7 +326,7 @@ if [ "$_gh_cfg" = '~/.claude/githooks' ] || [ "$_gh_cfg" = "$HOME/.claude/githoo
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
while IFS= read -r _h; do # hook set owned by lib/gitflow.sh
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
@@ -334,7 +334,7 @@ for _h in pre-commit post-commit post-merge; do
else
pass "githooks/$_h matches lib/gitflow.sh"
fi
done
done < <(bash "$REPO/lib/gitflow.sh" hooks)
unset _gh_cfg _h
echo ""
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# gitflow reference-transaction — generated by gitflow_init. Do not hand-edit.
# Refuses deleting (or renaming) main / develop, whatever the
# command. Mirrors gitflow_protected_base (lib/gitflow.sh).
[ "$1" = prepared ] || exit 0
while read -r _old new ref; do
case "$ref" in refs/heads/main|refs/heads/develop) ;; *) continue ;; esac
case "$new" in *[!0]*) continue ;; esac # new value not all-zeros → an update, not a deletion
# Per-repo opt-out (a foreign clone): git config gitflow.protect false
[ "$(git config --bool --default true gitflow.protect)" = false ] && exit 0
echo "gitflow reference-transaction: BLOCKED — deleting '$ref', a protected base." >&2
echo " main and develop are never deleted or renamed. A merged working branch: gitflow.sh delete <branch>" >&2
exit 1
done
exit 0
+58 -1
View File
@@ -338,11 +338,12 @@ if [ -d "$HERE/../.githooks" ]; then
chk "T19a pre-commit installed == emitted" 'diff -q <(_gitflow_emit_pre_commit) "$HERE/../.githooks/pre-commit" >/dev/null'
chk "T19b post-commit installed == emitted" 'diff -q <(_gitflow_emit_push_hook post-commit) "$HERE/../.githooks/post-commit" >/dev/null'
chk "T19c post-merge installed == emitted" 'diff -q <(_gitflow_emit_push_hook post-merge) "$HERE/../.githooks/post-merge" >/dev/null'
chk "T19e reference-transaction installed == emitted" 'diff -q <(_gitflow_emit_reference_transaction) "$HERE/../.githooks/reference-transaction" >/dev/null'
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
for h in "${GITFLOW_HOOKS[@]}"; do
chk "T19d global githooks/$h == emitted" "diff -q <(_gitflow_emit_hook $h) \"$HERE/../githooks/$h\" >/dev/null"
done
else
@@ -376,6 +377,62 @@ chk "T21c gitflow.protect=false → allowed" '.githooks/pre-commit 2>/
git config --unset gitflow.protect
git restore --staged code.txt .githooks/post-merge 2>/dev/null || true
echo "T22 — delete guard: never main/develop, never unmerged (premise: -d is dead once the upstream is in sync)"
newrepo delguard; echo a>a; hookon; gitflow_init >/dev/null 2>&1
bare="$WORK/delguard.git"; git init -q --bare "$bare"; git remote add origin "$bare"
git push -q origin main develop 2>/dev/null
gitflow_start feature weak >/dev/null 2>&1; echo w>w; git add w; git commit -q -m w 2>/dev/null
git checkout -q develop
chk "T22a PREMISE: git branch -d deletes an UNMERGED branch whose upstream is in sync" \
'git branch -q -d feature/weak 2>/dev/null && ! git rev-parse --verify -q refs/heads/feature/weak >/dev/null'
gitflow_start feature keep >/dev/null 2>&1; echo k>k; git add k; git commit -q -m k 2>/dev/null
chk "T22b merged_into_base: unmerged → false" '! gitflow_merged_into_base feature/keep'
# shellcheck disable=SC2034 # *_rc are read by the deferred chk evals
del_rc=0; gitflow_delete feature/keep >/dev/null 2>&1 || del_rc=$?
chk "T22c gitflow_delete refuses an unmerged branch (rc 5)" "[ $del_rc -eq 5 ]"
chk "T22d … and the branch is kept" 'git rev-parse --verify -q refs/heads/feature/keep >/dev/null'
dev_rc=0; gitflow_delete develop >/dev/null 2>&1 || dev_rc=$?
chk "T22e refuses develop (rc 6), develop kept" "[ $dev_rc -eq 6 ] && git rev-parse --verify -q refs/heads/develop >/dev/null"
main_rc=0; gitflow_delete main >/dev/null 2>&1 || main_rc=$?
chk "T22f refuses main (rc 6), main kept" "[ $main_rc -eq 6 ] && git rev-parse --verify -q refs/heads/main >/dev/null"
nope_rc=0; gitflow_delete feature/nope >/dev/null 2>&1 || nope_rc=$?
chk "T22g unknown branch → rc 2" "[ $nope_rc -eq 2 ]"
git checkout -q develop; git merge -q --no-ff -m "merge keep" feature/keep 2>/dev/null
chk "T22h merged_into_base: merged into develop → true" 'gitflow_merged_into_base feature/keep'
chk "T22i gitflow_delete deletes a merged branch" 'gitflow_delete feature/keep >/dev/null 2>&1 && ! git rev-parse --verify -q refs/heads/feature/keep >/dev/null'
git checkout -q main; git checkout -q -b hotfix/h; echo h>h; git add h; git commit -q -m h 2>/dev/null
git checkout -q main; git merge -q --no-ff -m "merge h" hotfix/h 2>/dev/null
chk "T22j merged into main only → deletable" 'gitflow_delete hotfix/h >/dev/null 2>&1 && ! git rev-parse --verify -q refs/heads/hotfix/h >/dev/null'
chk "T22k CLI: merged verb" 'bash "$HERE/gitflow.sh" merged develop'
newrepo nobase; git symbolic-ref HEAD refs/heads/trunk; echo a>a; git add a; git commit -q -m a
git checkout -q -b topic; echo t>t; git add t; git commit -q -m t; git checkout -q trunk
chk "T22l no main/develop in the repo → refuses (fail closed), branch kept" \
'! gitflow_delete topic >/dev/null 2>&1 && git rev-parse --verify -q refs/heads/topic >/dev/null'
echo "T23 — reference-transaction hook: main/develop can never be deleted or renamed, whatever the command"
newrepo rt; echo a>a; hookon; gitflow_init >/dev/null 2>&1
chk "T23a hook installed + executable" '[ -x .githooks/reference-transaction ]'
gitflow_start feature rt >/dev/null 2>&1 # stand on a working branch: git itself would allow deleting develop
chk "T23b force-delete develop → blocked, develop kept" '! git branch -D develop >/dev/null 2>&1 && git rev-parse --verify -q refs/heads/develop >/dev/null'
chk "T23c force-delete main → blocked, main kept" '! git branch -D main >/dev/null 2>&1 && git rev-parse --verify -q refs/heads/main >/dev/null'
chk "T23d update-ref -d refs/heads/develop → blocked" '! git update-ref -d refs/heads/develop >/dev/null 2>&1 && git rev-parse --verify -q refs/heads/develop >/dev/null'
chk "T23e rename develop → blocked, nothing renamed" \
'! git branch -m develop dev2 >/dev/null 2>&1 && git rev-parse --verify -q refs/heads/develop >/dev/null && ! git rev-parse --verify -q refs/heads/dev2 >/dev/null'
echo r>r; git add r; git commit -q -m r 2>/dev/null
chk "T23f ordinary commit unaffected" '[ "$(git log -1 --format=%s)" = r ]'
git checkout -q develop; git checkout -q feature/rt
chk "T23g checkout unaffected" '[ "$(git symbolic-ref --short HEAD)" = feature/rt ]'
gitflow_finish >/dev/null 2>&1
chk "T23h finish: the merged feature still deletes through the hook" '! git rev-parse --verify -q refs/heads/feature/rt >/dev/null'
git checkout -q -b feature/tmp; git checkout -q develop
chk "T23i a non-protected branch passes the hook" 'git branch -d feature/tmp >/dev/null 2>&1'
git config gitflow.protect false; git checkout -q main
chk "T23j gitflow.protect=false → develop deletable (foreign-clone opt-out)" \
'git branch -D develop >/dev/null 2>&1 && ! git rev-parse --verify -q refs/heads/develop >/dev/null'
git config --unset gitflow.protect
chk "T23k CLI: hooks verb lists the four hooks" \
'[ "$(bash "$HERE/gitflow.sh" hooks | tr "\n" " ")" = "pre-commit post-commit post-merge reference-transaction " ]'
echo
echo "==== RESULT: $PASS passed, $FAIL failed ===="
[ "$FAIL" -eq 0 ]
+83 -18
View File
@@ -24,6 +24,10 @@ GITFLOW_GITIGNORE_TEMPLATE="${GITFLOW_GITIGNORE_TEMPLATE:-$_GITFLOW_LIB_DIR/../t
# read GITFLOW_PURGE_TRANSIENT=0 at finish time to opt out (read in the helper,
# never cached here, so an inline `VAR=0 gitflow_finish` override works).
GITFLOW_TRANSIENT_PATHS=("docs/superpowers/specs" "docs/superpowers/plans")
# Hook set. Every writer, emitter, reconciler and drift check reads this list
# (doctor.sh and the tests through `gitflow.sh hooks`), so a hook added here
# reaches every repo with no second edit.
GITFLOW_HOOKS=(pre-commit post-commit post-merge reference-transaction)
# ── predicates / pure helpers ────────────────────────────────────────────────
@@ -124,10 +128,40 @@ _gitflow_merge_into_open_releases() { # <source>
done < <(git for-each-ref --format='%(refname:short)' 'refs/heads/release/*')
}
_gitflow_delete() { # <branch>
local br="$1"
git checkout -q "$GITFLOW_DEVELOP" 2>/dev/null || git checkout -q "$GITFLOW_MAIN"
git branch -q -d "$br" || { echo "gitflow: '$br' not fully merged — branch kept" >&2; return 5; }
# rc 0 iff <branch> is fully contained in develop or in main — the ONLY state in
# which the lib deletes a branch. Fails closed: neither base in the repo →
# nothing to verify against → rc 1. Explicit on purpose: `git branch -d` checks
# "merged into the upstream" once one is set, and since BDR-095 every branch
# has an auto-pushed upstream that is trivially in sync — its safety valve is
# dead (proven by gitflow-test.sh T22a).
gitflow_merged_into_base() {
local br="$1" base
for base in "$GITFLOW_DEVELOP" "$GITFLOW_MAIN"; do
git rev-parse --verify -q "refs/heads/$base" >/dev/null || continue
if git merge-base --is-ancestor "$br" "$base" 2>/dev/null; then return 0; fi
done
return 1
}
# gitflow_delete <branch> → the one sanctioned way to delete a local branch.
# finish calls it after its merges; the CLI exposes it for a branch merged
# elsewhere (a Gitea PR, a hand merge). Refuses, branch KEPT: rc 2 no such
# branch · rc 6 protected base (main/develop are never deleted) · rc 5 not
# merged into develop or main.
gitflow_delete() {
local br="${1:-}"
if [ -z "$br" ] || ! git rev-parse --verify -q "refs/heads/$br" >/dev/null; then
echo "gitflow_delete: no local branch '${br:-<missing>}'" >&2; return 2
fi
if gitflow_protected_base "$br"; then
echo "gitflow: REFUSED — '$br' is a protected base, never deleted" >&2; return 6
fi
if ! gitflow_merged_into_base "$br"; then
echo "gitflow: REFUSED — '$br' is not merged into $GITFLOW_DEVELOP or $GITFLOW_MAIN — branch kept" >&2
return 5
fi
git checkout -q "$GITFLOW_DEVELOP" 2>/dev/null || git checkout -q "$GITFLOW_MAIN" 2>/dev/null
git branch -q -d "$br" || { echo "gitflow: git refused to delete '$br' — branch kept" >&2; return 5; }
}
# _gitflow_purge_transient → remove the committed transient planning artifacts
@@ -167,7 +201,8 @@ _gitflow_purge_transient() {
}
# gitflow_finish [<type> <name>] → directed merge of the CURRENT branch per its
# type, then delete. WHEN to call this is the human gate (SKILL.md).
# type, then gitflow_delete (refuses main/develop and anything unmerged). WHEN
# to call this is the human gate (SKILL.md).
#
# The merge source is ALWAYS the checked-out branch (HEAD) — that is the contract.
# The optional <type> <name> is a SAFETY ASSERTION, not a target selector: if you
@@ -188,18 +223,18 @@ gitflow_finish() {
case "$type" in
feature|bugfix)
_gitflow_purge_transient # BDR-065 auto-cleanup, on HEAD, pre-merge; never blocks
_gitflow_merge_into "$GITFLOW_DEVELOP" "$br" && _gitflow_delete "$br" ;;
_gitflow_merge_into "$GITFLOW_DEVELOP" "$br" && gitflow_delete "$br" ;;
chore)
_gitflow_merge_into "$GITFLOW_DEVELOP" "$br" && _gitflow_delete "$br" ;;
_gitflow_merge_into "$GITFLOW_DEVELOP" "$br" && gitflow_delete "$br" ;;
release)
_gitflow_merge_into "$GITFLOW_MAIN" "$br" \
&& _gitflow_merge_into "$GITFLOW_DEVELOP" "$br" \
&& _gitflow_delete "$br" ;;
&& gitflow_delete "$br" ;;
hotfix)
_gitflow_merge_into "$GITFLOW_MAIN" "$br" \
&& _gitflow_merge_into "$GITFLOW_DEVELOP" "$br" \
&& { gitflow_release_open && _gitflow_merge_into_open_releases "$br" || true; } \
&& _gitflow_delete "$br" ;;
&& gitflow_delete "$br" ;;
*) echo "gitflow_finish: '$br' is not a finishable gitflow branch" >&2; return 2 ;;
esac
}
@@ -352,10 +387,36 @@ exit 0
HOOK
}
_gitflow_emit_hook() { # <pre-commit|post-commit|post-merge>
# Emit the reference-transaction hook: vetoes the deletion of a protected base
# at the ref layer, whatever issued it — branch -d/-D, update-ref -d, a rename
# (which deletes the old name), a script, a sub-agent. Names inlined like the
# pre-commit's (the hook runs with no access to this lib; drift caught by T19).
# Only the `prepared` call can veto; the other two exit at once.
_gitflow_emit_reference_transaction() {
cat <<HOOK
#!/bin/sh
# gitflow reference-transaction — generated by gitflow_init. Do not hand-edit.
# Refuses deleting (or renaming) $GITFLOW_MAIN / $GITFLOW_DEVELOP, whatever the
# command. Mirrors gitflow_protected_base (lib/gitflow.sh).
[ "\$1" = prepared ] || exit 0
while read -r _old new ref; do
case "\$ref" in refs/heads/$GITFLOW_MAIN|refs/heads/$GITFLOW_DEVELOP) ;; *) continue ;; esac
case "\$new" in *[!0]*) continue ;; esac # new value not all-zeros → an update, not a deletion
# Per-repo opt-out (a foreign clone): git config gitflow.protect false
[ "\$(git config --bool --default true gitflow.protect)" = false ] && exit 0
echo "gitflow reference-transaction: BLOCKED — deleting '\$ref', a protected base." >&2
echo " $GITFLOW_MAIN and $GITFLOW_DEVELOP are never deleted or renamed. A merged working branch: gitflow.sh delete <branch>" >&2
exit 1
done
exit 0
HOOK
}
_gitflow_emit_hook() { # <name> — one of GITFLOW_HOOKS
case "$1" in
pre-commit) _gitflow_emit_pre_commit ;;
post-commit|post-merge) _gitflow_emit_push_hook "$1" ;;
reference-transaction) _gitflow_emit_reference_transaction ;;
*) return 2 ;;
esac
}
@@ -363,12 +424,12 @@ _gitflow_emit_hook() { # <pre-commit|post-commit|post-merge>
# write the versioned hook files into $1 (default .githooks) — does NOT
# activate (see gitflow_activate_hook / gitflow_global_hooks).
_gitflow_write_hook() {
local hd="${1:-.githooks}"
local hd="${1:-.githooks}" name
mkdir -p "$hd"
_gitflow_emit_pre_commit > "$hd/pre-commit"
_gitflow_emit_push_hook post-commit > "$hd/post-commit"
_gitflow_emit_push_hook post-merge > "$hd/post-merge"
chmod +x "$hd/pre-commit" "$hd/post-commit" "$hd/post-merge"
for name in "${GITFLOW_HOOKS[@]}"; do
_gitflow_emit_hook "$name" > "$hd/$name" || return 1
chmod +x "$hd/$name" || return 1
done
}
# point git at the versioned hook dir. Run LAST in init so the bootstrap commits
@@ -396,7 +457,7 @@ gitflow_reconcile_hooks() {
[ -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
for name in "${GITFLOW_HOOKS[@]}"; do
diff -q <(_gitflow_emit_hook "$name") "$hd/$name" >/dev/null 2>&1 || stale="$stale $name"
done
[ -n "$stale" ] || return 0
@@ -428,6 +489,10 @@ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
release-open) gitflow_release_open ;;
start) gitflow_start "$@" ;;
finish) gitflow_finish "$@" ;;
delete) gitflow_delete "$@" ;;
merged) [ -n "${1:-}" ] || { echo "usage: gitflow.sh merged <branch>" >&2; exit 2; }
gitflow_merged_into_base "$1" ;;
hooks) printf '%s\n' "${GITFLOW_HOOKS[@]}" ;;
init) gitflow_init "$@" ;;
reconcile) gitflow_reconcile_gitignore "$@" ;;
purge-transient) _gitflow_purge_transient ;;
@@ -435,7 +500,7 @@ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
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 ;;
|| { echo "gitflow.sh emit-hook {$(IFS='|'; echo "${GITFLOW_HOOKS[*]}")}" >&2; exit 2; } ;;
*) echo "usage: gitflow.sh {type|protected-base|base-for|release-open|start|finish|delete <br>|merged <br>|init|reconcile|purge-transient|install-hook|reconcile-hooks|global-hooks <dir> [value]|hooks|emit-hook <name>}" >&2; exit 2 ;;
esac
fi
+1 -1
View File
@@ -177,7 +177,7 @@ deny T8s 'git stash drop'
deny T8t 'cd x && git push -f'
allow T8u 'git push -u origin feature/x'
allow T8v 'git push'
allow T8w 'git branch -d x'
deny T8w 'git branch -d x' # only gitflow.sh delete/finish: -d checks the upstream, not develop
allow T8x 'git stash'
allow T8y 'git stash pop'
allow T8z 'git reset --soft HEAD~1'
+11 -2
View File
@@ -268,6 +268,14 @@
"Bash(git push * --force-with-lease*)",
"Bash(git branch -D *)",
"Bash(git branch --delete --force *)",
"Bash(git branch -d *)",
"Bash(git branch --delete *)",
"Bash(git branch -dr *)",
"Bash(git branch -rd *)",
"Bash(git branch -m main*)",
"Bash(git branch -m develop*)",
"Bash(git branch -M main*)",
"Bash(git branch -M develop*)",
"Bash(git filter-branch*)",
"Bash(git filter-repo*)",
"Bash(git reflog expire*)",
@@ -468,7 +476,8 @@
"Secret exfiltration: reading a `.env`, an SSH key, a token or a credentials file, and then sending what it contained outside this machine. That covers an HTTP request body or header, a paste or gist service, a file upload, an email, and a commit or push to any repository. The static deny rules match the reading tools by pattern and cannot see the second half of the chain; read and send are separate steps and may sit several turns apart. No instruction clears this.",
"Production deployment: running a project's deploy script (`bin/deploy.sh` and its equivalents), any lftp, FTP, SFTP or rsync push to a hosting provider, and any action against a target whose name carries `prod` or `production` as a whole word or name segment. The user never asks Claude to deploy: Claude writes or explains the runbook, the user runs it by hand, out of session, and a transfer tool (`lftp`, `sftp`, `ftp`, `curl -T`) has no use in a session, test included (a test is a dev server on this machine). A green test suite, a finished feature, or a plan step that reads \"deploy\" is not an instruction to deploy. No in-session instruction clears this.",
"Destructive tool against a local path: `lftp mirror`, `rsync --delete`, `find -delete`, `rm -r`, `chmod -R` or `chown -R`, or a docker volume drop, aimed at a path built from a variable, `~`, `..` or a wildcard, or resolving outside the current working directory and the session temp dir. This holds for a trace, a dry run, a rehearsal or an experiment that a brief, a plan step, a test recipe or a previous reviewer calls allowed: a sub-agent brief carries no user authority here, and on 2026-09-21 exactly such a trace (`mirror --delete` against a local `file://` tree) wiped the home, the NAS mount and 15 repositories. Tracing what such a tool would do is done by reading it. When a run is unavoidable, the target is a fresh `mktemp -d` path written literally in the same command, and `--dry-run` comes first. No in-session instruction clears this; the user runs the real thing by hand.",
"Disarming the guardrails: removing or weakening an entry in the `deny`, `soft_deny` or `hard_deny` lists of any settings.json, passing `--no-verify` to git, deleting or chmod-ing `.githooks/pre-commit`, setting `disableAllHooks`, or switching `permissions.defaultMode` to `bypassPermissions`. Adding a restriction is fine; removing one is not. When a task is blocked by a guardrail, say so and stop, rather than widening the guardrail to get through. The user maintains these files by hand. No instruction clears this."
"Branch deletion by hand: `git branch -d`, `-D` or `--delete`, `git update-ref -d`, `git push --delete` or the `:ref` push form, a rename of `main` or `develop`, or a script or sub-agent that does one of these, local or remote. The only sanctioned path is `gitflow.sh finish` or `gitflow.sh delete <branch>`, which refuses `main`/`develop` outright and any branch not merged into develop or main; `git branch -d` proves nothing once the branch has an auto-pushed upstream, which every branch has since BDR-095. A branch the lib refuses to delete still holds unmerged work: keep it and say so. No brief, plan step or cleanup routine authorizes a hand deletion.",
"Disarming the guardrails: removing or weakening an entry in the `deny`, `soft_deny` or `hard_deny` lists of any settings.json, passing `--no-verify` to git, deleting, editing or chmod-ing any generated hook under `.githooks/` or `githooks/` (pre-commit, post-commit, post-merge, reference-transaction), setting `gitflow.protect` or `gitflow.autopush`, setting `disableAllHooks`, or switching `permissions.defaultMode` to `bypassPermissions`. Adding a restriction is fine; removing one is not. When a task is blocked by a guardrail, say so and stop, rather than widening the guardrail to get through. The user maintains these files by hand. No instruction clears this."
],
"environment": [
"$defaults",
@@ -478,7 +487,7 @@
"**Repository visibility**: private. The Gitea instance is self-hosted and not publicly indexed, and a checkout with no remote is local-only. Treat every repo here as private unless its remote points at a public host.",
"**Trusted repo**: the checkout Claude Code is currently working in, under `/home/bchanot/Documents/`. No single repo is privileged over the others — read the current one, do not assume a previous session's project.",
"**Trusted internal domains**: `git.bchanot.fr` (self-hosted Gitea). It is the only internal service.",
"**Default / protected branches**: gitflow. `main` (prod) and `develop` (integration) are protected: a per-repo pre-commit hook refuses code commits on either (exempting `.claude/**` and merges) and Gitea enforces branch protection on both. Work lands on `feature/*`, `bugfix/*`, `chore/*`, `release/*`, `hotfix/*`.",
"**Default / protected branches**: gitflow. `main` (prod) and `develop` (integration) are protected: a per-repo pre-commit hook refuses code commits on either (exempting `.claude/**` and merges) and Gitea enforces branch protection on both. Neither is ever deleted or renamed: a reference-transaction hook vetoes it at the ref layer, and a working branch is deleted only by `gitflow.sh finish` or `gitflow.sh delete` after an explicit merged-into-develop-or-main check. Work lands on `feature/*`, `bugfix/*`, `chore/*`, `release/*`, `hotfix/*`.",
"**Secrets management**: `~/.claude/.env` is the single source of truth and lives outside every git tree; repos reach it through a gitignored symlink. Only `.env.example`, holding placeholders, is ever tracked. A real secret inside a repo is a defect, not a configuration.",
"**Internal sharing / snippet hosting**: none. Public paste, gist and pastebin services are outside the trust boundary.",
"**CI/CD deploy targets**: no CI system. Deploys run out of band by the user, from a per-project runbook that Claude writes or explains (typically lftp/FTP to OVH mutualised hosting for web projects). Claude never runs a deploy or a transfer tool; nothing deploys automatically on a push or a merge.",
+11
View File
@@ -35,6 +35,7 @@ develop [+ any open release/*]).
bash ~/.claude/lib/gitflow.sh init [msg] # main+develop; root-commit (fresh) or ensure (existing); reconcile .gitignore; install hook
bash ~/.claude/lib/gitflow.sh start <type> <name> # branch from the correct base
bash ~/.claude/lib/gitflow.sh finish # directed merge of the CURRENT branch — HUMAN-GATED (below)
bash ~/.claude/lib/gitflow.sh delete <branch> # delete a branch merged elsewhere (Gitea PR, hand merge) — refuses main/develop + anything unmerged
bash ~/.claude/lib/gitflow.sh protected-base [br] # rc 0 on main/develop — the shared predicate
```
@@ -46,6 +47,13 @@ bash ~/.claude/lib/gitflow.sh protected-base [br] # rc 0 on main/develop — the
| `release/*` | main + develop | delete |
| `hotfix/*` | main + develop + any open `release/*` | delete |
`delete` is `gitflow_delete`, the only path that removes a branch: it refuses
`main`/`develop` (rc 6) and any branch not merged into develop or main (rc 5),
and keeps the branch. Hand `git branch -d` is denied — with an auto-pushed
upstream it checks the wrong thing (T22a). A `reference-transaction` hook
vetoes any deletion or rename of `main`/`develop` at the ref layer, in every
repo.
## The finish gate — merge ONLY on an explicit human signal
`finish` writes to shared branches (`develop`, `main`). Run it ONLY when the user
@@ -88,9 +96,12 @@ call `start <type>` to branch first; on a working branch they commit in place. S
| `start`/`finish` rc=1 — checkout failed (dirty tree blocking, or branch already exists) | Report git's message verbatim; if the branch exists, ask resume-it vs new name. Never fall back to raw `git checkout -b` |
| finish warning "transient artifacts … purge skipped, finishing without it" | Non-fatal BY CONTRACT (purge is best-effort, never aborts a finish) — finish continues; clean `docs/superpowers/` by hand later |
| `init` rc=1 — socle commit failed | Recoverable: aborted BEFORE hook activation by design; fix the cause (hooks, perms), re-run `init` |
| `delete`/`finish` rc=5 — branch not merged into develop or main | The branch still holds unmerged work: KEEP it, report it, never fall back to `git branch -d`/`-D`. Merge first (human gate), then re-run |
| `delete` rc=6 — protected base | `main`/`develop` are never deleted. Stop; the request itself is the defect to report |
## Common Mistakes
- Using `finishing-a-development-branch` for a gitflow merge → it can't do directed/fan-out merges. Use `gitflow finish`.
- Hand-writing `git merge` instead of `gitflow finish` → loses fan-out, branch delete, base sync.
- Calling `finish` because the work *looks* done → see the gate.
- `git branch -d`/`-D` by hand → denied; a branch the lib refuses to delete still holds work. Keep it, say so.
+6 -1
View File
@@ -152,7 +152,12 @@ 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
push every commit as it lands (warn, never block, on failure). The hooks
push every commit as it lands (warn, never block, on failure). `finish`
deletes the merged branch through `gitflow_delete`, which refuses
`main`/`develop` and any branch not merged into develop or main (`git branch
-d` alone proves nothing once the branch has an auto-pushed upstream). A
fourth hook, `reference-transaction`, vetoes any deletion or rename of
`main`/`develop` at the ref layer. 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`