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:
git status
now shows us this:
To revert the change that we just made, we’d run:
Checking the status, we see:
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:
Or,
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:
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:
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:
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:
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
:
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:
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:
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.
-
Anyone else find it amusing to read “hard head” in that command line? ↩