August 25, 2016 Posted in programming

Git Undo

Tell me if you recognize this scenario: you’re in the middle of rewriting your local commits when you suddenly realize that you have gone too far and, after one too many rebases, you are left with a history that looks nothing like the way you wanted. No? Well, I certainly do. And when that happens, I wish I could just CTRL+Z my way back to where I started. Of course, it’s never that simple — not even in a GUI.

It was in one of those moments of despair that I finally decided to set out to create my own git undo command. Here’s what I came up with and how I got there.

The Reflog

My story of undoing things in Git starts with the reflog. What’s the reflog, you might ask. Well, I’m here to tell you: every time a branch reference moves1 Git records its previous value in a sort of local journal. This journal is the called the reference log — or reflog for short.

In a repository there is a reflog for each branch as well as a separate one for the HEAD reference.

Getting the list of entries in a branch’s reflog is as easy as saying git reflog followed by the name of the branch:

git reflog master

shows the reflog entries for the master branch:

Output of git-reflog for the master branch

If you instead wanted to look at HEAD’s own reflog, you would simply omit the argument and say:

git reflog

which yields the same output, only for the HEAD reference:

Output of git-reflog for the HEAD reference

What isn’t immediately obvious is that the entries in the reflog are stored in reverse chronological order with the most recent one on top.

What is obvious, on the other hand, is that each entry has its own index. This turns out to be extremely useful, because we can use that index to directly reference the commit associated to a certain reflog entry. But more on that later. For now, suffice it to say that in order to reference a reflog entry, we have to use the syntax:


where the two parts separated by the @ sign are:

  • reference which can either be the name of a branch or HEAD
  • index which is the entry’s position in the reflog2

For example, let’s say that we wanted to look at the commit HEAD was referencing two positions ago. To do that, we could use the git show command followed by HEAD@{2}:

git show HEAD@{2}

If we, instead, wanted to look at the commit master was referencing just before the latest one we would say:

git show master@{1}

The Undo Alias

Here’s my point: the reflog keeps track of the history of commits referenced by a branch, just like a web browser keeps track of the history of URLs we visit.

This means that the commit referenced by @{1} is always the commit that was referenced just before the current one.

If we were to combine the reflog with the git reset command like this:

git reset --hard master@{1}

we would suddenly have a way to move HEAD, the index and the working directory to the previous commit referenced by a branch. This is essentially the same as pressing the back button in our web browser!

At this point, we have everything we need to implement our own git undo command, which we do in the form of an alias. Here it is:

git config --global alias.undo '!f() { \
    git reset --hard $(git rev-parse --abbrev-ref HEAD)@{${1-1}}; \
}; f'

I realize it’s quite a mouthful so let’s break it down piece by piece:

  1. !f() { ... } f
    Here, we’re defining the alias as a shell function named f which is then invoked immediately.

  2. $(git rev-parse --abbrev-ref HEAD)@{...}
    We use the git rev-parse command followed by the --abbrev-ref option to get the name of the current branch, which we then concatenate with @{...} to form the reference to a previous position in the reflog (e.g. master@{1}).

  3. ${1-1}
    We specify the position in the reflog as the first parameter $1 with a default value of 1. This is the whole reason why we defined the alias as a shell function: to be able to provide a default value for the parameter using the standard Bash syntax.

The beauty of using an optional parameter like this, is that it allows us to undo any number of operations. At the same time, if we don’t specify anything, it’s going to undo the just latest one.

Trying It Out

Let’s say that we have a history that looks like this:3

History before the rewrite

We have two branches — master and feature — that have diverged at commit C. For the sake of our example, let’s also assume that we wanted to remove the latest commit in master — that is commit F — and then merge the feature branch:

git reset --hard HEAD^
git merge feature

At this point, we would end up with a history looking like this:

History after the rewrite

As you can see, everything went fine — but we’re still not happy. For some reason, we want to go back to the way history was before. In practice, this means we need to undo our latest two operations: the merge and the reset. Time to whip out that undo alias:

git undo 2

This moves HEAD to the commit referenced by master@{2} — that is the commit the master branch was pointing to 2 reflog entries ago. Let’s go ahead and check our history again:

History restored with the undo alias

And everything is back the way it was. \o/

But what if wanted to undo the undo? Easy. Since git undo itself creates an entry in the reflog, it’s enough to say:

git undo

which, without argument, is the equivalent of saying git undo 1.

Did you find this useful? If you're interested in learning other techniques like the one described in this article, I wrote down a few more in my Pluralsight course Advanced Git Tips and Tricks.

  1. That is, it’s modified to point to a different commit than it did before.

  2. You can also use dates here. Try for example master@{yesterday} or HEAD@{2.days.ago} — pretty amazing, don’t you think?

  3. I like my history succinct and colorful. For this reason, I never use the plain git log; instead, I define an alias called lg where I use the --pretty option to customize its output. If you want to know more, I wrote about this a while ago when talking about the importance of a good-looking history.

comments powered by Disqus