PABCD Initiative Documentation Hub pabcd_initiative codexclaw cli-jaw

Branch Lifecycle — Cleanup, Automation, and Deletion Evidence

Source: dev-devops/references/branch-lifecycle.md

Branch Lifecycle — Cleanup, Automation, and Deletion Evidence

Last reviewed: 2026-08-26

Applies to: GitHub repositories with pull-request workflows; git worktrees

When to read: Stale branch/worktree cleanup, or automating branch deletion

Canonical owner: dev-devops §2.9

---

§1 The Two Halves of Branch Deletion

GitHub's repository setting delete_branch_on_merge covers exactly one case:

the head branch of a merged pull request. It does nothing for a pull request

closed without merging, and nothing for a branch that never had a pull request.

# What the host already handles
gh api repos/OWNER/REPO --jq .delete_branch_on_merge   # true => merged heads auto-delete
Branch originHandled by delete_branch_on_mergeNeeds automation
Head of a merged PRYesNo
Head of a PR closed unmergedNoYes — scheduled job
Never had a PRNoNo — human judgment, not automation

The third row is deliberate. A branch with no pull request has no recorded

terminal decision, so no rule can infer that its work was abandoned. Automating

it deletes work that was merely parked.

> Verified case: a delivery repository had delete_branch_on_merge enabled and still

> accumulated 59 deletable remote branches. Enabling the setting and calling

> branch hygiene done is the single most common version of this mistake.

---

§2 Deletion Plan — Keep Rules

Every keep rule below exists because the opposite behavior destroys work that is

still referenced. Apply them as an ordered filter over candidate branches.

#Keep whenBecause
1Branch is protected (main, dev, preview, gh-pages, or host-marked protected)Integration and release lines are never candidates
2Any PR that ever used it as a head is mergedReopening a PR whose head branch is gone cannot restore commits
3Any PR that ever used it as a head is openDeleting an open PR's head closes the PR
4It is the base of an open PRDeleting a stack parent closes the open child
5Any related PR is cross-repository (fork)The ref lives in the contributor's repository
6closed_at is missing on a related closed PRCannot compute eligibility; fail closed
7Newest closed_at is inside the grace periodLeaves room to reopen a mistaken close
8No PR ever used it as a headNo recorded terminal decision; out of automation scope

Rules 2 and 3 quantify over every PR that used the branch as a head, not the

most recent one. A branch reused across several PRs is common, and one merged or

open PR anywhere in that set is enough to keep it.

Rule 5 compares repository ids:

// Correct: a fork commonly reuses upstream branch names
const isCrossRepository =
  !pr.head || !pr.head.repo || pr.head.repo.id !== pr.base.repo.id;

// Wrong: name comparison silently misclassifies same-name forks
// const isCrossRepository = pr.head.label.startsWith(owner) === false;

Re-check host branch protection at delete time, not only at plan time. The plan

is a snapshot; protection is authoritative.

---

§3 Scheduled Automation Shape

A closed-PR cleanup job is a scheduled workflow with a narrow token, and its

trigger surface is a security boundary.

PropertySettingWhy
Triggerschedule onlyA branch-selected workflow_dispatch would run *that branch's* body with contents: write, bypassing default-branch review
Workflow permissions{}Grant at job level only
Job permissionscontents: write, pull-requests: readDelete refs; read PR state the keep rules need
Third-party actionsFull commit SHA pins + version commentMutable refs are a supply-chain hole
Planning logicPure module, unit-testedKeep rules must be testable without Actions or a live repository
Delete errorsTreat 404/422 as "already gone"The ref can move between plan and delete

> Scheduled workflows run only from the repository default branch. Landing

> the workflow on an integration branch like dev does not start it. It begins

> running when the change is promoted to the default branch — say so explicitly

> in the PR, or the job's silence reads as a defect.

Extract the deletion plan as a pure function so the keep rules can be tested

directly. Each rule in §2 deserves a test whose failure means real work is

destroyed: merged head, open head, stacked base, fork head, grace period,

missing timestamp, protected branch.

---

§4 Manual Cleanup Procedure

For a one-time cleanup of an already-degraded repository, work in this order.

Order matters: worktrees hold branches, and a held branch cannot be deleted.

1. Snapshot first (DEVOPS-BRANCH-SNAPSHOT-01).

CLEANUP_DIR="$(mktemp -d)"   # or a gitignored .tmp/ path in the worktree
git for-each-ref --format='%(objectname) %(refname)' refs/heads refs/remotes \
  > "$CLEANUP_DIR/refs-snapshot.txt"
git worktree list --porcelain > "$CLEANUP_DIR/worktrees-snapshot.txt"
gh pr list --state all --limit 1000 \
  --json number,state,headRefName,baseRefName,mergedAt,closedAt,isCrossRepository \
  > "$CLEANUP_DIR/prs.json"

Restoring a deleted remote branch needs only its SHA:

git push origin <sha>:refs/heads/<name>

2. Classify every ref against live PR state, not against name patterns.

Join branches to PRs by head ref, then apply §2. git branch -r --merged alone

is insufficient: squash-merge rewrites commits, so a squash-merged branch is not

an ancestor of the target and reads as unmerged.

3. Audit worktrees before removing them (DEVOPS-WORKTREE-DIRTY-01).

git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; do
  n="$(git -C "$wt" status --porcelain --untracked-files=no 2>/dev/null | wc -l)"
  [ "$n" != "0" ] && echo "DIRTY $n $wt"
done

A detached-HEAD worktree needs its own check: if its commit is reachable from no

branch and sits ahead of the integration line, it holds unique work.

git branch -a --contains <sha>            # empty => referenced by no branch
git rev-list --count origin/dev..<sha>    # >0 => carries commits dev does not

4. Delete in order: worktrees, then remote branches, then local branches.

git worktree remove --force <path>          # only after the dirty audit
git push --no-verify origin ':branch-a' ':branch-b'
git branch -D branch-a branch-b

--no-verify is often required because a pre-push hook that assumes a pushed

ref will reject a delete refspec. git branch -D (not -d) is correct for a

squash-merged branch, since -d demands ancestry the squash destroyed — but

only after §2 supplied independent evidence.

5. Set fetch.prune so future upstream deletions clean local tracking refs:

git config fetch.prune true

Note that git remote prune <fork> removes only *local tracking* refs for

branches already deleted upstream. It does not, and must not, touch the fork.

---

§5 Anti-Patterns

BannedWhyFix
delete_branch_on_merge alone, called doneClosed-unmerged PRs still accumulate foreverAdd the scheduled closed-PR job (§3)
Bulk-prune by name patternPatterns do not encode PR state, stacks, or forksPer-branch evidence (§2)
git branch -r --merged as sole merge proofSquash-merge breaks ancestry; merged branches read unmergedJoin to live PR state
Deleting a closed stack parentCloses the open child PRKeep any base of an open PR
git worktree remove --force before a dirty checkDiscards uncommitted work irrecoverablyAudit every worktree first
Deleting refs with no snapshotRemote deletions are unrecoverable without the SHAfor-each-ref snapshot first
workflow_dispatch on a contents: write cleanup jobRuns a chosen branch's body with write scopeSchedule-only trigger
Fork branch matching by nameSame-name forks get misclassified as localCompare repo ids

---

§6 Source

Measured branch and worktree cleanup, 2026-08-26. The repository carried 101

remote branches, 228 local branches, and 67 worktrees; 59 remote, 177 local, and

59 worktrees were removed against per-branch evidence, with open PRs, protected

lines, dirty worktrees, and one unreferenced detached commit preserved.

The automation gap in §1 was found during that cleanup and closed by

.github/workflows/cleanup-closed-pr-branches.yml with the plan extracted to

.github/scripts/closed-pr-branch-cleanup.cjs (lidge-jun/opencodex#2664,

merged as bae100aa7).