Skip to main content

Git Stash

·1174 words
Table of Contents

If you’ve used Git for any length of time, you’re probably already familiar with one of its most interesting non-core features: stash. git stash is great for when you need to tuck away some unfinished changes so you can switch to something else for a bit. From the documentation:

Use git stash when you want to record the current state of the working directory and the index, but want to go back to a clean working directory. The command saves your local modifications away and reverts the working directory to match the HEAD commit.

It’s extremely helpful and has much lower cognitive load than, say, making a work-in-progress commit. It doesn’t update my branch, I don’t have to think about undoing or overwriting it later, and I don’t have to worry about accidentally pushing it upstream. I also don’t have to stage all my changes, meaning I can separate the work that’s ready to commit from that which I’m still working on. In short, it’s like a magic wand you can wave to clean up your workspace, and when you’re ready, pick up right where you left off.

But I have to admit, I don’t really understand how it works. How does it know what changes are staged or not staged? How does it re-apply changes on a different branch from where they were started? If I have multiple stashes, is something going to get messed up if I try to apply one that’s not at the top of the stack? I don’t know the answers to these questions, but today, I’m going to find out.

Setup
#

Let me start by creating a fresh repository to work in:

❯ mkdir -p ~/temp/git-stash && cd ~/temp/git-stash
❯ git init
Initialized empty Git repository in /Users/rschmitz/temp/git-stash/.git/

I’m also going to create a directory objects to help keep track of some state between changes.

❯ mkdir objects
echo 'objects/*' >> .gitignore
❯ git add .gitignore
❯ git commit -m "Initial commit"
❯ git log --oneline
942c21f (HEAD -> main) Initial commit
❯ git ls-tree 942c21f
100644 blob a8060221c549895992b4ba8ce720a4a0f028f0ec	.gitignore

First Stash
#

Let’s get a closer look at how it works. First, let’s take a snapshot of what our git objects look like currently:

❯ find .git/{objects,refs} -type f > objects/initial

Now let’s update the working directory and index so we have something to stash:

echo 'hello, world!' > hello.txt
❯ git add hello.txt

What did this do?

❯ find .git/{objects,refs} -type f > objects/staged
❯ diff objects/{initial,staged}
1a2
> .git/objects/27/0c611ee72c567bc1b2abec4cbc345bab9f15ba

A new object! What is it?

❯ git cat-file -t 270c611ee72c567bc1b2abec4cbc345bab9f15ba
blob
❯ git cat-file blob 270c611ee72c567bc1b2abec4cbc345bab9f15ba
hello, world!

It’s a blob that contains the contents of the file we staged. That makes sense. So what happens when we stash it?

❯ git stash push "First stash"
Saved working directory and index state On main: First stash
❯ git stash list
stash@{0}: On main: First stash
❯ find .git/{objects,refs} -type f > objects/stashed
❯ diff objects/{staged,stashed}
1a2,3
> .git/objects/ab/00f5fdf158ca0841cd024af72cbd04b61306d5
> .git/objects/c9/2ab54c8931b35ac4fe6abb12079939eba7c4f0
3a6
> .git/objects/f7/f02f09e6017a0cd84cabbdb82fa96063e274ed
5a9
> .git/refs/stash

Oh, four new things - three objects and one ref - exciting. Let’s start with the ref:

❯ git reflog refs/stash
ab00f5f (refs/stash) refs/stash@{0}: On main: First stash

It’s pointing at the first object in the list. Ok, let’s check each object out:

❯ git cat-file -t ab00f5fdf158ca0841cd024af72cbd04b61306d5
commit
❯ git cat-file commit ab00f5fdf158ca0841cd024af72cbd04b61306d5
tree f7f02f09e6017a0cd84cabbdb82fa96063e274ed
parent 942c21f75928a18a4777db63a22d6c012fb09418
parent c92ab54c8931b35ac4fe6abb12079939eba7c4f0
author Richard Schmitz <richardmschmitz@gmail.com> 1682186649 -0700
committer Richard Schmitz <richardmschmitz@gmail.com> 1682186649 -0700

On main: First stash

It’s a commit with two parents - a merge commit. That’s interesting. The first parent is our initial commit, the second parent is the next object in the list, and the tree is the last object in the list. Ok, let’s check out the tree:

❯ git ls-tree f7f02f09e6017a0cd84cabbdb82fa96063e274ed
100644 blob a8060221c549895992b4ba8ce720a4a0f028f0ec	.gitignore
100644 blob 270c611ee72c567bc1b2abec4cbc345bab9f15ba	hello.txt

It’s got our two blobs - the one from our initial commit, and the one from when we staged hello.txt.

And the second parent commit:

❯ git cat-file commit c92ab54c8931b35ac4fe6abb12079939eba7c4f0
tree f7f02f09e6017a0cd84cabbdb82fa96063e274ed
parent 942c21f75928a18a4777db63a22d6c012fb09418
author Richard Schmitz <richardmschmitz@gmail.com> 1682186649 -0700
committer Richard Schmitz <richardmschmitz@gmail.com> 1682186649 -0700

index on main: 942c21f Initial commit

It’s a regular commit pointing at the same tree, and the parent is the initial commit.

To summarize: When we create a new stash, we get two commits: one holding the state of the index, and one holding the state of the working directory. Here’s what our commit graph looks like now.

❯ git log --oneline --graph --all --decorate
*   ab00f5f (refs/stash) On main: First stash
|\  
| * c92ab54 index on main: 942c21f Initial commit
|/  
* 942c21f (HEAD -> main) Initial commit

This is echoed in the Discussion section of the documentation.

Second Stash
#

So we have one stash. What happens if we add another?

❯ git stash list
stash@{0}: On main: First stash
echo 'goodbye, foo!' > goodbye.txt
❯ git add goodbye.txt
❯ git stash push "Second stash"
Saved working directory and index state On main: Second stash
❯ git stash list
stash@{0}: On main: Second stash
stash@{1}: On main: First stash
❯ find .git/{objects,refs} -type f > objects/stashed2
❯ diff objects/{stashed,stashed2}
0a1
> .git/objects/51/5ffeee635600cb42af691f6d9040bf1e69f6f0
1a3
> .git/objects/d8/c45a8c9994cf3947a8def9307d0d6cda737b67
4a7
> .git/objects/87/3c99c3bca040f3ff18ad9f0963e1fc6dfa69b7
7a11
> .git/objects/e0/36e3ea4736fa53cf6594f786b9b041665cfad5

Four new objects, just like before: two commits, a tree, and a blob.

❯ git cat-file -t e036e3ea4736fa53cf6594f786b9b041665cfad5
commit
❯ git cat-file -t d8c45a8c9994cf3947a8def9307d0d6cda737b67
commit
❯ git cat-file -t 515ffeee635600cb42af691f6d9040bf1e69f6f0
tree
❯ git cat-file -t 873c99c3bca040f3ff18ad9f0963e1fc6dfa69b7
blob

And here’s the commit graph:

❯ git log --oneline --graph --all --decorate
*   e036e3e (refs/stash) On main: Second stash
|\
| * d8c45a8 index on main: 942c21f Initial commit
|/
* 942c21f (HEAD -> main) Initial commit

Wait, this looks just like the last graph! What happened to our other two commits? They seem to have been replaced by the two new commits.

I suppose this is because the refs/stash reference moved to the new stash commit, so the old one (and associated index commit) are no longer traversable in the graph. See the reflog:

❯ git reflog refs/stash
e036e3e (refs/stash) refs/stash@{0}: On main: Second stash
ab00f5f refs/stash@{1}: On main: First stash

The previous commit is hidden away (stashed, even!) in the history of refs/stash. Both previous commits still exist in the database.

Conclusion
#

A stash is represented as two commits: one storing the state of the working tree, and one storing the state of the index. I glossed over this above for brevity, but there can actually be a third commit to record the state of untracked files as well. Showing this is left as an exercise for the reader!

Reapplying changes is simply a matter of applying those two or three commits on top of the current HEAD. That may be the same HEAD as when the stash was created, but it need not be. Of course, there can still be merge conflicts when reapplying a stash.

Further reading
#

Atlassian has a great tutorial on git stash, including an explanation of how it works with diagrams if you’re a visual learner.