Every “undo” question in Git has the same two-part answer: which of the three trees do you want changed, and has anyone else seen this commit. Get those two right and the command follows.
Topic 1: The Three Resets
git reset moves the current branch pointer, and optionally the index and working tree with it.
git reset --soft HEAD~1 # HEAD moves. Index and files untouched.
# → the commit is undone, changes still staged
git reset HEAD~1 # HEAD and index move. Files untouched. (--mixed, default)
# → the commit is undone, changes unstaged
git reset --hard HEAD~1 # All three move.
# → the commit and the changes are gone
The three in practice:
--soft— “the commit was premature but the work is right.” Redo the commit with a better message or different scope. This is also how you squash the last N commits by hand:git reset --soft HEAD~3 && git commit.--mixed(default) — “unstage everything and let me rebuild the commit.” Also the way to undo agit add:git resetwith no arguments.--hard— “delete this.” The only Git command in daily use that destroys uncommitted work irrecoverably. Committed work survives in the reflog; uncommitted work does not.
git reset --hard ORIG_HEAD # undo the last merge, rebase or reset
ORIG_HEAD is set before every operation that moves HEAD substantially. It is the one-step undo for a merge that went wrong, and it is easier to type under pressure than a reflog lookup.
Topic 2: Reset vs Revert
The decision rule is one question: has anyone else pulled this commit?
git reset | git revert | |
|---|---|---|
| Mechanism | Moves the branch pointer backwards | Creates a new, opposite commit |
| History | Rewritten | Appended |
| Safe on a shared branch | No | Yes |
| Leaves a record of the mistake | No | Yes |
| Requires a force-push | Yes | No |
git revert 9f8e7d # new commit undoing that one
git revert HEAD~3..HEAD # a range, newest first
git revert -n 9f8e7d # stage the reversal without committing
Reverting a merge needs -m to say which parent is the mainline:
git revert -m 1 <merge-sha> # keep the first parent (the branch you were on)
And it carries a trap that surprises people: after reverting a merge, re-merging the same branch brings back nothing, because Git considers those commits already merged. The fix is to revert the revert (git revert <revert-sha>) before re-merging, which is exactly as awkward as it sounds. This is a strong argument for reverting the individual commits, or for fixing forward, on anything complicated.
”Undo the last push” — the honest answer for a shared branch is always revert:
git revert HEAD
git push
Resetting and force-pushing a shared branch breaks every clone that has it, and the person you inconvenience most is the one who pulled two minutes ago.
Topic 3: Restore, and the Older Spellings
Modern Git separates “move a branch pointer” (reset) from “change files” (restore):
git restore <file> # discard working-tree changes
git restore --staged <file> # unstage, keep the file
git restore --source=HEAD~2 <file> # bring back an older version
git restore --staged --worktree <file> # both
Older equivalents you will meet in every existing tutorial and script:
git checkout -- <file> → git restore <file>
git reset HEAD <file> → git restore --staged <file>
git checkout <branch> → git switch <branch>
git checkout -b <branch> → git switch -c <branch>
Both work. The new spellings exist because checkout doing three unrelated jobs was a genuine source of accidents — “discard my changes” and “switch branches” should not be the same word.
Topic 4: Stash
git stash push -m "wip: retry logic" # save tracked changes, clean the tree
git stash -u # ...including untracked files
git stash -a # ...including ignored files too
git stash list
git stash show -p stash@{0} # what is actually in it
git stash pop # apply the newest and delete it
git stash apply stash@{2} # apply a specific one, keep it
git stash drop stash@{0}
git stash branch fix/thing stash@{0} # create a branch from a stash
Untracked files are not stashed by default. You stash, switch branches, and the new file is still sitting in your working tree — polluting the other branch, or being deleted by a git clean you run later. -u is what you almost always want, and there is no config to make it the default; put it in an alias.
Stash is for the next ten minutes. It has no branch, no name unless you supply one, no CI, and it cannot be pushed. Six stashes later, stash@{3} means nothing to anybody including you. When work needs to survive longer than a coffee break, commit it on a branch:
git switch -c wip/retry-logic && git commit -am "wip: retry logic"
A commit is named, dated, recoverable through the reflog, and pushable to a machine you do not own — none of which is true of a stash.
Recovering a dropped stash is possible but unpleasant, which is another argument for branches:
git fsck --unreachable | grep commit | cut -d' ' -f3 | \
xargs git log --merges --no-walk --format='%H %ci %s'
Topic 5: Cleaning Untracked Files
git clean -n # DRY RUN — always run this first
git clean -f # delete untracked files
git clean -fd # ...and untracked directories
git clean -fdx # ...and ignored files too (build output, node_modules)
git clean -i # interactive
git clean is the most destructive command in Git, because untracked files were never recorded anywhere. There is no reflog, no object, no recovery. The -n dry run costs one second and has saved a great many .env files and uncommitted scratch work.
-x deserves particular care: it removes ignored files, which usually means your build cache, your virtualenv, and your local configuration. That is sometimes exactly the intent — a genuinely clean tree — and it is never something to type absent-mindedly.
Topic 6: The Undo Decision Table
| Situation | Command | Why |
|---|---|---|
| Bad message, not pushed | git commit --amend | Rewrites the last commit |
| Forgot a file, not pushed | git add f && git commit --amend --no-edit | Same commit, now complete |
| Undo last commit, keep work | git reset --soft HEAD~1 | Only HEAD moves |
| Unstage a file | git restore --staged <file> | Index only |
| Discard file changes | git restore <file> | Destructive, no reflog |
| Undo a pushed commit | git revert <sha> | Appends; safe for everyone |
| Undo a pushed merge | git revert -m 1 <sha> | Mainline parent is 1 |
| Wrong branch, not committed | git stash -u → switch → git stash pop | Carry the work over |
| Wrong branch, committed | git switch -c right, then reset --hard HEAD~1 on the wrong one | Move the commit |
| Deleted a branch | git reflog → git branch <name> <sha> | The commits still exist |
| Bad merge, not pushed | git reset --hard ORIG_HEAD | One step back |
| Everything is wrong | git reflog → git reset --hard HEAD@{n} | Time travel to any prior state |
Every row above the “pushed” line is a rewrite and every row below it is an append. That is the whole distinction.
Try it yourself: commit, then run all three resets on three copies of the same branch and diff the results of git status after each. Ten minutes, and afterwards you will never have to guess which flag you want.
Common mistake: reaching for git reset --hard to “clean things up” while there is uncommitted work in the tree. The commits come back from the reflog; the uncommitted work does not, because nothing ever recorded it. Before anything destructive, either commit on a scratch branch or run git stash -u — both take seconds and both are fully reversible.