|
| 1 | ++++ |
| 2 | +title = 'Better shell aliases in git' |
| 3 | +date = 2024-07-01T05:29:58-04:00 |
| 4 | ++++ |
| 5 | + |
| 6 | +# Better shell aliases in git: adding an external shell script |
| 7 | + |
| 8 | +10 years ago, I read [this blog post on GitHub Flow git aliases](https://haacked.com/archive/2014/07/28/github-flow-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: |
| 9 | + |
| 10 | +``` |
| 11 | +[alias] |
| 12 | + foo = "!f() { echo foobar; }; f" |
| 13 | +``` |
| 14 | + |
| 15 | +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](https://git-scm.com/docs/git-config): "if the alias is prefixed with an exclamation point, it will be treated as a shell command". [Other sources around the same time](https://www.atlassian.com/blog/git/advanced-git-aliases) also began promoting that same trick. |
| 16 | + |
| 17 | +That trick opens the door to let you create clever aliases like [`git browse`](https://haacked.com/archive/2017/01/04/git-alias-open-url), which navigates to the remote URL of your repo in a web browser. The one Phil published is very Windows/Git Bash specific, and it doesn't work for repos cloned with the `[email protected]:my/repo` form, so I modified it in my config to become: |
| 18 | + |
| 19 | +``` |
| 20 | +[alias] |
| 21 | + 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" |
| 22 | +``` |
| 23 | + |
| 24 | +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 ugly looking. 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. |
| 25 | + |
| 26 | +Recently, I stumbled upon an [Oh-My-Zsh](https://github.com/ohmyzsh/ohmyzsh) plugin called [git-commit](https://github.com/ohmyzsh/ohmyzsh/blob/master/plugins/git-commit/git-commit.plugin.zsh) which takes this pattern to a new extreme. It stores the function in a string, and then generates 12 little alias monsters like this: |
| 27 | + |
| 28 | +``` |
| 29 | +[alias] |
| 30 | + 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" |
| 31 | +``` |
| 32 | + |
| 33 | +It's really tricky if not 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 - it's 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 when OMZ updates. 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 script. If they did that, then most of the craziness goes away, like so: |
| 34 | + |
| 35 | +``` |
| 36 | +[alias] |
| 37 | + build = "!omz_git_commit build" |
| 38 | + chore = "!omz_git_commit chore" |
| 39 | + ci = "!omz_git_commit ci" |
| 40 | + docs = "!omz_git_commit docs" |
| 41 | + feat = "!omz_git_commit feat" |
| 42 | + fix = "!omz_git_commit fix" |
| 43 | + perf = "!omz_git_commit perf" |
| 44 | + refactor = "!omz_git_commit refactor" |
| 45 | + rev = "!omz_git_commit rev" |
| 46 | + style = "!omz_git_commit style" |
| 47 | + test = "!omz_git_commit test" |
| 48 | + wip = "!omz_git_commit wip" |
| 49 | +``` |
| 50 | + |
| 51 | +They also wouldn't need to reach out and touch people's gitconfig every time Oh-My-Zsh has a new commit. |
| 52 | + |
| 53 | +## The antipattern |
| 54 | + |
| 55 | +By now you can probably see where this is going - these kinds of complex git aliases become crazy hard to maintain over time. My `gitconfig` became a mess of functions I could no longer read or understand. Add to that the abomination of mixing code and configuration in one file, and I feel like `!f() { echo foobar; }; f` has become a true antipattern. For the occasional one-off, fine. But once I got past a certain number of these aliases at a certain complexity, it was time to refactor. So, what's the better way? Glad you asked. |
| 56 | + |
| 57 | +## My solution |
| 58 | + |
| 59 | +My solution has been to simply create a shell script external to my gitconfig that my git aliases can call. 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 and read all the code. This git extensions script file can be written as a POSIX script, Bash, Zsh, Fish, Oil, Nushell, Xonsh - whatever shell 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 all most of git subcommand handlers in one place. |
| 60 | + |
| 61 | +### Preparing to use your script |
| 62 | + |
| 63 | +For demo purposes, we'll create a simple POSIX script for our "git extensions" called `gitex`. We'll put it in `~/bin/gitex` and make it executable: |
| 64 | + |
| 65 | +```sh |
| 66 | +mkdir -p ~/bin && touch ~/bin/gitex |
| 67 | +chmod u+x ~/bin/gitex |
| 68 | +``` |
| 69 | + |
| 70 | +Make sure you have added `~/bin` to your `$PATH`. If you don't know how to do that, consult your preferred shell's documentation. |
| 71 | + |
| 72 | +#### Bash |
| 73 | + |
| 74 | +For Bash, you might need to do something like this: |
| 75 | + |
| 76 | +```bash |
| 77 | +echo 'export PATH="~/bin:$PATH"' >> ~/.bashrc |
| 78 | +``` |
| 79 | + |
| 80 | +#### Zsh |
| 81 | + |
| 82 | +For Zsh, you could add this to your `${ZDOTDIR:-$HOME}/.zshrc`: |
| 83 | + |
| 84 | +```zsh |
| 85 | +path+=(~/bin $path) |
| 86 | +``` |
| 87 | + |
| 88 | +#### Fish |
| 89 | + |
| 90 | +For Fish, you could add this to your `config.fish`: |
| 91 | + |
| 92 | +```fish |
| 93 | +fish_add_path --global --prepend ~/bin |
| 94 | +``` |
| 95 | + |
| 96 | +### Setting up your script |
| 97 | + |
| 98 | +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. |
| 99 | + |
| 100 | +```sh |
| 101 | +#!/bin/sh |
| 102 | +##? gitex - git extensions; make git shell aliases that don't suck |
| 103 | +gitex() { |
| 104 | + # Call the subcommand function if it exists. |
| 105 | + if typeset -f "gitex_${1}" > /dev/null; then |
| 106 | + local subcmd=$1 |
| 107 | + shift |
| 108 | + "gitex_${subcmd}" "$@" |
| 109 | + else |
| 110 | + echo >&2 "gitex: subcommand not found '$1'." |
| 111 | + return 1 |
| 112 | + fi |
| 113 | +} |
| 114 | +gitex "$@" |
| 115 | +``` |
| 116 | + |
| 117 | +### Extending your script |
| 118 | + |
| 119 | +You can now add new subcommand functions to your `~/bin/gitex` script. Let's add our `git browse` command. |
| 120 | + |
| 121 | +```sh |
| 122 | +##? browse: Open web browser to git remote URL |
| 123 | +gitex_browse() { |
| 124 | + local url=$( |
| 125 | + git config remote.${1:-origin}.url | |
| 126 | + sed -e 's#^.*@#https://#' -e 's#.git$##' -e 's#:#/#2' |
| 127 | + ) |
| 128 | + git web--browse $url |
| 129 | +} |
| 130 | +``` |
| 131 | + |
| 132 | +And now finally, you can add your new `gitex` aliases to your `gitconfig`: |
| 133 | + |
| 134 | +``` |
| 135 | +[alias] |
| 136 | + browse = "!gitex browse" |
| 137 | +``` |
| 138 | + |
| 139 | +Hope this was helpful! For people who've been shell scripting for awhile, this is probably all 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. |
| 140 | + |
| 141 | +### A special note for alternative shell users |
| 142 | + |
| 143 | +If you are a [Fish shell](https://fishshell.com/) 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. |
| 144 | + |
| 145 | +For Fish users, there's another alternative. You can, in fact, write Fish functions and assign them to git aliases. POSIX is cross-platform and has a lot of advantages, but if you use Fish you probably know those pros/cons all too well. You 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"](https://fishshell.com/). |
| 146 | + |
| 147 | +It's as simple as defining your Fish functions (`gitex_foo`, `gitex_bar`, `gitex_baz`, etc), and then add this to your `gitconfig`: |
| 148 | + |
| 149 | +``` |
| 150 | +[alias] |
| 151 | + foo = "!fish -P -c 'gitex_foo $argv'" |
| 152 | + bar = "!fish -P -c 'gitex_bar $argv'" |
| 153 | + baz = "!fish -P -c 'gitex_baz $argv'" |
| 154 | +``` |
| 155 | + |
| 156 | +## In conclusion |
| 157 | + |
| 158 | +You can [check out my dotfiles](https://github.com/mattmc3/dotfiles/) if you want to see [my `gitex` implementation](https://github.com/mattmc3/dotfiles/blob/main/bin/gitex). Happy scripting! |
0 commit comments