Fuzzy branches: a brief example of a git custom command.

Ben Sinclair - Oct 13 '19 - - Dev Community

It's popular for people to do things like this in order to save a couple of keystrokes:

alias gco='git checkout'
Enter fullscreen mode Exit fullscreen mode

Honestly, I'm not a fan, so I thought I'd see if I could do better.

What do I want to achieve?

I want to lower the effort in changing branches. It's a modest goal, and one that's theoretically addressed by the alias above. But say you want to switch between two long-names feature branches:

$ git branch

    develop
    feature/TCKT-123-bug-frobinator-crashes-doohickey
  * feature/TCKT-124-feat-add-flux-capacity
Enter fullscreen mode Exit fullscreen mode

Now, you can have some kind of tab completion installed for git, so you can type git checkout f<tab>3<tab> and get where you want to go. That's one for the vimmers among us; let's say we don't care for that way of working and sweep that whole solution under the Rug Of Digression.

We can make our own command instead.

Let it work on as many systems as possible

Instead of using a shebang like #!/bin/bash or #!/usr/bin/env bash we'll use #!/bin/sh. If we don't have any requirements beyond POSIX, why add any dependencies?
Here's a relevant Stack Overflow question about the recommended POSIX shebang but suffice it to say that this one is more likely to exist than bash, and that env might not even be available if /usr isn't mounted yet.

#!/bin/sh
Enter fullscreen mode Exit fullscreen mode

Ensure we're in a git repository

We want to bail out of the script early if it's not going to work.
We don't want to see an initial git error and then a bunch of artifacts from our script.

if ! git status >/dev/null; then
  exit
fi
Enter fullscreen mode Exit fullscreen mode

Pass through git's regular error messages and exit status

Redirecting STDOUT to /dev/null means that if we are in a git repo, the command won't spew irrelevant status information to the console.
We could include a redirect for STDERR with 2>&1, but if the command fails, we want it to display an error. Likewise, we could provide our own error code with something like exit 1 to show there's been a problem, but exit on its own will pass through the previous error code. It's effectively the same as exit $?. This way we don't need to worry about compatibility with other scripts, they will get the appropriate git exit code.

Add support for fuzzy matching

We can take the output from git branch and pass it through fzf which will do the heavy lifting for us.

    if command -v fzf >/dev/null; then
      branch="$(git branch -a | grep -oE '[^ ]+$' | fzf)"

      if [ -n "$branch" ]; then
        git checkout "$branch"
      fi
Enter fullscreen mode Exit fullscreen mode

This looks a little scary! It's searching for a branch that contains the first argument to the script, but what's that odd-looking grep for?
-o means to only return the bit of the line that matches the pattern, not the whole line as in grep's default behaviour.
-E '[^ ]+$' is an extended regex to grab the last word on the line1.
Why is this important? It's because if one of the branches is the current branch, then it will have a prefix like ' * '. Check the example with the feature branches above to see what I mean. We need to make sure we have the branch name and only the branch name.

Make it pretty!

We can use fzf's --preview option to get something helpful for each result. The obvious choice is a git show of the branch:

git branch -a | grep -oE '[^ ]+$' | fzf --preview='git show --color=always --stat {}
Enter fullscreen mode Exit fullscreen mode

Degrade gracefully if there's no available fuzzy matching tool

We can tell if a command is available like this2:

if command -v fzf >/dev/null; then
  # Do stuff
fi
Enter fullscreen mode Exit fullscreen mode

Use grep to work with a partial match instead.

We'd like to be able to checkout a branch using a unique string, such as the ticket numbers in the example above. This uses a cut-down version of our fuzzy branch finding command from earlier.

matches=$(git branch | grep -i "$1" | grep -oE '[^ ]+$')
Enter fullscreen mode Exit fullscreen mode

Do something sensible if multiple matches are found

We can count the matches with grep -c and if it returns more than one, print3 the possibilities.

This time we don't need to enclude all the crazy regex, because we're not interested having a sanitised result.

count=$(git branch | grep -ci "$1")

if [ "$count" = "1" ]; then
  shift
  git checkout "$matches" "$@"
elif [ "$count" = "0" ]; then
  printf "No match for '%s'" "$1"
else
  printf "Multiple matches:\n"

  for match in $matches; do
    printf "%s\n" "$match"
  done
fi
Enter fullscreen mode Exit fullscreen mode

Do something sensible if no branch is specified

In the fuzzy route, we open fzf by default, so what can we do that's as similar as possible? How about listing all local branches?

if command -v fzf >/dev/null; then
  git branch -a
fi
Enter fullscreen mode Exit fullscreen mode

Provide usage instructions if passed the --help flag

We're going to use a case statement as a little dispatcher.
Each possible command line argument will get matched against a different pattern and we'll use that to decide what to do next.

case "$1" in
  --help|-h) 
    printf "Usage: %s [pattern]\n" "$(basename "$0")"
    ;;

# ...

esac
Enter fullscreen mode Exit fullscreen mode

Here, we can explicitly catch both -h and --help and display a help message.

Safely handle unexpected flags

We don't have any other flags in this simple script, and we don't want to pass anything through to downstream executables like git or grep that starts with a - in case they interpret it as a flag.
We're keeping things clean by quoting variables (hence "$1" instead of $1), but it pays to program defensively. Right? Right. I know you're with me on this one.
In fact, this is the only place we want to supply our own 'error' exit code.

case "$1" in
  --help|-h) 
    printf "Usage: %s [pattern]\n" "$(basename "$0")"
    ;;

  -*)
    printf "Usage: %s [pattern]\n" "$(basename "$0")"
    exit 1
    ;;

# ...

esac
Enter fullscreen mode Exit fullscreen mode

It goes after the --help pattern because otherwise that would take precedence.

Use this command to extend git.

If we call it git-co and save it somewhere on our PATH, then it will work exactly as if it was a native git subcommand4

Putting it all together

Time's up. Let's do this. -- Leeroy Jenkins

A little bit of nesting things and swapping conditionals and we get to the tl;dr of this post!

#!/bin/sh

# co: "Check Out".
# Checkout an existing branch based on a partial match.
#
# If FZF is installed, this script will use it.

if ! git status >/dev/null; then
  exit
fi

case "$1" in
  --help|-h) 
    printf "Usage: %s [pattern]\n" "$(basename "$0")"
    ;;

  -*)
    printf "Usage: %s [pattern]\n" "$(basename "$0")"
    exit 1
    ;;

  "")
    if command -v fzf >/dev/null; then
      branch="$(git branch -a | grep -oE '[^ ]+$' | fzf --preview='git show --color=always --stat {}')"

      if [ -n "$branch" ]; then
        # remove the "remotes/origin/..." branch name prefix.
        branch="$(echo "$branch" | sed -e "s#^remotes/[^/]*/##")"
        git checkout "$branch"
      fi
    else
      git branch -a
    fi
    ;;

  *)
    matches=$(git branch | grep -i "$1" | grep -oE '[^ ]+$')
    count=$(git branch | grep -ci "$1")

    if [ "$count" = "1" ]; then
      shift
      git checkout "$matches" "$@"
    elif [ "$count" = "0" ]; then
      printf "No match for '%s'" "$1"
    else
      printf "Multiple matches:\n"

      for match in $matches; do
        printf "%s\n" "$match"
      done
    fi
    ;;
esac
Enter fullscreen mode Exit fullscreen mode

Postscript

This post was partly inspired by comments about fzf in Neovim for web development and the fact that there are a lot of posts about aliases here.

I've also talked more generally about extending command-line programs before.

Cover image by Zach Reiner on Unsplash


  1. There are multiple ways to match the last word, this is just the way I happened to choose. 

  2. command is more portable than which, but for either, we have to redirect its STDOUT to keep our output clean. 

  3. I'm using printf here because it's more portable than echo

  4. Well, it won't support git help co, because we haven't gone so far as to make an installer and manual file. ¯\_(ツ)_/¯ 

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .