1

I've been having an issue that I am really trying to understand in order to fix it, but I cannot find any useful information on the web. I have a Github repo from which I'm pulling some changes using the rebase strategy, so I can keep a tidier commit history. This is something I've already done in the past without any problem, but now whenever I try to pull from dev branch (the default one on Github) with $ git pull, $ git pull origin dev, $ git pull --rebase or $ git pull --rebase origin dev when I have any committed changes, git just goes into detached HEAD state with the last commit uploaded to the Github remote repo. This is the output:

$ git pull
Note: switching to 'c350456eb80718e02415a95a5660502ef2eab405'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at c350456 [doc](readme) Añade configuración de plantilla commit
Atención nombre de rama incorrecto⚠ 
No se cumple con las normas de formato de ramas:
    - Incluye alguna de las etiquetas (feat,fix,hotfix,refactor,style,doc,test,release).
    - Separado por / escribe un título para la rama.

error: could not detach HEAD

NOTE: The text that says: "Atención nombre de rama incorrecto..." is from a post checkout hook I have created in order to check branches name against a naming convention

This is the output of git status:

$ git status
On branch dev
Your branch and 'origin/dev' have diverged,
and have 1 and 1 different commits each, respectively.
  (use "git pull" to merge the remote branch into yours)

nothing to commit, working tree clean

This is my local git repo config:

$ git config --local -l core.repositoryformatversion=0

   core.filemode=true
   core.bare=false
   core.logallrefupdates=true
   [email protected]:osr-solar/crm-backend.git
   remote.origin.fetch=+refs/heads/*:refs/remotes/origin/*
   branch.dev.remote=origin
   branch.dev.merge=refs/heads/dev
   pull.rebase=true
   commit.template=commit_template
   user.name=angbongon
   [email protected]

I hope someone can give me some guidance. Thanks in advance.

EDIT (10/13/2020):

Well, I found out that the post-checkout hook makes the rebase process stop. This in turn leaves the pull operation on the detached HEAD state. So the thing now is how to avoid the hook execution when a git pull --rebase is being made. I guess there is an option like --no-verify but I would like to find a better, more automated solution.

EDIT (10/14/2020):

This is my post-checkout hook:

#!/bin/bash

BRANCH=`git rev-parse --abbrev-ref HEAD`
BRANCH_REGEX="(feat|fix|hotfix|refactor|style|doc|test|release)\/[a-z0-9._-]+"
MAIN_BRANCHES="(master|dev)"

ERROR_MSG="$(tput bold)Atención nombre de rama incorrecto$(tput setaf 3)⚠ $(tput sgr0)
No se cumple con las normas de formato de ramas:
    - Incluye alguna de las etiquetas (feat,fix,hotfix,refactor,style,doc,test,release).
    - Separado por / escribe un título para la rama.
"
if [[ (! $BRANCH =~ $BRANCH_REGEX) && ($3 = 1) && (! $BRANCH =~ $MAIN_BRANCHES)]]; then
    echo "$ERROR_MSG"
    exit 1
fi
exit 0

The problem? When the branch doesn't match de regex it exits with code 1, this is the code for an operation that wasn't successful in shell. When the code is thrown it makes the rebase operation abort what will eventually lead to the detached HEAD state.

8
  • Can you add some output from running those commands?
    – zrrbite
    Commented Oct 13, 2020 at 19:03
  • "...whenever I try to pull from dev branch..." What command(s) are you using, and what output are they showing? Commented Oct 13, 2020 at 19:12
  • Adding to zrrbite's comment : would you by any chance have active conflicts when rebasing ? Check the output of git status
    – LeGEC
    Commented Oct 13, 2020 at 19:14
  • @zrrbite done! Check it out on the edit Commented Oct 13, 2020 at 19:22
  • @MarkAdelsberger I have made some edits to the post, check them out. Thanks! Commented Oct 13, 2020 at 19:23

2 Answers 2

1

TL;DR

You have a post-checkout hook that is causing git rebase to fail (or stop without finishing, anyway). You need to fix that, or use git rebase --continue or git rebase --abort if rebase itself recorded the in-progress rebase.

(It's clearly a Git bug that a post-checkout hook causes this problem at all, but precisely what the bug is—i.e., what Git should be doing: whether it should be using the post-checkout hook at all—is still not settled on the Git mailing list.)

Long (and mostly tangential as it turns out)

Start with this: the git pull command is meant to be a convenience. It runs git fetch, which obtains new commits from some other Git repository. Then it runs a second Git command, because just obtaining those new commits doesn't do anything with the new commits. You have them, but none of them are in any of your branches. Your Git repository has its own branch names, quite independent of all other Git repositories, even the one you cloned to make your Git repository, and git fetch did not touch any of your branches.

You get to choose which second command git pull will run. The default is to run git merge.1 You can choose to have git pull run git rebase instead. How do you know which one to use? That's up to you,2 but if you choose git rebase, you now know that you need to know what git rebase will do.


1There is a rare case where git pull just runs git checkout, but in this rare case it doesn't matter which second command you chose.

2Often, my answer is I don't know which one, if either, to use and therefore I tend not to use git pull at all. I like to run git fetch, see what I got, and only then decide whether to run a second Git command at all, and if so, which one to use. But everyone has a different way of working; Git is a tool-set, not a solution.


When git rebase fails

When the git rebase works, all is good. But when git rebase fails, you are left in this "detached HEAD" mode.

The word fails might be too strong here. Rebase, in Git, is the equivalent of doing a series of git cherry-pick operations, one for each commit that rebase copies, and—just like git merge—a cherry-pick can have a merge conflict. So each of these copies can result in a merge conflict, just like if you have git pull run git merge, or if you run git merge yourself.

What you have to do in this situation is resolve the conflict, then resume the operation (merge or rebase or cherry-pick—whatever it was that stopped). That still doesn't explain the detached-HEAD though. What does explain the detached HEAD is how rebase itself works.

How rebase works, in a nutshell

Git is all about commits. It's not about branches, though branch names help you (and Git) find commits, and it's not about files, though commits contain files. It's all about the commits.

Each commit has a snapshot of all the files that Git knew about, at the time you (or whoever) made the commit. Each commit has a hash ID: that's how Git actually finds the commits. The branch name, if there is one, is just there to find the hash ID; the hash ID finds the commit. And, each commit stores the hash ID of some previous commit.3 In our normal cases, that's the hash ID of the previous commit, which is how Git stores history: as a series of commits, linked up to each other, but backwards.

This trick where each commit stores the hash ID of its parent commit means that we can draw the commits like this:

... <-F <-G <-H

Here H stands in for the actual hash ID of the last commit in the chain. Commit H holds a snapshot, and also holds the hash ID of its parent commit G. That lets Git find commit G. Commit G holds a snapshot, and the hash ID of its parent F. That lets Git find F. Commit F holds ... well, you should get the idea by this point. 😀

We still have to know the hash ID of the last commit, though. Would you like to memorize the big ugly hash ID of commit H? I wouldn't. But we can just have the computer remember it for us, using a branch name:

...--F--G--H   <-- branch

The name branch now remembers the hash ID of H for us.

When you have multiple branch names, each branch na


3Technically, each commit stores a list of hash IDs. This list can be completely empty, or can be more than one hash ID, but the most common case is a one-entry list. That's a normal everyday commit. A two-entry list is a merge commit. A three-or-more-entry list is also a merge commit, but it's mainly for showing off. 😀 You don't normally find these in most repositories, except for with git stash, which makes weird merge commits that aren't regular merge commits. And the very first commit anyone makes in a new, empty repository has that empty list: it's the root commit.


When you have more than one name, there may be more than one "last commit"

Look at what happens if you have two names. They could point to the same commit:

...--F--G--H   <-- branch1, branch2

In this situation, it doesn't matter which name we pick: we'll get commit H either way. We still have to pick one, though. The one we pick, that's where we'll attach the special name HEAD. Git will now know that that name is the name to use:

...--F--G--H   <-- branch1 (HEAD), branch2

This is the normal attached HEAD situation.

In this situation, when we make a new commit, we get a new commit whose parent in the current commit:

             I
            /
...--F--G--H

What Git does right after it makes this new commit, though, is to write the hash ID of the new commit—it's a new unique hash ID—into the current branch name. So branch2 still points to H, but now branch1 points to I:

             I   <-- branch1 (HEAD)
            /
...--F--G--H   <-- branch2

Note that HEAD is still attached to the name branch1.

If we now git checkout branch2, Git switches back to commit H. The files we saved in commit I are all safe there in commit I; but now we have the files that were saved earlier in commit H, and our HEAD is attached to the name branch2:

             I   <-- branch1
            /
...--F--G--H   <-- branch2 (HEAD)

Let's make a new commit now, and call it J, to get this:

             I   <-- branch1
            /
...--F--G--H
            \
             J   <-- branch2 (HEAD)

Now let's go back to branch1 so that we're working with commit I:

             I   <-- branch1 (HEAD)
            /
...--F--G--H
            \
             J   <-- branch2

Note that no part of any commit has ever changed, in all of this. The existing commits don't change at all. We have Git copy the files out of the commit, so that we can see them and work on them. We make new commits. But none of the existing commits change. They literally can't change.

"Moving" a commit

But what if we decide that it would be nice if commit I came after commit J? We'd like commit I to change. It can't, but we'd like it to.

So, instead of changing I, let's copy I to a new and improved commit, that's like I, but comes after J. To do this, we'll use git cherry-pick, which copies one commit. We'll start with git checkout branch2 to get back on branch2. Then we'll create a new branch name, temp for temporary:

             I   <-- branch1
            /
...--F--G--H
            \
             J   <-- branch2, temp (HEAD)

Now we run git cherry-pick. It needs the hash ID of commit I, for which we can find the hash ID, or just use the branch name—Git will find the commit hash ID from the name. Either way, git cherry-pick does its magic, which involves using Git's merge code, and makes a copy of commit I. Let's call the new commit I'.

Like any new commit, writing I' involves updating the branch name. The branch name that gets updated is our temporary name, temp, because that's where HEAD is attached. Here it is:

             I   <-- branch1
            /
...--F--G--H
            \
             J   <-- branch2
              \
               I'  <-- temp (HEAD)

Now, we've already seen that while commits can't change, branch name can and do move all the time. So let's yank the name branch1 off commit I and make it point to I':

             I   ???
            /
...--F--G--H
            \
             J   <-- branch2
              \
               I'  <-- branch1, temp (HEAD)

Now let's attach HEAD to the name branch1, using git checkout branch1 or similar, and then delete the temporary name entirely:

             I   ???
            /
...--F--G--H
            \
             J   <-- branch2
              \
               I'  <-- branch1 (HEAD)

What happened to commit I? Nothing: it's still in there. You just can't see it any more. Commit I' is the one at the end of branch1 now.

It looks like we changed I, but we didn't. We copied it.

Rebase is this, automated

This same process works even if there are multiple commits:

          A--B--C   <-- feature (HEAD)
         /
...--o--*--o--o   <-- main

can become:

          A--B--C   ???
         /
...--o--*--o--o   <-- main
               \
                A'-B'-C'  <-- feature (HEAD)

We just do a git checkout feature followed by a git rebase main. The rebase command automatically:

  • checks out the target commit, in this case, the tip of main;
  • copies the commits that need copying, one at a time, using or as if by using git cherry-pick; and then
  • moves the branch name at the end of the copying.

Rebase internally uses "detached HEAD" mode

When we did our manual one-commit-copy process, we made a temporary branch name, temp. We did a git checkout temp to attach HEAD there. Rebase doesn't bother with a temporary branch name, though.

Git has this detached HEAD mode in which HEAD just points directly to some commit. This looks like this:

          A--B--C   <-- feature
         /
...--o--*--o--o   <-- main
               \
                A'  <-- HEAD

Here, we've successfully copied A to A'. Then we stopped, for some reason.

The git cherry-pick command can stop in the middle for some reason: because of a merge conflict. A merge conflict can happen with any merge, including the internal weird merge for a cherry-pick. Since git rebase uses git cherry-pick, rebase can also stop in the middle.

If it does stop, your job is to fix the conflict and run git rebase --continue. Or, if you decide the rebase is a bad idea, you can run git rebase --abort; then Git will give up on the rebase and just go back to an attached HEAD, abandoning any effort so far. In this case that would produce:

          A--B--C   <-- feature (HEAD)
         /
...--o--*--o--o   <-- main
               \
                A'  ???

and it would look like you never started the rebase.

Since git pull runs git rebase ...

Since git pull actually runs git rebase, it can stop just like this, with an in-progress rebase that has suspended in the middle of a commit copy. Your job is to finish the merge and resume the rebase.

3
  • 2
    Thanks for all that quality info. But I think you are not actually answering the question I made. I would like to know why a simple git pull --rebase, in which I am downloading the changes made remotely to a file I haven't edited locally. Normally this wouldn't cause any problem but it does now. Even if there was a problem git would enter to an interactive rebase mode in which I could made changes to the conflicting files, but it's not the case . After disabling the post-checkout hook I have been able to rebase without problem. Commented Oct 13, 2020 at 20:15
  • Aha - your post-checkout hook is causing the rebase to fail. It's still a case of rebase failing, it's just that it's because of the post-checkout hook. There is some discussion on the Git mailing list about this: in particular, whether git rebase should even run that post-checkout hook at all.
    – torek
    Commented Oct 14, 2020 at 6:58
  • I think I can get away with it changing a bit of the code in the post-checkout. I'll update the original post so you can see the before and after of the hook. Commented Oct 14, 2020 at 8:10
0

Found the solution!

As I explained in the post's last edit the problem was with the post-checkout hook:

The problem? When the branch doesn't match de regex it exits with code 1, this is the code for an operation that wasn't successful in shell. When the code is thrown it makes the rebase operation abort what will eventually lead to the detached HEAD state.

So the solution is as easy as changing the exit code when the branch that is being checked out doesn't match the branch naming convention. This is exit code 0. Leaving the hook script like this:

#!/bin/bash

BRANCH=`git rev-parse --abbrev-ref HEAD`
BRANCH_REGEX="(feat|fix|hotfix|refactor|style|doc|test|release)\/[a-z0-9._-]+"
MAIN_BRANCHES="(master|dev)"

ERROR_MSG="$(tput bold)Atención nombre de rama incorrecto$(tput setaf 3)⚠ $(tput sgr0)
No se cumple con las normas de formato de ramas:
    - Incluye alguna de las etiquetas (feat,fix,hotfix,refactor,style,doc,test,release).
    - Separado por / escribe un título para la rama.
"
if [[ (! $BRANCH =~ $BRANCH_REGEX) && ($3 = 1) && (! $BRANCH =~ $MAIN_BRANCHES)]]; then
    echo "$ERROR_MSG"
>>> exit 0 <<<<<<<<<<
fi
exit 0

I think that you could control weather you are making a rebase pull or just changing branches looking at the parameters $1 and $2, that are the ref of the previous HEAD and the ref of the new HEAD (which may or may not have changed) respectively. Looking at @torek answer would give some hints on how to tackle this "workaround" but for the purpose of my hook (being a warning message) I find my solution just fits.

Thanks to all the ones commenting. Specially to @torek and @ElpieKay who gave some clues.

Not the answer you're looking for? Browse other questions tagged or ask your own question.