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
The only question that makes deletion safeIf every commit on a branch is reachable from the default branch, deleting the ref discards nothing. If any commit is unreachable, the ref is the only thing keeping that work alive, and deletion makes it recoverable only through the reflog on one machine.Are all of this branch's commits reachable from main?yes — fully mergedSafe to deletethe ref is the only lossno — unique commitsAsk a persondeletion loses workage tells you nothing about this; reachability tells you everything

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.

What an unmerged branch list usually containsMost unmerged branches carry a handful of commits that were superseded or abandoned. A small number carry substantial work that was paused, and those are the ones worth a conversation rather than a deletion policy.unmerged branches by unique commit count1-2 commits383-10 commits14more than 105the right-hand bar is why blanket deletion by age is indefensible

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'
What the idle sweep is allowed to doThe weekly sweep finds pull requests with no activity for three weeks, comments asking the author whether the work is still wanted, and stops. Closing, reassigning and deleting all require a person, because each of them destroys context the bot cannot evaluate.Weekly sweepno activity, 21 daysComment to the authorstill wanted?Author answersor does notA person decidesclose, revive, splitthe bot's job ends at the third box

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.