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.

What the three modes of git reset change. Soft moves only the branch and HEAD. Mixed moves the branch and HEAD and also resets the staging area. Hard moves the branch and HEAD and resets both the staging area and the working tree. Branch and HEAD Staging area Working tree --soft --mixed (default) --hard moved kept kept moved reset kept moved reset reset (edits lost)
Every mode moves the branch. The mode only decides how far the reset reaches into the staging area and the working tree.

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 1 to 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.

After git reset --hard HEAD~1, the branch main and HEAD point to commit 19b6001. Commit 99aed3e still exists and still points to its parent 19b6001, but no branch points to it. Only the reflog entry HEAD@{1} still records its hash. e9f93d2 19b6001 99aed3e unreachable HEAD main HEAD@{1}
A reset moves only the branch label. The commit it left behind is still there, and the reflog still knows its hash.

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.reflogExpire and gc.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> or git reset --hard that 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 --unreachable can 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.