Why is there a difference between "git worktree add" with checkout and "git checkout"?

Issue

Two commands that should, according to documentation, do the same thing, have different results, and I do not understand why nor the implication of the difference.

The First Command

git worktree add -d "c:\temp\junk\blah" 209134fc8f

Result:

c:\temp\junk\blah>git status
Not currently on any branch.
nothing to commit, working tree clean

The Second Command

git worktree add -d "c:\temp\junk\blah"
cd "c:\temp\junk\blah"
git checkout 209134fc8f

Result:

c:\temp\junk\blah>git status
HEAD detached at 209134fc8f
nothing to commit, working tree clean

I expected these two commands to give the same status result. They do not. So my questions:

Why do these two commands not have the same status result?
Is there a meaningful difference between not being on any branch vs the HEAD being detached on a commit? Both seem to work the same for later commands. I am using "-d" specifically because I do not want to create a new branch for this temporary worktree.

Solution

Why do these two commands not have the same status result?

Because git status is too clever for its own good—or maybe for your good. 😀

Is there a meaningful difference between not being on any branch vs the HEAD being detached on a commit?

This depends on what you consider "meaningful".

There are two or three keys to understanding this answer:

  1. Git has (optional) reflogs (which are on by default for your case).
  2. Each added working tree has its own HEAD.
  3. As mentioned, git status is clever, perhaps too clever.

We must expand on each of these to get where we’re going.

Reflogs

To understand reflogs in Git, we must start with the idea of a "ref" or "reference". These "refs" are the generalized term for branch names, tag names, remote-tracking names, and all sorts of other names. Almost all of these refs—or all of these refs, depending on which part of which Git documentation you believe at any given moment—are spelled with full names that start with refs/. For instance, the branch name main is really just short for refs/heads/main. The heads part of this is what makes it a branch name: if it were refs/tags/main, it would be the tag name main.

(This means you can have branch and tag names that are identical, once shorn of their refs/heads/ and refs/tags/ prefixes. Don’t do that. Git won’t get them mixed up, but you will, especially because the rules for which name takes priority depend on which command you use.)

There’s one very special name, which part of Git calls a pseudo-ref, and that’s HEAD (written in all uppercase like this: lowercase sometimes works on some systems, but don’t use it: it’s a trap). This is not the only pseudo-ref as there are also names like CHERRY_PICK_HEAD and MERGE_HEAD and ORIG_HEAD and more, but unlike the other pseudo-refs, HEAD can have a reflog.

Each ref or pseudo-ref stores one (1) hash ID. (This makes FETCH_HEAD, which stores more stuff in it, not really a pseudo-ref, although in Git’s usual squirrelly fashion, you can sometimes use FETCH_HEAD as a pseudo-ref. For historical reasons, Git is not always systematic and just does whatever seemed fine ad hoc at the time, and now Git is stuck that way forever, or at least until Git 3.0 or something.)

The one hash ID stored in a branch name like main is, by definition, the last commit "in" or "on" that branch. Two or more names can identify the same commit; in this case, both branches contain exactly the same set of commits, since the set of commits "in" or "on" any one branch is determined by reading the branch name to find the tip commit, then working backwards through the commits themselves. No part of any commit can ever be changed once the commit is made, so if names X and Y both select hash a123456..., and we work backwards from that commit, we’ll always find the same commits. (We can add new commits to the repository, but we cannot change or remove existing commits. We can change the hash ID stored in any branch name at any time, but again, we cannot change or remove the existing commits.)

So far, that’s just saying how things are, so now let’s get to the purpose of the reflog. Whenever we store a new hash ID in some existing name, it might be nice to save the old hash ID somewhere, so that we can see which commit some branch had as its tip commit yesterday, or last week, or whatever. This is what a branch-name reflog does.

Given that refs in general appear under refs/* names, Git simply stores a reflog for each such ref (currently in files, in .git/logs/refs/, but that’s an implementation detail: these entries are in effect database entries, and it might be more efficient to store them in a real database, someday.) These reflog entries carry a date-and-time-stamp and a hash ID, plus a message and various bits of auxiliary data; and you can dump out the contents of any particular ref’s log with git reflog or git log -g (these are internally the same, except that they have different default --formats).

Reflog entries thus tell you what hash ID was in some ref at some earlier point in time. This is how [email protected]{yesterday} or [email protected]{2.hours.ago} works: Git checks in the reflog to see what value main stored 24 hours, or 2 hours, ago. These entries do eventually expire, and at least right now, if you delete a name, its reflog entries also vanish, though there have been vague plans to keep the reflog entries around to be able to "un-delete" the name.

Besides having reflog entries for branch names, remote-tracking names, and even tag names—though ideally a tag name’s hash ID never changes—Git has reflog entries for HEAD. These are updated whenever Git updates HEAD, including when you switch branches. Running git reflog with no arguments dumps out the HEAD reflog. While deleting a branch name deletes the branch’s reflog, the HEAD reflog may retain the hash IDs that were in that branch, if HEAD was attached to that branch.

Added work-trees, and what is and is not shared

When you add a working tree with git worktree add, you pick a branch name or commit hash ID that Git should check out, as in your example. If you use or create a branch name, the added working tree uses the same refs/heads/ names as the original working tree. This is what leads to the "must be a name that is not checked out in any other working tree" restriction, though to describe this correctly I would have to go into more detail about the index.

One thing that is not shared is obvious once you think about it: since HEAD literally holds the branch name—that is, .git/HEAD, a plain file, holds the literal text ref: refs/heads/master (plus a newline) if the main working tree is "on" branch master—each added working tree needs to get its own HEAD, so that it can be on a different branch. And that’s just what happens here: instead of using .git/HEAD, each added working tree uses a different pseudo-ref (still spelled HEAD but not stored in .git/HEAD).

This is why you have to use all-caps for HEAD, even on Windows and macOS systems: in the added working tree, if you type in head in lowercase, Git does not consider that a match to HEAD (uppercase) and therefore doesn’t use the per-working-tree HEAD that’s stored somewhere else. Instead, Git tries to open the file .git/head, which—because of case-insensitive file systems—opens .git/HEAD instead and Git thinks you mean whatever commit is in the main working tree, rather than whatever commit is in the added working tree. So if you don’t like typing out HEAD in all caps, consider using @, which is a one-character synonym that does work correctly, even in added working trees.

Now, with all that in mind, remember our reflogs. There’s a reflog for HEAD, so for added working trees to work right, there must be a separate reflog for each added working tree HEAD. And in fact there is.

(As alluded to above, there’s also a separate index or staging area for each added working tree. Certain pseudo-refs, such as those for bisection, are extended this same way. Basically everything that "ought to be" per-worktree is per-worktree. When it isn’t handled consistently like this, that’s a bug. There were many such bugs in the early versions of git worktree, first introduced in Git 2.5. One particularly bad one was fixed in 2.15, and a few more in 2.17.)

We’re finally ready to address your original question.

git status and detached HEADs

The first output line from git status is normally On branch branch, at least when you are on some branch. But you can be in "detached HEAD" mode, when you’re on some specific commit, rather than being on some branch. In early versions of Git, when you are in detached-HEAD mode, git status just tells you that you’re not on any branch. Similarly, git branch would say HEAD detached at hash.

At some point (pre-Git-2.4), git status got taught some extra cleverness, to dig through the reflogs a bit and try to figure out if you were still "on" the commit that some earlier branch name selected, and say so:

HEAD detached at <hash>

or, if you’d moved HEAD since then by checking out some earlier commit or using git reset or whatever:

HEAD detached from <hash>

In Git 2.4, git branch got taught the same trick. And that’s more or less where things stand today, aside from a lot of minor tweaking.

When you see "not currently on any branch", this means that:

  • HEAD is detached, and
  • there’s nothing in the HEAD reflog to indicate an earlier commit or branch, so no way to pick at vs from.

When you see at or from, there’s something in the HEAD reflog, and if there’s an appropriate branch name you will get the name, otherwise you will get the hash ID.

Answered By – torek

This Answer collected from stackoverflow, is licensed under cc by-sa 2.5 , cc by-sa 3.0 and cc by-sa 4.0

Leave a Reply

(*) Required, Your email will not be published