What Exactly Does Our Casual `git stash` Do Under the Hood?
git stash is a programmer’s secret weapon. The boss drops a case in your lap (biangbiang) and says it needs a hot fix before end of day — what do you do? Stash, switch to the branch tied to the release, patch things up. Then the product manager slams down a feature update (kuangkuang) and says it’s simple, could you just knock it out first for me? What do you do? Stash, cut a new feature branch, roll up your sleeves, and get to work. Without git stash, life would be at least half as bright.
git is stubborn: if you haven’t committed the work in your hands, it refuses your request to switch branches — which is perfectly reasonable, since switching branches (which really just means switching to some commit in history; and git checkout -b creates a ref pointing at the current HEAD) involves updating the working directory and rebuilding git’s index. If the working directory has uncommitted changes, git certainly can’t just wipe out all your hard-earned effort.
That’s what makes git stash feel so warm and welcoming. It gathers your unfinished, hard-to-commit work (committing inevitably drags in commit hooks — running unit tests, running static checks) into a temporary holding area, so that once the new task is done you can git stash pop and restore your previous work. The mechanism behind it is remarkably like a CPU exception — which goes to show that programmers only ever have a handful of tricks up their sleeves; they just dress them up in different costumes for different scenes.
So what, exactly, is git stash doing behind the scenes? This is something I need to explore as I work on gitrocks. Come on now — dong ci da ci, dong ci da ci — shake out your tea-oil hair, roll those shoulders, and let’s type together (wow, you can really type~).
First Attempt
Create a new repo, git add hello.txt (content: hello world!\n), then git commit -a -m "init commit". Afterwards, git add goodbye.txt (content: goodbye world!). Once these operations are done in sequence, the entire objects / logs / refs directory looks like this:
logs
HEAD
refs
heads
master
objects
11
a295f9eedc7635bce94d7d2f82328fa5032ed1
55
b909b4a7cad81fe818b364959775188bfccf3d
60
09101760644963fee389fc730acc4c437edc8f
a0
423896973644771497bdc03eb99d5281615b51
info
pack
refs
heads
master
tags
Let’s take a look at the init commit:
$ git cat-file -p 11a2
tree 6009101760644963fee389fc730acc4c437edc8f
author Author <author@example.com> 1574787652 -0800
committer Author <author@example.com> 1574787652 -0800
gpgsig -----BEGIN PGP SIGNATURE-----
iQB1BAAWCAAdFiEEDAfXiyARvxtLDYya/74NqNQekKwFAl3dWksACgkQ/74NqNQekKzqAgD9HCMmZOL6JRNZZd1jKoDXR8d65q0/aIgeOFQ5SHcdFzUBAMcQypPkhC9dSmV3PydpnddnIp03I6Fja1d2O+bF3nsP
=VDnV
-----END PGP SIGNATURE-----
init commit
Next, let’s try a git stash. And lo — something magical happens! Under logs/refs, a stash reference appears, and under objects, three brand-new objects pop out with a satisfying kuang kuang kuang:
logs
HEAD
refs
heads
master
stash
objects
11
a295f9eedc7635bce94d7d2f82328fa5032ed1
55
b909b4a7cad81fe818b364959775188bfccf3d
60
09101760644963fee389fc730acc4c437edc8f
68
0c79419d0067c90c1103329d9e1effef0fd216
a0
423896973644771497bdc03eb99d5281615b51
de
8fcd1649da1ab5a6694622508c0e95178ddd02
e8
c012220c2e1116ffa1de1264773bd92d31f36b
info
pack
refs
heads
master
stash
tags
Let’s see what these new objects actually are.
680c is a tree containing hello.txt and goodbye.txt. Normally, this tree would only be produced on commit:
$ git cat-file -p 680c
100644 blob 55b909b4a7cad81fe818b364959775188bfccf3d goodbye.txt
100644 blob a0423896973644771497bdc03eb99d5281615b51 hello.txt
de8f is a bit interesting — it’s a commit, with commit message WIP on master: 11a295f init commit:
$ git cat-file -p de8f
tree 680c79419d0067c90c1103329d9e1effef0fd216
parent 11a295f9eedc7635bce94d7d2f82328fa5032ed1
parent e8c012220c2e1116ffa1de1264773bd92d31f36b
author Author <author@example.com> 1574787769 -0800
committer Author <author@example.com> 1574787769 -0800
WIP on master: 11a295f init commit
Here 11a295f is the commit from the earlier git cat-file -p 11a2. As you can see, this commit doesn’t carry my GPG signature.
e8c0 leaves me a little puzzled — it’s also a commit:
$ git cat-file -p e8c0
tree 680c79419d0067c90c1103329d9e1effef0fd216
parent 11a295f9eedc7635bce94d7d2f82328fa5032ed1
author Author <author@example.com> 1574787769 -0800
committer Author <author@example.com> 1574787769 -0800
index on master: 11a295f init commit
Comparing de8f and e8c0 carefully, we find that apart from the commit message being different, de8f also has an extra parent pointing at e8c0.
Now let’s look at what’s stored in the newly-added .git/logs/refs/stash:
$ cat .git/logs/refs/stash
0000000000000000000000000000000000000000 de8fcd1649da1ab5a6694622508c0e95178ddd02 Author <author@example.com> 1574787769 -0800 WIP on master: 11a295f init commit
And .git/refs/stash:
$ cat .git/refs/stash
de8fcd1649da1ab5a6694622508c0e95178ddd02
Finally, let’s see where master points:
$ cat .git/HEAD
ref: refs/heads/master
$ cat .git/refs/heads/master
11a295f9eedc7635bce94d7d2f82328fa5032ed1
Now the whole picture is clear. The relationships between the various objects are:
Let’s see what happens with git stash pop:
logs
HEAD
refs
heads
master
objects
jec 11
a295f9eedc7635bce94d7d2f82328fa5032ed1
55
b909b4a7cad81fe818b364959775188bfccf3d
60
09101760644963fee389fc730acc4c437edc8f
68
0c79419d0067c90c1103329d9e1effef0fd216
a0
423896973644771497bdc03eb99d5281615b51
de
8fcd1649da1ab5a6694622508c0e95178ddd02
e8
c012220c2e1116ffa1de1264773bd92d31f36b
info
pack
refs
ref heads
ads master
tags
.git/logs/refs/stash and .git/refs/stash are gone. Nothing else changed.
As a pedantic programmer, let’s run git stash again. Logically, the new stash should only produce a new .git/logs/refs/stash and .git/refs/stash file, without producing new commits — yet git stubbornly produces two new commits again, all the same:
$ git cat-file -p 9a06
tree 680c79419d0067c90c1103329d9e1effef0fd216
parent 11a295f9eedc7635bce94d7d2f82328fa5032ed1
parent 7bb55ed97e4d922a2e2ae04bf2f2f7e04807f5de
author Author <author@example.com> 1574788382 -0800
committer Author <author@example.com> 1574788382 -0800
WIP on master: 11a295f init commit
$ git cat-file -p 7bb5
tree 680c79419d0067c90c1103329d9e1effef0fd216
parent 11a295f9eedc7635bce94d7d2f82328fa5032ed1
author Author <author@example.com> 1574788382 -0800
committer Author <author@example.com> 1574788382 -0800
index on master: 11a295f init commit
This time we’re old hands at it:
$ cat .git/logs/refs/stash
0000000000000000000000000000000000000000 9a063baa8146a819548b0a59e27180ac6951ba3c Author <author@example.com> 1574788382 -0800 WIP on master: 11a295f init commit
$ cat .git/refs/stash
9a063baa8146a819548b0a59e27180ac6951ba3c
$ cat .git/refs/heads/master
11a295f9eedc7635bce94d7d2f82328fa5032ed1
With this information in hand, let’s sketch out the current shape of the DAG once more:
We stash pop the goodbye.txt back out, then git commit -m "second commit":
$ git cat-file -p b139
tree 680c79419d0067c90c1103329d9e1effef0fd216
parent 11a295f9eedc7635bce94d7d2f82328fa5032ed1
author Author <author@example.com> 1574789596 -0800
committer Author <author@example.com> 1574789596 -0800
gpgsig -----BEGIN PGP SIGNATURE-----
iQB1BAAWCAAdFiEEDAfXiyARvxtLDYya/74NqNQekKwFAl3dYeMACgkQ/74NqNQekKzuEQEAnpcMk/EBPhARtgKU+EplxhMUuJJOaFdka9bk2c22nL8BALaFzvsUlUdSwvZjFRrjW623Z0CktaIumyHpFefcGbME
=vT0q
-----END PGP SIGNATURE-----
second commit
$ cat .git/refs/heads/master
b1390fc37a960f97e9e637c3f82315da94120167
As you can see, a brand-new b139 commit is created. master points at it.
Finally, let’s look at the current DAG:
Why does git stash generate two commits? Let’s keep digging.
Second Attempt
Since the object structure of the previous repo is already complex enough, let’s switch to another directory and create a new git repo. Then we repeat what we just did: add hello.txt, commit, then add goodbye.txt. At this point the database looks like this:
logs
HEAD
refs
heads
master
objects
55
b909b4a7cad81fe818b364959775188bfccf3d
60
09101760644963fee389fc730acc4c437edc8f
a0
423896973644771497bdc03eb99d5281615b51
c6
2f16377b077275eace0239f24504becc359763
info
pack
refs
heads
master
tags
We modify hello.txt so it becomes hello tyr!. git status tells us one file is staged and one isn’t:
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: goodbye.txt
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: hello.txt
Let’s take a deep breath and run git stash:
logs
HEAD
refs
heads
master
stash
objects
27
8efbd09a21dbd2b443bd7a879013abfee434ca
55
b909b4a7cad81fe818b364959775188bfccf3d
60
09101760644963fee389fc730acc4c437edc8f
62
d7bceb43b713c838ce04075fa23fb263aa01f5
68
0c79419d0067c90c1103329d9e1effef0fd216
a0
423896973644771497bdc03eb99d5281615b51
a4
f7a95ef0b8dcbb8d89af09a488f7cb3166fadc
c3
efe55f18835756842af5f53a64d1d78dc5ed6c
c6
2f16377b077275eace0239f24504becc359763
info
pack
refs
heads
master
stash
tags
Five new objects! Let’s explore:
$ git cat-file -p 278e
tree 680c79419d0067c90c1103329d9e1effef0fd216
parent c62f16377b077275eace0239f24504becc359763
author Author <author@example.com> 1574791207 -0800
committer Author <author@example.com> 1574791207 -0800
index on master: c62f163 init commit
$ git cat-file -p 62d7
100644 blob 55b909b4a7cad81fe818b364959775188bfccf3d goodbye.txt
100644 blob c3efe55f18835756842af5f53a64d1d78dc5ed6c hello.txt
$ git cat-file -p 680c
100644 blob 55b909b4a7cad81fe818b364959775188bfccf3d goodbye.txt
100644 blob a0423896973644771497bdc03eb99d5281615b51 hello.txt
$ git cat-file -p c3ef
hello tyr!
$ git cat-file -p a4f7
tree 62d7bceb43b713c838ce04075fa23fb263aa01f5
parent c62f16377b077275eace0239f24504becc359763
parent 278efbd09a21dbd2b443bd7a879013abfee434ca
author Author <author@example.com> 1574791207 -0800
committer Author <author@example.com> 1574791207 -0800
WIP on master: c62f163 init commit
$ cat .git/refs/stash
a4f7a95ef0b8dcbb8d89af09a488f7cb3166fadc
$ cat .git/logs/refs/stash
0000000000000000000000000000000000000000 a4f7a95ef0b8dcbb8d89af09a488f7cb3166fadc Author <author@example.com> 1574791207 -0800 WIP on master: c62f163 init commit
Now it’s clear: commit a4f7 uses the tree 62d7, while commit 278e uses the tree 680c. 62d7 contains the modified hello.txt, and the stash reference points at a4f7. A diagram makes this more intuitive:
In other words, when we run git stash, we stash two kinds of data:
- Data in the staging area (goodbye.txt)
- Data in the working area (the modified hello.txt)
Why This Design?
At first I found this quite baffling. This unfinished work gets tossed, without ceremony, into an immutable object database — isn’t that a waste of resources? Why not just drop it into a stash directory and be done with it? While building gitrocks, I explored some “clever” alternatives — putting it in a directory, throwing it into sqlite, and so on. But the deeper I dug, the more I realized git was right. git users, after all, may — like you and me — be running wild across a dozen branches a day. If everywhere they went they left behind a trail of git stash “I-was-here” marks, then you’d have to record the parent commit, generate a tree for the stash to preserve the directory structure, and store the modified files somewhere — and all of this is already implemented beautifully by the commit code. Using one piece of code to solve two problems — there’s a beauty in that simplicity. Beyond that, although the generated commits ultimately go unused, the intermediate trees and subtrees have a good chance of being reused, so none of the earlier computation is wasted. Finally, git provides gc, which recycles those orphans — after git stash pop, those left “circling the tree three times with no branch to rest on” — thereby saving space.
After nearly two hours of exploration, I calmly accepted a simple fact: your grandpa is still your grandpa. The ingenuity of git’s design still requires serious contemplation. The Master once said: “Do I possess knowledge? No, I do not.” If even Confucius could be that humble, then I still have work to do.