GitGroundworkGit
12 / 18 written chapters

G12 · Part 3 · Rescue

Undoing anything

Pick the right undo for the job: restore, reset, revert, amend or reflog, and know what each one can and cannot bring back.

12 min read3 diagrams13 sections
13On this page
  1. The decision table
  2. reset's three modes, precisely
  3. restore: files, not history
  4. revert: undoing in public
  5. Reverting a merge, and its trap
  6. commit --amend
  7. ORIG_HEAD: the one-step undo
  8. reflog: the undo for your undo
  9. How long "recoverable" lasts
  10. Recovering a deleted branch
  11. stash: the parking lot
  12. Recovering a dropped stash
  13. git clean: the one undo you can't undo
Fresher Mid Senior

"I messed up, how do I undo it?" is the most common Git question, and the honest answer is it depends what you are undoing. Two questions decide it. First, where does the mistake live: in your working files, in the staging area (the index), or in a commit? Second, has anyone else seen it? A commit that only exists on your laptop can be rewritten freely. A commit that has been pushed to a shared branch should only ever be undone by adding a new commit on top.

In one minute

Messed up a file you have not committed? git restore file puts it back (and throws your edits away). Staged something by mistake? git restore --staged file. Wrong last commit that you have not pushed? git reset --soft HEAD~1 undoes it and keeps the changes. Already pushed? git revert <commit> adds a new commit that reverses it. Think something is lost? git reflog lists everywhere you have been, and almost anything you ever committed can be found there.

The decision table

I want to undo…CommandSafe after a push?
Edits to a file, back to the last commitgit restore src/cart.tsYes, it is local only
Some of the edits in a file, hunk by hunkgit restore -p src/cart.tsYes
A file, back to how it was three commits agogit restore --source=HEAD~3 src/cart.tsYes, it only changes your files
A git add (unstage, keep the edits)git restore --staged src/cart.tsYes
Adding a new file to trackinggit rm --cached secrets.envYes
The last commit's messagegit commit --amendNo, it rewrites the commit
A file I forgot in the last commitgit add f.ts && git commit --amend --no-editNo
The last commit, keeping its changes stagedgit reset --soft HEAD~1No
The last commit, keeping its changes unstagedgit reset HEAD~1No
The last commit and all its changesgit reset --hard HEAD~1No, and uncommitted edits are lost
A commit that is already on a shared branchgit revert <sha>Yes, this is the tool for it
A merge that is already on a shared branchgit revert -m 1 <merge-sha>Yes, but read the merge section below
A merge or rebase I just finished locallygit reset --hard ORIG_HEADNo
A merge or rebase that is still in conflictgit merge --abort / git rebase --abortYes, nothing was committed
A commit I made on main instead of a branchgit branch feat && git reset --hard origin/main && git switch featOnly if you have not pushed main
A deleted branch, a bad reset, a lost rebasegit reflog, then git branch or git resetYes, it is local recovery
A dropped stashgit fsck --lost-found, then git stash apply <sha>Yes
Untracked junk filesgit clean -n, then git clean -fdYes, but it cannot be undone

reset's three modes, precisely

git reset <commit> always does the same first step: it moves the branch that HEAD points at to the commit you name. The mode decides how far the change spreads from there. Git keeps three copies of your project in play: the commit the branch points at, the index (what the next commit will contain), and the working tree (the files on disk). Each mode overwrites one more of them.

HEAD → branch which commit main points at (the history) Index what your next commit will be (the staging area) Working tree the files in your editor (uncommitted edits live here) reset --soft reset --mixed (the default) reset --hard: uncommitted edits are overwritten
Each bar shows what that mode overwrites to match the target commit. Anything outside the bar is left exactly as it was.
ModeBranchIndexWorking treeTypical use
--softMovesKeptKeptFold the last few commits into one: git reset --soft HEAD~3 && git commit
--mixedMovesResetKeptUncommit and restage by hand, or split a commit in two
--hardMovesResetResetThrow work away, or snap a branch to origin/main
--keepMovesResetUpdated, but local edits keptA safer --hard: it refuses if it would overwrite a file you have edited

With a path, reset behaves differently: git reset HEAD src/cart.ts never moves the branch, it only copies that file from the commit into the index. That is the old way to unstage a file, and it is exactly what git restore --staged does today. git restore is the tool for files; git reset is the tool for moving branches. Keeping them apart in your head makes both easier.

restore: files, not history

git restore (Git 2.23 and later) copies file content from somewhere into somewhere. By default it copies from the index into the working tree, which discards unstaged edits. The flags pick the source and the destination.

$ git restore src/cart.ts
$ git restore --staged src/cart.ts
$ git restore --staged --worktree src/cart.ts
$ git restore --source=v2.3.0 src/cart.ts
$ git restore --source=main~5 --staged --worktree -- src/

The first discards unstaged edits. The second takes it out of the index but keeps the edits. The third makes both match HEAD. The last two pull an older version of a file or folder into your tree without touching history, so you can commit the old version as a normal forward change. Before 2.23 all of this was git checkout -- file, which you will still see in older answers online.

revert: undoing in public

reset moves the branch backwards, as if the commits never happened. revert works out the inverse of a commit's diff and records it as a new commit. History only grows, so nobody else's clone is contradicted.

BEFORE RESET --HARD HEAD~2 REVERT D A B C D main A B C D main only the reflog points here now A B C D D′ undoes D main
Reset rewinds the pointer. Anyone who already pulled C and D now disagrees with you. Revert moves forward with a commit whose diff is D's diff upside down, so every clone agrees.
$ git revert 8f2c9d1
[main 3b7e1a4] Revert "feat: coupon stacking"
 2 files changed, 4 insertions(+), 31 deletions(-)

$ git revert --no-commit HEAD~3..HEAD
$ git commit -m "Revert the coupon series; breaks tax rounding"

The second form reverts the last three commits as one commit. Ranges are applied newest first, which is the order that avoids needless conflicts. A revert can conflict just like a merge, if later commits touched the same lines. Resolve it, git add, then git revert --continue.

The rule

Private, unpushed history: reset, amend and rebase are all fine. Shared, pushed history: revert. Reverting is honest. The log shows that a change went in and was later backed out, which is what happened.

Reverting a merge, and its trap

A merge commit has two parents, so "the inverse of its diff" is ambiguous: the diff against which parent? You must choose with -m. -m 1 means "keep parent 1", the branch you were on when you merged (usually main), and undo everything the other side brought in.

$ git revert -m 1 4c1d9e2
[main a07f3b5] Revert "Merge pull request #412 from acme/feature/coupons"
 6 files changed, 12 insertions(+), 188 deletions(-)

This undoes the content, but not the history. The feature commits are still ancestors of main. Git decides what a merge needs to bring in by walking the graph, and it sees those commits as already merged. So when the team fixes the bug on the feature branch and merges again, only the new fix arrives. The original work stays reverted, and it looks like the merge silently lost code.

A B M: merge R: revert -m 1 M M2: merge again main F1 F2 F3: the fix feature M2 brings in only F3. F1 and F2 are already ancestors of main, so R's undo of them still stands.
The fix is to revert the revert first: git revert R puts F1 and F2 back, then the merge of F3 lands on top of them.

The alternative is to rebase the feature onto the new main, which gives F1 and F2 new IDs, so Git no longer treats them as merged. Both work. Reverting the revert is simpler and keeps the story readable. Either way, write it in the commit message so the next person understands.

commit --amend

Amend does not edit a commit. Commits are immutable, because their ID is a hash of their content. Amend builds a new commit with the same parent, your staged changes and the message you give, and moves the branch to it. The old commit is still in the object database and in the reflog.

$ git commit --amend -m "fix: round tax per line, not per order"
$ git add tests/tax.test.ts
$ git commit --amend --no-edit
$ git commit --amend --reset-author --no-edit
$ git push --force-with-lease

--reset-author fixes a commit made with the wrong email. If the commit was already pushed, the amended one has a different ID, so the push is rejected as non-fast-forward. On your own branch, --force-with-lease is correct: it refuses if someone else pushed in the meantime. On a shared branch, don't amend. For anything older than the last commit, use git commit --fixup=<sha> and git rebase -i --autosquash.

ORIG_HEAD: the one-step undo

Before a command makes a big jump, Git writes the old position to .git/ORIG_HEAD. reset, merge, rebase and am all set it. So a merge or rebase you regret the moment it finishes is one command away: git reset --hard ORIG_HEAD. It only holds the most recent one, so use it straight away or use the reflog.

reflog: the undo for your undo

Every time a ref moves, Git appends a line to a log. There is one log for HEAD at .git/logs/HEAD and one per branch under .git/logs/refs/heads/. Each line records the old ID, the new ID, who, when, and why.

$ tail -n 1 .git/logs/HEAD
8f2c9d1e4b7a0c3f5e6d9b2a1c4f7e0d3b6a9c2e e4f9a01b3c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4f Priya Shah <priya@example.com> 1758873600 +0100	reset: moving to HEAD~3

$ git reflog
e4f9a01 (HEAD -> feature/cart) HEAD@{0}: reset: moving to HEAD~3
8f2c9d1 HEAD@{1}: commit: feat: coupon stacking
a1b2c3d HEAD@{2}: commit: feat: coupon field
5e6f7a8 HEAD@{3}: checkout: moving from main to feature/cart

$ git reset --hard 8f2c9d1
HEAD is now at 8f2c9d1 feat: coupon stacking

HEAD@{1} means "where HEAD was one move ago", and you can use it anywhere a commit is expected: git diff HEAD@{1}, git branch rescue HEAD@{3}. Times work too: main@{yesterday}, main@{2.hours.ago}. git reflog show feature/cart shows just that branch's log.

How long "recoverable" lasts

Reflog entries are local. They are never pushed, never cloned, and a fresh clone starts with an empty one. They also expire, and garbage collection then deletes what nothing points to.

SettingDefaultMeaning
gc.reflogExpire90 daysReflog entries for commits still on the branch
gc.reflogExpireUnreachable30 daysReflog entries for commits no longer on the branch, such as those you reset away
gc.pruneExpire2 weeksHow old an unreferenced object must be before gc deletes it

So a commit you reset away is safe for about a month through the reflog, and then for at least two more weeks as a loose object. git gc --auto runs by itself after some commands, so don't count on anything beyond that. The only way to destroy it early is on purpose: git reflog expire --expire=now --all && git gc --prune=now. That is also what you run after scrubbing a leaked secret from history, together with rotating the secret, since other clones still have it.

Recovering a deleted branch

git branch -D deletes the branch and its reflog, but HEAD's reflog still remembers the commits you made while you were on it. Git also prints the tip ID when it deletes, so check your scrollback first.

$ git branch -D feature/coupons
Deleted branch feature/coupons (was 8f2c9d1).

$ git branch feature/coupons 8f2c9d1

$ git reflog | grep feature/coupons
5e6f7a8 HEAD@{4}: checkout: moving from feature/coupons to main

In the reflog, the entry that moved you away from the branch shows where you left it: the commit just before that line, HEAD@{5}, is the branch tip. If the branch was pushed, origin/feature/coupons may still exist locally until you run git fetch --prune.

stash: the parking lot

git stash takes your uncommitted changes, saves them, and resets your files to HEAD, so you can switch branches cleanly.

$ git stash push -m "half-done coupon UI"
Saved working directory and index state On feature/cart: half-done coupon UI

$ git stash list
stash@{0}: On feature/cart: half-done coupon UI
stash@{1}: WIP on main: 5e6f7a8 chore: bump deps

$ git stash pop
$ git stash apply stash@{1}
$ git stash push -u -m "with new files"
$ git stash branch coupon-ui stash@{0}

pop applies the newest stash and drops it if it applied cleanly. apply keeps it in the list. Untracked files are left behind unless you pass -u, which people forget constantly. git stash branch makes a new branch at the commit you stashed from and applies the stash there, which avoids conflicts when the original branch has moved on.

Stash is not storage

Stashes are local, never pushed, easy to forget and easy to drop. If the work matters for more than an afternoon, commit it on a scratch branch instead.

For seniors: what a stash really is

A stash entry is an ordinary merge-shaped commit. Its first parent is the commit you were on, its second parent is a commit holding your staged changes (index on …), and with -u a third parent holds the untracked files. The stash commit's own tree is your working tree. The list is just the reflog of refs/stash, which is why entries expire like any reflog and why a stash never leaves your machine. Git 2.51 added git stash export --to-ref refs/stashes/mine, which turns the whole list into a normal chain of commits you can push, and git stash import to rebuild the list on another machine.

Recovering a dropped stash

A stash is stored as commits, and the stash list is just the reflog of refs/stash. git stash drop or pop removes the entry, so the stash commit is left with nothing pointing to it. It is still in the database until gc prunes it. Git prints its full ID when you drop it. If that is gone from your screen, ask fsck for dangling commits.

$ git fsck --lost-found
Checking object directories: 100% (256/256), done.
dangling commit 5d1e2f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e
dangling commit 9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b
dangling blob 1f2e3d4c5b6a79880a1b2c3d4e5f60718293a4b5

$ git log --oneline --no-walk 5d1e2f0 9c8b7a6
5d1e2f0 WIP on feature/cart: 8f2c9d1 feat: coupon stacking
9c8b7a6 index on feature/cart: 8f2c9d1 feat: coupon stacking

$ git stash apply 5d1e2f0

Look for the WIP on commit (or On branch: if you used -m). The index on commit next to it is the staged half that the stash recorded separately. --lost-found also copies each dangling object into .git/lost-found/ so you can browse them. Dangling blobs are worth checking too: every git add writes a blob, so staged work that was never committed can sometimes be rescued with git show <blob-sha>.

git clean: the one undo you can't undo

git clean deletes untracked files: build output, stray logs, the file you forgot to add. Git never stored them, so no reflog or fsck can bring them back. It refuses to run without -f for that reason.

$ git clean -n -d
Would remove coverage/
Would remove notes.txt
Would remove src/cart.old.ts

$ git clean -fd
$ git clean -fdx
$ git clean -i

-n is a dry run: always run it first. -d includes untracked folders. -x also deletes ignored files such as node_modules/ and .env, which is a fresh-checkout reset and is rarely what you want. -X deletes only ignored files. -i asks about each one.

Say this out loud once

If it was ever committed, or even staged, it is almost certainly recoverable for a few weeks. If it was never added, only your editor's local history can save it. Committing often is not just tidiness, it is what makes your work rescuable.