Git & GitHub Module 8 – Undoing and recovering
Park unfinished work with stash and worktrees
Set work aside with git stash push, pop and apply, take untracked files too, rescue a stash that conflicts, and check out two branches with git worktree.
What you will learn
- Save and restore uncommitted work with git stash push, pop and apply
- Choose the stash options that take untracked files, ignored files or only staged changes
- Recover from a stash that conflicts when it is applied, with git stash branch
- Explain what a stash entry is made of, and bring back one that was dropped
- Use git worktree to work on two branches at once without stashing
Before you start
What you’ll learn · Before you start
What you will learn
- Save and restore uncommitted work with git stash push, pop and apply
- Choose the stash options that take untracked files, ignored files or only staged changes
- Recover from a stash that conflicts when it is applied, with git stash branch
- Explain what a stash entry is made of, and bring back one that was dropped
- Use git worktree to work on two branches at once without stashing
Before you start
On this page
Halfway through a feature, a bug on main has to be fixed now, and your folder holds four kinds of unfinished work: a
staged change, an unstaged edit, a new file and an ignored build folder. A plain git stash parks only the first two.
git stash push records uncommitted changes in a stash entry: A record of uncommitted changes made by git stash push: a commit of the working tree whose parents are the commit you were on and a commit of the index, plus one of new files with -u or -a. Entries are named stash@{0}, stash@{1} and so on, newest first. and puts your files back
to the last commit, so you can switch branches, fix the bug and come back with git stash pop; --include-untracked
adds new files, and --all adds ignored ones as well. A worktree: A working folder attached to a repository, with its own HEAD, index and files. git worktree add creates more of them, so several branches of one repository can be checked out at once, while commits, branches and stashes are shared. avoids parking
altogether: git worktree add ../hotfix main checks out a second branch in a second folder of the same repository,
so your half-done work stays exactly where it is.
Every session here runs with real Git 2.54.0 on a canteen app: what each stash option takes, a pop that conflicts, the commits a stash is made of, why old stashes never expire on their own, a second worktree, and moving a stash to another computer.
A stash parks your changes, and pop brings them back
Asha is halfway through a timetable feature, with one file edited and one new file, when the price of vada on main turns out to be wrong. She parks everything, fixes main and comes back:
from gitsession import session
session(r"""
git --version
git init -q -b main canteen
cd canteen
printf 'idli 30\nvada 25\n' > prices.txt
printf 'Breakfast 7 to 10\n' > hours.txt
git add prices.txt hours.txt
git commit -q -m "Add prices and hours"
git switch -q -c timetable
# Half-done work: an edited file and a new one
printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
printf 'Mon: poha\nTue: upma\n' > timetable.txt
git status --short
# Urgent: the price of vada on main is wrong. Park everything, the new file too
git stash push --include-untracked -m "wip timetable"
git status --short
git stash list
git switch -q main
printf 'idli 30\nvada 20\n' > prices.txt
git commit -q -am "Fix the price of vada"
# Back to the feature, and the parked work back into the files
git switch -q timetable
git stash show --include-untracked --stat
git stash pop
git status --short
git stash list
""") Output
$ git --version
git version 2.54.0 (Apple Git-157)
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli 30\nvada 25\n' > prices.txt
$ printf 'Breakfast 7 to 10\n' > hours.txt
$ git add prices.txt hours.txt
$ git commit -q -m "Add prices and hours"
$ git switch -q -c timetable
# Half-done work: an edited file and a new one
$ printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
$ printf 'Mon: poha\nTue: upma\n' > timetable.txt
$ git status --short
M hours.txt
?? timetable.txt
# Urgent: the price of vada on main is wrong. Park everything, the new file too
$ git stash push --include-untracked -m "wip timetable"
Saved working directory and index state On timetable: wip timetable
$ git status --short
$ git stash list
stash@{0}: On timetable: wip timetable
$ git switch -q main
$ printf 'idli 30\nvada 20\n' > prices.txt
$ git commit -q -am "Fix the price of vada"
# Back to the feature, and the parked work back into the files
$ git switch -q timetable
$ git stash show --include-untracked --stat
hours.txt | 1 +
timetable.txt | 2 ++
2 files changed, 3 insertions(+)
$ git stash pop
On branch timetable
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: hours.txt
Untracked files:
(use "git add <file>..." to include in what will be committed)
timetable.txt
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (d0a936597cb0e5a2785f7f940a9a5f9c58b71ced)
$ git status --short
M hours.txt
?? timetable.txt
$ git stash list
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 urgent_fix_stash.py
Step through the same session and follow where her unfinished work is at each moment:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Half-done work. On the branch timetable, hours.txt has an edit and timetable.txt is a new file: git status --short shows M hours.txt and ?? timetable.txt. Then the price of vada on main turns out to be wrong.
- Park it. git stash push --include-untracked -m "wip timetable" records both changes in a stash entry and puts the folder back to the last commit. Without -u the new file would have stayed behind.
- Fix main. With a clean folder, Asha switches to main, sets vada to 20 and commits Fix the price of vada. Nothing of the timetable work is in her way, or in the commit.
- Look before applying. Back on timetable, git stash show --include-untracked --stat lists what the entry holds: 2 files changed, 3 insertions. Add -p to see the whole diff.
- Pop it back. git stash pop applies the entry to the files, so the edit and the new file are back, and then drops the entry, printing its ID, d0a9365…. That ID is the way back if you drop something by mistake.
git stash push --include-untracked -m "wip timetable"saved both files and left the folder clean. The message is whatgit stash listshows; without-man entry is named after the commit it was made on,WIP on timetable: …, which tells you little a week later.git stash listnames entriesstash@{0},stash@{1}and so on, newest first, with the branch each was made on.git stash show --include-untracked --statsummarised the entry before it was used. Add-pto see the whole diff.git stash popput the changes back into the files and then dropped the entry, printing its ID:Dropped refs/stash@{0} (d0a9365…). That ID is the way back if you drop something by mistake.
git stash apply applies an entry and keeps it in the list, which is safer when you are not sure the result is what
you want, or when the same change should go onto two branches. Both take an entry’s name, as in
git stash apply stash@{1}, and with no name they use the newest. After a drop, the numbers of the older entries
shift down by one. git stash drop stash@{1} removes one entry and git stash clear removes all of them.
By default, applying puts changes to existing files back unstaged, even those that were staged when you stashed;
only new files that were staged come back staged, so that Git keeps tracking them. --index tries to restore the
staged part as staged too, and fails when that would conflict.
Checkpoint
Check: Stash, pop and apply
A plain stash takes tracked changes only
A plain git stash takes less than many people expect. This session parks a feature with an edited file, a new
file and an ignored build folder:
from gitsession import session
session(r"""
git init -q -b main canteen
cd canteen
printf 'build/\n' > .gitignore
printf 'Breakfast 7 to 10\n' > hours.txt
git add .gitignore hours.txt
git commit -q -m "Add the opening hours"
git switch -q -c timetable
printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
printf 'Mon: poha\n' > timetable.txt
mkdir build
printf 'cache\n' > build/cache.txt
git status --short --ignored
# A plain stash takes tracked changes only
git stash
git status --short --ignored
# The new file is not parked: it follows you to main
git switch -q main
git status --short
git switch -q timetable
git stash pop -q
# --include-untracked (-u) adds new files; --all (-a) adds ignored ones too
git stash push -q --include-untracked
git status --short --ignored
git stash pop -q
git stash push -q --all
git status --short --ignored
git stash pop -q
# --staged parks only what is staged
git add hours.txt
git stash push -q --staged -m "lunch hours only"
git status --short
git stash list
""") Output
$ git init -q -b main canteen
$ cd canteen
$ printf 'build/\n' > .gitignore
$ printf 'Breakfast 7 to 10\n' > hours.txt
$ git add .gitignore hours.txt
$ git commit -q -m "Add the opening hours"
$ git switch -q -c timetable
$ printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
$ printf 'Mon: poha\n' > timetable.txt
$ mkdir build
$ printf 'cache\n' > build/cache.txt
$ git status --short --ignored
M hours.txt
?? timetable.txt
!! build/
# A plain stash takes tracked changes only
$ git stash
Saved working directory and index state WIP on timetable: 51da1eb Add the opening hours
$ git status --short --ignored
?? timetable.txt
!! build/
# The new file is not parked: it follows you to main
$ git switch -q main
$ git status --short
?? timetable.txt
$ git switch -q timetable
$ git stash pop -q
# --include-untracked (-u) adds new files; --all (-a) adds ignored ones too
$ git stash push -q --include-untracked
$ git status --short --ignored
!! build/
$ git stash pop -q
$ git stash push -q --all
$ git status --short --ignored
$ git stash pop -q
# --staged parks only what is staged
$ git add hours.txt
$ git stash push -q --staged -m "lunch hours only"
$ git status --short
?? timetable.txt
$ git stash list
stash@{0}: On timetable: lunch hours only
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 what_stash_takes.py
The plain stash took the edit of hours.txt and nothing else. The new file timetable.txt stayed in the folder and
followed Asha to main, where git status still showed it: switching branches leaves untracked files in place.
--include-untracked took it too, and --all also took the ignored build/ folder. --staged took only the staged
edit and left the new file alone.
This program runs each option on the same four files with real Git: a staged change, an unstaged change, a new file and an ignored file. For each it shows whether the stash recorded the change, and whether the change is still in the folder afterwards:
"""Runs git stash push with each option on the same four files, with real Git, and prints what each one did.
hours.txt has a staged change, menu.txt an unstaged one, timetable.txt is new (untracked) and cache.log is
ignored. For each file the table says whether the stash recorded it and whether the change is still in the
folder afterwards. Every option starts from a fresh copy of the same repository.
"""
import os
import shutil
import subprocess
import tempfile
from gitsession import git_env
OPTIONS = ["", "--keep-index", "--staged", "--include-untracked", "--all"]
FILES = ["hours.txt", "menu.txt", "timetable.txt", "cache.log"]
work = tempfile.mkdtemp(prefix="stash-table-")
def git(repo, *args):
return subprocess.run(["git", *args], cwd=repo, env=git_env(work), capture_output=True, text=True)
def put(repo, name, content):
with open(os.path.join(repo, name), "w") as f:
f.write(content + "\n")
template = os.path.join(work, "template")
os.mkdir(template)
git(template, "init", "-q", "--template=", "-b", "main") # no sample hooks: smaller copies
put(template, ".gitignore", "*.log")
put(template, "hours.txt", "Breakfast 7 to 10")
put(template, "menu.txt", "idli")
git(template, "add", ".")
git(template, "commit", "-q", "-m", "Start")
put(template, "hours.txt", "Lunch 12 to 3")
git(template, "add", "hours.txt")
put(template, "menu.txt", "idli\nvada")
put(template, "timetable.txt", "Mon: poha")
put(template, "cache.log", "warm")
def changed_after(repo):
"""The files whose change is still in the folder: modified, staged, new or ignored, as git status shows."""
out = git(repo, "status", "--porcelain", "--ignored").stdout
return {line[3:] for line in out.splitlines()}
def recorded(repo):
"""The files the stash entry holds: changes in its working-tree and index commits, and its untracked files."""
names = set(git(repo, "diff", "--name-only", "stash@{0}^1", "stash@{0}").stdout.split())
names |= set(git(repo, "diff", "--name-only", "stash@{0}^1", "stash@{0}^2").stdout.split())
# A third parent exists only with --include-untracked or --all: a commit of just the new (and ignored) files
names |= set(git(repo, "ls-tree", "-r", "--name-only", "stash@{0}^3").stdout.split())
return names
print(f"{'git stash push':<28}", " ".join(f"{name:<14}" for name in FILES))
for option in OPTIONS:
repo = tempfile.mkdtemp(dir=work)
shutil.copytree(template, repo, dirs_exist_ok=True)
git(repo, "stash", "push", "-q", *([option] if option else []))
kept, left = recorded(repo), changed_after(repo)
cells = [("stashed" if f in kept else "-") + (", left" if f in left else "") for f in FILES]
print(f"{option or '(no option)':<28}", " ".join(f"{c:<14}" for c in cells))
shutil.rmtree(work) Output
git stash push hours.txt menu.txt timetable.txt cache.log (no option) stashed stashed -, left -, left --keep-index stashed, left stashed -, left -, left --staged stashed -, left -, left -, left --include-untracked stashed stashed stashed -, left --all stashed stashed stashed stashed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stash_options.py
Read down a column to follow one kind of change, and across a row for one option. The measured rows come down to this:
| Option | Records | Leaves in the folder |
|---|---|---|
| none | changes to tracked files, staged or not | new files and ignored files |
--keep-index, -k |
the same | also the staged changes, which stay staged |
--staged, -S |
only staged changes | everything else |
--include-untracked, -u |
tracked changes and new files | ignored files |
--all, -a |
everything, ignored files included | nothing |
Two more ways to choose: git stash push -p asks about each hunk, and paths after -- limit the stash to those
files, as in git stash push -- hours.txt. Use --all with care: it also sweeps up build output and installed
dependencies, so the entry can be large and slow to make and to apply. --keep-index suits testing part of your
work: stage what you want to commit, stash with -k -u, run the tests on exactly the staged state, then pop.
Version note
git stash push --staged arrived in Git 2.35. Since Git 2.52, setting stash.index to true makes pop and
apply restore the index as well, as --index does. Git 2.55 makes git checkout -m <branch> save your local
changes in a stash before it switches; the sessions on this page run on Git 2.54.0, which does not do that yet.
Checkpoint
Check: What each stash option takes
A pop that conflicts keeps the entry
Applying a stash is a merge of the stashed changes into whatever the files are now. If the same lines changed in the meantime, it conflicts:
from gitsession import session
session(r"""
git init -q -b main canteen
cd canteen
printf 'Breakfast 7 to 10\n' > hours.txt
git add hours.txt
git commit -q -m "Add the opening hours"
# Park an edit of the hours
printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
git stash push -q -m "lunch hours"
# Meanwhile the same line changes on the branch
printf 'Breakfast 7 to 11\n' > hours.txt
git commit -q -am "Serve breakfast until 11"
! git stash pop
git status --short
git stash list
# Back out: put the file back to the last commit; the stash entry is still there
git restore --staged --worktree hours.txt
git status --short
# Apply it where it was made instead: on a new branch from the commit it was based on
git stash branch lunch-hours
git log --oneline --decorate --graph --all
git stash list
""") Output
$ git init -q -b main canteen
$ cd canteen
$ printf 'Breakfast 7 to 10\n' > hours.txt
$ git add hours.txt
$ git commit -q -m "Add the opening hours"
# Park an edit of the hours
$ printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
$ git stash push -q -m "lunch hours"
# Meanwhile the same line changes on the branch
$ printf 'Breakfast 7 to 11\n' > hours.txt
$ git commit -q -am "Serve breakfast until 11"
$ git stash pop
Auto-merging hours.txt
CONFLICT (content): Merge conflict in hours.txt
On branch main
Unmerged paths:
(use "git restore --staged <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: hours.txt
no changes added to commit (use "git add" and/or "git commit -a")
The stash entry is kept in case you need it again.
[exit status 1]
$ git status --short
UU hours.txt
$ git stash list
stash@{0}: On main: lunch hours
# Back out: put the file back to the last commit; the stash entry is still there
$ git restore --staged --worktree hours.txt
$ git status --short
# Apply it where it was made instead: on a new branch from the commit it was based on
$ git stash branch lunch-hours
Switched to a new branch 'lunch-hours'
On branch lunch-hours
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: hours.txt
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (32db1e286db63f36ddff16878e44ca56af5894d5)
$ git log --oneline --decorate --graph --all
* d9b2e02 (main) Serve breakfast until 11
* ee62d06 (HEAD -> lunch-hours) Add the opening hours
$ git stash list
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pop_conflict.py
When the apply conflicts, pop does not drop the entry: Git says so in its last line, and git stash list still has
it. Step through the conflict, the way back out and the branch that takes the change instead:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The stash is made. On Add the opening hours, ee62d06, the line Lunch 12 to 3 is added to hours.txt and parked with git stash push -m "lunch hours". The entry remembers the commit it was made on.
- The line changes on main. Meanwhile main gets Serve breakfast until 11, d9b2e02, which changes the same line of hours.txt that the stash entry changes.
- git stash pop conflicts. Applying is a merge of the stashed change into the files as they are now, and both sides changed the same line. Git writes conflict markers into hours.txt, git status --short shows UU, and the last line says the stash entry is kept in case you need it again.
- Back out. git restore --staged --worktree hours.txt puts the file back to the last commit. Nothing is lost: the entry is still in git stash list, so it can be applied again.
- git stash branch. git stash branch lunch-hours creates a branch at ee62d06, the commit the stash was made on. There the change applies without a conflict.
- Applied and dropped. On lunch-hours, hours.txt holds the parked change, not staged yet, and the entry is dropped with its ID printed, 32db1e2…. Commit there, then merge or rebase as usual.
You then have three ways forward:
- Resolve it. Edit
hours.txtto the version you want andgit addit. Becausepopkept the entry, rungit stash dropyourself once you are happy. - Back out.
git restore --staged --worktree hours.txtputs the file back to the last commit, as in the session. The stash is untouched, so nothing is lost. - Apply it where it was made.
git stash branch lunch-hourscreates a branch at the commit the stash was made on, applies the stash there, where it cannot conflict, and drops it. The log showslunch-hoursstarting fromAdd the opening hours, before the change on main. Commit there, then merge or rebase as usual.
Key idea
A pop that conflicts keeps its entry, so nothing is lost: resolve and drop it yourself, back out with
git restore, or let git stash branch apply it on the commit it was made on.
Checkpoint
Check: A pop that conflicts
A stash is a set of commits, and they outlive a drop
A stash entry is not a special file. It is a commit whose tree holds your working tree, with the commit you were on
as its first parent and a second commit, holding the index, as its second parent. With --include-untracked or
--all, a third parent holds the new files:
from gitsession import session
session(r"""
git init -q -b main canteen
cd canteen
printf 'Breakfast 7 to 10\n' > hours.txt
git add hours.txt
git commit -q -m "Add the opening hours"
printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
git add hours.txt
printf 'Breakfast 7 to 10\nLunch 12 to 3\nDinner 7 to 10\n' > hours.txt
printf 'Mon: poha\n' > timetable.txt
git stash push -q --include-untracked -m "hours and timetable"
# The entry is a commit with three parents: HEAD, the index and the new files
git log --oneline --graph refs/stash
git show --format=%s --stat stash@{0}^3
# Drop it: the reference goes, but the commits stay until garbage collection removes them
git stash drop
git stash list
# Lost the ID that drop printed? git fsck lists the commits that nothing points to
git fsck
lost=$(git fsck | awk '/dangling commit/ {print $3}')
git stash apply -q "$lost"
git status --short
""") Output
$ git init -q -b main canteen
$ cd canteen
$ printf 'Breakfast 7 to 10\n' > hours.txt
$ git add hours.txt
$ git commit -q -m "Add the opening hours"
$ printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
$ git add hours.txt
$ printf 'Breakfast 7 to 10\nLunch 12 to 3\nDinner 7 to 10\n' > hours.txt
$ printf 'Mon: poha\n' > timetable.txt
$ git stash push -q --include-untracked -m "hours and timetable"
# The entry is a commit with three parents: HEAD, the index and the new files
$ git log --oneline --graph refs/stash
*-. 4a771a3 On main: hours and timetable
|\ \
| | * 61f3cf5 untracked files on main: ee62d06 Add the opening hours
| * 096183a index on main: ee62d06 Add the opening hours
|/
* ee62d06 Add the opening hours
$ git show --format=%s --stat stash@{0}^3
untracked files on main: ee62d06 Add the opening hours
timetable.txt | 1 +
1 file changed, 1 insertion(+)
# Drop it: the reference goes, but the commits stay until garbage collection removes them
$ git stash drop
Dropped refs/stash@{0} (4a771a35b285e20d8f6e491a86c4845f27d08e2e)
$ git stash list
# Lost the ID that drop printed? git fsck lists the commits that nothing points to
$ git fsck
dangling commit 4a771a35b285e20d8f6e491a86c4845f27d08e2e
$ lost=$(git fsck | awk '/dangling commit/ {print $3}')
$ git stash apply -q "$lost"
$ git status --short
M hours.txt
?? timetable.txt
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stash_is_commits.py
Step through the same entry as Git builds it, commit by commit, then drop it and bring it back:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Where the work starts. HEAD is on ee62d06, Add the opening hours. Lunch 12 to 3 is staged in hours.txt, Dinner 7 to 10 is added on top without staging, and timetable.txt is a new file.
- A commit of the index. git stash push --include-untracked first records the staged state as a commit, 096183a index on main, whose parent is ee62d06.
- A commit of the new files. With -u, the new files get a commit of their own, 61f3cf5 untracked files on main. It holds timetable.txt only, and it has no parent.
- The entry itself. Last comes 4a771a3, a commit of the working tree with three parents: HEAD, the index commit and the untracked commit. refs/stash points at it, and the stash list calls it stash@{0}.
- git stash drop. Dropping removes the entry from refs/stash and its reflog, and prints Dropped refs/stash@{0} with the full ID. The commits are untouched: nothing points at them now, so git fsck reports dangling commit 4a771a3….
- Back by its ID. git stash apply with that ID brings the changes back: M hours.txt and ?? timetable.txt. Until garbage collection prunes them, a dropped entry's commits are still there to apply.
refs/stash points at the newest entry, and the older entries are the reflog: A log of every position that HEAD and each branch has had in one repository, newest first. It is never pushed or cloned. Its entries are named like HEAD@{2}, and apart from stashes they expire after 90 days by default, or 30 for commits no branch reaches. More in “Recover lost commits with the reflog” of that
reference, which is why their names, stash@{1}, look like reflog names. git stash drop removes the reflog entry,
not the commits. They stay in the repository until garbage collection prunes them, which by default it does once they
are more than two weeks old. Until then, git fsck lists the stash commit as a
dangling object: An object in the repository that nothing points to: no reference, index entry or other object uses it, such as a blob that was staged but never committed. git fsck lists such objects until garbage collection deletes them. More in “Recover lost commits with the reflog” commit, and git stash apply with its ID brings the changes back. The ID
that drop printed is the quicker route, if it is still on your screen.
Old stashes never expire on their own
Git documents an expiry of 90 days for reflog entries, or 30 days for entries whose commit no branch reaches; the
reflog lesson of this module shows what Git 2.54 actually applies. Git’s source makes an exception for refs/stash:
unless you configure an expiry for it, stash entries never expire. This session makes two stashes dated 100 and 40
days ago and runs garbage collection:
from gitsession import session
session(r"""
git init -q -b main canteen
cd canteen
printf 'Breakfast 7 to 10\n' > hours.txt
git add hours.txt
git commit -q -m "Add the opening hours"
# Two stashes made 100 and 40 days ago: GIT_COMMITTER_DATE sets the time Git records, here now minus n days
printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
GIT_COMMITTER_DATE="@$(( $(date +%s) - 100 * 86400 )) +0530" git stash push -q -m "lunch hours"
printf 'Breakfast 7 to 10\nDinner 7 to 9\n' > hours.txt
GIT_COMMITTER_DATE="@$(( $(date +%s) - 40 * 86400 )) +0530" git stash push -q -m "dinner hours"
git stash list --date=relative
# Garbage collection with the default settings keeps every stash entry, however old
git gc -q
git stash list --date=relative
# Only an expiry you ask for removes them: here, entries older than 60 days
git reflog expire --expire=60.days.ago refs/stash
git stash list --date=relative
""", fixed_time=False) Output
$ git init -q -b main canteen
$ cd canteen
$ printf 'Breakfast 7 to 10\n' > hours.txt
$ git add hours.txt
$ git commit -q -m "Add the opening hours"
# Two stashes made 100 and 40 days ago: GIT_COMMITTER_DATE sets the time Git records, here now minus n days
$ printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
$ GIT_COMMITTER_DATE="@$(( $(date +%s) - 100 * 86400 )) +0530" git stash push -q -m "lunch hours"
$ printf 'Breakfast 7 to 10\nDinner 7 to 9\n' > hours.txt
$ GIT_COMMITTER_DATE="@$(( $(date +%s) - 40 * 86400 )) +0530" git stash push -q -m "dinner hours"
$ git stash list --date=relative
stash@{6 weeks ago}: On main: dinner hours
stash@{3 months ago}: On main: lunch hours
# Garbage collection with the default settings keeps every stash entry, however old
$ git gc -q
$ git stash list --date=relative
stash@{6 weeks ago}: On main: dinner hours
stash@{3 months ago}: On main: lunch hours
# Only an expiry you ask for removes them: here, entries older than 60 days
$ git reflog expire --expire=60.days.ago refs/stash
$ git stash list --date=relative
stash@{6 weeks ago}: On main: dinner hours
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 old_stashes.py
git gc kept both entries, even the one older than 90 days. Only git reflog expire with an explicit --expire
removed the older one. So a stash list can quietly grow for years. It is worth reading git stash list from time to
time, turning entries you still need into branches with git stash branch, and dropping the rest.
Move a stash to another computer
A stash lives in one repository and is never pushed. Git 2.51 added git stash export, which turns stash entries
into an ordinary chain of commits on a reference you choose, and git stash import, which turns them back. Pushing
and fetching that reference moves the stashes:
from gitsession import session
session(r"""
git init -q -b main laptop
cd laptop
printf 'Breakfast 7 to 10\n' > hours.txt
git add hours.txt
git commit -q -m "Add the opening hours"
git clone -q --bare . ../shared.git
git remote add origin ../shared.git
printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
git stash push -q -m "lunch hours"
# Turn the stash list into commits on a ref, and push that ref like a branch
git stash export --to-ref refs/stashes/laptop
git push -q origin refs/stashes/laptop
# On the other computer: fetch the ref, then import the stashes from it
cd ~
git clone -q shared.git desktop
cd desktop
git stash list
git fetch -q origin refs/stashes/laptop:refs/stashes/laptop
git stash import refs/stashes/laptop
git stash list
git stash pop -q
git status --short
""") Output
$ git init -q -b main laptop
$ cd laptop
$ printf 'Breakfast 7 to 10\n' > hours.txt
$ git add hours.txt
$ git commit -q -m "Add the opening hours"
$ git clone -q --bare . ../shared.git
$ git remote add origin ../shared.git
$ printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
$ git stash push -q -m "lunch hours"
# Turn the stash list into commits on a ref, and push that ref like a branch
$ git stash export --to-ref refs/stashes/laptop
$ git push -q origin refs/stashes/laptop
# On the other computer: fetch the ref, then import the stashes from it
$ cd ~
$ git clone -q shared.git desktop
$ cd desktop
$ git stash list
$ git fetch -q origin refs/stashes/laptop:refs/stashes/laptop
$ git stash import refs/stashes/laptop
$ git stash list
stash@{0}: On main: lunch hours
$ git stash pop -q
$ git status --short
M hours.txt
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stash_export.py
The second clone started with an empty stash list, because stashes are not part of a clone or a push. After the fetch and the import, it had the laptop’s entry and applied it. Delete the reference once both sides have the stash, so the chain of commits does not linger in the shared repository.
Git Patch and Diff Viewer (Apply, Reverse, Split) Save a stash as a patch with git stash show -p stash@{0} > lunch.patch, and read it here before you apply it anywhere.Checkpoint
Check: Stash entries as commits
A worktree checks out a second branch in a second folder
A worktree is a second working folder attached to the same repository, with its own HEAD, its own index and its own files. Here is the same urgent fix as before, done in a worktree, so the timetable work never moves:
from gitsession import session
session(r"""
git init -q -b main canteen
cd canteen
printf 'idli 30\nvada 25\n' > prices.txt
printf 'Breakfast 7 to 10\n' > hours.txt
git add prices.txt hours.txt
git commit -q -m "Add prices and hours"
git switch -q -c timetable
printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
printf 'Mon: poha\nTue: upma\n' > timetable.txt
# The same urgent fix, without touching the half-done work: a second working tree for main
git worktree add ../hotfix main
git worktree list
cd ../hotfix
printf 'idli 30\nvada 20\n' > prices.txt
git commit -q -am "Fix the price of vada"
cd ../canteen
git status --short
git log --oneline main
# One branch can be checked out in only one worktree
! git worktree add ../second timetable
# Refs are shared: the stash list is the same in every worktree
git stash push -q -m "lunch hours"
git -C ../hotfix stash list
git stash pop -q
git worktree remove ../hotfix
git worktree list
""") Output
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli 30\nvada 25\n' > prices.txt
$ printf 'Breakfast 7 to 10\n' > hours.txt
$ git add prices.txt hours.txt
$ git commit -q -m "Add prices and hours"
$ git switch -q -c timetable
$ printf 'Breakfast 7 to 10\nLunch 12 to 3\n' > hours.txt
$ printf 'Mon: poha\nTue: upma\n' > timetable.txt
# The same urgent fix, without touching the half-done work: a second working tree for main
$ git worktree add ../hotfix main
Preparing worktree (checking out 'main')
HEAD is now at 4e1dbec Add prices and hours
$ git worktree list
~/canteen 4e1dbec [timetable]
~/hotfix 4e1dbec [main]
$ cd ../hotfix
$ printf 'idli 30\nvada 20\n' > prices.txt
$ git commit -q -am "Fix the price of vada"
$ cd ../canteen
$ git status --short
M hours.txt
?? timetable.txt
$ git log --oneline main
eb5a75e Fix the price of vada
4e1dbec Add prices and hours
# One branch can be checked out in only one worktree
$ git worktree add ../second timetable
Preparing worktree (checking out 'timetable')
fatal: 'timetable' is already used by worktree at '~/canteen'
[exit status 128]
# Refs are shared: the stash list is the same in every worktree
$ git stash push -q -m "lunch hours"
$ git -C ../hotfix stash list
stash@{0}: On timetable: lunch hours
$ git stash pop -q
$ git worktree remove ../hotfix
$ git worktree list
~/canteen 4e1dbec [timetable]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 worktree_hotfix.py
git worktree list named the two folders and the branch each has checked out. What they share, and what each keeps
for itself:
Two worktrees of one repository
Text description of the diagram
Three boxes are joined in a column. At the top is ~/canteen, the main worktree, where HEAD is on the timetable branch. In the middle is the repository, the folder ~/canteen/.git: its commits, branches, tags and stashes are shared by both worktrees. At the bottom is ~/hotfix, a linked worktree created with git worktree add ../hotfix main, where HEAD is on main.
Each worktree has its own HEAD, its own index and its own files, so work in one does not touch the other.
git worktree add ../hotfix mainchecked outmainin a new folder next to the project. The fix was committed there, andgit log mainin the original folder sees it at once, because both folders share one repository.git statusin~/canteenstill shows the half-done work, untouched.- By default a branch can be checked out in only one worktree at a time: adding a second worktree for
timetablefailed. To look at a commit in another folder without a branch, usegit worktree add --detach <path> <commit>. - References under
refs/are shared, apart from a few that the manual lists, such asrefs/bisect, so the stash list is the same in every worktree; HEAD and the index belong to each worktree. git worktree remove ../hotfixdeletes the folder and its record. It refuses while the folder has uncommitted changes or untracked files;--forceoverrides that. If you delete a worktree folder by hand,git worktree pruneclears the leftover record.
Stash or worktree?
Which one fits depends on the interruption. Answer three questions about yours, and the table rules out what cannot work and says why the rest fit:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way to park | Setting up | New and ignored files | Two tasks at once | Coming back | Cleaning up |
|---|---|---|---|---|---|
| git stash push Takes: tracked changes, out of the folder and back | Good one command | Poor new files stay in the folder and follow you to the other branch | Poor your work is out of the folder until you pop it | Mixed applying is a merge, which can conflict | Mixed pop or drop; an entry you forget never expires |
| git stash push -u Takes: tracked changes and new files | Good one command | Mixed takes new files too; ignored ones stay unless you use --all | Poor your work is out of the folder until you pop it | Mixed applying is a merge, which can conflict | Mixed pop or drop; an entry you forget never expires |
| git worktree add Takes: nothing: a second folder of the same repository | Mixed one command, and a second folder with its own files | Good untouched: they stay in the first folder | Good tests in one folder while you code in the other | Good nothing to apply: your work never moved | Good git worktree remove, which refuses while work is uncommitted |
How the answers decide
- “Must the other task run while you keep working, such as a long test suite?” Yes, both at the same time: rules out git stash push and git stash push -u. A stash takes your work out of the folder, so the two tasks cannot run at once: only a second working tree allows that.
- “Does your unfinished work include new files that Git does not track yet?” Yes, new files: rules out git stash push. A plain stash leaves new files in the folder, where they follow you to the other branch: add -u, or use a worktree.
- “Does your unfinished work include new files that Git does not track yet?” Yes, new files: adds a caveat to git stash push -u. -u takes the new files, but ignored ones, such as a build folder, stay in place unless you use --all.
- “Is the other task a quick fix that you finish before you come back?” No, it takes longer or needs its own build: adds a caveat to git stash push and git stash push -u. The longer work stays parked, the easier it is to forget, and stash entries never expire: a worktree keeps it in view.
- “Is the other task a quick fix that you finish before you come back?” Yes, a few minutes: adds a caveat to git worktree add. For a quick fix a second folder is more set-up than the job needs; a stash and a pop are two commands.
In short: a stash for a quick interruption on tracked work, with -u as soon as new files are involved; a worktree
when the other task takes longer, needs its own build, or has to run while you keep working.
Checkpoint
Check: Worktrees
Practise: predict what git stash push takes
Before you trust a stash with an afternoon of work, you should know what it will take. Write that rule as code, with the measured table above as the answer key:
Exercise · Easy · Python
Predict what git stash push takes
git stash push records some of your changes in a stash entry and takes them out of the folder; others it does not touch at all. Write that rule as a function: stash_push(files, option=None) in stash_model.py.
files maps each changed file to its kind:
"staged": a change in the index, waiting for the next commit;"unstaged": a change to a tracked file that is not staged;"untracked": a new file Git does not track yet;"ignored": a file that.gitignorematches.
option is None or one of --keep-index, --staged, --include-untracked and --all, or their short forms -k, -S, -u and -a. Return a dictionary that maps every file to a pair (stashed, left): stashed is True when the stash entry records the file's change, and left is True when the change is still in the folder afterwards. The table that stash_options.py recorded with real Git above is the answer key for one file of each kind. The sample tests import the function from stash_model.py and run in your browser.
Starter code · stash_model.py
def stash_push(files, option=None):
"""What git stash push does to each file: a dict of file name -> (stashed, left)."""
# Replace this line with your code.
return {name: (False, True) for name in files} The sample tests · test_stash_model.py
from stash_model import stash_push
ONE_OF_EACH = {"hours.txt": "staged", "menu.txt": "unstaged", "timetable.txt": "untracked", "cache.log": "ignored"}
def test_plain_push():
"""takes tracked changes, staged or not, and leaves new and ignored files alone"""
assert stash_push(ONE_OF_EACH) == {
"hours.txt": (True, False),
"menu.txt": (True, False),
"timetable.txt": (False, True),
"cache.log": (False, True),
}
def test_keep_index():
"""records the staged change but also leaves it in place"""
assert stash_push(ONE_OF_EACH, "--keep-index") == {
"hours.txt": (True, True),
"menu.txt": (True, False),
"timetable.txt": (False, True),
"cache.log": (False, True),
}
def test_staged():
"""takes only what is staged"""
assert stash_push(ONE_OF_EACH, "--staged") == {
"hours.txt": (True, False),
"menu.txt": (False, True),
"timetable.txt": (False, True),
"cache.log": (False, True),
}
def test_include_untracked():
"""also takes new files, but not ignored ones"""
assert stash_push(ONE_OF_EACH, "--include-untracked") == {
"hours.txt": (True, False),
"menu.txt": (True, False),
"timetable.txt": (True, False),
"cache.log": (False, True),
}
def test_all():
"""takes every change, ignored files included"""
assert stash_push(ONE_OF_EACH, "--all") == {
"hours.txt": (True, False),
"menu.txt": (True, False),
"timetable.txt": (True, False),
"cache.log": (True, False),
}
def test_short_options():
"""-k, -S, -u and -a mean the same as their long forms"""
for short, long in [("-k", "--keep-index"), ("-S", "--staged"), ("-u", "--include-untracked"), ("-a", "--all")]:
assert stash_push(ONE_OF_EACH, short) == stash_push(ONE_OF_EACH, long)
def test_many_files_of_one_kind():
"""treats every file of a kind the same way"""
files = {"a.txt": "untracked", "b.txt": "untracked", "c.txt": "unstaged"}
assert stash_push(files) == {"a.txt": (False, True), "b.txt": (False, True), "c.txt": (True, False)}
assert stash_push(files, "-u") == {"a.txt": (True, False), "b.txt": (True, False), "c.txt": (True, False)}
def test_nothing_changed():
"""returns an empty dictionary when there is nothing to stash"""
assert stash_push({}) == {}
assert stash_push({}, "--all") == {} A hint
Turn a short option into its long form first. Then decide each kind of file on its own, with two questions: which options make the stash record this kind of change, and is there an option that records it but leaves it in place?
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
To watch a session on your own computer, keep gitsession.py beside it and start it with Python 3, as in
python3 pop_conflict.py. Everything happens in a temporary folder that the helper treats as home, so none of your
repositories or settings change. old_stashes.py is the one session that reads the real clock, which is why it
prints ages instead of commit IDs.
"""Plays a terminal session with real Git: each command is printed after "$ ", then what it printed.
Every session starts in a new, empty folder that is also its home folder ("~" in the output), so your own Git
settings play no part, and GIT_CONFIG_NOSYSTEM=1 skips the computer's system-wide settings file. Commits get a
fixed author, Asha Rao, and a fixed clock that moves on one minute per line. A commit's ID covers its author and
time, so the IDs printed here are the ones you get when you run the file yourself. A session started with
fixed_time=False uses the real time instead, for commands whose behaviour depends on how old an entry is.
Each line of a script is one /bin/sh command; the session itself does "cd" and keeps "NAME=$(command)" for the
lines after it, as a shell would. A line that starts with "! " is meant to fail: its exit status is shown. Any
other command that fails stops the session. The folder is deleted at the end.
"""
import atexit
import functools
import os
import re
import shutil
import subprocess
import sys
import tempfile
# The first commit time, in seconds since 1970, and India's offset from UTC (Git's own date format).
START = 1767240000
ZONE = "+0530"
@functools.cache
def _git_folder():
"""The folder that holds the git program. On macOS, /usr/bin/git only starts the Command Line Tools' git."""
found = subprocess.run(["git", "--exec-path"], capture_output=True, text=True).stdout.strip()
return found if found and os.path.isfile(os.path.join(found, "git")) else None
def git_env(home, minute=0, fixed_time=True):
"""The environment every command runs with: no personal settings, a fixed author and a fixed time."""
folder = _git_folder()
stamp = f"@{START + 60 * minute} {ZONE}"
env = {
"PATH": os.pathsep.join(p for p in (folder, os.environ.get("PATH", "/usr/bin:/bin")) if p),
"HOME": home,
"XDG_CONFIG_HOME": os.path.join(home, ".config"),
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_AUTHOR_NAME": "Asha Rao",
"GIT_AUTHOR_EMAIL": "asha@example.com",
"GIT_COMMITTER_NAME": "Asha Rao",
"GIT_COMMITTER_EMAIL": "asha@example.com",
"GIT_AUTHOR_DATE": stamp,
"GIT_COMMITTER_DATE": stamp,
"LANG": "C",
"LC_ALL": "C",
"TERM": "dumb",
}
if not fixed_time:
del env["GIT_AUTHOR_DATE"], env["GIT_COMMITTER_DATE"]
return env
def session(script, fixed_time=True):
"""Runs each line of the script in order, starting in the home folder."""
home = tempfile.mkdtemp(prefix="git-lesson-")
atexit.register(shutil.rmtree, home, True)
spellings = sorted({home, os.path.realpath(home)}, key=len, reverse=True)
saved = {}
def tidy(text):
for spelling in spellings:
text = text.replace(spelling, "~")
return text
cwd = home
for minute, line in enumerate(script.strip().splitlines()):
command = line.strip()
if not command:
continue
if command.startswith("#"):
print(command)
continue
should_fail = command.startswith("! ")
if should_fail:
command = command[2:]
print(f"$ {command}")
if command.startswith("cd "):
target = command[3:].strip()
if target == "~" or target.startswith("~/"):
target = home + target[1:]
cwd = os.path.normpath(os.path.join(cwd, target))
continue
keep = re.fullmatch(r"([A-Za-z_]\w*)=\$\((.+)\)", command)
done = subprocess.run(
keep.group(2) if keep else command,
shell=True,
cwd=cwd,
env={**git_env(home, minute, fixed_time), **saved},
stdout=subprocess.PIPE,
stderr=subprocess.PIPE if keep else subprocess.STDOUT,
text=True,
)
if keep:
saved[keep.group(1)] = done.stdout.strip()
print(tidy(done.stderr), end="")
else:
print(tidy(done.stdout), end="")
if done.returncode != 0:
print(f"[exit status {done.returncode}]")
if not should_fail:
sys.exit(1)
elif should_fail:
sys.exit(f"{command!r} was meant to fail, and it worked") Finish with a self-check on parking work: one question at a time, the reason for every answer as soon as you give it, and more questions on whichever of the five ideas you miss:
Practice that adapts to you
Practise this lesson
14 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What is the difference between git stash pop and git stash apply?
Both apply the changes recorded in a stash entry to the files you have now. git stash pop then drops the entry,
while git stash apply keeps it in the list, so you can apply the same changes again elsewhere or simply keep a
copy until you are sure the result is right. If applying conflicts, pop keeps the entry as well, so you resolve the
conflict and drop it yourself. Either way, changes to existing files come back unstaged unless you add --index. A
dropped entry is not gone at once: its commits stay until garbage collection prunes them, and git stash apply with
the ID that the drop printed brings the changes back.
Two harder questions start from these sessions: a teammate’s branch whose tests must run while you keep coding, and a pop that conflicts in the middle of the day.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
You must review a teammate's branch and run its 20-minute test suite, while you keep coding on your own branch, which has uncommitted changes and a 2 GB ignored build cache. Stash or worktree? Walk through the commands and what each choice costs.
-
Fresher to mid
git stash pop on main reports a conflict in timetable.py. What state are your working tree, index and stash list in now, and how do you either finish the job safely or back out completely?
Key takeaways
git stash push -m "message"parks uncommitted changes,git stash popbrings them back and drops the entry, andgit stash applykeeps it.- A plain stash takes tracked changes only:
-uadds new files,-aignored files too, and--stagedonly the index. - When a pop conflicts, the entry is kept, so you resolve and drop it, back out with
git restore, or usegit stash branch. - A stash entry is a set of commits under
refs/stash, so a dropped one can be found withgit fsckuntil it is pruned, and an entry nobody configured an expiry for never expires. git worktree add <path> <branch>gives a second checkout of the same repository, with one branch per worktree and every reference shared.git stash exportandgit stash importmove stashes through an ordinary reference that you push and fetch.
Terms in this lesson
- stash entry
- A record of uncommitted changes made by git stash push: a commit of the working tree whose parents are the commit you were on and a commit of the index, plus one of new files with -u or -a. Entries are named stash@{0}, stash@{1} and so on, newest first.
- worktree
- A working folder attached to a repository, with its own HEAD, index and files. git worktree add creates more of them, so several branches of one repository can be checked out at once, while commits, branches and stashes are shared.
References
- git-stash documentation (The Git project)
- git-worktree documentation (The Git project)
- git-config documentation: stash.index and gc.reflogExpire (The Git project)
- git-fsck documentation (The Git project)
- git-gc documentation (The Git project)
- git-checkout documentation (-m) (The Git project)
- Git source code: reflog.c, where unconfigured stash entries never expire (The Git project)
- Git 2.35 release notes (git stash --staged) (The Git project)
- Git 2.51 release notes (git stash export and import) (The Git project)
- Git 2.52 release notes (stash.index) (The Git project)
- Git 2.55 release notes (git checkout -m saves local changes in a stash) (The Git project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress