Undoing in Git: reset, revert, and restore

1. Overview

Anyone who uses Git eventually needs to undo something: fix a file mistake, accidentally git add something, or commit code they shouldn’t have. Git has three main commands for this: restore, reset, and revert. Their names sound similar, but each command affects a different area.

This article walks through each command using the same sample repository. After reading, you’ll know which command to use in each situation. You’ll also learn how to rescue commits if you accidentally use reset --hard.

2. Setting up a sample repository

First, remember that Git has three areas:

  • Working tree: the files you’re editing on disk.
  • Staging area (index): what will go into the next commit.
  • Commit history: the saved commits, with HEAD pointing to the current commit.

Let’s create a small repository with four commits:

mkdir order-service && cd order-service
git init -b main

echo "class Order {}" > Order.java
git add Order.java && git commit -m "Add Order class"

echo "class Customer {}" > Customer.java
git add Customer.java && git commit -m "Add Customer class"

echo "class Order { int total; }" > Order.java
git commit -am "Add total field to Order"

echo "class PaymentService {}" > PaymentService.java
git add PaymentService.java && git commit -m "Add PaymentService"

git log --oneline

The git commit -am command stages every already-tracked file and commits immediately. Your git log output will look like this (hashes on your machine will differ):

d4e5f60 (HEAD -> main) Add PaymentService
c3d4e5f Add total field to Order
b2c3d4e Add Customer class
a1b2c3d Add Order class

3. git restore: undoing uncommitted changes

git restore came in Git 2.23. This command only works with files. It never moves HEAD or rewrites commit history. In older Git versions, git checkout -- <file> did this job.

3.1. Discarding changes in the working tree

Suppose you edit Order.java and then change your mind:

echo "class Order { int total; String note; }" > Order.java
git status --short
git restore Order.java
cat Order.java
 M Order.java
class Order { int total; }

git status --short shows M, meaning the file was modified but not staged. git restore Order.java copies the contents from the staging area to the working tree. Since nothing was staged, this content matches HEAD.

Note: Changes discarded by git restore are permanently lost. Git never saved them, so reflog can’t rescue them either. If you’re unsure, run git stash instead of restore.

3.2. Unstaging a file

You accidentally git added a file you’re not ready to commit:

echo "class Order { int total; String note; }" > Order.java
git add Order.java
git restore --staged Order.java
git status --short
 M Order.java

The --staged option copies contents from HEAD to the staging area. The result is that the file is “unstaged”, while the content you just edited remains in the working tree. The old equivalent command is git reset HEAD Order.java.

To discard both staging and working tree changes at once, use:

git restore --staged --worktree Order.java

3.3. Restoring a file from an old commit

The --source option lets you get a file’s content from any commit:

git restore --source=HEAD~2 Order.java
cat Order.java
git status --short
class Order {}
 M Order.java

HEAD~2 is the “Add Customer class” commit. At that commit, Order.java didn’t have total yet. By default the command only writes to the working tree, so the file now shows M (unstaged). You can review it, then git add and commit if you want to keep it. Otherwise run git restore Order.java to revert.

4. git reset: moving the branch pointer

git reset <commit> moves the current branch (and HEAD) to the specified commit. The three modes --soft, --mixed, --hard differ in whether the staging area and working tree are overwritten.

Mode Branch/HEAD Staging area Working tree
--soft move to dest commit unchanged unchanged
--mixed (default) move to dest commit reset to dest commit unchanged
--hard move to dest commit reset to dest commit reset to dest commit

All examples below start from a clean state at commit d4e5f60.

4.1. –soft: undo commit, keep staged changes

git reset --soft HEAD~1
git log --oneline -1
git status --short
c3d4e5f (HEAD -> main) Add total field to Order
A  PaymentService.java

The “Add PaymentService” commit is gone from the branch. The file PaymentService.java is still there and shows as A (staged). This approach works when you want to fix the message or squash several commits into one:

git commit -m "Add PaymentService for payment processing"

4.2. –mixed: undo commit and unstage

git reset HEAD~1
git status --short
?? PaymentService.java

This is the default mode. The branch moves back one commit, and the staging area is reset. PaymentService.java is a new file so it’s now untracked (??). Use --mixed when you want to split up a commit: stage parts individually, then commit them as separate smaller commits.

4.3. –hard: discard everything

git add PaymentService.java && git commit -m "Add PaymentService"
git reset --hard HEAD~1
ls
HEAD is now at c3d4e5f Add total field to Order
Customer.java  Order.java

The commit, staging, and working tree of all tracked files are all reset to c3d4e5f. PaymentService.java is gone.

Note: reset --hard deletes all uncommitted changes to tracked files. These changes cannot be rescued. On the other hand, it does not delete untracked files. To delete untracked files you must use git clean -fd, and that command is equally dangerous.

4.4. Rescuing commits with reflog

A commit that was reset isn’t lost right away. Git records every time HEAD moves in reflog:

git reflog -3
c3d4e5f HEAD@{0}: reset: moving to HEAD~1
9f8e7d6 HEAD@{1}: commit: Add PaymentService
c3d4e5f HEAD@{2}: commit: Add PaymentService for payment processing

You just need to reset to the commit you want to recover:

git reset --hard 9f8e7d6
git log --oneline -1
9f8e7d6 (HEAD -> main) Add PaymentService

Reflog only exists on your machine. By default, entries that are no longer referenced expire after 30 days and are cleaned up by garbage collection. So rescue commits as soon as possible.

4.5. When NOT to use reset

Don’t reset commits that have been pushed to a shared branch. After reset, local and remote histories diverge, forcing you to git push --force. Colleagues who pulled those commits will encounter a messy history and might accidentally push them back up.

If you must force an update, for example on a personal branch, use a safer command:

git push --force-with-lease

--force-with-lease refuses to push if remote has new commits you haven’t fetched yet. This way you won’t accidentally overwrite someone else’s work.

5. git revert: undoing with a new commit

git revert doesn’t erase history. Instead it creates a new commit with the opposite content of the commit being undone. The history only grows, it’s not rewritten, making this the safe approach for pushed branches.

5.1. Reverting a single commit

git revert --no-edit HEAD
git log --oneline -3
ls
1a2b3c4 (HEAD -> main) Revert "Add PaymentService"
9f8e7d6 Add PaymentService
c3d4e5f Add total field to Order
Customer.java  Order.java

--no-edit uses the default message without opening an editor. The original commit 9f8e7d6 remains in the history. The new commit 1a2b3c4 deletes PaymentService.java. The body of the new commit includes a line “This reverts commit 9f8e7d6…”, helping others trace the origin.

You can revert even an old commit in the middle of history, not just HEAD:

git revert --no-edit c3d4e5f
cat Order.java
class Order {}

If later commits modified the same lines of code, Git will report a conflict. You fix the conflict, run git add, then run git revert --continue. To abort the process, use git revert --abort.

5.2. Reverting multiple commits into one

By default, reverting each commit creates a separate commit. To combine them, use --no-commit:

git revert --no-commit HEAD~3..HEAD~1
git commit -m "Undo changes to Customer and Order"

The syntax A..B selects commits after A up to and including B. Git applies each commit in reverse to the staging area, then you commit once.

5.3. Reverting a merge commit

A merge commit has two parents. So Git needs to know which branch you want to keep as the “main line”:

git revert -m 1 <hash-merge-commit>

-m 1 selects the first parent, usually the branch you merged into (e.g., main). The result is that all changes brought in by the other branch are undone.

Note: After reverting a merge, if you merge the same branch again, Git treats the old commits as “already merged”. Those changes won’t come back. To bring them back, you must revert the revert commit itself.

6. Choosing the right command

Situation Command to use
Discard unstaged changes to a file git restore <file>
Unstage a file git restore --staged <file>
Get an old version of a file git restore --source=<commit> <file>
Amend/squash the last commit (not pushed) git reset --soft HEAD~1
Split a commit (not pushed) git reset HEAD~1
Completely discard local commits git reset --hard <commit>
Undo a commit (already pushed) git revert <commit>

A quick rule to remember:

  • restore works with files.
  • reset works with branches, use it locally.
  • revert works with shared history.

7. Common mistakes

  • Confusing reset and revert on a shared branch. This is the most common mistake. If it’s pushed, use revert.
  • Running reset –hard with uncommitted changes. Check git status first. If you have changes, run git stash first.
  • Thinking reset –hard cleans up the directory. Untracked files remain. You need git clean -n to preview, then git clean -fd to delete.
  • Forgetting that HEAD~1 and HEAD^ differ on merge commits. HEAD^2 is the second parent, while HEAD~2 is the grandparent (parent of parent). With linear history, HEAD~1 and HEAD^ are the same.
  • Using git checkout out of habit. This command still works, but checkout has too many roles and it’s easy to accidentally type it when you mean to switch branches. In modern Git, restore and switch are clearer.

8. Conclusion

In this article, you’ve seen three ways to undo in Git:

  • git restore recovers file content in the working tree or staging area without touching history.
  • git reset moves a branch to a different commit, with three levels: --soft, --mixed, --hard. Use this only for unpushed commits.
  • git revert creates a reverse commit, the safe choice for shared branches.

When you slip up, remember git reflog. Most “lost” commits are still there waiting for you to recover them.

gitversion-controlresetrevertrestore