Mert Özen Further With Every Line
DevOps Git & GitHub — #16

Undoing Changes in Git, Part 1: git restore and git reset

Mert Özen Nov 6, 2026 8 min 2 views
Undoing Changes in Git, Part 1: git restore and git reset

Undoing files in the working tree and index with git restore and bringing them back from history with --source; what the --soft, --mixed and --hard modes of git reset do to HEAD, the index and the working tree, getting back with ORIG_HEAD, and the old checkout/reset forms.

Version note: The commands in this post were tested with Git 2.54; git restore has existed since Git 2.23 and is no longer experimental since Git 2.51.

Guess First

Ada added a discount parameter to the cart and staged the change. Then she added a console.log line for debugging but didn't stage it:

$ git add src/cart.js
$ git status -s
MM src/cart.js
$ git diff
diff --git a/src/cart.js b/src/cart.js
index 3f2d943..cf90641 100644
--- a/src/cart.js
+++ b/src/cart.js
@@ -1,5 +1,6 @@
 function total(items, discount = 0) {
   const sum = items.reduce((acc, item) => acc + item.price, 0);
+  console.log("total", sum, discount);
   return sum - discount;
 }

MM says the file changed twice: the left letter is the change in the index, the right one the change in the working tree. Now Ada runs git restore src/cart.js. Which version does the file go back to: the discount-free version from the last commit, or the discount version she staged?

$ git restore src/cart.js
$ git status -s
M  src/cart.js
$ git diff --staged
diff --git a/src/cart.js b/src/cart.js
index 4876df7..3f2d943 100644
--- a/src/cart.js
+++ b/src/cart.js
@@ -1,5 +1,6 @@
-function total(items) {
-  return items.reduce((sum, item) => sum + item.price, 0);
+function total(items, discount = 0) {
+  const sum = items.reduce((acc, item) => acc + item.price, 0);
+  return sum - discount;
 }

 module.exports = { total };

The discount version. The console.log is gone and the discount is still staged. By default git restore brings a file back from the index, not from the last commit. This whole post is about questions like that: which command touches which area?

Three Areas, Two Commands

Remember the three areas from the third post. HEAD is the latest commit your current branch points to. The index (staging area) is the draft of the next commit. The working tree is the files on disk that you edit. Undoing a change means pulling one or more of these areas back to the content of another area.

The two commands in this post work at different scales. git restore works at the file level: it brings specific files back to an earlier state in the index or the working tree and doesn't touch the branch or commits. git reset works at the commit level: it moves your current branch to another commit and, depending on the mode, drags the index and the working tree along. Think of a book draft: restore is an eraser on a single page, reset is saying "ignore everything after this chapter".

git restore: Undoing the Working Tree

As you saw in Guess First, git restore with just a file name brings the file in the working tree back to its state in the index. Unstaged changes are lost; staged ones stay put. That's why you can use the stage as a checkpoint: stage a step that works, keep experimenting, and if the experiment goes badly, return to that point with git restore.

A warning: there's no way back from this command. An unstaged change never entered Git's object database; even the reflog can't rescue it. git restore . does this for every file in the folder at once. Before running it, check with git diff what you're about to lose.

--staged: Unstaging

If you want to take the discount change out of the stage but keep it in the file, you use --staged. This time the index goes back to HEAD and the working tree isn't touched:

$ git restore --staged src/cart.js
$ git status -s
 M src/cart.js
$ git restore src/cart.js
$ git status -s

The difference between M and M is which column the letter is in: the change left the index and now lives only in the working tree. The git restore that follows threw it away too, and status came back empty. To do both at once you pass both options. Imagine you broke the file and staged it:

$ git add src/cart.js
$ git status -s
M  src/cart.js
$ git restore --staged --worktree src/cart.js
$ git status -s

When --staged and --worktree are given together, the source is HEAD; that's been the case since Git 2.27. In old posts and StackOverflow answers you'll see other commands for the same jobs: git reset HEAD src/cart.js to unstage, git checkout -- src/cart.js to undo the working tree. Both still work, and checkout won't be removed in Git 3.0 either. But because checkout both switches branches and overwrites files, it surprises you when called with the wrong argument; restore does one job.

--source: Bringing a File Back from History

If you need a file as it was a few commits ago, you pick the source yourself with --source. Say two more commits have been made in the meantime:

$ git log --oneline
22e4005 Add Ulysses
3a58559 Raise Dune price
7f7eeec Add cart total
61419c1 Add book list
5102cb8 Add README and .gitignore
$ git restore --source=HEAD~2 src/books.js
$ git diff
diff --git a/src/books.js b/src/books.js
index 345c437..aaa1ad1 100644
--- a/src/books.js
+++ b/src/books.js
@@ -1,7 +1,6 @@
 const books = [
-  { id: 1, title: "Dune", price: 14 },
+  { id: 1, title: "Dune", price: 12 },
   { id: 2, title: "Emma", price: 9 },
-  { id: 3, title: "Ulysses", price: 15 },
 ];

 module.exports = { books };
$ git restore src/books.js

HEAD~2 is two commits back from HEAD, which is 7f7eeec. The file as it was at that point landed in the working tree; the commits and the branch stay where they are, and git diff shows it as an ordinary change. You can commit it if you want, or undo it as in the last command. You can also give a branch name, a tag or a hash as the source.

git reset --soft: Moving Only the Branch

Now let's move to the commit level. The last two commits are really one piece of work: updating the book list. You want to turn them into a single commit:

$ git reset --soft HEAD~2
$ git status -s
M  src/books.js
$ git log --oneline -1
7f7eeec Add cart total
$ git commit -m "Update book list"
[main b0a86e1] Update book list
 1 file changed, 2 insertions(+), 1 deletion(-)
$ git log --oneline
b0a86e1 Update book list
7f7eeec Add cart total
61419c1 Add book list
5102cb8 Add README and .gitignore

--soft moved only the branch: main now points to 7f7eeec. The index and the working tree stayed as they were, so the combined change of the two commits was waiting, staged. A single commit merged them into one. To fix the message of the last commit, commit --amend is enough; --soft helps when you want to combine or rearrange several commits.

git reset --mixed: Undoing the Commit and the Stage

--mixed is the default mode; it's what runs when you don't name a mode. It moves the branch and resets the index to that commit too, without touching the working tree. While committing a note in the README, Ada also pulled the debug line into the commit because of -a:

$ git commit -am "Document how to run the cart"
[main ea4bddd] Document how to run the cart
 2 files changed, 3 insertions(+)
$ git reset HEAD~1
Unstaged changes after reset:
M	README.md
M	src/cart.js
$ git status -s
 M README.md
 M src/cart.js
$ git add README.md && git commit -m "Document how to run the cart"
[main 3213cc1] Document how to run the cart
 1 file changed, 2 insertions(+)
$ git status -s
 M src/cart.js

The commit was undone and the changes left the stage, but they're still in the files. Ada picked only the README and committed it again; the debug line stayed in the working tree. This is the mode for "undo the commit, let me decide again what to stage".

git reset --hard: Undoing Everything

--hard makes all three areas match the target commit: the branch moves, and the index and the working tree go back to that commit's content. If you run it without a commit, the target is HEAD, so only uncommitted changes are thrown away:

$ git reset --hard
HEAD is now at 3213cc1 Document how to run the cart
$ git status -s
$ git reset --hard HEAD~1
HEAD is now at b0a86e1 Update book list
$ git log --oneline -2
b0a86e1 Update book list
7f7eeec Add cart total
$ git reset --hard ORIG_HEAD
HEAD is now at 3213cc1 Document how to run the cart
$ git log --oneline -2
3213cc1 Document how to run the cart
b0a86e1 Update book list

The first command silently deleted the debug line. The second threw away the last commit as well. The third brought it back: ORIG_HEAD is a reference where commands that move HEAD in big steps, such as reset, merge and rebase, record the position before the move. Commits aren't deleted right away and stay reachable for a while; we'll see how to find them in the reflog post. But the uncommitted line thrown away by the first command has no such way back. A more cautious alternative is --keep: it moves the branch but stops if that would overwrite an uncommitted change.

Common Pitfalls

The first pitfall is resetting commits that were already pushed. When you undo history locally, the remote still points to the old commits; your next push is rejected, getting past it takes a force push, and that breaks your teammates' history. The safe way to undo a shared commit is git revert, the subject of the next post.

The second is typing --hard out of habit. Most of the time what you want is --soft or --mixed; the changes stay in place and you can change your mind. Make looking at git status and git diff before --hard a reflex.

The third is believing reset --hard cleans up everything. It doesn't touch untracked files; that's what git clean is for, and it's irreversible too, so check what would be deleted with git clean -n first.

The fourth is forgetting restore's default source. git restore with just a file name brings the file back from the index, not from HEAD; if you want to throw away a staged change too, you need --staged --worktree.

A Small Experiment

★☆☆ Make two separate changes in a file and stage one of them. Try git restore, git restore --staged and git restore --staged --worktree in turn, and after each one read the two columns of the git status -s output.

★★☆ Make three commits, then squash them into one with git reset --soft HEAD~3. Do the same with --mixed; what do you have to do this time before committing again?

★★★ Run git reset --hard HEAD~2, git reset --soft HEAD~1 and git reset --hard ORIG_HEAD in that order. Guess before you run them: will you end up back at the original commit? Check with git log --oneline -3 and explain the result by when ORIG_HEAD gets written.

We've seen the ways to undo things locally. In the next post we move to undoing in shared history: adding an inverse commit with git revert without deleting history, choosing between reset and revert, reverting a merge commit with -m 1 and undoing several commits at once with --no-commit.