Git checkout to a commit 2 commits before hash

It's not clear to me whether you need to look backward or forward from some particular commit.

The key to all of this, though, is to recognize that Git doesn't care very much at all about branch names. What Git cares about is the commit graph. Any time you are dealing with this sort of issue, you should draw the graph.

Here are some sample graphs. Round o nodes represent commits. The * is the commit whose ID you have in your hands, and I gave "potentially interesting" commits one-letter names:

...--A--o--*--o--B--...--o   <-- branchname
     ^           ^
   2 before    2 after

This graph is pretty easy. Commit A comes two before commit *, so if you have the hash of *—let's say it's 1234567—you can write 1234567~2. The ~2 means "go back two first-parents" (see below). Finding commit B is tougher (see below).

But we might have this mess:

                o--D--o   <-- branch1
               /
              /  E--o     <-- branch2
      A--o   /  /    \
          \ /  /      \
...--B--o--*--o--F-...-o   <-- branch3
            \         /
             o--C----o

Here, commits A and B are both two steps before *, while all of C, D, E, and F are two steps after.

It is much easier, in Git, to find commits that are "before", because Git's internal arrows all point "backwards". The branch names, which are all on the right and hence point to the latest commit on the branch—this is in fact how branches work in Git: the name points to the tip commit, and the tip commit points backwards (leftward in these graphs) to earlier commit(s)—simply get Git started in terms of finding commits.

Given a commit, Git follows the backwards arrow(s) to the parent(s) of that commit. Here * is a merge commit, bringing the A--o and B--o lines together.

The order of the two parents of * is not something shown in the graph itself but let's say that the A line is actually found via the second parent. Let's say that * is again 1234567. Then 1234567~2 is commit B (not A) because ~ follows only first parents. To find A, you can write 1234567^2 (which names the upper o before *) and then add ^1 or ~1 to go back one more parent to A.

In other words, the two commits are now 1234567~2 and 1234567^2~1. (There are more ways to write this.) But the easiest thing is to run git log --graph --oneline 1234567 and observe the graph—Git will draw it vertically but you'll see two lines come out of *, which will let you find both A and B.

If there are more than two ways to get to more than two commits that are "two steps back", you'll see that in the graph, as we do for the many commits that are "two steps forward". But ... to find the commits that are "two steps forward" is noticeably much harder.

Again, Git starts from the newest (rightmost in our drawings) tip commits and works backwards. I drew three things that eventually merge into the tip of branch3,1 but note that commit D—which is also two steps forward—can be found only by searching backwards from branch1.

There is no "search forward"

Git has no way to "search forward" or "step forward" from a commit. All operations that move forward (there is one!) actually work by moving backwards. Basically, we start from all possible starting points, i.e., all branch names / tips.2 Then we have Git list everything that goes back to the commit we want, printing those commit's hash IDs if and only if they are descendants3 of the commit we're looking at.

The command that prints all these IDs is git rev-list (which is actually just git log,4 told to only print the commits' hash IDs instead of logging the commits.) The flag that says "give me descendants" is --ancestry-path, and it must be combined with several more pieces of information. In particular, we must tell Git which commit is the "interesting" one, and we do that with the ^ prefix (not suffix this time):

git rev-list --ancestry-path ^1234567 --branches

The ^1234567 tells Git which commit stops the graph traversal and limits the set of printed revisions to those that are descendants of 1234567. The --branches tells Git, as usual, where to start the search: from all branch tips. (We can also add --tags, or use --all to say all references: all branches, all tags, the stash if there is one, and whatever else may exist. However, --branches, or --branches --tags, is probably what you want here.)

Now, the problem with git rev-list is that it just spills out all the revision IDs. That includes not just commits C, D, E, and F, but also all the "uninteresting" o commits that come after commit *. There are numerous possible solutions to this, but perhaps the simplest is to go back to using git log --graph --oneline: that will, instead of printing just the (full) hash, print (abbreviated) hashes and commit messages, plus draw the graph. Now you can eyeball the graph to find commits that are "two ahead".

(Of course, since git log and git rev-list are basically the same command, you can also run git rev-list --graph. There is no need for --oneline since the output is one commit ID per line anyway. But git rev-list is a plumbing command, meant for use in scripts, and is not very user-friendly, while git log is meant for interacting with users, and tries to be more immediately-useful.)


1This three-parent merge is a thing called an "octopus merge" and is uncommon in practice. More typically you'd have two merges, one that brings branch2 into branch3, and another—earlier or later—that brings the now-unnamed bottom-row line of commits into branch3.

2We might also want to start from tags. For instance, consider this graph fragment:

...--o--*--o--o   <-- branch
         \
          o--o    <-- tag: v0.98alpha

Here, version 0.98alpha was deemed "bad", and the two commits reachable from the tag are no longer on any branch. But if it turns out that one of those two commits has some precious data or code in it, you can still find it, by starting from the tag, and if needed, working backwards to the previous commit. The commit two-steps-back here, marked *, is also on branch branch, so you would find that even without starting from tag v0.98alpha.

3In these directed graphs that Git uses, an ancestor is any commit you can reach by starting from some commit and working backwards: the commit itself, any of its parents, any of its parents' parents—any of its grandparents—and so on. This is the same meaning as "ancestors" for yourself, except that in Git, you are considered your own ancestor.

Because Git's arrows point backwards, it's easy to find ancestors.

The term "descendants" works the same: you are your own descendant, but your children and grandchildren and great-grandchildren are also your descendants, just as you would expect. Git can't actually find these grandchildren directly, but given a pair of commits, there is a very easy test for "is commit Y a descendant of commit X": we just reverse the test. For Y to be a descendant of X, X must be an ancestor of Y. Since Git can answer the "is ancestor" question, we reverse the test and get our answer.

4There are a few significant differences between git log and git rev-list, so they're not quite the same command. But they are build from the same source code and can do exactly the same thing, depending on options. They just set up different options by default: log prints human-readable things, uses a pager, uses color output, and so on; while rev-list prints machine-readable things and does not use a pager. The flags needed to make one act like the other are not always obvious, either.


Use git log to see the modifications made to the file:

git log myfile

When you figured out the commit hash you want to revert to, use

git checkout thehash myfile