A long time ago, I wrote a useful set of git aliases to support the GitHub flow. My favorite alias was bdone which would:

  1. Checkout the default branch.
  2. Run git up to make sure you’re up to date.
  3. Run git bclean to delete all the branches that have been merged into the default branch.

And this worked great for a long time. The way it worked was it would list all the branches that have been merged into the default branch and then delete them. In my case, I didn’t use git branch --merged to list merged branches because I didn’t know about it at the time.

However, my aliases stopped working for me recently after joining PostHog. The main reason is on pretty much all of their repositories, they use Squash and Merge when merging PRs.

Image of a set of cars being squashed together

When you use git merge --squash or GitHub’s “Squash and merge” feature, Git creates a new commit on the target branch that combines all the changes from the source branch into a single commit. This new commit doesn’t retain any reference to the original commits from the source branch. As a result, Git doesn’t consider the source branch as merged, and commands like git branch --merged won’t show it.

But here’s the thing about Git. There’s almost always a way.

Solution

When you merge a PR on GitHub, it shows you a “Delete branch” button:

image of a delete branch button

This is a great feature. It’s a good way to clean up branches that have been merged into the default branch. In fact, you can configure GitHub to “Automatically delete head branches” when merged:

image showing configuring a repository to automatically delete head branches

I highly recommend you do the same. When the remote branch is deleted, Git will track it as “gone”. For example, if you run git branch -vv you’ll see something like this:

> git branch -vv

  haacked/decide-v4      ba39408 [origin/haacked/decide-v4: gone] Fix demo to handle variants
  haacked/fix-sample-app ec15751 [origin/haacked/fix-sample-app] Handle variants
* haacked/local-only     e78d2f6 Do important stuff
  main                   ab885d0 [origin/main] chore: Bump to v1.0.2

Notice that haacked/decide-v4 is marked as gone.

We can use git’s porcelain to list branches and their tracking information in a more easily parseable format.

> git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/

haacked/decide-v4 [gone]
haacked/fix-sample-app
haacked/local-only
master

Let’s make this an alias to list these gone branches:

> git config --global alias.gone "!git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '\$2 == \"[gone]\" { print \$1 }'"

Now we can use the alias to list gone branches:

> git gone

haacked/decide-v4

Next step is to update my old bclean alias to use the new gone alias.

> git config --global alias.bclean "!git gone | xargs -r git branch -D"

Unfortunately, since git doesn’t know it’s been merged, we have to do a force delete. That’s a bit scary, but this won’t touch local branches or any branches that are still tracking a remote branch. With this alias, you can run git bclean to delete all the branches that have been merged into the default branch. Finally, we have the old bdone alias to switch to the default branch, run git up, and then run bclean.

Here’s the complete set of aliases mentioned in this post you can cut and paste into your .gitconfig:

[alias]
    default = "!git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'"
    bclean = "!git gone | xargs -r git branch -D"
    # Switches to specified branch (or the default branch if no branch is specified), runs git up, then runs bclean.
    bdone = "!f() { DEFAULT=$(git default); git checkout ${1-$DEFAULT} && git up && git bclean; }; f"
    gone = "!git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '$2 == \"[gone]\" { print $1 }'"

Or, if you want to use the git command line, you can use the following:

git config --global alias.default "!git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'"
git config --global alias.gone "!git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '\$2 == \"[gone]\" { print \$1 }'"
git config --global alias.bclean "!git gone | xargs -r git branch -D"
git config --global alias.bdone "!f() { DEFAULT=\$(git default); git checkout \${1:-\$DEFAULT} && git up && git bclean; }; f"

Note

When using the git default alias, it’s possible you’ll encounter the following error:

fatal: ref refs/remotes/origin/HEAD is not a symbolic ref

This alias relies on the presence of the origin/HEAD symbolic reference. In some cases, especially with newly cloned repositories or certain configurations, this reference might not be set. To fix it:

git remote set-head origin --auto

So now, my old workflow is back. When I merge a PR, I can run git bdone from the branch I merged to clean up the branches. All is right with the world again.

And as always, you can find these aliases and more in my dotfiles repo. See this blog post for more context on how you can include them in your dotfiles.