Learn Docker With My Newest Course

Dive into Docker takes you from "What is Docker?" to confidently applying Docker to your own projects. It's packed with best practices and examples. Start Learning Docker →

Delete Old or Unused Local Git Branches Using grep and xargs

delete-old-or-unused-local-git-branches-using-grep-and-xargs.jpg

I like cleaning up unused branches and doing it manually can be tedious. Here's a 1 liner you can turn into an alias.

Quick Jump:

If you prefer video, I recorded a demo video on YouTube going over what’s written below with a number of examples.

The use case around this is, let’s say you have a project you’re working on and:

  • You make a new local branch to do your work (git checkout -b feature/something-cool)
  • You push that branch up and make a PR from it on GitHub, GitLab, Bitbucket, etc..
  • Someone reviews that work and merges that PR into your main branch

In the end your remote repo doesn’t build up any branch cruft because chances are you configured your remote git service of choice to delete the PR branch after it’s been merged.

But what about your local repo?

If you executed the above bullet points and locally ran git checkout main && git pull you would get the latest merged in changes but your local PR branch is still lingering around.

You can check by running git branch. To your surprise (or maybe not surprise) you might have dozens or even hundreds of old or unused branches.

# Creating a 1 Liner to Clean Up Old Local Branches

This won’t be our end game solution but it will work. The reason it’s not the end game solution is because we can turn it into a more general alias later (which we will).

Before we write any code, let’s whip up a little bit of pseudo code of how it’ll work:

  • List all of the branches in this git repo
  • Whitelist 1 or more branches that will never get deleted such as main, master, production or whatever branch names you’ve chosen to be long running branches
  • Skip deleting the currently checked out branch even if it’s not in the whitelist because this could be a current work in progress branch that you will make a PR out of later
  • Easily allow being able to do a dry run without really deleting the branches
  • Delete the filtered list of branches

You could generalize this as:

  • Get a list of input
  • Filter the list
  • Run a command for each item in the list

Cool, Time for Some Code

By the way the demo video goes into more detail and shows running these commands. My gut tells me if you’re reading this, you might not need the extra details provided in the video, but just in case I’ve also done videos in the past about Unix pipes if you’re interested.

I created a dummy repo and a couple of branches:
mkdir /tmp/gcb \
  && cd $_ \
  && git init \
  && touch 1 2 3 \
  && git add -A \
  && git commit -m "Initial commit" \
  && git checkout -b mastery && git checkout master \
  && git checkout -b feature/something-cool && git checkout master \
  && git checkout -b SANDBOX-123 && git checkout master
List all of the branches (getting the list of input):
git branch

That gives us this, the * is the currently checked out branch btw:

  SANDBOX-123
  feature/something-cool
* master
  mastery
Whitelist 1 or more branches (filter the list: part 1):
# -w matches whole words only so "mastery" does not match "master"
# -E is for enabling exteded regular expressions, the parens is a regex OR
# -v inverts the match which gives us everything BUT what matches
git branch | grep -wEv "(main|master)"

That gives us this:

  SANDBOX-123
  feature/something-cool
  mastery
Skip deleting the current branch (filter the list: part 2):

To demo this better, you’ll want to git checkout SANDBOX-123 before running the command:

# The --show-current flag is only available with git 2.2+ by the way
git branch | grep -wEv "(main|master)" | grep -wv "$(git branch --show-current)"

That gives us this (notice how the SANDBOX-123 branch is missing, that’s good!):

  feature/something-cool
  mastery

At this point we’ve accomplished the first 4 bullet points of our pseudo code. We get the dry run capability for free because we haven’t run the command to delete the matches, they only get printed out.

Delete the filtered list of branches (run a command for each item):

We’re still checked out to SANDBOX-123 so we would expect (2) branches to be deleted:

# I broke the line into 2 with a \ so the command fits cleanly on my site
git branch \
  | grep -wEv "(main|master)" | grep -wv "$(git branch --show-current)" | xargs git branch -D

That gives us this and it deleted the (2) branches as expected:

Deleted branch feature/something-cool (was 05abdae).
Deleted branch mastery (was 05abdae).

It didn’t delete master because we whitelist that branch and it didn’t delete SANDBOX-123 since we were checked out to that branch but if you ran git checkout master and then re-ran the command it will delete SANDBOX-123.

There we go, that’s our 1 liner. It works well but you’ll probably have to search your shell history to remember that command when you need it so let’s spruce it up and make a function alias that we can run instead.

# Making It Easier to Use with a Function Alias

I use zsh but feel free to put this in whatever file your shell expects for aliases. My aliases file is in my dotfiles repo.

Here’s a copy of the function:
gcb () {
    local default_branch_whitelist="(main|master)"
    local skip_branches="${1:-"${default_branch_whitelist}"}"
    local dry_run="${2}"

    # We only have a single --dry-run flag set so use the default branch list
    # and make sure dry_run is set.
    if [ "${skip_branches}" = "--dry-run" ]; then
      skip_branches="${default_branch_whitelist}"
      dry_run="--dry-run"
    fi

    git branch | grep -wEv "${skip_branches}" \
        | grep -wv "$(git branch --show-current)" \
        | { if [ -n "${dry_run}" ]; then cat -; else xargs git branch -D; fi; }
}
Here’s a couple of ways to call it:
  • gcb deletes everything but the default whitelist of branches
  • gcb --dry-run shows you branches that will be deleted but doesn’t delete them
  • gcb "(main|production)" deletes everything but your custom whitelist of branches
  • gcb "(main|production)" --dry-run shows you but doesn’t delete your custom branches

The first if condition handles setting the arguments up correctly. Technically both arguments are optional so we needed a way to differentiate that --dry-run is for doing dry runs and it’s not a branch name. Also git itself will not accept --dry-run as a branch name so we’re clear of false positives.

The last bit of the pipeline allows us to optionally perform the delete. The idea is -n "${dry_run}" will only return true when that variable has a value (ie. NOT empty).

If that’s the case we know we want to do a dry run which in our case pipes it into cat which does nothing except output the results of the previous pipe which is basically saying the delete action is optional. That’s a useful pattern for conditional pipes.

If dry run is not set then we use xargs which will run the command we feed to it for each line of output. This handles the last bullet point of our pseudo code from earlier in the post.

The one line if statement can’t be safely reduced to A && B || C:

Technically { [ -n "${dry_run}" ] && cat - || xargs git branch -D; } works instead of writing out the if ... then ... else ... fi that we did in the function but if cat - ever fails then the xargs command will execute and you might end up deleting branches by accident.

ShellCheck warned me of that one with: https://www.shellcheck.net/wiki/SC2015

# Demo Video

Timestamps

  • 0:27 – The use case for wanting to do this
  • 2:02 – Preparing a dummy git repo with a few branches
  • 2:55 – Talking through the rules we want for our pipeline of commands
  • 4:27 – Filtering the list of branches to exclude specific branches
  • 6:44 – Avoid deleting the currently checked out branch
  • 8:17 – We have our dry run since we didn’t delete anything yet
  • 8:27 – Deleting the branches using xargs
  • 9:54 – Creating a function alias to make it easier to use
  • 10:30 – Handling 2 optional arguments
  • 11:41 – Running the alias with a few different arguments
  • 14:01 – Going over the if condition to handle the optional args
  • 14:53 – Most of the pipeline is the same as our original commands
  • 14:50 – Creating an optional pipe based on the dry run condition
  • 16:11 – The short form syntax for an if condition isn’t safe

What’s your workflow for deleting old local branches? Let me know below!

Never Miss a Tip, Trick or Tutorial

Like you, I'm super protective of my inbox, so don't worry about getting spammed. You can expect a few emails per year (at most), and you can 1-click unsubscribe at any time. See what else you'll get too.



Comments