Cleaning Up Stale Git Branches Locally & On Github

3 min read Kane Jamison Kane Jamison

I've built up tons of stale branches and wanted to clean them up locally and on origin. These commands take care of that.

⚠️
Obviously, if you are working in a repo that might have historical / legacy reasons for keeping all of these branches, maybe don't run these commands without checking the branches you'll be deleting.

How to Delete Local Branches That Have Been Deleted From Github / Origin

Nowadays in my repos I configure Github to "Automatically delete head branches" after we squash and merge. But I didn't used to do that before the feature existed, so we have a lot of branches hanging around on Github that could be deleted.

But as far as I can tell, those branches still stick around on my local device.

So this first command deletes all local branches that no longer exist on origin, except for my current branch:

git fetch -p && git branch -vv | grep ': gone]' | awk '{print $1}' | xargs -I {} git branch -D {}

It shouldn't delete:

  1. Local-only branches — branches you created but never pushed
  2. Branches still on origin — even if you don't care about them locally, if they exist remotely, they're kept
  3. Your current branch — git won't let you delete the branch you're on

Since this is one I'll run periodically moving forward, I've also set it up as a zsh alias in my .zshrc file.

# git helpers
alias cleanbranches="git fetch -p && git branch -vv | grep ': gone]' | awk '{print $1}' | xargs -I {} git branch -D {}"

How to Delete Github / Origin Branches That Aren't Attached to PRs

Now - we used to not automatically delete branches on squash and merge. So that means we also have a ton of historical branches on Github (130+) but we only have ~40 live Pull Requests, meaning there's a good 70-90 branches on Github that we probably don't need any more.

So, this command checks what branches exist on github that don't have a PR attached to them:

gh api repos/:owner/:repo/branches --paginate --jq '.[].name' | while read branch; do
  if [ "$branch" != "main" ] && [ "$branch" != "master" ]; then
    pr_count=$(gh pr list --head "$branch" --state all --json number --jq 'length')
    if [ "$pr_count" -eq 0 ]; then
      echo "$branch"
    fi
  fi
done

Then this version of the command changes the echo to a delete command to actually delete them:

gh api repos/:owner/:repo/branches --paginate --jq '.[].name' | while read branch; do
  if [ "$branch" != "main" ] && [ "$branch" != "master" ]; then
    pr_count=$(gh pr list --head "$branch" --state all --json number --jq 'length')
    if [ "$pr_count" -eq 0 ]; then
      git push origin --delete "$branch"
    fi
  fi
done

What this does:

  1. Lists all branches on the remote
  2. Skips main/master
  3. Checks if any PR (open, closed, or merged) exists with that branch as the head
  4. Deletes branches with zero associated PRs

This command is a little slow since it does an API call for every branch, but still only took a minute or so.

How to Delete Github / Origin Branches That Are Attached to Closed / Merged PRs

That last command does not take care of our old branches attached to closed PRs, though. This command will list those branches that are attached to PRs which were closed or merged:

gh api repos/:owner/:repo/branches --paginate --jq '.[].name' | while read branch; do
  if [ "$branch" != "main" ] && [ "$branch" != "master" ]; then
    open_pr_count=$(gh pr list --head "$branch" --state open --json number --jq 'length')
    if [ "$open_pr_count" -eq 0 ]; then
      echo "$branch"
    fi
  fi
done

And again, this next command changes echo to delete and actually deletes them:

gh api repos/:owner/:repo/branches --paginate --jq '.[].name' | while read branch; do
  if [ "$branch" != "main" ] && [ "$branch" != "master" ]; then
    open_pr_count=$(gh pr list --head "$branch" --state open --json number --jq 'length')
    if [ "$open_pr_count" -eq 0 ]; then
      git push origin --delete "$branch"
    fi
  fi
done

Again, this command is a little slow since it does an API call for every branch, but still only took a minute or so.