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.
13On this page
- The decision table
- reset's three modes, precisely
- restore: files, not history
- revert: undoing in public
- Reverting a merge, and its trap
- commit --amend
- ORIG_HEAD: the one-step undo
- reflog: the undo for your undo
- How long "recoverable" lasts
- Recovering a deleted branch
- stash: the parking lot
- Recovering a dropped stash
- git clean: the one undo you can't undo
"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.
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… | Command | Safe after a push? |
|---|---|---|
| Edits to a file, back to the last commit | git restore src/cart.ts | Yes, it is local only |
| Some of the edits in a file, hunk by hunk | git restore -p src/cart.ts | Yes |
| A file, back to how it was three commits ago | git restore --source=HEAD~3 src/cart.ts | Yes, it only changes your files |
A git add (unstage, keep the edits) | git restore --staged src/cart.ts | Yes |
| Adding a new file to tracking | git rm --cached secrets.env | Yes |
| The last commit's message | git commit --amend | No, it rewrites the commit |
| A file I forgot in the last commit | git add f.ts && git commit --amend --no-edit | No |
| The last commit, keeping its changes staged | git reset --soft HEAD~1 | No |
| The last commit, keeping its changes unstaged | git reset HEAD~1 | No |
| The last commit and all its changes | git reset --hard HEAD~1 | No, and uncommitted edits are lost |
| A commit that is already on a shared branch | git revert <sha> | Yes, this is the tool for it |
| A merge that is already on a shared branch | git revert -m 1 <merge-sha> | Yes, but read the merge section below |
| A merge or rebase I just finished locally | git reset --hard ORIG_HEAD | No |
| A merge or rebase that is still in conflict | git merge --abort / git rebase --abort | Yes, nothing was committed |
| A commit I made on main instead of a branch | git branch feat && git reset --hard origin/main && git switch feat | Only if you have not pushed main |
| A deleted branch, a bad reset, a lost rebase | git reflog, then git branch or git reset | Yes, it is local recovery |
| A dropped stash | git fsck --lost-found, then git stash apply <sha> | Yes |
| Untracked junk files | git clean -n, then git clean -fd | Yes, 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.
| Mode | Branch | Index | Working tree | Typical use |
|---|---|---|---|---|
--soft | Moves | Kept | Kept | Fold the last few commits into one: git reset --soft HEAD~3 && git commit |
--mixed | Moves | Reset | Kept | Uncommit and restage by hand, or split a commit in two |
--hard | Moves | Reset | Reset | Throw work away, or snap a branch to origin/main |
--keep | Moves | Reset | Updated, but local edits kept | A 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.
$ 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.
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.
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.
| Setting | Default | Meaning |
|---|---|---|
gc.reflogExpire | 90 days | Reflog entries for commits still on the branch |
gc.reflogExpireUnreachable | 30 days | Reflog entries for commits no longer on the branch, such as those you reset away |
gc.pruneExpire | 2 weeks | How 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.
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.
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.
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.
Finished reading?
0 of 18 chapters read in this series