Checking out a Git branch further back than the head

February 6, 2022

Famously, if you want to check out a repository at some arbitrary commit back from the head of your current branch, you normally do this with just 'git checkout <commit>'. I do this periodically when making bug reports in order to verify that one specific commit is definitely the problem. Equally famously, this puts you into what Git calls a 'detached HEAD' state, where Git doesn't know what branch you're on even if the commit is part of a branch, or even part of 'main'.

It's possible to move a branch (including 'main') back to an older commit while staying on the branch. This avoids Git complaints about being in a detached HEAD state and makes 'git status' do useful things like report how many commits you are behind the upstream tip. As far as I know so far, the way you do this is:

git checkout -B main <commit>

As 'git status' will tell you, you can return to the tip from this state by doing 'git pull'. Equivalently, you can do 'git merge --ff-only origin/main', which avoids fetching anything new from your upstream. This second option gives away the limitation of this approach.

The limitation is that you can only do all of this if you don't have any local commits that you rebase on top of the upstream. If you do have local commits, I think that you want to live with being in the detached HEAD state unless you like doing a bunch of work (and I'm assuming here that you can live without your local changes; otherwise life gets more complicated). Doing all of this back and forth movement of what 'main' is smoothly relies on your normal main being the same as origin/main, and that's not the case if you're rebasing local commits on top of origin/main every time you pull it.

(Git has a syntax for 'N commits back from HEAD' as part of selecting revisions (also), but for almost everything I do what I care about is a specific commit I'm picking out of 'git log', not a number of commits back from the tip.)

It's a little bit annoying that you have to specify the branch name in 'git checkout' even though it's the current branch name. As far as I know, Git has no special name you can use for 'the current branch, whatever it's called', although it does have a variety of ways of getting the name of the current branch. If you're scripting this 'back up to a specific commit on a branch', you can use one of those commands, but for use on the fly I'll just remember to type 'main' or 'master' (depending on what the repository uses) or whatever.

(This is one of the Git things that I don't want to have to work out twice. Although Git being Git, it may in time acquire a better way to do this.)


Comments on this page:

From 193.219.181.219 at 2022-02-07 00:01:51:

It's a little bit annoying that you have to specify the branch name in 'git checkout' even though it's the current branch name.

For re-pointing the current branch, I believe you want git reset --keep $commit? (Or the more well known --hard but that discards local changes; --keep keeps them like a checkout would.)

By nyanpasu64 at 2022-02-07 02:15:05:

Not entirely sure what you mean by this article, but git pull --rebase may be useful.

By John Marshall at 2022-02-07 05:28:00:

If the main difficulty is getting back to your local main afterwards, I would do these explorations on a new temporary branch that you delete afterwards:

    git checkout -b tmp <commit>
    git branch -u main    # or origin/main to not count your local commits

Setting the upstream with -u gives you the reporting of how far behind tmp is. I don't think it's currently possible to combine these two commands into a single command.

For brief explorations limited to maybe 20 or so commits back, I generally just go into detached HEAD mode and keep track of where I am manually with an equivalent of:

    git log --oneline --date-order -25 HEAD main

This will show "(HEAD)" in the context of the commits leading forwards to main. (What I actually use is a wrapper around git forest, which gives a more aesthetically pleasing form of log --graph output.)

By Walex at 2022-02-07 16:49:13:

This discussion is based on a common misconception, that 'git' has "branches", but it does not, either reified or conceptual, in 'git' branches exist only in the imagination of the user (and of the "porcelain").

So 'git' only has refs and chains of uplinks (lineages) among complete snapshots of the working tree. One cannot move upo and down a branch because they don't exist. What people call "brnach" is actually just a ref that gets automagically updated on commit, and anyhow refs are just syntactic sugaring for commit ids.

Moving to an uplink commit and then doing a 'pull'/'merge'/'rebase' looks monstrous to me because it means essentially re-playing history unnecessarily, because the real goal is simply to examine different points uplink and then return downlink to the "tip". There are two opposite but equivalent ways to do that:

  • Label the current "tip" with a temporary label, move uplink by commit id, and then return to the "tip"/HEAD using that temporary label. One can also return to the previous HEAD by looking up its commit id in the log.

  • Label the destination commit with a temporary label, and then return to the "tip"/HEAD.

The latter is what is being quite nicely proposed here:

   git checkout -b tmp <commit>
   git branch -u main

Because putting a ref on a commit id creates a "branch" and viceversa. But note that a tag might be more appropriate, but not sure that one can checkout a tag. I have just checked 'git checkout' and it has a discussion of similar points on detached HEAD status:

"It is important to realize that at this point nothing refers to commit f. Eventually commit f (and by extension commit e) will be deleted by the routine Git garbage collection process, unless we create a reference before that happens. If we have not yet moved away from commit f, any of these will create a reference to it:

  $ git checkout -b foo   (1)
  $ git branch foo        (2)
  $ git tag foo           (3)

1. creates a new branch foo, which refers to commit f, and then updates HEAD to refer to branch foo. In other words, we’ll no longer be in detached HEAD state after this command. 2. similarly creates a new branch foo, which refers to commit f, but leaves HEAD detached. 3. creates a new tag foo, which refers to commit f, leaving HEAD detached.

If we have moved away from commit f, then we must first recover its object name (typically by using git reflog), and then we can create a reference to it. For example, to see the last two commits to which HEAD referred, we can use either of these commands:

  $ git reflog -2 HEAD # or
  $ git log -g -2 HEAD"

But note that a tag might be more appropriate, but not sure that one can checkout a tag.

Not in the same sense that you can check out a branch. You can tell git checkout to check out a tag, but it will just use the tag name as a shorthand for the tagged commit, and will check out that commit and put you in detached HEAD state.

And if you think about it, it’s clear that HEAD cannot point to a tag the same way it can point to a branch, because what if you made a commit? When you make a commit while HEAD points to a branch, that branch is updated to point to the new commit. If HEAD pointed to a tag, what should be updated upon commit?

This discussion is based on a common misconception

I agree. The way I’d phrase it is that Git only has a commit graph, with no other notion of provenance for individual commits than its parent pointers. Branches are just bookmarks in this graph that help you keep track of positions in it.

The name of the branch that was checked out when a commit was created is completely immaterial to the commit. The only notion of branches in this graph comes from the fact that the parents of commits are ordered: the first parent of a merge commit is “on the same branch” while the secondary parent(s) is/are the “side branch(es)” being merged in.

Specifically branches are updatable bookmarks: HEAD always points to some commit in the graph, but if it points to it indirectly by way of a bookmark, i.e. branch, then when you make a commit, the branch is updated. If HEAD points to a commit directly and you make a commit, then HEAD itself is updated. That’s all there is to it.

I use this detached HEAD state all the time. There is really no particular reason for branches to exist or not exist in any particular point in a timeline order of events while you are clambering about the commit graph – they just help you keep track of positions in the graph, same as pins on a map do. Sometimes I create commits first and only a create branch to point to them afterwards; the commits come out just the same as if I’d done it the other way around.

The one and only gotcha about a detached HEAD is due to the facts that ⓐ only commits that are reachable from some ref are visible by default, and ⓑ if you create a commits while HEAD is detached, HEAD is the only reference to them. So if you then inadvertently point HEAD somewhere else, those commits are no longer reachable from any ref and you have to know more Git than the cheatsheet summary to reveal them again. Most of the time the commit ID in question will still be somewhere on your screen, though.

I think the big thing about using Git fluently is situational awareness. I have the current HEAD shown in my shell prompt, and I either check git log --graph --branches frequently (alias it to taste) and/or have a graphical repo browser open alongside my terminal. If you are trying to keep track of your location purely mentally I imagine it would get confusing sometimes – I wouldn’t even want to try working that way. Make sure your place in the graph is right in front of you at all times, though, and in my experience it is impossible to get confused.

Written on 06 February 2022.
« Go 1.18 won't have a 'constraints' package of generics helpers
What does it mean for a filesystem to perform well on modern hardware? »

Page tools: View Source, View Normal, Add Comment.
Search:
Login: Password:
Atom Syndication: Recent Comments.

Last modified: Sun Feb 6 22:59:39 2022
This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.