Undoing things in Git safely: restore, reset and revert
Git can undo almost anything, but it offers several commands that sound alike and behave very differently. Pick the wrong one and you either fail to undo the mistake or create a bigger one, such as rewriting history your teammates already pulled. This guide explains the three areas Git tracks, then matches common mistakes to the safe command for each, and finishes with how to recover when something seems lost.
Three places your work lives
Every undo command makes more sense once you know which of these it changes:
- Working tree: the files on disk that you edit.
- Index (staging area): a snapshot of what the next commit will contain.
git addcopies changes from the working tree into the index. - HEAD: a pointer to the current commit, usually through the current branch.
git committurns the index into a new commit and moves the branch forward.
The modern commands map cleanly onto these areas: git restore changes files in the working tree or index, git reset moves the branch pointer, and git revert adds a new commit. Older tutorials use git checkout for several of these jobs; restore and switch were added in Git 2.23 to split that overloaded command.
Undo changes that are not committed yet
Discard edits to a file
git restore src/app.js
This replaces the file in the working tree with the version in the index. The uncommitted edits are gone for good, since Git never stored them, so be sure first.
Unstage a file but keep the edits
git restore --staged src/app.js
This copies the file from HEAD back into the index. Your working tree is untouched.
Put work aside temporarily
git stash push -m "half-done refactor"
git stash pop
Stashing saves your uncommitted changes and cleans the working tree, so you can switch branches. Add -u to include untracked files.
Remove untracked files
git clean -n # preview what would be deleted
git clean -fd # delete untracked files and folders
Always run the preview first. Untracked files are not in Git, so they cannot be recovered from it.
Undo commits that are only on your machine
If you have not pushed, you can freely rewrite history, because nobody else has the commits.
Fix the last commit
git commit --amend -m "Better message"
git add forgotten-file.js && git commit --amend --no-edit
Amending replaces the last commit with a new one that includes the current index and, optionally, a new message.
Uncommit but keep the changes
git reset --soft HEAD~1 # changes stay staged
git reset HEAD~1 # changes stay in the working tree, unstaged (--mixed)
HEAD~1 means "one commit before HEAD". Use HEAD~3 to undo three commits. The branch pointer moves back; your files keep the changes so you can recommit them differently.
Throw commits away completely
git reset --hard HEAD~1
This moves the branch back and overwrites the index and working tree. Any uncommitted changes are destroyed. The discarded commits are still recoverable from the reflog for a while, as described below, but uncommitted work is not.
Combine several commits into one
git reset --soft HEAD~3
git commit -m "Add login page"
This is the simplest way to squash the last few commits. Interactive rebase, git rebase -i HEAD~3, gives finer control, such as reordering or editing individual commits.
Undo commits that are already pushed
Once others may have pulled your commits, rewriting them causes trouble: their history no longer matches the remote, and the next pull produces confusing merges or duplicated commits. On shared branches like main, add a new commit that reverses the change instead:
git revert a1b2c3d
git revert creates a new commit whose changes are the exact opposite of the given commit. History stays intact, everyone can pull normally, and the revert itself can be reverted later if needed. Reverting a merge commit requires telling Git which parent to keep, usually with -m 1.
When you must rewrite a pushed branch
On your own feature branch, rewriting after pushing is common, for example after rebasing onto main or squashing before review. Push with:
git push --force-with-lease
Unlike --force, this refuses to overwrite the remote branch if someone else pushed to it since your last fetch, so you cannot silently delete a colleague's commits.
Quick decision table
| Situation | Command | Rewrites history? |
|---|---|---|
| Discard uncommitted edits to a file | git restore <file> | No, but edits are lost |
| Unstage a file | git restore --staged <file> | No |
| Fix the last unpushed commit | git commit --amend | Yes |
| Uncommit, keep changes | git reset --soft HEAD~1 | Yes |
| Delete unpushed commits | git reset --hard HEAD~1 | Yes |
| Undo a pushed commit | git revert <hash> | No |
Recovering "lost" commits with the reflog
Git records every position HEAD has pointed to in the reflog, including commits you reset away or that disappeared in a bad rebase:
git reflog
# a1b2c3d HEAD@{0}: reset: moving to HEAD~1
# 9f8e7d6 HEAD@{1}: commit: Add payment webhook
git branch rescue 9f8e7d6
Find the entry from before the mistake, then create a branch at it or reset back to it. Reflog entries are kept for about 90 days by default, and they are local to your clone. The reflog cannot recover changes that were never committed, which is a good argument for committing early and often on feature branches.
Habits that make undo easy
- Commit small, focused changes. They are easier to revert individually.
- Create a backup branch before risky operations:
git branch backup-before-rebase. - Run
git statusbefore any reset, restore or clean. - Protect shared branches on your Git host so force pushes are rejected.
If you do not remember the exact syntax, the Git Command Generator builds these commands from a description of what you want to do, with a warning on each one that rewrites history or deletes work.