Git Stash: Setting Unfinished Work Aside
Saving unfinished work with git stash: -u for untracked files, named entries, list and show, apply versus pop, --staged and --index, the stash.index setting, conflicts during a pop, stash branch and the stash export/import commands from Git 2.51.
Version note: The commands in this post were tested with Git 2.54; --staged has existed since Git 2.35, stash export/import since Git 2.51 and stash.index since Git 2.52.
Guess First
Ada is in the middle of the discount feature. src/cart.js has changed and a new src/coupons.js file for coupon codes has been created, but none of it is committed yet. Right then Alan writes: Emma's price is wrong in production and needs fixing now. To set the unfinished work aside, Ada runs git stash:
$ git status -s
M src/cart.js
?? src/coupons.js
$ git stash
Saved working directory and index state WIP on main: 7f7eeec Add cart total
What does git status -s show now? Is the working tree clean?
$ git status -s
?? src/coupons.js
It isn't. By default git stash only saves changes to files Git tracks; src/coupons.js is untracked, so it stayed where it was. If Ada switched to another branch like this, the file would follow her and could even end up in the fix commit by accident. A stash is like putting unfinished work up on a shelf; but the shelf only takes what you hand it.
-u and -m: Everything, Under a Name
Ada takes the stash back and saves it again, this time with the right options:
$ git stash pop
On branch main
Your branch is up to date with 'origin/main'.
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: src/cart.js
Untracked files:
(use "git add <file>..." to include in what will be committed)
src/coupons.js
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (a0de88f6a4b8823c8467d9d9447ade09958eb939)
$ git stash push -u -m "discount work in progress"
Saved working directory and index state On main: discount work in progress
$ git status -s
pop brought the changes back and deleted the stash entry; the Dropped line at the end says so. Then she ran git stash push with two options. -u (--include-untracked) puts untracked files into the stash too. -m gives the entry a name; unnamed stashes are listed as WIP on main, and once three of them pile up nobody remembers which is which. A plain git stash is really a git stash push without options. The git stash save "message" form you'll see in old posts is deprecated in favour of push.
The working tree is clean, so Ada makes the fix and commits it:
$ git commit -am "Fix Emma price"
[main eb8683a] Fix Emma price
1 file changed, 1 insertion(+), 1 deletion(-)
Looking Inside the Stash
Before the shelf is emptied, let's see what's on it:
$ git stash list
stash@{0}: On main: discount work in progress
$ git stash show
src/cart.js | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
$ git stash show -p --include-untracked
diff --git a/src/cart.js b/src/cart.js
index 4876df7..3f2d943 100644
--- a/src/cart.js
+++ b/src/cart.js
@@ -1,5 +1,6 @@
-function total(items) {
- return items.reduce((sum, item) => sum + item.price, 0);
+function total(items, discount = 0) {
+ const sum = items.reduce((acc, item) => acc + item.price, 0);
+ return sum - discount;
}
module.exports = { total };
diff --git a/src/coupons.js b/src/coupons.js
new file mode 100644
index 0000000..037f9a7
--- /dev/null
+++ b/src/coupons.js
@@ -0,0 +1,3 @@
+const coupons = { WELCOME10: 10 };
+
+module.exports = { coupons };
stash@{0} is the most recently saved entry; when a new stash is added, the old one becomes stash@{1}. Numbers shift, names don't; this is where -m pays off. By default git stash show gives only statistics and doesn't show untracked files. -p adds the full diff, and --include-untracked adds the untracked files that went into the stash.
Behind the scenes a stash entry is made of ordinary commits: one holds the state of the working tree, one the index, and if you passed -u, one the untracked files. The latest entry lives in the refs/stash ref, earlier ones in that ref's reflog. That's why a stash is local; it doesn't travel when you push.
apply or pop?
Both apply the changes from the stash to the working tree. The difference comes afterwards: if pop succeeds it deletes the entry, while apply leaves the entry in place:
$ git stash apply -q
$ git stash list
stash@{0}: On main: discount work in progress
$ git stash drop
Dropped refs/stash@{0} (10e2323fc1fba3609ea631c1bc15b94b7f75457b)
$ git status -s
M src/cart.js
?? src/coupons.js
After apply the entry stayed in the list; when I was done, I deleted it myself with drop. When you want to apply the same change to more than one branch, or you're not sure the apply will go smoothly, apply is safer. You delete the entry once you've seen the change land. For everyday use pop is enough, because it doesn't delete the entry if a conflict occurs anyway; we'll see that below.
--staged: Stashing Only the Stage
Sometimes you want to stash only part of the unfinished work. Since Git 2.35, --staged puts only the changes in the index into the stash and leaves the rest in the working tree. Ada stages the discount and doesn't stage the note in the README:
$ git add src/cart.js
$ git status -s
M README.md
M src/cart.js
?? src/coupons.js
$ git stash push --staged -m "discount only"
Saved working directory and index state On main: discount only
$ git status -s
M README.md
?? src/coupons.js
$ git stash pop -q
$ git status -s
M README.md
M src/cart.js
?? src/coupons.js
The staged discount is gone; the README and the untracked file stayed. But note: pop brought the change back, not as staged. The default behaviour is not to restore the stash's index state but to apply all the changes to the working tree. If you want the index back too, you pass --index:
$ git add src/cart.js
$ git stash push --staged -m "discount only"
Saved working directory and index state On main: discount only
$ git stash pop -q --index
$ git status -s
M README.md
M src/cart.js
?? src/coupons.js
This time it came back with M , that is, staged. If you don't want to type that every time, Git 2.52 added the stash.index setting; when it's on, apply and pop behave as if --index was given:
$ git stash push --staged -m "discount only"
Saved working directory and index state On main: discount only
$ git config set stash.index true
$ git stash pop -q
$ git status -s
M README.md
M src/cart.js
?? src/coupons.js
$ git config unset stash.index
The setting also affects the automatic stashes that commands like git pull or git rebase make with --autostash.
Conflicts During a Pop
Ada commits the README and stashes the discount work again. Meanwhile a commit that changes the same line of the cart total lands on main:
$ git commit -q -m "Document how to run the cart" -- README.md
$ git status -s
M src/cart.js
?? src/coupons.js
==== conflict
$ git stash push -u -m "discount work in progress"
Saved working directory and index state On main: discount work in progress
$ git commit -am "Multiply price by quantity"
[main 03ce348] Multiply price by quantity
1 file changed, 1 insertion(+), 1 deletion(-)
$ git stash pop
Auto-merging src/cart.js
CONFLICT (content): Merge conflict in src/cart.js
On branch main
Your branch is ahead of 'origin/main' by 3 commits.
(use "git push" to publish your local commits)
Unmerged paths:
(use "git restore --staged <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: src/cart.js
Untracked files:
(use "git add <file>..." to include in what will be committed)
src/coupons.js
no changes added to commit (use "git add" and/or "git commit -a")
The stash entry is kept in case you need it again.
$ git status -s
UU src/cart.js
?? src/coupons.js
$ git stash list
stash@{0}: On main: discount work in progress
Two lines matter. UU says the file was changed on both sides and is in conflict. The last line says that pop didn't delete the entry: because of the conflict the stash stays in the list, so nothing was lost. Inside the file are the markers you know from the tenth post, just with different labels:
$ cat src/cart.js
<<<<<<< Updated upstream
function total(items) {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
=======
function total(items, discount = 0) {
const sum = items.reduce((acc, item) => acc + item.price, 0);
return sum - discount;
>>>>>>> Stashed changes
}
module.exports = { total };
Updated upstream is the version in the current commit; Stashed changes is the version in the stash. You combine the two to resolve the conflict, then delete the entry by hand:
$ git add src/cart.js
$ git stash drop
Dropped refs/stash@{0} (c1113e6f760849d644f0c1555fb1e3e082b5884b)
$ git status -s
M src/cart.js
?? src/coupons.js
Here git add only marks the conflict as resolved; the change waits in the stage, ready for a commit. If you forget the drop, the entry stays in the list and could be applied a second time by mistake later.
git stash branch
There's one more way to avoid a conflict: applying the stash to a new branch created from the commit it was saved on. On that commit the change fits cleanly:
$ git stash push -m "spring coupon"
Saved working directory and index state On main: spring coupon
$ git commit -q -am "Raise welcome coupon"
$ git stash branch feature/spring-coupon
Switched to a new branch 'feature/spring-coupon'
On branch feature/spring-coupon
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: src/coupons.js
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (bc59c1b149f72f756a039591a90189bc0434029a)
$ git branch -v
* feature/spring-coupon a1b82e5 Add welcome coupon
main 49a67f2 [ahead 6] Raise welcome coupon
git stash branch created a new branch from the commit the stash was taken on (a1b82e5), applied the change there, and since that succeeded, deleted the entry. Meanwhile main had moved on to 49a67f2 and changed the same line; but because the branch was created from the old point, there was no conflict. It's the calmest way to bring back a stash that has sat on the shelf for a long time. Then you commit and merge that branch like any other piece of work; if there's going to be a conflict, it shows up during the merge, with the tools you're used to.
Moving a Stash to Another Machine
Because a stash is local, for a long time there was no official way to move one to another machine. Git 2.51 brought an interchange format with git stash export and git stash import: stash entries are written to an ordinary ref, that ref is pushed and fetched, and on the other side it's taken back into the stash list:
$ git stash push -m "spring coupon"
Saved working directory and index state On feature/spring-coupon: spring coupon
$ git stash list
stash@{0}: On feature/spring-coupon: spring coupon
$ git stash export --to-ref refs/stashes/ada
$ git push origin refs/stashes/ada
To ../bookstore.git
* [new reference] refs/stashes/ada -> refs/stashes/ada
In the clone on Ada's laptop:
$ git fetch origin refs/stashes/ada:refs/stashes/ada
From ../bookstore
* [new ref] refs/stashes/ada -> refs/stashes/ada
$ git stash import refs/stashes/ada
$ git stash list
stash@{0}: On feature/spring-coupon: spring coupon
If you pass --print instead of --to-ref, export doesn't write a ref; it prints the hash of the commit it created. The ref name is up to you; putting it under refs/stashes/ keeps it from mixing with branches and tags. Still, in most cases it's simpler to commit the unfinished work on a branch and push it; export/import is for when you really want to move it as a stash.
Common Pitfalls
The first pitfall is using the stash as long-term storage. A week later you won't remember what stash@{3} was, and you'll have drifted away from the commit it was saved on. For work that will live longer than a few hours, create a branch and commit.
The second is forgetting untracked files. As you saw in Guess First, a plain git stash doesn't take new files. If the unfinished work includes new files, use -u. There's also -a to take files listed in .gitignore, but you usually don't want that.
The third is git stash clear and the wrong drop. Both delete entries without asking. Deleted stash commits stay in the object database for a while and can be found with git fsck --unreachable, but that's a rescue operation; we'll look at that kind of recovery in the reflog post.
The fourth is believing the stash was deleted after a conflict. It wasn't; running git stash drop after resolving is your job.
A Small Experiment
★☆☆ Change a file and add a new one. Try a plain git stash first, then git stash push -u -m "...", and look at the output of git status -s and git stash list after each.
★★☆ Stage one change, leave another unstaged and run git stash push --staged. apply the entry first without --index, then with --index; how do the two columns of git status -s change?
★★★ Take a stash, then make a commit that changes the same line. Hit the conflict with git stash pop and confirm the entry stayed in git stash list. Clean up the working tree without resolving the conflict, then apply the same entry conflict-free with git stash branch.
With this post we've completed the tools for undoing and setting work aside. In the next post we'll see how to move a single commit from one branch to another: picking a single commit or a range with git cherry-pick, recording its origin with -x, the flow during conflicts and carrying a hotfix to an old release branch.