Reset, Revert and Stash

The three reset modes mapped onto the three trees, why revert is the only safe undo for pushed work, and what stash quietly leaves behind.

intermediate 20 min lesson hands-on task included

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

RESET MOVES THE BRANCH POINTER — THE FLAG DECIDES HOW MUCH ELSE MOVES HEAD INDEX WORKING TREE --soft undo the commit, keep everythi MOVED untouched untouched safe --mixed (default) undo the commit and the stagin MOVED MOVED untouched safe --hard undo everything — changes are MOVED MOVED MOVED destructive RESET — rewrites this branch's history A ─ B ─ C ─ D → A ─ B C and D are unreferenced. The reflog still has them, until gc eventually removes them. Use on local, unpushed work only. REVERT — adds a new, opposite commit A ─ B ─ C ─ D → A ─ B ─ C ─ D ─ D′ Nothing is rewritten, so it is safe on a shared branch — and the mistake stays in the record. This is the correct undo for anything already pushed.
Each mode moves HEAD; the flag decides how far the change propagates. Only --hard touches your files, which is why it is the only one of the three that can destroy uncommitted work.

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 a git add: git reset with 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 resetgit revert
MechanismMoves the branch pointer backwardsCreates a new, opposite commit
HistoryRewrittenAppended
Safe on a shared branchNoYes
Leaves a record of the mistakeNoYes
Requires a force-pushYesNo
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

STASH IS A COMMIT YOU CANNOT SEE — ON A REF CALLED refs/stash dirty working tree tracked changes + index + untracked files (ignored!) stash@{0} a real commit object git stash list clean tree switch branches freely stash push git stash pop (apply + drop) · git stash apply (keeps it) THE TWO THINGS THAT SURPRISE PEOPLE 1. Untracked files are NOT stashed by default. git stash -u includes them. 2. A stash has no branch and no name. Six of them, two weeks later, are indistinguishable. THE HABIT THAT REPLACES IT git stash push -m "wip: retry logic" …or better: commit on a branch. A commit is named, dated, recoverable and pushable. Stash is for the next ten minutes, not the next week.
A stash is a commit on a hidden ref. The two boxes at the bottom are the parts that bite: untracked files are excluded by default, and an unnamed stash from two weeks ago is indistinguishable from every other one.
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

SituationCommandWhy
Bad message, not pushedgit commit --amendRewrites the last commit
Forgot a file, not pushedgit add f && git commit --amend --no-editSame commit, now complete
Undo last commit, keep workgit reset --soft HEAD~1Only HEAD moves
Unstage a filegit restore --staged <file>Index only
Discard file changesgit restore <file>Destructive, no reflog
Undo a pushed commitgit revert <sha>Appends; safe for everyone
Undo a pushed mergegit revert -m 1 <sha>Mainline parent is 1
Wrong branch, not committedgit stash -u → switch → git stash popCarry the work over
Wrong branch, committedgit switch -c right, then reset --hard HEAD~1 on the wrong oneMove the commit
Deleted a branchgit reflog → git branch <name> <sha>The commits still exist
Bad merge, not pushedgit reset --hard ORIG_HEADOne step back
Everything is wronggit 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.