feat(gitflow): remove the origin copy of a branch once its merge is verified

`gitflow_delete` now ends with `_gitflow_delete_remote`: after the local
copy is gone, the remote tip is read with `ls-remote --exit-code`, checked
against develop/main with the same ancestor test, and only then removed
with `push origin --delete`. Same contract as the pushes (BDR-095): best
effort, warn never fail. No origin, `GITFLOW_NO_PUSH=1` or
`gitflow.autopush false` skip it; an unreachable origin or a remote tip
holding commits the bases lack keeps the remote branch, loudly. A base is
never targeted, by construction and by an explicit guard.

The static deny on hand `git push --delete` stays: it matches the Bash
tool's command string, the lib is the sanctioned path. Prose (hard_deny,
environment), doctrine, gitflow SKILL (table, op, warning row),
SETTINGS.md and CHANGELOG updated. T24: 9 checks (finish removes the
copy, bases untouched, unmerged remote tip kept, never pushed silent,
unreachable origin loud, autopush opt-out). 161/163, the 2 failures are
the pre-existing T16a (gitleaks absent on this host).
This commit is contained in:
bastien
2026-09-24 11:50:16 +02:00
parent abd1254d66
commit 68c9df354b
7 changed files with 104 additions and 18 deletions
+38 -5
View File
@@ -143,11 +143,43 @@ gitflow_merged_into_base() {
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_remote <br> → remove origin/<br> once the LOCAL copy is gone.
# Same contract as the pushes (BDR-095): best effort, warn never fail; skipped
# under GITFLOW_NO_PUSH=1, gitflow.autopush=false or no origin. The REMOTE tip
# is re-checked against develop/main before the delete: a commit pushed from
# elsewhere that never reached a base (or that this clone has never fetched)
# keeps the remote branch alive, loudly. Never a base, by construction and by
# the explicit guard below.
_gitflow_delete_remote() {
local br="$1" out rc tip
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0
[ "$(git config --bool --default true gitflow.autopush)" = false ] && return 0
git remote get-url origin >/dev/null 2>&1 || return 0
gitflow_protected_base "$br" && return 0
out="$(_gitflow_timeout git ls-remote --exit-code --heads origin "refs/heads/$br" 2>/dev/null)"; rc=$?
[ "$rc" -eq 2 ] && return 0 # no remote copy — nothing to remove
if [ "$rc" -ne 0 ]; then
echo "gitflow: origin unreachable — remote copy of '$br' NOT removed. By hand: git push origin --delete $br" >&2
return 0
fi
tip="${out%%[[:space:]]*}"
if ! gitflow_merged_into_base "$tip"; then
echo "gitflow: origin/$br holds commits not merged into $GITFLOW_DEVELOP or $GITFLOW_MAIN — remote copy KEPT" >&2
return 0
fi
if _gitflow_timeout git push -q origin --delete "$br" >/dev/null 2>&1; then
echo "gitflow: removed origin/$br (tip merged)" >&2
else
echo "gitflow: remote delete of '$br' FAILED — remote copy NOT removed. By hand: git push origin --delete $br" >&2
fi
return 0
}
# gitflow_delete <branch> → the one sanctioned way to delete a branch, local
# copy then origin copy. 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
@@ -162,6 +194,7 @@ gitflow_delete() {
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_delete_remote "$br"
}
# _gitflow_purge_transient → remove the committed transient planning artifacts