Search code examples
gitgit-rebase

Changes lost while rebasing and accidentally switching to different branch


On branch foo I started a rebase like this: git rebase --interactive HEAD~1, where I wanted to add changes for last commit in file A.

I made my changes, git add them, and then git commit --amend them. (Please note that I have not issued git rebase --continue command yet)

Then I switched to branch bar via git checkout bar; did nothing there and switched back to foo via git checkout foo. When checking file A, I found that all changes I did during rebase were gone, even though git status says:

Last command done (1 command done):
   e deadbee Nice commit message

Is it possible to get those changes back?


Solution

  • When you start an interactive rebase, Git puts you in "detached HEAD" mode. When you check out a branch name by name, Git puts you in "attached HEAD" mode, i.e., back on a branch. This rather badly disrupts an ongoing rebase, because any new commits you made are now difficult to find.

    Lemuel Nabong's answer contains the key (but is wrong): you must re-check-out the appropriate detached-HEAD commit, which you can find using git reflog. Don't do this with git reset, do it with git checkout hash or git checkout HEAD@{number}, after finding the correct commit in the reflog. You should then be able to continue your rebase.

    Long description

    What detached HEAD means here is that the special file .git/HEAD (which always exists) no longer contains the name of a branch. Normally HEAD, or .git/HEAD, contains a string like ref: refs/heads/master to indicate that the current branch is the one named master. The current branch then determines the current commit.

    For doing certain kinds of work, though—including interactive rebase—Git changes .git/HEAD so that it contains instead a raw commit hash ID. The interesting thing about this mode is that you can make new commits, which get new hash IDs that are different from every existing commit. When you do this, those new commits' IDs can only by found by reading .git/HEAD itself.

    A picture, I think, makes this a lot clearer. If we start with a tiny repository with just three commits in it, we can draw them like this, using single uppercase letters to stand in for those horrible hash ID strings like ccdcbd54c4475c2238b310f7113ab3075b5abc9c. We'll call our first commit A, our second B, and our third C:

    A <-B <-C   <--master
    

    Commit C, our latest commit, has its hash ID stored under the name master. We say that the name master points to C. Commit C itself stores the hash ID of commit B as its parent, so we say that C points to B. Commit B stores A's hash ID in turn, so B points to A. Commit A is the very first commit ever made, so it has no parent at all. Git calls this a root commit, and it's where the action stops if we run git log, for instance, because there's no earlier commits to look at.

    Hence, Git always works backwards: a branch name points to the last commit on the branch. The commit itself remembers the previous commit, and so on. If we go to add a new commit to master, we run:

    git checkout master   # if needed
    ... do things to modify files ...
    git add file1 file2 ...
    git commit
    

    The commit step packages up the latest snapshot (from the index aka staging area, where git add copied them, but we'll leave that for another topic), then writes out a new commit D whose parent is the current commit C:

    A <-B <-C   <--master
             \
              D
    

    Finally, having written out the new commit, git commit writes the new commit's hash ID—whatever it turns out to be; it's not easily predicted—into the name master so that master now points to D:

    A <-B <-C
             \
              D   <--master
    

    and the commit is done.

    The way that Git knows which branch name to update, if you have more than one branch name, is by attaching HEAD to it. Suppose that instead of committing D on master, we do this:

    git checkout master
    git checkout -b develop    # create new develop branch
    

    Now the drawing looks like this (I'm dropping the internal arrows, we know they always point backwards and they get hard to draw):

    A--B--C   <-- master, develop (HEAD)
    

    We do our work, git add, and git commit, and since HEAD is attached to develop rather than master, Git writes new commit D's hash ID into develop rather than master, giving:

    A--B--C   <-- master
           \
            D   <-- develop (HEAD)
    

    A detached HEAD just means that instead of having HEAD attached to some branch name, HEAD points directly to some commit. If we detached HEAD now and had it point to commit D, we could draw this as:

    A--B--C   <-- master
           \
            D   <-- develop, HEAD
    

    If we now make a new commit E, we'll get this:

    A--B--C   <-- master
           \
            D   <-- develop
             \
              E   <-- HEAD
    

    If we now say git checkout master, this is what happens:

    A--B--C   <-- master (HEAD)
           \
            D   <-- develop
             \
              E   <-- ???
    

    The way to get back to where we were is to find some name for commit E (remember, its real name is some big ugly hash ID).

    Both rebase and git commit --amend work by making new commits. The special thing that --amend does is to make the new commit with its parent being the current commit's parent. If we start with:

    A--B--C   <-- master
           \
            D   <-- develop (HEAD)
    

    and run git commit --amend, Git makes a new commit E whose parent is D's parent C, rather than D itself. Git then writes that into the appropriate name—develop in this case—giving:

            E   <-- develop (HEAD)
           /
    A--B--C   <-- master
           \
            D   <-- ??? [abandoned?]
    

    This is where reflogs come in

    Each branch name has a reflog, recording the commit IDs that the branch name used to point-to. That is, if master pointed to A at one time—which it must have—then the reflog for master includes the hash ID for commit A. This reflog also includes the hash ID for commit B. Once master no longer points directly to C, the master reflog will contain the hash id of C as well, and so on.

    There's also a reflog for HEAD itself, recording the hash IDs that HEAD has pointed-to, either directly (detached) or indirectly (by being attached to a branch name). So git reflog HEAD shows you those reflog entries, which allows you to find the actual hash ID for the commit you're looking for.

    One downside with reflog entries is that they eventually expire: after 30 to 90 days, Git assumes you don't care any more. That particular down-side won't apply here since the commit you are looking for is fresh. The other (an other?) down-side is that the commits found in the reflog tend to all look alike, and there may be an awful lot of them, so it can be hard to find them in the noise. One thing that helps is to note that they're kept in order: the @{1} entry is the old value from a moment ago, the @{2} entry is the one from before that, and so on. So if you only recently switched, the one you want will be in the top few.