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:
+58
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user