Devs, like Firefighters, live and die by their tools. Well, not literally. Unlike firefighters, when we made mistakes in using our tools, the spectrum of effect is rather broad. Ranging from mild inconvenience to mistakenly dropping prod db.
Most of the time, we only focus on how our tools help our work. Disregarding the amount of complexity hidden inside the tool itself. Those complexities often arise from needs to cover multiple possible workloads. With only small subset of those features is directly usable for our use case.
While this may be enough for day-to-day work, there are times when we fall into “add another tool” trap. Mostly because we need to improve our experience with existing tools, or because we think that our current tools did not cover our use case. Despite the fact that our current tool can actually handle the task just fine.
This is either due to our lack of understanding of the tool. Or simply due to lack of desire to learn said tool in-depth. While the later factor is somewhat difficult to deal with, the former can be “fixed” by doing some exploratory exercises and a lot of reading. Yes, it boils down to good-old RTFM. What a surprise!
Let’s explore how we can make better use of our tool by exploring a simple use-case for git, paraphrased from this StackOverflow question:
Given a stash named x, I want to manage git stash by using x as reference
Dissecting behavior with GIT_TRACE
Like usual, a good understanding of behavior is needed if we want to adjust said behavior. Reading manpages works to some extent, but direct observation is the best source of information. Fortunately, git provides the flag to dump the actual execution logic via GIT_TRACE environment variable.
Running a test command git stash list foo bar on an empty stash stack would show an interesting behavior.
› GIT_TRACE=1 git stash list foo bar
10:21:02.870834 exec-cmd.c:139 trace: resolved executable path from Darwin stack: /Library/Developer/CommandLineTools/usr/bin/git
10:21:02.871021 exec-cmd.c:238 trace: resolved executable dir: /Library/Developer/CommandLineTools/usr/bin
10:21:02.871319 git.c:460 trace: built-in: git stash list foo bar
Can you spot it?
Git pass through any excess arguments to its subcommand. This is part of git design choices. You can define a subcommand simply by creating a script called git-somefancyname in your PATH. Executing those script as subcommand (git somefancyname) will be resolved as script execution. Git made zero assumptions about those subcommands, and will pass along any given arguments towards them.
So what? I’ve already know that! You say.
Well, let’s build on from those knowledge and try out a different feature, git alias. I’ll defer the extensive detail to its manpage. In short: alias works by mapping either a subcommand or a shell command to another subcommand. Given the earlier observed behavior, we can safely assume that any extra arguments is also passed along to the alias. We can test it by creating a proof-of concept alias, like so:
› git config --global alias.do '!$@'
› GIT_TRACE=1 git do echo hello
10:42:23.989846 exec-cmd.c:139 trace: resolved executable path from Darwin stack: /Library/Developer/CommandLineTools/usr/bin/git
10:42:23.990286 exec-cmd.c:238 trace: resolved executable dir: /Library/Developer/CommandLineTools/usr/bin
10:42:23.990882 git.c:750 trace: exec: git-do echo hello
10:42:23.990904 run-command.c:655 trace: run_command: git-do echo hello
10:42:23.991826 run-command.c:655 trace: run_command: '$@' echo hello
hello echo hello
As the trace shows, alias internally resolved just like subcommands, so git do became run_command: git-do, with extra arguments passed down. This confirms our earlier assumption: that alias have similar behavior to custom git subcommand behavior.
With that, let’s try to build our tagged stash feature using git alias. It has the benefit of focus, since alias force us to use simpler commands rather than using full-blown shell scripts. Since the behavior is similar, in theory we can later migrate our alias as dedicated subcommand if need be.
Note that the aliased command itself (
!$@) is not very useful, if not dangerous. One can rungit do rm -rf ~/*to make one’s morning coffee taste sour, but one should not do that anyway. Let’s just pretend that everything is cool and move on to our main task.
Tagged Stash
First, let’s define our desired operations for manipulating stash. At the very least, we want to be able to:
storea stash under a tag- get a reference of a stash (
sref) from a tag inspectthe content of a tagged stash- apply stash (
take) using tag - drop (
throw) a tagged stash
Note that we abuse git nomenclature by using stash name as tag name. Since stash are local, this should not affect the repository or other developer’s workflow at all.
Storing and referencing
The first two alias, store and sref, is simple enough to implement:
storetake a name and optionally list of files to be stored. This is the simplest one since we only alias a builtin command with a shorthand.git stash push -m <[tag] […files]>sreftake a name and convert it to stash reference. We can use--prettyto make sure the content is terse enough, and thengrepname supplied.stash list --pretty=format:%gd --grep <[tag]>
From what we know about subcommand behavior, we can skip <args> part and define those alias as such:
› git config --global alias.store 'stash push -m'
› git config --global alias.sref 'stash list --pretty=format:%gd --grep'
# test it out in some repo
› git status --porcelain
M config/packages/sentry.yaml
# store the changed files into stash tagged sentry-bc
› git store sentry-bc config/packages/sentry.yaml
Saved working directory and index state On foo/bar: sentry-bc
# try to look it up
› git --no-pager sref sentry-bc
stash@{0}%
Next, let’s move on to inspect. This one is a bit tricky, but once you know how shell commands work, it’s easy to understand. Chain a command that will make use of sref as an argument to git stash show. Like so:
› git config --global alias.inspect '!git stash show -p $(git sref $1)'
# try to inspect
› git inspect sentry-bc
Too many revisions specified: 'stash@{0}' 'sentry-bc'
what? what?
It seems our inspect alias is broken. But why?
If you look closely in our initial test with git do, you can see that technically nothing is wrong. Git will pass down any argument to its subcommands, so passing echo hello to !$@ will be resolved as echo hello echo hello, hence the output hello echo hello.
This means that our inspect alias will also receive the tag name and pass it down to stash show. We can check if that’s the case by running trace:
› GIT_TRACE=1 git inspect sentry-bc
11:26:07.972969 exec-cmd.c:139 trace: resolved executable path from Darwin stack: /Library/Developer/CommandLineTools/usr/bin/git
11:26:07.973378 exec-cmd.c:238 trace: resolved executable dir: /Library/Developer/CommandLineTools/usr/bin
11:26:07.974044 git.c:750 trace: exec: git-inspect sentry-bc
11:26:07.974054 run-command.c:655 trace: run_command: git-inspect sentry-bc
11:26:07.974391 run-command.c:655 trace: run_command: 'git stash show -p $(git sref $1)' sentry-bc
# omitted for brevity
11:26:08.004304 git.c:460 trace: built-in: git stash show -p 'stash@{0}' sentry-bc
Too many revisions specified: 'stash@{0}' 'sentry-bc
We need to “swallow” the arguments somehow, to make sure it didn’t get passed down. A patchwork solution is to just echo the arguments back.
› git config --global alias.inspect '!git stash show -p $(git sref $1) && echo looking for tag:'
# note: the diff is shown in a pager,
# terminal output only show echo
› git inspect sentry-bc
looking for tag: sentry-bc
# Hello, there!
› git inspect obi-wan
looking for tag: obi-wan
Now, it works as expected, except when we’re looking for unknown tag e.g. obi-wan. The default behavior of git stash is to show first stash item if it didn’t receive any arguments. Since sref will return empty when it didn’t find any match, it will result in misleading diff with stash@{0}.
Fortunately, the fix is rather simple. We can replace git stash show with git diff:
› git config --global alias.inspect '!git diff $(git sref $1) -R && echo looking for tag:'
# note: diff should show empty,
# we can also check directly using git --no-pager
› git inspect obi-wan
looking for tag: obi-wan
Taking and Throwing Away
After understanding those behavior and quirks, we can easily work out how to implement the rest of the alias. Here’s both take and throw, which behaves similarly.
› git config --global alias.take "!git sref $1 | xargs git stash apply -q && echo 'apply stash:'"
› git config --global alias.throw "!git sref $1 | xargs git stash drop -q && echo 'remove stash:'"
It will take stash reference from sref, apply it to its respective stash subcommand (apply or drop) using xargs, and echo back the arguments.
All Done!
Coming back to our earlier requirements, we have provided all the necessary alias to cover the scenarios:
› git config --global --list | grep alias
# truncated for brevity
alias.store=stash push -m
alias.sref=stash list --pretty=format:%gd --grep
alias.inspect=!git diff $(git sref $1) -R && echo looking for tag:
alias.take=!git sref $1 | xargs git stash apply -q && echo 'apply stash:'
alias.throw=!git sref $1 | xargs git stash drop -q && echo 'remove stash:'
# truncated for brevity
Notes, Caveats, Etc
As usual, the task of exploring and refining above example is left as an exercise for the readers.
Some details are omitted, some explanation simplified, and some obvious bug skipped. Such is life of devs with deadline to follow. Not that this post have submission deadline, but its a familiar and understandable excuse. It is also a good excuse to “force” our dear reader to dissect this post and dive deep into it themselves.
Here’s a list a few of those omissions as a starting point :
- What will happen if
--grepduringsrefreturns multiple matches? - Why is
git diffis shown in reverse ininspect? - Why use
&&and not;for separating commands?
