Better git shell aliases: using an external shell script

10 years ago, I read this blog post on GitHub Flow git aliases by Phil Haack. From it, I learned a few really clever tricks. Even though I never much cared for using ‘GitHub Flow’ as a git workflow, I used some of those tricks for my own git aliases. One of those being this basic pattern:

[alias]
  foo = "!f() { echo \"foobar: $@\"; }; f"

This lovely little mess of an alias embeds a one-line shell function tersely named “f” directly into a git command. From the git manual : “if the alias is prefixed with an exclamation point, it will be treated as a shell command”. Other sources around the same time also began promoting that same trick. It lets you handle all the other arguments passed in, so that typing git foo bar baz will print the string “foobar: bar baz”.

That trick opens the door to let you create all sorts of clever aliases like git browse , which navigates to the remote URL of your repo in a web browser. The ‘browse’ git alias Phil published in that link is very Windows/Git Bash specific, and it doesn’t work for repos cloned with the git@github.com:my/repo form, so I had to modify it in my config to become:

[alias]
  browse = "!f() { REPO_URL=$(git config remote.origin.url | sed -e 's|^.*@|https://|' -e 's|.git$||' -e 's|:|/|2'); git web--browse $REPO_URL; }; f"

And now I have this lovely little mess of an alias. Notice that long horizontal scroll? Yeah… that one-liner is about 150 characters long and pretty gnarly. But it works, so I kept it and moved on. For 10 years I popped useful aliases in and out of my gitconfig, many with this inline shell pattern.

Recently, I stumbled upon an Oh-My-Zsh plugin called git-commit which takes this pattern to a new extreme. It stores the function in a string, and then generates 12 little alias monsters like this:

[alias]
  build = "!a() { if [ \"$1\" = \"-s\" ] || [ \"$1\" = \"--scope\" ]; then local scope=\"$2\"; shift 2; git commit -m \"build(${scope}): ${@}\"; else git commit -m \"build: ${@}\"; fi }; a"

It’s really tricky (if not nearly impossible) to read and understand that. Especially with all the backslash escaping. Now, the authors of git-commit aren’t intending for that alias to actually be maintained in its gitconfig form - that result is generated from a string in a script. But even so, forget about changing that easily, customizing it to your needs, or testing it without copy/pasting it into something usable. They even added some extra magic to update the aliases in your gitconfig whenever OMZ updates! Imagine the chaos if that had a bug!

I started to wonder why they didn’t just include an actual script file (let’s call it omz_git_commit), and then have the aliases simply call out to that shell script. If they did that, then most of the craziness goes away, like so:

[alias]
  build = "!omz_git_commit build"
  chore = "!omz_git_commit chore"
  ci = "!omz_git_commit ci"
  docs = "!omz_git_commit docs"
  feat = "!omz_git_commit feat"
  fix = "!omz_git_commit fix"
  perf = "!omz_git_commit perf"
  refactor = "!omz_git_commit refactor"
  rev = "!omz_git_commit rev"
  style = "!omz_git_commit style"
  test = "!omz_git_commit test"
  wip = "!omz_git_commit wip"

They also wouldn’t need to risk modifying people’s gitconfigs every time Oh-My-Zsh has a new commit. As I started to search more, I realized that there’s a whole lot of examples of the foo = "!f() { <YOLO!>; }; f" pattern of git aliases, but not a lot of great examples of writing a separate shell script.

The antipattern

By now you can probably see where this is going - these kinds of complex inline git aliases are an antipattern. My gitconfig was a mess of shell scripts I could not easily read or understand. Add to that the problem of mixing code and configuration in one file. For the occasional one-off, sure. Fine. Do whatever. But once I got past a certain number of these aliases at a certain complexity, it was time to refactor.

Code should live in a place where it’s easy to evaluate, execute, and evolve, not in some config strings where you can’t do any of those things.

- me, shower thoughts

My solution

There are a couple different solutions here. First, instead of git aliases, you could simply switch to regular shell aliases (or functions). This is a perfectly acceptable option, and there are already discussions you can find on that topic.

However, my solution was to improve my existing git aliases by simply creating an external shell script and changing my gitconfig aliases to call it. That way, I retain my muscle memory, but move the existing code to someplace more appropriate.

That script needs to be in the shell’s $PATH for git to find it. With this script I can do more complicated actions without feeling like I have to cram everything into one line. And, it’s easy to see what’s in that script, understand what is being done, and evolve and test all the code. This script file can be written as a POSIX script, Bash, Zsh, Fish, Dash, Oil, Nushell, Xonsh - whatever you want. And, you don’t have to convert your git aliases wholesale - you can start just with the ones that reach a certain complexity. Though I do find it easier to have most of my git subcommand handlers in one place.

Preparing to use your script

For demo purposes, we’ll create a simple POSIX script for our “git extensions” called gitex. We’ll put it in ~/bin/gitex (though you could use ~/.local/bin or anyplace else you prefer). We also need to make it executable:

mkdir -p ~/bin && touch ~/bin/gitex
chmod u+x ~/bin/gitex

Make sure you have added ~/bin to your $PATH. If you don’t know how to do that, consult your preferred shell’s documentation.

Bash

For Bash, you might need to do something like this:

echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc

Zsh

For Zsh, you could add this to your ${ZDOTDIR:-$HOME}/.zshrc:

path=(~/bin $path)

Fish

For Fish, you could add this to your config.fish:

fish_add_path --global --prepend ~/bin

Setting up your script

Now that we have our new ~/bin/gitex script set up, let’s make a simple primary function so we can extend our script with subcommands as we add new git aliases.

#!/bin/sh
##? gitex - git extensions; make git shell aliases that don't suck

# POSIX test for whether or not a function exists.
is_function() {
  [ "$#" -eq 1 ] || return 1
  type "$1" | sed "s/$1//" | grep -qwi function
}

# This main function serves to call your custom subcommand
# functions (eg: 'gitex_foo') in this same gitex file.
gitex() {
  local subcmd
  if is_function "$1"; then
    subcmd="$1"
    shift
    "gitex_${subcmd}" "$@"
  else
    echo >&2 "gitex: subcommand not found '$1'."
    return 1
  fi
}
gitex "$@"

Extending your script

You can now add new subcommand functions to your ~/bin/gitex script. Let’s add our git browse command.

##? browse: Open web browser to git remote URL
gitex_browse() {
  local url
  url="$(
    git config "remote.${1:-origin}.url" |
      sed -e 's|^.*@|https://|' -e 's|.git$||' -e 's|:|/|2'
  )"
  git web--browse "$url"
}

And now finally, you can add your new gitex aliases to your gitconfig:

[alias]
  browse = "!gitex browse"

Hope this was helpful! For people who’ve been shell scripting for awhile, this is probably old hat. But for folks who have only just begun to dive into this space and have copy/pasted others’ scripts over time, hopefully this is a helpful next step in your shell scripting journey.

A special note for alternative shell users

If you are a Fish shell user, it can be especially daunting (or at the least, annoying) to deal in POSIX shell syntax. If you want to write your gitex script in Fish (or another scripting language), you can easily do that by changing the shebang from #!/bin/sh to #!/usr/bin/env fish and then writing your script in that language.

For Fish users, there’s another alternative. You can, in fact, write plain old Fish functions and assign them to git aliases! While POSIX is cross-platform and has a lot of advantages, if you use Fish you probably know all those pros/cons and can weigh whether any of that really matters to you - that’s a whole different article. Let’s assume that you, for whatever reason, prefer to write your scripts in a “sensible language” because you want to “never write esac again” .

It’s as simple as defining Fish functions (ex: gitex_foo, gitex_bar, etc), and then add this to your gitconfig:

[alias]
  foo = "!fish -P -c 'gitex_foo $argv'"
  bar = "!fish -P -c 'gitex_bar $argv'"
  baz = "!fish -P -c 'gitex_baz $argv'"

In conclusion

You can check out my dotfiles if you want to see my gitex implementation . It has some helpful extras like supporting kebab-case-aliases, as well as my favorite alias git cloner, which enhances git clone with some extras:

  • It lets you clone using repo short names (ohmyzsh/ohmyzsh)
  • It assumes you want to clone a default location (~/repos which is configurable), instead of $PWD, unless you provide a destination directory arg (eg: .)
  • It can add flags you might forget, but usually want like --recurse-submodules

Example:

$ git cloner --depth 1 sorin-ionescu/prezto
clone command modified to:
  git clone --recurse-submodules --depth 1 git@github.com:sorin-ionescu/prezto.git ~/repos/sorin-ionescu/prezto

Happy scripting!