Undoing Things
How to get out of trouble: discarding changes with restore, the three modes of reset, reverting a published commit, stashing unfinished work and recovering almost anything with the reflog.
Sooner or later everyone edits the wrong file, commits to the wrong branch or runs a command they did not fully understand. Git is forgiving: nearly every mistake can be undone, as long as you pick the right tool. This page builds on the short overview in Version Control with Git and on the three areas (working tree, staging area, repository) explained in Staging and Committing.
Which undo do I need?
Start by asking two questions: where is the change I want to undo (working tree, staging area, a commit), and has it already been shared with others? The answer points to one command.
| I want to undo… | Command | Section |
|---|---|---|
| An edit I have not staged | git restore <file> |
Discarding edits |
| A file I staged by mistake | git restore --staged <file> |
Unstaging files |
| The last commit, not yet pushed |
git reset --soft HEAD~1 (or
git commit --amend to fix it)
|
Moving a branch with reset |
| A commit that is already pushed | git revert <commit> |
Reverting a published commit |
| A branch I deleted | git branch <name> <hash> |
Recovering a deleted branch |
| A commit I "lost" with reset or rebase |
git reflog, then
git reset --hard <hash> (see the
warning below) or
git branch <name> <hash>
|
The reflog |
| Half-finished work, to come back to later |
git stash, later git stash pop
|
Stashing work in progress |
| Untracked files and folders cluttering the project |
git clean -n, then git clean -f
|
Removing untracked files |
Discarding changes with restore
git restore copies a file's
content from somewhere Git already has it into the working tree or
the staging area. By default it restores the working tree from the
staging area; with --staged it restores the staging
area from the last commit (HEAD); with
--source=<commit> it takes the file from any
commit you name.
The command arrived in Git 2.23 (2019), together with
git switch, to split the many jobs of the older
git checkout. Its manual page still called it
experimental for years; since Git 2.51 (August 2025) it is
officially stable. The older equivalents still work and appear in
many tutorials: git checkout -- <file> discards
edits and git reset HEAD <file> unstages.
Discarding edits in the working tree
Sam has been experimenting with the hostname in
router.cfg and wants the committed version back:
git init -q net-tools && cd net-tools
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'hostname R1-TEST\n' > router.cfg
git status --short
git restore router.cfg
git status --short
cat router.cfg
M router.cfg hostname R1
Before the restore, git status --short shows
M: modified in the working tree (second column),
nothing staged (first column). After the restore the second
git status prints nothing, because the working tree
matches the last commit again.
Unstaging files
Staging a file is not a commitment.
git restore --staged takes the file out of the next
commit and leaves your edit in the working tree:
git init -q net-tools && cd net-tools
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'hostname R1-TEST\n' > router.cfg
git add router.cfg
git status --short
git restore --staged router.cfg
git status --short
cat router.cfg
M router.cfg M router.cfg hostname R1-TEST
The M moves from the first column (staged) to the
second (not staged), and the file still contains the new hostname.
Nothing was lost. To discard the staged and the
working-tree version in one go, combine both options:
git restore --staged --worktree router.cfg.
Moving a branch with reset
git reset <commit> moves the current branch (and HEAD with it) to another commit. The commits after that point are no longer part of the branch. What happens to their changes depends on the mode, which decides how many of the three areas Git also resets to match the target commit.
The three examples below start from the same three commits. Because
the examples on this site use a fixed author and date, the commit
hashes are the same each time, so you can compare the logs directly.
HEAD~1 means "the parent of the current commit" (see
Exploring History).
--soft: undo the commit, keep everything staged
git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'interface Gi0/1\n description Uplink\n' >> router.cfg
git commit -q -am "Describe uplink"
git log --oneline
git reset --soft HEAD~1
git status --short
git log --oneline
99aed3e Describe uplink 19b6001 Add R1 config e9f93d2 Add README M router.cfg 19b6001 Add R1 config e9f93d2 Add README
The commit 99aed3e is gone from the log, but its change
is still staged (M in the first column). A new
git commit would recreate it, perhaps with a better
message or together with a forgotten file. This is the safest way to
"uncommit".
--mixed: undo the commit and unstage
git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'interface Gi0/1\n description Uplink\n' >> router.cfg
git commit -q -am "Describe uplink"
git log --oneline
git reset --mixed HEAD~1
git status --short
git log --oneline
99aed3e Describe uplink 19b6001 Add R1 config e9f93d2 Add README Unstaged changes after reset: M router.cfg M router.cfg 19b6001 Add R1 config e9f93d2 Add README
Mixed is the default: git reset HEAD~1 does the same.
The change is now only in the working tree, so you can stage parts
of it again in a different arrangement, for example to split one
large commit into several smaller ones.
--hard: undo the commit and discard its changes
A hard reset makes the branch, the staging area and the working tree all match the target commit. Every difference is thrown away:
git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'interface Gi0/1\n description Uplink\n' >> router.cfg
git commit -q -am "Describe uplink"
git log --oneline
git reset --hard HEAD~1
git status --short
git log --oneline
99aed3e Describe uplink 19b6001 Add R1 config e9f93d2 Add README HEAD is now at 19b6001 Add R1 config 19b6001 Add R1 config e9f93d2 Add README
git status --short prints nothing: the working tree is
clean, and the uplink description is no longer in
router.cfg. The commit 99aed3e itself
still exists in the object database for a while, which is why the
reflog can bring it back.
Fixing a commit made on the wrong branch
git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'interface Gi0/1\n description Uplink\n' >> router.cfg
git commit -q -am "Describe uplink"
git status --short
git branch uplink-description
git reset --hard HEAD~1
git switch uplink-description
git log --oneline main
git log --oneline uplink-description
HEAD is now at 19b6001 Add R1 config Switched to branch 'uplink-description' 19b6001 Add R1 config e9f93d2 Add README 99aed3e Describe uplink 19b6001 Add R1 config e9f93d2 Add README
The empty git status --short output confirms there was
nothing uncommitted to lose, which is what makes
--hard safe here. The commit was never copied:
uplink-description simply points to
99aed3e, and main points to its parent
again. If the commit should go onto an existing branch
instead, switch to that branch and use
git cherry-pick (see
Rebasing and Rewriting History).
Reverting a published commit
git revert <commit> creates a new commit that applies the exact opposite of the commit you name. History only grows; nothing is removed or rewritten. That makes it the right tool for commits that others may already have pulled: their copies stay valid, and the next pull simply brings in the undo.
git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'interface Gi0/1\n description Uplink\n' >> router.cfg
git commit -q -am "Describe uplink"
git revert --no-edit HEAD
git log --oneline
cat router.cfg
[main 69f09b1] Revert "Describe uplink" Date: Mon Mar 2 09:00:00 2026 +0000 1 file changed, 2 deletions(-) 69f09b1 Revert "Describe uplink" 99aed3e Describe uplink 19b6001 Add R1 config e9f93d2 Add README hostname R1
Both the original commit and its revert stay in the log, which is
also an honest record of what happened. Without
--no-edit, Git opens your editor with a prepared
message (Revert "Describe uplink" plus "This reverts
commit …"); it is good practice to add a line explaining
why.
-
You can revert any commit, not just the latest:
git revert 19b6001. If later commits changed the same lines, you get a conflict to resolve, as in a merge. -
Reverting a merge commit needs
-m 1to say which parent is the "main line" to go back to. - A revert can itself be reverted, which re-applies the original change.
Stashing work in progress
A stash is a shelf for uncommitted
changes. git stash saves your modifications to tracked
files (both staged and unstaged) as a special commit, then cleans
the working tree so you can switch branches, pull or fix something
urgent. git stash pop brings the changes back.
git init -q net-tools && cd net-tools
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git stash
git status --short
git stash list
git stash pop
Saved working directory and index state WIP on main: 73f0728 Add R1 config
stash@{0}: WIP on main: 73f0728 Add R1 config
On branch main
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: router.cfg
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (e2a065c17fcb8d1d178c4e20d3d72edbf4919390)
After git stash, git status --short prints
nothing: the NTP line is safely on the shelf.
git stash list shows the stack; the newest entry is
always stash@{0}, and "WIP" stands for work in
progress. pop re-applies the newest entry and drops it
from the list.
| Command | What it does |
|---|---|
git stash push -m "NTP work" |
Stash with a descriptive message instead of "WIP on …" |
git stash -u |
Also stash untracked files (by default they stay where they are) |
git stash apply |
Re-apply the newest stash but keep it in the list |
git stash pop stash@{2} |
Re-apply and drop a specific entry |
git stash show -p stash@{0} |
Show the stashed changes as a diff |
git stash drop stash@{0} |
Delete one entry; git stash clear deletes all
of them
|
Removing untracked files
git restore, reset and
stash leave untracked files alone: files Git has never
been told about. To delete those, use git clean.
Because the deleted files were never committed, there is no way
back, so Git protects you twice: it refuses to run without
-f (force) by default (the setting
clean.requireForce), and -n (dry run)
shows what it would remove.
git init -q net-tools && cd net-tools
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'debug output\n' > capture.log
mkdir backup && cp router.cfg backup/
git status --short
git clean -n
git clean -n -d
git clean -f -d
git status --short
?? backup/ ?? capture.log Would remove capture.log Would remove backup/ Would remove capture.log Removing backup/ Removing capture.log
Without -d, untracked folders are skipped,
which is why the first dry run lists only capture.log.
Add -x to remove ignored files too (build output,
virtual environments, and also any local .env file with
your credentials), and -i to choose interactively.
The reflog: Git's safety net
The reflog (reference log) is a private diary of every position HEAD and each branch have had in your repository: every commit, reset, switch, merge, rebase and amend. When a reset or rebase seems to have destroyed commits, the commits are usually still in the object database; they are just no longer reachable from any branch. The reflog tells you their hashes.
git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 config"
printf 'interface Gi0/1\n description Uplink\n' >> router.cfg
git commit -q -am "Describe uplink"
git reset --hard HEAD~1
git log --oneline
git reflog
git reset --hard 99aed3e
git log --oneline
HEAD is now at 19b6001 Add R1 config
19b6001 Add R1 config
e9f93d2 Add README
19b6001 HEAD@{0}: reset: moving to HEAD~1
99aed3e HEAD@{1}: commit: Describe uplink
19b6001 HEAD@{2}: commit: Add R1 config
e9f93d2 HEAD@{3}: commit (initial): Add README
HEAD is now at 99aed3e Describe uplink
99aed3e Describe uplink
19b6001 Add R1 config
e9f93d2 Add README
Read the reflog from the top: HEAD@{0} is where HEAD is
now, HEAD@{1} where it was one step earlier, and so on.
The entry before the reset shows the "lost" commit
99aed3e. Resetting back to it restores the branch
completely. Instead of the hash you can write
git reset --hard HEAD@{1}, or, to inspect first without
moving anything, create a branch:
git branch rescue 99aed3e.
Recovering a deleted branch
Deleting a branch deletes only its name. When you delete it, Git even prints the hash it pointed to, so recovery is one command:
git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
git switch -q -c ntp-servers
printf 'ntp server 192.0.2.10\n' > ntp.cfg
git add ntp.cfg && git commit -q -m "Add NTP servers"
git switch -q main
git branch -D ntp-servers
git branch ntp-servers 0c2f61b
git log --oneline ntp-servers
Deleted branch ntp-servers (was 0c2f61b). 0c2f61b Add NTP servers e9f93d2 Add README
If that message has scrolled away, git reflog still
shows the commit (for example as
commit: Add NTP servers or in a
checkout: moving from ntp-servers to main entry).
How long the reflog remembers
- The reflog is local. It is never pushed or cloned; a fresh clone starts with an empty one, and a teammate's reflog cannot help you.
-
Entries expire. By default Git keeps entries for 90 days, and
entries for commits that are no longer reachable from any branch
for 30 days (
gc.reflogExpireandgc.reflogExpireUnreachable). After that, garbage collection may delete the unreachable commits for good. - For a recovery, there is no hurry measured in minutes, but do not wait weeks: create a branch on the commit as soon as you find it, and it is safe again.
What cannot be recovered
Git can only give back what it once stored. Anything that was committed, even on a branch you deleted or a commit you reset away, is recoverable through the reflog for weeks. Changes that never became a commit are a different story:
-
Edits discarded with
git restore <file>,git checkout -- <file>orgit reset --hardthat were never staged or committed. - Untracked files deleted with
git clean -f. -
A stash you dropped is a special case: its commit becomes
unreachable, and
git fsck --unreachablecan sometimes still find it, but this is a last resort.
Content you had staged but never committed sits in the
object database as a loose blob, and
git fsck --lost-found may recover it, without its file
name. That is rescue work, not a plan. The habit that really
protects you is simple: commit early and often on a branch. Small
local commits cost nothing, can be tidied up before you push (see
Rebasing and Rewriting History), and turn almost every accident into a two-minute reflog lookup.