Reverting changes in Git

Coming from Subversion, I’ve definitely got some pre-conceived notions about how reverting changes should work. For those that don’t know, svn revert is used to discard local changes in your working tree. It’s a command that I use often. Either because I find my change heading down the wrong path, or I simply was marking up the code while studying it’s functionality and I want to discard my changes.

When I first started using Git, reverting local changes was a big challenge. git revert is meant to revert a changeset, and not local changes, which did not match my mental model. I’ve been using Git everyday for a while now, and I’m still not happy with the situation. There are just a few too many edges in there, and it requires just a little too much thinking to pick out the right tool and take care of the job.

git checkout

The first time I saw this, I was confused. I kept asking myself “what does checking out have to do with discarding changes?” When you think about what git checkout does, it starts making a little more sense. From the git checkout manpage, we have:

Updates files in the working tree to match the version in the index or the specified tree. If no paths are given, git checkout will also update HEAD to set the specified branch as the current branch.

The key here is “update files in the working tree to match the version in the index.” Let me demonstrate. Let’s say you had a file called foo.txt, and we echoed some data to the end of it:

jszakmeister$ echo foo >> foo.txt

git status now shows us this:

jszakmeister$ git status -s
 M foo.txt

To revert the change that we just made, we’d run:

jszakmeister$ git checkout -- foo.txt

Checking the status, we see:

jszakmeister$ git status -s

So the change has been discarded. That’s because the version of the file in the index matches HEAD. We know that because we don’t have any staged changes. So the rule here is:

git checkout — will discard unstaged changes in <path>.

Keep in mind, you can discard changes for your entire working copy with:

jszakmeister$ git checkout -- .

Or,

jszakmeister$ git checkout HEAD

The former method has the nice effect of only discarding changes below your current working directory, if you’re further down in the tree… something that I did with Subversion quite often. Unlike Subversion, the command is recursive by default. Despite the risk of blowing away a tree’s worth of changes, I prefer git’s choice of being recursive by default.

git reset

Okay, so now we know how to discard unstaged changes, but what about staged changes? That’s where git reset comes into play. Unfortunately, it’s more complicated than I care for. The man page for git reset has this to say:

SYNOPSIS

git reset [-q] [] [—]
git reset —patch [] [—] […]
git reset [—soft | —mixed | —hard | —merge | —keep] [-q] []

DESCRIPTION

In the first and second form, copy entries from to the index. In the third form, set the current branch head (HEAD) to , optionally modifying index and working tree to match. The defaults to HEAD in all forms.

The take away here is that git reset changes the index to match a commit. And depending on the form, it may update your working tree as well.

Let’s go back to the previous example, except this time I’m going to add the change to the index:

jszakmeister$ echo foo >> foo.txt
jszakmeister$ git add foo.txt
jszakmeister$ git status -s
M  foo.txt

So you have a few choices at this point. Let’s walk through them.

Blow it all away

If you’re looking to discard all the changes, and get a clean working tree, simply run:

jszakmeister$ git reset --hard HEAD

This would be the “third form” mentioned above1. This resets foo.txt in the index to match HEAD, and also updates the working tree to match. At this point, we have a nice clean working tree:

jszakmeister$ git status -s

It’s a bit hard-core though. Chances are, if you have staged changes, you actually want to keep some of them. That’s when things get more interesting.

Selectively reverting staged changes

Let’s tweak our example just a little bit. Let’s say we have changes to foo.txt and bar.txt staged, and we only want to revert the changes to foo.txt. Here’s the status of our working tree:

jszakmeister$ git status -s
M  foo.txt
M  bar.txt

We don’t want to run git reset --hard HEAD at this point, as it would discard our changes to bar.txt. So, we’re left with a two step process. First, we unstage the change with a different form of git reset, and then we discard the unstaged change with git checkout:

jszakmeister$ git reset foo.txt
Unstaged changes after reset:
M   foo.txt
jszakmeister$ git checkout foo.txt
jszakmeister$ git status -s
M  bar.txt

We can see that our changes too foo.txt have been discarded. It just seems like a little too much work to get there though.

But wait! That’s not all!

I ran across this situation a few times now. I’m getting ready to make an initial commit, and I’ve added a file that I didn’t intend. My gut reaction is to run git reset to unstage the change. However, I’m met promptly with an error:

jszakmeister$ git status -s
A  foo.txt
jszakmesiter$ git reset foo.txt
fatal: Failed to resolve 'HEAD' as a valid ref.

The issue is that on a brand new repository, there is no HEAD. So the symbolic ref fails to resolve, and the command fails to execute. It feels like Git should set up HEAD to point to some default empty tree commit, but it doesn’t. So what do you do in this case? You remove it from the index:

jszakmeister$ git rm --cached foo.txt
rm 'foo.txt'
jszakmeister$ git status -s
?? foo.txt

3-to-1

So there you have it. Unfortunately, it takes three commands in Git to replicate what I could do with one command in Subversion. Am I ready to change back? No, the benefits of Git are just too great. Do I feel that there is still more room for Git to improve? You betcha. The nice part is that the community is genuinely concerned about making Git more friendly, and smoothing out the burrs. I hope this is one area that can be improved upon. Until things change, hopefully the above can serve as a reference for the various ways of reverting changes.


  1. Anyone else find it amusing to read “hard head” in that command line?