Closing stale branches and pull requests Jump to heading
Branch lists grow without limit because deleting a branch feels risky and nobody is responsible for it. Three years later git branch -r returns four hundred names, tab completion is useless, and finding the release branch takes a search. The temptation is a bot that deletes anything untouched for ninety days — which will eventually delete work somebody needed. The distinction that makes cleanup safe is reachability, not age, and this recipe builds on it within pull request automation and bots.
When to use this approach Jump to heading
- Remote branch listings are long enough that people stop reading them.
- Merged branches persist after their pull requests close.
- Old pull requests sit open with no indication of whether they are alive.
- Fetches are slow because of ref count rather than object count.
- If your repository has twenty branches, do this by hand once a quarter and skip the automation.
Step 1 — Separate reachable branches from the rest Jump to heading
A branch whose tip is an ancestor of the default branch contains nothing that would be lost. That is the only category safe to delete without asking.
git fetch --prune origin
# Fully merged: every commit is reachable from the default branch
git branch -r --merged origin/main | sed 's|origin/||' | grep -vE '^\s*(main|HEAD)' > /tmp/merged.txt
# Not merged: these contain commits that exist nowhere else
git branch -r --no-merged origin/main | sed 's|origin/||' > /tmp/unmerged.txt
wc -l /tmp/merged.txt /tmp/unmerged.txt # Verification: prove a merged branch adds nothing
git log --oneline origin/main..origin/feature-x | wc -l # expect 0 Step 2 — Delete merged branches automatically at merge time Jump to heading
The cleanest moment to delete a branch is when its pull request merges, because that is exactly when it becomes reachable.
# Repository setting: delete the head branch on merge
gh api -X PATCH repos/:owner/:repo -f delete_branch_on_merge=true
# Verification
gh api repos/:owner/:repo --jq '.delete_branch_on_merge' # Sweep the backlog once, after reading the list
while read -r b; do
case "$b" in main|master|release/*|preview) continue ;; esac
git push origin --delete "$b"
done < /tmp/merged.txt Exclude long-lived branches by name rather than trusting the merged check. A release branch can be fully merged and still be the thing your deployment tracks — the pattern described in environment and deployment branches.
Step 3 — Triage the unmerged list by author, not by date Jump to heading
Unmerged branches need a decision from someone who knows what they were for. Group them so the conversation is short.
# Unmerged branches with their last committer and age
while read -r b; do
git log -1 --format='%ci %an %(refname:short)' "origin/$b" 2>/dev/null \
| sed "s|\$| $b|"
done < /tmp/unmerged.txt | sort | head -30 # How much unique work would be lost, per branch?
while read -r b; do
n=$(git rev-list --count "origin/main..origin/$b" 2>/dev/null || echo 0)
printf '%4s commits %s\n' "$n" "$b"
done < /tmp/unmerged.txt | sort -rn | head -20 A branch with two commits from someone who left is a different conversation from one with forty commits from an active colleague. The count is what makes the triage quick.
Step 4 — Archive instead of deleting when nobody is sure Jump to heading
A tag costs nothing and keeps the commits reachable forever, which turns an irreversible decision into a reversible one.
# Archive a branch as a tag, then delete the branch
b=feature/old-payments-rework
git tag "archive/$b" "origin/$b"
git push origin "archive/$b"
git push origin --delete "$b" # Verification: the work is still reachable and restorable
git log --oneline -3 "archive/$b"
git switch -c restored-payments "archive/$b" # if anyone ever needs it SAFETY WARNING — deleting an unmerged branch removes the last reference to those commits on the server. They survive locally in reflogs for a while and become unreachable objects on the remote, subject to garbage collection. Once collected, the work is gone. Archive-then-delete costs one tag and removes that risk entirely; recovery techniques are covered in recovering lost commits with git reflog.
Step 5 — Notify about idle pull requests, never close them Jump to heading
For pull requests the equivalent distinction is between reminding and deciding. A bot may do the first.
# .github/workflows/idle.yml
name: idle
on:
schedule: [{ cron: '0 9 * * 1' }]
permissions:
pull-requests: write
jobs:
nudge:
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ github.token }}
run: |
cutoff=$(date -u -d '21 days ago' +%Y-%m-%d)
gh pr list --state open --search "updated:<$cutoff" --json number,title,author \
--jq '.[] | "\(.number)\t\(.author.login)\t\(.title)"' |
while IFS=$(printf '\t') read -r n who title; do
gh pr comment "$n" --body "@$who this has been quiet for three weeks. Still wanted? If it is blocked, say what on — otherwise it will keep drifting."
done # Verification: the job comments and nothing changes state
gh pr list --state open --search 'updated:<2026-08-28' --json number --jq 'length' Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Is deleting a merged branch ever wrong? Jump to heading
Only if something references the branch name — a deployment tracking it, a documentation link, a pipeline trigger. The commits are safe by definition. Exclude named branches explicitly and the operation is reversible in any case, since the name can be recreated at the same commit.
How do we handle branches from people who have left? Jump to heading
Triage them like any other: reachable ones go, unmerged ones get archived as tags with a note. Offboarding is a good moment to do this sweep deliberately rather than leaving it to a policy — see offboarding a developer from every repository.
Do stale branches actually cost anything? Jump to heading
Refs cost little space but real attention: every listing, every tab completion, every search gets noisier. The measurable cost shows up in fetch time once ref counts reach the thousands, and in the time people spend working out which of five similarly named branches is current.
Related Jump to heading
- Pull Request Automation & Bots — the parent topic and the automation boundary.
- Recovering Lost Commits With git reflog — what recovery looks like when deletion went too far.
- Environment & Deployment Branches — the long-lived branches cleanup must never touch.