Git recovery: undo at the right layer
- Pick the undo for where a mistake lives:
restore,restore --staged,commit --amend,reset, orrevertfor anything already pushed - Predict what
reset --soft,--mixedand--harddo to the branch, the index and the working tree — including untracked and staged-only files - Use
stashwithout losing work (-u,applyvspop), and spot the path-limited stash that saves nothing so the nextpopapplies an older entry - Recover a "lost" commit with
git reflogorORIG_HEAD, and say what the reflog can't bring back
Read
Undo at the layer where the mistake lives
FlagEvery git undo works on one of the three places a change lives — working tree, index, commits — plus one question for commits: has anyone else got it yet? Find the row, run that command.
| The mistake | Lives in | Undo |
|---|---|---|
| Edited a file, want the committed version back | working tree | git restore app.ts |
| Staged a file you didn't mean to | index | git restore --staged app.ts |
| Last commit's message is wrong / forgot a file | last commit, not pushed | git commit --amend |
| Last commit shouldn't exist yet; keep the changes | commits, not pushed | git reset --soft HEAD~1 |
| A commit is already pushed and shared | shared history | git revert <commit> |
| "I reset/rebased and my commit is gone" | the reflog | git reflog, then reset to it |
git restore app.ts # working tree ← index: discard unstaged edits (gone for good)
git restore --staged app.ts # index ← HEAD: unstage; your edits stay on disk
git add forgotten.ts
git commit --amend --no-edit # replace the last commit with one that includes the file
git revert a1b2c3d # a NEW commit that applies the opposite change
git restore and git switch (added in Git 2.23) split the old do-everything git checkout:
restore for files, switch for branches. Their manual pages labelled them experimental until Git
2.51 dropped the label, and git status suggests them in its own hints.
The two words to keep straight
- Amend and reset rewrite history.
--amenddoesn't edit a commit — commits never change — it makes a new one with a new id and moves the branch to it. Fine while the commit exists only on your machine. Once it's pushed, rewriting means a force-push, and everyone who pulled the old commit now has a history that disagrees with yours. - Revert adds history. It records a new commit that undoes an old one, so every clone simply pulls it like any other commit. That's why it's the undo for anything already shared.
One command in this table really destroys work: git restore <file> (and reset --hard) on
unstaged edits. Git never stored them, so there's nothing to recover. Almost everything else —
even a "deleted" commit — can come back, for a while (the complex tier covers the limits).
Stash without surprises
Flaggit stash takes your uncommitted changes, saves them as an entry on a stack, and resets
the working tree to HEAD. pop reapplies the top entry and drops it.
git stash push -m "wip: retry logic" # save tracked changes (staged and unstaged)
git stash list # stash@{0}: On main: wip: retry logic
git stash show -p stash@{0} # what's in it
git stash pop # apply the newest entry and drop it
git stash apply stash@{1} # apply a specific one and keep it
Two defaults to know:
- Untracked files aren't stashed. A new file you never added stays in the working tree.
git stash push -uincludes them. popdrops,applykeeps. When the apply hits a conflict,popkeeps the entry too — but get in the habit ofapply, check, thengit stash drop.
The stash that saves nothing
A path-limited stash saves only changes to those paths. If they have none, nothing is saved —
and the next pop takes whatever was already on top. A real run (Git 2.50): an old experiment is
stashed, then you edit only config.json and try to stash app.ts:
% git stash list
stash@{0}: On main: old experiment # from last week: an edit to app.ts
% git status --short
M config.json
% git stash push -- app.ts # app.ts has no changes
No local changes to save
% echo $?
0
% git stash pop
...
modified: app.ts # last week's experiment is back
modified: config.json
Dropped refs/stash@{0} (f58ebec…)
No local changes to save with exit code 0, then pop applies a different, older stash
and deletes it from the list. In an interactive session you might notice the message; in a
script you won't:
git stash && git pull && git stash pop # on a clean tree, pops someone else's work
On a clean tree, git stash saves nothing (exit 0), and pop either applies an old entry or
fails with No stash entries found. By hand: read git stash list before you pop. In a script,
compare the top of the stack before and after, and pop only if it changed:
before=$(git rev-parse -q --verify refs/stash || true) # empty stack: prints nothing, exits 1
git stash push -- app.ts
after=$(git rev-parse -q --verify refs/stash || true)
if [ "$before" != "$after" ]; then git stash pop; fi
The || true matters under set -e: with no stash entries, rev-parse exits 1, and that
failed assignment would stop the script before it stashed anything.
The reflog — git's undo history, and what it can't bring back
FlagEvery time HEAD or a branch moves — commit, reset, rebase, amend, switch — git appends a line to
a reflog: "this ref used to point here". Commits that no branch points at any more are still
in the object store, and the reflog is the list of where they are.
A real run: three commits, then a reset --hard HEAD~2 too many.
% git log --oneline
9947bb8 Add checkout form # two commits "gone"
% git reflog
9947bb8 HEAD@{0}: reset: moving to HEAD~2
c4929d4 HEAD@{1}: commit: Show card errors inline
9694b65 HEAD@{2}: commit: Validate card number
9947bb8 HEAD@{3}: commit (initial): Add checkout form
HEAD@{1} is "where HEAD was one move ago". Two ways back:
git reset --hard HEAD@{1} # move the branch back (or: git reset --hard ORIG_HEAD)
git branch rescue c4929d4 # or keep the commit on a new branch and decide later
ORIG_HEAD holds where HEAD was before the last reset, merge or rebase — handy right after
the mistake. The reflog is better when you've done more since. git reflog show main lists the
branch's own moves, and git log -g shows the same entries with full commit details.
The limits
- It's local. Reflogs record moves "in the local repository". They aren't pushed, and a fresh
clone starts with one entry (
clone: from …). A teammate can't recover your lost commit from their reflog, and you can't recover it after deleting the clone. - It expires.
git gc(which git also runs automatically now and then) prunes reflog entries: by default after 90 days, or 30 days for entries that aren't reachable from the branch tip any more (gc.reflogExpire,gc.reflogExpireUnreachable). Once an entry is gone, the commit can be garbage-collected. - It only knows commits and ref moves. Unstaged edits were never stored anywhere.
git restore <file>orreset --hardon them is final.
The last resort: staged but never committed
git add does store content — as a blob object — even if you never commit it. After a
reset --hard threw away a staged file, the blob is "dangling" (nothing refers to it), and
git fsck can find it:
% git fsck --lost-found
dangling blob 248d032f84c5b8ce411481462743cfe1e50b31ba
% cat .git/lost-found/other/248d032f…
staged work
You get the contents back, without the file name. It's slow archaeology, but it's the difference between "lost" and "found it". The habit that makes it unnecessary: commit early on a branch — a commit is in the reflog; a dropped stash or a staged-only file is much harder to find.
Quiz
You ran git add . and staged secrets.json by mistake. You want it out of the next commit but your edits kept on disk. Which command?
A commit pushed to main yesterday broke checkout. Three teammates have pulled since. What's the right undo?
Your last three local commits should be one. Which sequence leaves you with one commit holding all three commits' changes?
FlagYour working tree has notes.md (never added) and helper.ts (new, staged with git add, never committed). You run git reset --hard. What's left?
git stash list shows stash@{0}: On main: old experiment. You edited only config.json, then ran git stash push -- app.ts and git stash pop. What happened?
You run git stash, switch to another branch, and your new, never-added draft.md is still there in the working tree. Why?
You ran git reset --hard HEAD~2 — one commit too far. Nothing was pushed. How do you get the commits back?
Which of these can the reflog not help you recover?
FlagFill in the blank
Unstage app.ts and keep the edits on disk.
git restore app.ts
Undo the last commit but keep its changes staged, ready to recommit.
Flaggit reset HEAD~1
Stash everything, including files you haven't added yet, with a label you'll recognise.
Flaggit stash push -m "wip: retry logic"
Undo the reset --hard you just ran.
git reset --hard Revert a merge commit, keeping the branch you merged into as the mainline.
Flaggit revert 1 4f2a9c1
Predict the output
# HEAD's commit changed a.txt ("a1" → "a2") and added b.txt
git reset -q HEAD~1
git status --short% git stash list
stash@{0}: On main: old experiment
% git status --short
M config.json
% git stash push -- app.ts; echo "exit=$?"
% git stash pop -q; git status --short# You: git reset --hard HEAD~1 && git push --force (removes "Double the price" from main) # Your teammate had already pulled "Double the price". They run: git pull git push
Challenge
Model what git reset does to the three trees
Flagreset.ts implements git reset --soft | --mixed | --hard <target> over a tiny repo model
(repo.ts): a store of commits (each a full snapshot of its files), branches as pointers, an
index, a working tree, a reflog and ORIG_HEAD. status() prints what git status --short
would, and the tests compare it with what real git printed for the same repo:
c1 "First" a.txt = "a1"
c2 "Second" a.txt = "a2", b.txt = "b" ← main, HEAD
working tree also has scratch.txt (untracked)
Each of the four bugs is a common belief about reset:
- "--soft unstages the changes." It doesn't touch the index at all — after
reset --soft HEAD~1the undone commit's changes are still staged, ready to recommit. - "reset changes my files."
--mixed(the default) resets the index only; the working tree keeps every edit. - "--hard wipes the folder." It rewrites the files git tracks. An untracked file survives
(deleting those is
git clean's job). A file that was only staged, never committed, is tracked — so--harddoes remove that one. - "reset deletes the commits." It moves a branch. The old tip stays in the object store,
in
ORIG_HEADand in the reflog, which is howgit reset --hard ORIG_HEADundoes a reset.
Your task
Edit reset.ts so the tests pass:
- Every mode moves the current branch to the target and keeps
HEADon that branch --softleaves the index and working tree alone: the undone changes show as staged--mixedresets the index to the target and leaves the working tree alone--hardmakes the index and every tracked file match the target--hardleaves untracked files in the working tree--hard HEADdiscards staged and unstaged work, including a file that was only ever staged- No commit is deleted:
ORIG_HEADand the reflog lead back to the old tip, and resetting to it undoes the reset - The repo passed in is not modified