Terminal, Git & GitHub
Terminal, Git & GitHub

Git recovery: undo at the right layer

Full · ~40 min
After this unit you can
  • Pick the undo for where a mistake lives: restore, restore --staged, commit --amend, reset, or revert for anything already pushed
  • Predict what reset --soft, --mixed and --hard do to the branch, the index and the working tree — including untracked and staged-only files
  • Use stash without losing work (-u, apply vs pop), and spot the path-limited stash that saves nothing so the next pop applies an older entry
  • Recover a "lost" commit with git reflog or ORIG_HEAD, and say what the reflog can't bring back

Read

Simple
1 / 2

Undo at the layer where the mistake lives

Flag

Every 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 mistakeLives inUndo
Edited a file, want the committed version backworking treegit restore app.ts
Staged a file you didn't mean toindexgit restore --staged app.ts
Last commit's message is wrong / forgot a filelast commit, not pushedgit commit --amend
Last commit shouldn't exist yet; keep the changescommits, not pushedgit reset --soft HEAD~1
A commit is already pushed and sharedshared historygit revert <commit>
"I reset/rebased and my commit is gone"the refloggit 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. --amend doesn'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).

Medium
1 / 2

Stash without surprises

Flag

git 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 -u includes them.
  • pop drops, apply keeps. When the apply hits a conflict, pop keeps the entry too — but get in the habit of apply, check, then git 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.

Complex

The reflog — git's undo history, and what it can't bring back

Flag

Every 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> or reset --hard on 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?

Flag

A commit pushed to main yesterday broke checkout. Three teammates have pulled since. What's the right undo?

Flag

Your last three local commits should be one. Which sequence leaves you with one commit holding all three commits' changes?

Flag

Your 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?

Flag

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?

Flag

You run git stash, switch to another branch, and your new, never-added draft.md is still there in the working tree. Why?

Flag

You ran git reset --hard HEAD~2 — one commit too far. Nothing was pushed. How do you get the commits back?

Flag

Which of these can the reflog not help you recover?

Flag

Fill in the blank

Unstage app.ts and keep the edits on disk.

Flag
git restore  app.ts

Undo the last commit but keep its changes staged, ready to recommit.

Flag
git reset  HEAD~1

Stash everything, including files you haven't added yet, with a label you'll recognise.

Flag
git stash push  -m "wip: retry logic"

Undo the reset --hard you just ran.

Flag
git reset --hard 

Revert a merge commit, keeping the branch you merged into as the mainline.

Flag
git 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
Flag
% 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
Flag
# 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
Flag

Challenge

Model what git reset does to the three trees

Flag

reset.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:

  1. "--soft unstages the changes." It doesn't touch the index at all — after reset --soft HEAD~1 the undone commit's changes are still staged, ready to recommit.
  2. "reset changes my files." --mixed (the default) resets the index only; the working tree keeps every edit.
  3. "--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 --hard does remove that one.
  4. "reset deletes the commits." It moves a branch. The old tip stays in the object store, in ORIG_HEAD and in the reflog, which is how git reset --hard ORIG_HEAD undoes a reset.

Your task

Edit reset.ts so the tests pass:

  • Every mode moves the current branch to the target and keeps HEAD on that branch
  • --soft leaves the index and working tree alone: the undone changes show as staged
  • --mixed resets the index to the target and leaves the working tree alone
  • --hard makes the index and every tracked file match the target
  • --hard leaves untracked files in the working tree
  • --hard HEAD discards staged and unstaged work, including a file that was only ever staged
  • No commit is deleted: ORIG_HEAD and the reflog lead back to the old tip, and resetting to it undoes the reset
  • The repo passed in is not modified

Loading the in-browser editor…

Further reading