Branch cleanup
Authors GitHub Actions workflows that delete stale branches. This skill installs
workflows; it never deletes anything itself. At runtime there is no agent and no model
call — deterministic bash + gh only.
Verified GitHub behaviour (the delete event, ruleset errors, gh footguns, base-branch
semantics) lives in reference/github-facts.md. Read it
before changing any deletion logic.
The one rule that must never be relaxed
Before deleting ANY branch, check for open PRs that target it as base:
gh pr list --base <branch> --state open --json number --limit 1000
GitHub documents it plainly: "If the branch is associated with at least one open pull
request, deleting the branch closes the pull requests." That is often unrecoverable —
you cannot retarget a closed PR, nor reopen it while its base is missing.
Two traps that make this worse than it looks:
gh pr list --limit defaults to 30. A stack deeper than 30 silently under-reports
and the guard passes when it shouldn't. Always pass an explicit high limit.
- Auto-retargeting will not save you. It only applies to a head branch whose PR is
already merged, and it appears to live in the web deletion path — community
reports (one confirmed by GitHub staff as a bug) say deleting a ref via API or
git push --delete closes dependent PRs instead. A bot must assume the close path.
All of this runs inside delete_branch(), so no path can bypass it — head-PR,
base-PR and tip-freshness are re-checked immediately before the API call, after any
earlier filtering. That placement is deliberate: the guards previously drifted between
the event path and the sweep more than once. Each check fails closed: if
the API call errors (a plausible rate-limit mid-sweep, since it runs once per branch), the
branch is skipped rather than assumed safe.
It is also not the only head-side guard: a branch with any open PR from it as head is
skipped too, so a second open PR from the same head can't be destroyed by a stale
closed event for a different one.
What handles what
| Path |
Handled by |
Why |
| PR merged |
the repo's native delete_branch_on_merge setting |
more reliable than an Action racing it — do not duplicate this in a workflow |
| PR closed unmerged |
pull_request: closed + merged == false workflow |
the native setting only fires on merge |
| Branch that never had a PR, or was skipped by a guard |
scheduled orphan sweep |
the backstop; not optional |
Prerequisite: enable delete_branch_on_merge
gh api repos/{owner}/{repo} --jq .delete_branch_on_merge # check
gh api -X PATCH repos/{owner}/{repo} -F delete_branch_on_merge=true # enable
Use -F, not -f — -f sends the string "true". Two facts worth surfacing:
- There is no org-level default.
PATCH /orgs/{org} has no such property, so it must
be set per repo. scripts/enable-auto-delete-org.sh
does it in bulk (dry-run by default, paginates past 100 repos, skips archived/forks and
repos where you lack admin).
- It CAN be set at repo creation via
POST /user/repos and POST /orgs/{org}/repos
(org endpoint needs an org owner to set true) — but not via the
template-generate endpoint, so template-created repos need a follow-up PATCH.
Stacked PRs
Beyond the base-PR guard:
- Grace period (
grace_minutes). Stack tooling force-pushes and sometimes closes
and reopens PRs during a restack, so a closed event is not proof of abandonment.
This is an age filter, never a sleep: inside the window the event path defers to the
sweep, which re-checks live state when it runs. Sleeping in the job would bill Actions
minutes for every closed PR to do nothing. Cost: cleanup latency becomes
grace_minutes + sweep interval. Default 0 — no stack tooling was found in these
repos; set it to ~10 if you adopt GitHub's native stacks (gh stack), Graphite,
ghstack or spr. Native stacks restack the same way: merging a lower layer rebases and
retargets every layer above it, so upper branches move without any human touching them.
- Live re-query, always on. The webhook payload is a snapshot and the run may have
queued, so PR state is re-read at delete time even at
grace_minutes: 0.
excluded_patterns for stack-tool scratch refs (gt/*, spr/*).
- Stacks merge bottom-up in bursts; the sweep paces itself between deletions.
gh stack merge lands a whole stack atomically, so an N-layer stack can produce N
near-simultaneous closures — exactly the burst the pacing exists for.
Other guards
- Never deleted:
main, master, develop, dev, staging, production,
release/*, hotfix/*, and the repo's actual default branch. Configurable.
- Fork PRs skipped — the head branch isn't ours to delete.
- Already-deleted is success. The API returns 422
Reference does not exist, not 404.
- Ruleset-blocked deletions are reported distinctly (422 +
Cannot delete this protected ref) — those need a human, not a retry. Detection keys off the message,
because 422 is overloaded. A gh ruleset check pre-flight is not a reliable
predictor: it reports configured rules and ignores the caller's bypass.
- Branches that advanced after the decision are preserved. Every deletion re-reads
the tip and refuses if it moved. Two traps this navigates, both verified against the
API rather than assumed:
- The event path compares against
github.event.pull_request.head.sha — the
close-time SHA from the webhook — and refuses to delete if that input is
missing. It must not use the PR's headRefOid: that tracks the branch even
after closure, so comparing it to the live tip can never differ.
- The sweep uses the repository activity API for a real
push/force_push/branch_creation timestamp. committedDate is
author-controlled and proves nothing about when a ref moved — a force-push to an
older commit leaves it earlier than the PR closure, and a branch recreated today at
an ancient commit looks years old. The commit date is kept only as a secondary
signal; either firing keeps the branch, and a failed lookup fails closed.
- A tip-SHA comparison is not enough on its own. A branch deleted and recreated at
the same commit — what GitHub's "Restore branch" button does — passes any
SHA check unchanged. So every deletion also re-reads ref activity and refuses if
the ref was pushed or recreated after the decision point (PR closure for the event
path, sweep start for the sweep). Ref identity is not its SHA alone.
- Sweep is report-only by default — it lists what it would delete; deleting requires
sweep_delete: true.
Known failure mode
Merge queues can stop the native auto-delete from firing even when enabled, and
gh pr merge --delete-branch on a queue-enabled repo has been reported to delete the
branch before merge, closing the PR and evicting it from the queue. Neither is
officially documented. The scheduled sweep is what covers the first case.
Rolling out across many repos
- Pin the reusable workflow to a full commit SHA — it holds
contents: write.
- Replace
YOUR-ORG in the caller's uses: with your organisation.
- Start with
sweep_delete: false (the default) and read a few reports before enabling
deletion — the sweep is the path that touches branches nobody explicitly closed.
Install
- Host
templates/reusable-branch-cleanup.yml
once in the org's .github repo. Do not copy it per repo.
- Add
templates/caller-branch-cleanup.yml to
each repo (~30 lines). Replace YOUR-ORG and keep the
uses: pinned to a full commit SHA (the template ships an obvious
placeholder so it cannot silently run unpinned; a tag is mutable and can be moved). It forwards a delete-capable token; @main would let any change there
take effect across every repo at once.
- Enable
delete_branch_on_merge (above).
- Private repos: on the hosting repo set Settings → Actions → General → Access
to "Accessible from repositories in the organization", or every caller fails.
- For new repos, add a
workflow-templates/ entry in the org .github repo. A public
.github repo is no longer required (changed 2025-09-18) — an internal one serves
internal + private repos.
- Test with
workflow_dispatch and dry_run: true first.
Installation is idempotent: detect an existing workflow, diff it, offer an upgrade — never
clobber local edits without showing them first. Validate generated YAML (actionlint if
available, else a YAML parse) before writing.
See also
vercel-preview-cleanup — the downstream
companion: deleting a branch is what triggers preview-deployment cleanup. Install this
skill first; without branch deletion its delete event rarely fires.
- Neon's Vercel integration reaps preview database branches once the git branch is
gone — no code needed, another reason timely deletion matters.
1---2name: branch-cleanup3description: Install GitHub Actions workflows that delete stale git branches safely — the closed-without-merging path plus a scheduled orphan sweep — and enable the native delete_branch_on_merge setting that handles merged PRs. Host-agnostic: no Vercel, Neon or any external service required, works in any GitHub repo. Never deletes a branch that open PRs target as their BASE (that would CLOSE those PRs and destroy a stack). Use when asked to clean up / delete stale, merged, abandoned or orphaned branches, stop branches piling up, auto-delete branches after merge, enable delete_branch_on_merge across an org, add a branch retention or sweep workflow, or safely prune branches in a repo that uses stacked PRs — GitHub's native stacks (gh stack), Graphite, ghstack or spr.4---56# Branch cleanup78Authors GitHub Actions workflows that delete stale branches. **This skill installs9workflows; it never deletes anything itself.** At runtime there is no agent and no model10call — deterministic bash + `gh` only.1112Verified GitHub behaviour (the `delete` event, ruleset errors, `gh` footguns, base-branch13semantics) lives in [`reference/github-facts.md`](./reference/github-facts.md). Read it14before changing any deletion logic.1516## The one rule that must never be relaxed1718**Before deleting ANY branch, check for open PRs that target it as base:**1920```bash21gh pr list --base <branch> --state open --json number --limit 100022```2324GitHub documents it plainly: *"If the branch is associated with at least one open pull25request, deleting the branch closes the pull requests."* That is often **unrecoverable** —26you cannot retarget a closed PR, nor reopen it while its base is missing.2728Two traps that make this worse than it looks:2930- **`gh pr list --limit` defaults to 30.** A stack deeper than 30 silently under-reports31 and the guard passes when it shouldn't. Always pass an explicit high limit.32- **Auto-retargeting will not save you.** It only applies to a *head* branch whose PR is33 *already merged*, and it appears to live in the **web** deletion path — community34 reports (one confirmed by GitHub staff as a bug) say deleting a ref via API or35 `git push --delete` **closes** dependent PRs instead. A bot must assume the close path.3637All of this runs **inside `delete_branch()`**, so no path can bypass it — head-PR,38base-PR and tip-freshness are re-checked immediately before the API call, after any39earlier filtering. That placement is deliberate: the guards previously drifted between40the event path and the sweep more than once. Each check **fails closed**: if41the API call errors (a plausible rate-limit mid-sweep, since it runs once per branch), the42branch is skipped rather than assumed safe.4344It is also not the only head-side guard: a branch with *any* open PR from it as head is45skipped too, so a second open PR from the same head can't be destroyed by a stale46`closed` event for a different one.4748## What handles what4950| Path | Handled by | Why |51| --- | --- | --- |52| PR **merged** | the repo's native `delete_branch_on_merge` setting | more reliable than an Action racing it — **do not** duplicate this in a workflow |53| PR **closed unmerged** | `pull_request: closed` + `merged == false` workflow | the native setting only fires on merge |54| Branch that **never had a PR**, or was skipped by a guard | scheduled orphan sweep | the backstop; **not optional** |5556### Prerequisite: enable `delete_branch_on_merge`5758```bash59gh api repos/{owner}/{repo} --jq .delete_branch_on_merge # check60gh api -X PATCH repos/{owner}/{repo} -F delete_branch_on_merge=true # enable61```6263Use `-F`, not `-f` — `-f` sends the string `"true"`. Two facts worth surfacing:6465- **There is no org-level default.** `PATCH /orgs/{org}` has no such property, so it must66 be set per repo. [`scripts/enable-auto-delete-org.sh`](./scripts/enable-auto-delete-org.sh)67 does it in bulk (dry-run by default, paginates past 100 repos, skips archived/forks and68 repos where you lack admin).69- **It CAN be set at repo creation** via `POST /user/repos` and `POST /orgs/{org}/repos`70 (org endpoint needs an org owner to set `true`) — but **not** via the71 template-generate endpoint, so template-created repos need a follow-up PATCH.7273## Stacked PRs7475Beyond the base-PR guard:7677- **Grace period (`grace_minutes`).** Stack tooling force-pushes and sometimes **closes78 and reopens** PRs during a restack, so a `closed` event is *not* proof of abandonment.79 This is an **age filter, never a sleep**: inside the window the event path defers to the80 sweep, which re-checks live state when it runs. Sleeping in the job would bill Actions81 minutes for every closed PR to do nothing. Cost: cleanup latency becomes82 `grace_minutes + sweep interval`. **Default 0** — no stack tooling was found in these83 repos; set it to ~10 if you adopt **GitHub's native stacks (`gh stack`)**, Graphite,84 ghstack or spr. Native stacks restack the same way: merging a lower layer rebases and85 retargets every layer above it, so upper branches move without any human touching them.86- **Live re-query, always on.** The webhook payload is a snapshot and the run may have87 queued, so PR state is re-read at delete time even at `grace_minutes: 0`.88- **`excluded_patterns`** for stack-tool scratch refs (`gt/*`, `spr/*`).89- Stacks merge bottom-up in **bursts**; the sweep paces itself between deletions.90 `gh stack merge` lands a whole stack **atomically**, so an N-layer stack can produce N91 near-simultaneous closures — exactly the burst the pacing exists for.9293## Other guards9495- **Never deleted:** `main`, `master`, `develop`, `dev`, `staging`, `production`,96 `release/*`, `hotfix/*`, and the repo's actual default branch. Configurable.97- **Fork PRs skipped** — the head branch isn't ours to delete.98- **Already-deleted is success.** The API returns 422 `Reference does not exist`, not 404.99- **Ruleset-blocked deletions are reported distinctly** (422 + `Cannot delete this100 protected ref`) — those need a human, not a retry. Detection keys off the *message*,101 because 422 is overloaded. A `gh ruleset check` pre-flight is **not** a reliable102 predictor: it reports configured rules and ignores the caller's bypass.103- **Branches that advanced after the decision are preserved.** Every deletion re-reads104 the tip and refuses if it moved. Two traps this navigates, both verified against the105 API rather than assumed:106 - The event path compares against `github.event.pull_request.head.sha` — the107 **close-time** SHA from the webhook — and **refuses to delete if that input is108 missing**. It must *not* use the PR's `headRefOid`: that **tracks the branch even109 after closure**, so comparing it to the live tip can never differ.110 - The sweep uses the **repository activity API** for a real111 `push`/`force_push`/`branch_creation` timestamp. `committedDate` is112 **author-controlled** and proves nothing about when a ref moved — a force-push to an113 older commit leaves it earlier than the PR closure, and a branch *recreated* today at114 an ancient commit looks years old. The commit date is kept only as a secondary115 signal; either firing keeps the branch, and a failed lookup fails closed.116 - **A tip-SHA comparison is not enough on its own.** A branch deleted and recreated at117 the *same* commit — what GitHub's **"Restore branch"** button does — passes any118 SHA check unchanged. So every deletion also re-reads ref *activity* and refuses if119 the ref was pushed or recreated after the decision point (PR closure for the event120 path, sweep start for the sweep). Ref identity is not its SHA alone.121- **Sweep is report-only by default** — it lists what it would delete; deleting requires122 `sweep_delete: true`.123124## Known failure mode125126**Merge queues can stop the native auto-delete from firing** even when enabled, and127`gh pr merge --delete-branch` on a queue-enabled repo has been reported to delete the128branch *before* merge, closing the PR and evicting it from the queue. Neither is129officially documented. The scheduled sweep is what covers the first case.130131## Rolling out across many repos132133- **Pin the reusable workflow to a full commit SHA** — it holds `contents: write`.134- **Replace `YOUR-ORG`** in the caller's `uses:` with your organisation.135- Start with `sweep_delete: false` (the default) and read a few reports before enabling136 deletion — the sweep is the path that touches branches nobody explicitly closed.137138## Install1391401. Host [`templates/reusable-branch-cleanup.yml`](./templates/reusable-branch-cleanup.yml)141 **once** in the org's `.github` repo. Do not copy it per repo.1422. Add [`templates/caller-branch-cleanup.yml`](./templates/caller-branch-cleanup.yml) to143 each repo (~30 lines). **Replace `YOUR-ORG`** and keep the144 `uses:` pinned to a **full commit SHA** (the template ships an obvious145 placeholder so it cannot silently run unpinned; a tag is mutable and can be moved). It forwards a delete-capable token; `@main` would let any change there146 take effect across every repo at once.1473. Enable `delete_branch_on_merge` (above).1484. **Private repos:** on the *hosting* repo set **Settings → Actions → General → Access**149 to "Accessible from repositories in the organization", or every caller fails.1505. For new repos, add a `workflow-templates/` entry in the org `.github` repo. A **public**151 `.github` repo is no longer required (changed 2025-09-18) — an *internal* one serves152 internal + private repos.1536. Test with `workflow_dispatch` and `dry_run: true` first.154155Installation is idempotent: detect an existing workflow, diff it, offer an upgrade — never156clobber local edits without showing them first. Validate generated YAML (`actionlint` if157available, else a YAML parse) before writing.158159## See also160161- [`vercel-preview-cleanup`](../vercel-preview-cleanup/SKILL.md) — the **downstream**162 companion: deleting a branch is what triggers preview-deployment cleanup. Install this163 skill first; without branch deletion its `delete` event rarely fires.164- Neon's Vercel integration reaps preview **database** branches once the git branch is165 gone — no code needed, another reason timely deletion matters.