Search code examples
gitmergerebase

How do I process a specific merge conflict with a given strategy when rebasing?


Say I am performing an interactive git rebase, to tidy up my repository by e.g. rearranging, separating, or squashing commits.

git rebase -i HEAD~100

Say also that I expect to encounter many merge conflicts where the desired behavior is to resolve as though passing -s recursive -X theirs to git rebase, but also that there will still be some conflicts where I will want to do something else, and I need to decide case by case how to proceed.

When rebase hits a merge conflict, it drops you into a shell in an environment where there is an ongoing partially completed merge. Is there a way I can discard this partially completed merge, and re-run the merge with my desired strategy, for just this one commit, without breaking my ongoing rebase?

Some command which I can use roughly like this:

git rebase --retry --step -s recursive -X theirs

The desired command should operate only on a single step of the rebase, applying just a single commit, overriding the default merge process, and if necessary, discarding an ongoing partially completed merge and repeating it with the same input files. Does any such command exist?


Solution

  • First, a short side note:

    Say also that I expect to encounter many merge conflicts where the desired behavior is to resolve as though passing -s recursive -X theirs

    The -s recursive here is the default for modern rebase (although it will probably soon be -s ort, as the ort strategy, which is the upcoming replacement for recursive, is almost ready for general use). You can just use -X theirs here.

    When rebase hits a merge conflict, it drops you into a shell in an environment where there is an ongoing partially completed merge. Is there a way I can discard this partially completed merge, and re-run the merge with my desired strategy, for just this one commit, without breaking my ongoing rebase?

    Yes. I'm afraid this answer is a bit long, as there is a lot of background to know here. I suspect you already know some of it (or maybe even all), but if so, forgive the loquaciousness. I've tried to organize this by section, at least.

    Background

    This hasn't been very well documented in the past, and now that git rebase is a C program, it's harder to see (no pun intended on the C/see homonym here) how it all works. But there's a general formula here. Any rebase, interactive or not, works by doing the following steps:

    • Enumerate the commits to be copied (if any). Save the current branch name, or remember that the current position was a "detached HEAD".
    • Use git checkout --detach or git switch --detach or equivalent to get a detached-HEAD check-out of the desired target (--onto) commit.
    • Use git cherry-pick or equivalent to copy each commit in the original list, one by one. (Note that each cherry-pick is a slightly twisted form of merge, hence your desire to add -X extended-strategy arguments to the merge strategy.)
    • Once all commits have been copied, use the saved branch name to force that branch name to point to whichever commit is now HEAD. If there was no saved branch name (detached HEAD at startup), do nothing at this step (remain in detached-HEAD mode).

    There's some minor additional work involved here, such as updating reflogs, saving the original branch's stored hash ID in ORIG_HEAD, and making and cleaning up a directory in which to save the rebase state in the case of merge conflicts and the like. But those four steps are the heart of rebasing.

    Interactive rebase adds a big extra wrinkle, and lately, new capabilities as well: rather than just listing the commits to cherry-pick, then immediately going at it, the interactive rebase presents you with the list, in which each cherry-pick is a command line in an instruction sheet. You get to edit the instruction sheet, and only then turn over the sheet to the executor, who executes each instruction, one at a time.

    Besides the basic pick (do-a-cherry-pick) instruction, you get some alternatives, such as squash and fixup, reword, edit, and exec; and in the newfangled --rebase-merges mode, there are instructions that allow Git to run git reset and git merge and to save the hash IDs of commits generated by the various steps involved, which results in the ability to take a commit sequence that includes original merge commits, and re-run git merge commands.1

    In all of these cases, though, we preserve the heart of the original rebase: list commits; detach HEAD; copy commits, one by one; move branch name and re-attach HEAD. And, regardless of the type of merge, or the back-end to be used,2 it's possible for any one "copy commit" step to fail with a merge conflict. And that's just where your paragraph comes in.


    1It's impossible to use git cherry-pick to re-perform a merge, so a standard rebase drops merge commits. I won't, in this answer, go into details about how Git chooses which commits to copy for a rebase (this gets complicated), but using --rebase-merges suppresses the dropping of merge commits: the merge commits are now "copied" using merge commands in the instruction sheet. There's a defect of sorts here though: Git never saves the original merge's -s and -X options, so the re-performed merge later does not know to use -s ours or -X theirs or whatever was appropriate.

    2The back end is responsible for the copying of each commit. In the bad old days, the one back-end available used git format-patch and git am. This back-end still exists today, but today the default back-end uses git cherry-pick; the interactive back end always used git cherry-pick, and git rebase -s recursive, git rebase -k, git rebase -m, and git rebase -p switched to the interactive back end, even if not actually working interactively. Some of the rebase options are still implemented only by the git am-based back end.


    The state at merge conflict

    When you do hit a merge conflict, the state, inside Git, at this point is:

    • A git am or git cherry-pick hit a conflict. This command has terminated, but it has left a merge conflict behind. This merge conflict is where all Git merge conflicts are: in Git's index.

    • The rebase command that ran the above command has also terminated. It has left behind some "resume state" so that you can run git rebase --continue. This state is in the rebase temporary directory (inside the .git directory; the precise location depends on whether you're in an added work-tree, and your Git version).

    • You are in detached HEAD state, and the current commit is the last commit that was successfully copied (or the --onto target if this is the first commit and it has yet to be successfully copied).

    Your job at this point is to resolve the merge conflict. You can do this any way you like. When you run git rebase --continue, Git expects the index and working tree to be ready to complete this copy, by running git commit to commit the cherry-pick. You may, optionally, run git commit yourself; if you do, Git may be able to guess that you committed the copy, and will proceed to the next commit it should copy, or you can explicitly tell Git to proceed anyway, with git rebase --skip.3

    In your case, you can start with:

    git reset --hard
    

    to clean up the index and reset the working tree. Then, do whatever you like to set up the index: for instance,

    git cherry-pick -n -X theirs <hash>
    

    The -n here is to make sure that git cherry-pick does not go on to make the commit itself. If it does, that's not too big a problem: just use git rebase --skip instead of git rebase --continue. I tested this with Git 2.27 and it did not auto-detect the need to skip.

    I did find that the commit being cherry-picked (with the failure) is stored in REBASE_HEAD at this point. In older versions of interactive rebase, which actually ran git cherry-pick directly—the current one has it built in and thus can make a few internal alterations—it would be in CHERRY_PICK_HEAD. You can also find its hash ID (abbreviated) in git status output. So the command sequence I actually used was:

    $ git status
    interactive rebase in progress; onto 7753c04
    Last commands done (2 commands done):
       pick 2cd436b 1
       pick a50fcb5 4
    Next commands to do (2 remaining commands):
       pick 7bcbde0 2
       pick d162089 3
      (use "git rebase --edit-todo" to view and edit)
    You are currently rebasing branch 'master' on '7753c04'.
      (fix conflicts and then run "git rebase --continue")
      (use "git rebase --skip" to skip this patch)
      (use "git rebase --abort" to check out the original branch)
    
    Unmerged paths:
      (use "git restore --staged <file>..." to unstage)
      (use "git add <file>..." to mark resolution)
            both modified:   afile
    
    no changes added to commit (use "git add" and/or "git commit -a")
    $ git reset --hard
    HEAD is now at 2cd436b 1
    $ git cherry-pick -n -X theirs REBASE_HEAD
    Auto-merging afile
    $ git rebase --continue
    hint: Waiting for your editor to close the file... 
    

    (and at this point my editor was open on the commit message). I wrote that out, and:

    [detached HEAD 8c95399] 4
     1 file changed, 2 insertions(+)
    Auto-merging afile
    CONFLICT (content): Merge conflict in afile
    error: could not apply 7bcbde0... 2
    Resolve all conflicts manually, mark them as resolved with
    "git add/rm <conflicted_files>", then run "git rebase --continue".
    You can instead skip this commit: run "git rebase --skip".
    To abort and get back to the state before "git rebase", run "git rebase --abort".
    Could not apply 7bcbde0... 2
    $ git reset --hard
    HEAD is now at 8c95399 4
    $ git rev-parse REBASE_HEAD
    7bcbde00fb66f08d46b1abc5f718c88d144179c8
    $ git cherry-pick -n -X theirs REBASE_HEAD
    Auto-merging afile
    $ git rebase --continue 
    hint: Waiting for your editor to close the file... 
    

    Writing that out and exiting my editor then finished my rebase testing:

    [detached HEAD 5ce59c6] 2
     1 file changed, 1 deletion(-)
    Successfully rebased and updated refs/heads/master.
    

    3I don't think this "I'll figure out that you did a commit, so I'll --skip for you" part was ever explicitly called out, so there might be versions of Git where it doesn't work. I recall seeing it happen, but in testing just now, it didn't happen—but that might have to do with the particular case I'm testing here.


    Using the edit command

    When you change pick to edit in the interactive instructions, this tells the rebase code that, after successfully copying the commit, it should pause, much as it would after a failed copy that leaves conflicts in the index. But this stops after the successful copy, so the state is different:

    • You're still in detached HEAD state, but the current commit is the copy Git just made.
    • The rebase command has terminated, but has left state behind as usual and expects you to run git rebase --continue so that it can read the saved state and continue.

    If you want to make changes to the copy that Git just made, you can do so:

    • Update your working tree as desired and/or use git reset and/or git add and/or any other Git commands you like, to update your index.
    • Run git commit --amend. This shoves the current (HEAD) commit aside, making instead a new commit whose parent(s) is/are the parent(s) of the current commit. HEAD then becomes this new commit. Since you're in detached HEAD state, the previously-current commit's hash ID is only to be found in the HEAD reflog.

    You can use this to "split" a commit that has too much stuff in it. For instance, suppose you made a commit that fixes two bugs, but you realize afterwards that it would be better to make two separate commits. It's no longer the most recent commit, where you updated the documentation. So, you can run git rebase -i HEAD~2 to redo the last two commits. Change the first one from pick to edit and write out the instruction sheet. Then:

    # HEAD commit fixed two bugs, one in file-one.py and one in file-two.py.
    # Get file-two.py from HEAD~ into the index, leaving the fix in the
    # working tree.
    git restore --source=HEAD~ file-two.py -S
    git commit --amend --edit
    

    and fix up the commit message to say that we are fixing just the one file, then:

    git add file-two.py
    git commit
    

    and write a new commit message about fixing just this one file. Then:

    git rebase --continue
    

    to copy the remaining commit with the documentation update.

    Conclusion

    The things to keep in mind are:

    • Rebase works, fundamentally, by copying old commits (old and lousy?) to new (new and improved?) ones.
    • The usual way to copy one commit is to use git cherry-pick, so that's what rebase does now. Another way is git format-patch ... | git am, so the old rebase did that; this method has the advantage of being able to copy multiple commits, but the disadvantage of not being able to copy an "empty" (no-diff) commit.
    • The way we find commits is by starting from a branch name, then working backwards, so, having copied some commits to new-and-improved ones, we need to have Git move a branch name. Rebase therefore ends by moving the branch name—the name to move being the branch we were on when we started. Internally, rebase used detached-HEAD mode.
    • Any time any rebase stops, it's in this internal detached-HEAD mode. Your job is to fix up whatever needs fixing up, then continue the rebase.

    The reason for the work stoppage determines the precise state: it's always detached-HEAD, but sometimes it's in the middle of a conflict, and sometimes it's because you said edit. If it's in the middle of a conflict, the copy that was supposed to happen is also still in progress, so there's no actual copy yet; if not, the copy did happen, and then rebase stopped.