Git Tagged Stash

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 run git 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:

  • store a stash under a tag
  • get a reference of a stash (sref) from a tag
  • inspect the 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:

  • store take 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]>
  • sref take a name and convert it to stash reference. We can use --pretty to make sure the content is terse enough, and then grep name 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 --grep during sref returns multiple matches?
  • Why is git diff is shown in reverse in inspect?
  • Why use && and not ; for separating commands?

Dipublikasikan oleh

avatar Tidak diketahui

Bambang Pamungkas

Programmer, writer, reader, father, husband.

kom entar?

Situs ini menggunakan Akismet untuk mengurangi spam. Pelajari bagaimana data komentar Anda diproses.