Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Automation By James Joyner IV · · 10 min read

Automating Stale Branch and PR Cleanup With AI Guardrails

Use AI and the GitHub API to find, summarize, and safely retire stale branches and abandoned pull requests with notify-then-wait grace periods and human gates.

  • #automation
  • #github
  • #git
  • #cleanup

I ran git branch -r | wc -l on our main monorepo last quarter and got back a number I will not repeat in polite company. Hundreds of remote branches. Dozens of pull requests last touched during an administration that no longer holds office. Half of them merged long ago and never deleted; the other half abandoned mid-thought, their authors having moved teams, companies, or careers. The repo had become an archaeological dig.

The instinct is to write a one-liner that nukes everything older than ninety days. Resist it. A branch is cheap to keep and occasionally expensive to lose, and the moment you automate deletion you have built a machine that can quietly destroy someone’s unmerged work. What follows is the approach I actually shipped: AI does the tedious reading and triage, the GitHub API does the listing, and a slow, gated, recoverable workflow does the retiring. The model is a fast junior engineer. It drafts; a human signs.

List branches by age and merge status

You cannot clean up what you have not measured. Start with a read-only inventory. The GitHub CLI plus a little jq gets you every branch with its last commit date and whether it is merged into the default branch.

# Every branch sorted by last commit, oldest first
git for-each-ref --sort=committerdate refs/remotes/origin \
  --format='%(committerdate:short) %(refname:short)'

# Merged-into-main branches are the safe first wave
git branch -r --merged origin/main \
  | grep -v 'origin/main$' \
  | grep -v 'origin/HEAD'

For richer metadata than the local clone holds, hit the REST API. GraphQL is better when you want commit dates and associated PRs in a single round trip:

gh api graphql -f query='
{
  repository(owner: "acme", name: "platform") {
    refs(refPrefix: "refs/heads/", first: 100,
         orderBy: {field: TAG_COMMIT_DATE, direction: ASC}) {
      nodes {
        name
        target { ... on Commit { committedDate } }
        associatedPullRequests(first: 1) {
          nodes { number state }
        }
      }
    }
  }
}'

That output is your raw material. Note that “stale” is two distinct populations: merged branches that nobody pruned (low risk) and unmerged branches that may contain real work (high risk). Treat them completely differently.

Let AI summarize what an abandoned PR was trying to do

The reason cleanup stalls is that nobody remembers what feature/payment-retry-v3 was for. Reading a six-month-old diff to find out is exactly the kind of toil worth handing off. Pull the PR’s title, body, commit messages, and diff stat, and ask the model for a plain-English summary and a recommendation.

PR=4821
gh pr view $PR --json title,body,commits,files \
  | claude -p "Summarize what this PR was trying to accomplish in 3 sentences. \
State whether the work looks complete, partial, or superseded. \
Do NOT recommend deletion — just describe."

The constraint in that prompt matters. The model summarizes; it does not get a vote on destruction. I have seen confidently wrong “this is obsolete” calls on PRs that were one rebase away from shipping. Use the summary to brief a human, not to skip one. This is the same pattern I lean on for eliminating toil with AI: automate the reading, keep the deciding.

Pro Tip: Feed the model the diff stat (--name-only) rather than the full patch for triage. It is cheaper, it avoids dumping proprietary source into a prompt, and a list of touched files is usually enough to recognize intent. Never hand a model production credentials or write-scoped tokens just to read metadata.

Label and comment before you ever delete

The cardinal rule: never auto-delete on the first pass. The first pass only labels and notifies. Here is the script that marks a stale PR and posts a courteous warning with a deadline.

#!/usr/bin/env bash
set -euo pipefail
CUTOFF=$(date -d '90 days ago' +%Y-%m-%d)

gh pr list --state open --json number,updatedAt,labels \
  --jq ".[] | select(.updatedAt < \"$CUTOFF\") | .number" \
| while read -r PR; do
  gh pr edit "$PR" --add-label "stale"
  gh pr comment "$PR" --body \
"This PR has had no activity since before $CUTOFF and is now labeled \`stale\`.
It will be closed in **14 days** unless someone removes the label or pushes a commit.
The branch is **not** deleted on close and can be restored. Questions? Reply here."
done

The comment does three jobs: it tells a human what will happen, it tells them how to stop it, and it tells them it is reversible. An automated action without a visible back-out path is just a surprise outage waiting to happen.

Build a grace period: label, wait, archive

The lifecycle is a state machine with time built in, not a single cron that swings an axe. Mine runs daily and looks like this:

  1. Day 0 — PR crosses the inactivity threshold. Add stale, post the warning comment. Nothing is destroyed.
  2. Days 1-14 — Grace period. Any commit, comment, or label removal resets the clock. The author owns the outcome.
  3. Day 14 — If still labeled stale and still untouched, close the PR (not delete the branch) and add auto-closed.
  4. Day 44 — Only branches whose PR was auto-closed 30 days prior, and which are merged or empty, become eligible for branch deletion — and that step still requires a human to approve the batch.
# Day-14 close step — closes the PR, leaves the branch intact
gh pr list --state open --label stale --json number,updatedAt \
  --jq ".[] | select(.updatedAt < \"$(date -d '14 days ago' +%Y-%m-%d)\") | .number" \
| while read -r PR; do
  gh pr close "$PR" --comment "Auto-closing after 14-day grace period. Branch retained." \
    --delete-branch=false
done

Closing is reversible with a click. Deleting a branch is a separate, slower, gated stage. Keep them separate. The wait is not a bug; the wait is the whole point. This is approval-gate thinking applied to a background job, the same philosophy behind ChatOps approval gates for AI-suggested actions.

Protect default and release branches with an exclusion list

Every cleanup workflow needs an exclusion list it can never override, and it should fail closed: if the list cannot be read, do nothing. Branch protection rules on GitHub already block deletion of protected branches, but do not rely on a single layer.

# protected.txt holds globs you will never touch
# main
# master
# develop
# release/*
# hotfix/*

is_protected() {
  local branch="$1"
  while read -r pattern; do
    [[ -z "$pattern" || "$pattern" == \#* ]] && continue
    [[ "$branch" == $pattern ]] && return 0
  done < protected.txt
  return 1
}

is_protected "release/2026.06" && echo "SKIP" || echo "eligible"

Scope the blast radius further: run the destructive stage against one repository at a time, cap each batch at a fixed count, and require the workflow to print the full list of targets and get an explicit human approval before acting. A junior engineer who could delete every branch in the org in one command is not someone you give an org-wide token. Neither is a script.

Pro Tip: Add org-defaults, the integration branch your team merges into, and anything matching release/* or v*.*.* to the exclusion list before your first run — these are the ones that look “stale” by commit date but are load-bearing. A branch that has not moved in six months is not the same as a branch nobody needs.

Deletion is recoverable, but still gate it

Here is the reassuring part and the reason people get reckless. Deleting a Git branch deletes a pointer, not the commits. As long as the SHA is reachable, you can bring it back. GitHub keeps deleted branches restorable from the PR page for a long window, and locally the reflog remembers.

# GitHub: restore from the closed PR's branch, or recreate from the SHA
gh api repos/acme/platform/git/refs -f ref="refs/heads/feature/payment-retry-v3" \
  -f sha="9f3c1aa"

# Local: the reflog still knows where the branch pointed
git reflog show feature/payment-retry-v3
git branch feature/payment-retry-v3 9f3c1aa

Recoverability is a safety net, not a license. The SHA is only restorable while it remains reachable and before garbage collection prunes it; on a busy fork or after a git gc --prune=now, that window closes. So before any deletion, the workflow logs the branch name and SHA to a durable record. That log is your back-out path. Treat “we can probably get it back” as the last line of defense, never the first.

Where the human signs

The shape that has held up for me: AI reads and summarizes, the GitHub API lists and labels, a multi-day grace period gives every human a chance to object, an exclusion list protects what matters, and a logged SHA makes the rare mistake recoverable. The model is fast and tireless and occasionally confidently wrong, exactly like a good junior engineer. You would not let a junior force-push a mass deletion on their first week, and you should not let the automation do it either.

Start with the merged-and-pruned safe wave to build trust, keep the destructive stage gated behind a human and a printed target list, and let the grace period do its quiet work. If you want to lift more of the triage with prompts, the automation category and the prompt-packs collection have ready-made starting points, and Claude does a solid job summarizing diffs when you keep it to metadata. Clean repos are not a one-time scrub. They are a slow, gated, reversible habit.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.