Git Collaboration and Code-Review Workflow For a Complicated Merge

Have you ever started a merge, then realized you're in over your head and should get more eyes on it? Merges can be ugly, and complicated enough to warrant: collaboration on a feature branch; a pull request; and a proper code review. Conflicts often arise because of changes made by other developers on your team, and you should feel confident enough about your source-control tools to know that you can involve those other developers in the merge process. Don't rush through conflict resolution, potentially squashing other people's changes, just because you don't know how to get all your local edits into a place where others can see them and give you the help you need!

The natural work-flow here typically goes something like this: you start the merge; you see a bunch of conflicts; you wade into the mess and start resolving conflicts; you realize you need help. At this point, you have a merge-in-progress on your local computer. How do you get all those local changes into a state where others can help you sort out the mess?

One approach that seems to make perfect sense is to branch what you have, pull in colleagues on the newly created merge-review branch, and then merge that merge-review branch into the original target branch. At that point, git should be smart enough to know that the original source branch was merged into the target branch... right?

Not so. And you'll find out, the hard way, the next time you try to merge from the source branch to the target branch. All the old conflicts, which you already resolved, will crop up again! What went wrong?

The key is that git doesn't try to be smart about tracing a merge through an intermediate branch. It's up to you to tell git that the target branch is now up-to-date with the original source branch, and this is done by pushing a merge-commit with one parent in the original source branch and another in the target branch. That merge-commit is what tells git when the source branch was last merged to the target branch. Until that merge-commit is in place, merges from source to target will include all the changes that were in the original merge that you spent so much time resolving.

In a nutshell, here's a solid procedure for getting a complicated merge onto its own branch, where it's open to collaboration, and then dealing with the subsequent merges necessary to complete the original messy merge. (Remember that the source branch is the one with the commits that you're trying to merge to the target branch.)

  1. Note the HEAD commit of the source branch; we'll call it source-commit (while on the source branch, you can use git log --oneline --max-count=1 HEAD to see the HEAD commit)
  2. Create a feature branch off the target branch; we'll call it the merge-review branch
  3. Merge from the source branch to the merge-review branch (not the target branch)
  4. Resolve conflicts on the merge-review branch and push your commits
  5. Post a pull-request to merge the merge-review branch into the target branch
  6. Once the pull-request has been approved, and the merge-review branch has been merged into the target branch, issue the following git commands to inform git that the source branch has been merged to the target branch (by way of the merge-review branch, but git doesn't need to know that), up through the source-commit:
git checkout <target branch>
git merge -s ours <source-commit>
git push

The key is that special merge command. With -s ours, you're telling git to completely ignore the contents of the source branch, and trust that what's on the target branch is already correct, up through the source commit (because you already executed the merge, through a separate merge-review branch). Specifying the source-commit is essential, because other people may have made subsequent commits on the source branch. If you leave off the source-commit, that implies you want to merge the source branch into the target branch as of the source branch's HEAD commit... and ignore all the stuff that's changed on the source branch, which may include commits made after you started the big messy merge. This will cause future merges to completely ignore the subsequent commits on the source branch; the next time someone merges from source -> target, git will look at the most recent merge-commit that has parents in the source and target branches, and it will incorporate all the commits made to the source branch after that point. By specifying source-commit in your special merge -s ours commit, you're telling git that any changes made after that commit have yet to be merged.

But earlier on, when I described the natural workflow, I mentioned that you typically don't know you're going to need help until you've been struggling with the merge conflicts for a while. What if you didn't note the HEAD commit at the time you started that merge?

If you already created the merge-review branch, while you were in the midst of the merge-conflict state, then the merge-in-progress was lost. You'll have to go through the logs of the source branch to figure out which commit was HEAD at the time you started the merge. To be safe, you should also check to see if specific file changes from the source branch also exist on the target branch as well; if not, then you know you need to look further back in the logs of the source branch.

What if you haven't branched yet? You're still in the merge-in-progress state, you've realized you should create a merge-review branch, and you want to be see which commits are included in the merge you're currently performing... does git offer a command to help with this? Yes! You can use this handy command to get details about the commits that are part of your merge-in-progress:

git log --oneline --left-right HEAD...MERGE_HEAD

(HEAD...MERGE_HEAD should be typed verbatim, with three dots. These commit aliases, HEAD and MERGE_HEAD define the range of commits that were pulled in via your merge and will be stacked on top of the old HEAD of your target branch if you commit the merge, at which point MERGE_HEAD becomes the new HEAD.)

Using the output from that command, you can identify the most recent commit on the source branch (the source-commit), make a note of it, and then create the merge-review branch.

Hope this helps! The key for me was understanding that git needs a merge-commit with a parent in the original source branch. Until that's there, it will think it needs to re-merge all the changes that you already dealt with in your merge-review branch. There may be a way to use an octopus merge when merging the merge-review branch to the target branch, so you can skip the second merge, but my git fu isn't there yet.

Note: git merge -s ours is NOT the same as git merge -X ours. The former, which is the one you want to use here, says "I don't care what's on the source branch; I want to use what I already have on the target branch". The latter, which is the same as git merge --strategy-option ours, tells git to perform the merge using the standard "recursive" merge strategy, but where there's a conflict, use the copy of the file that's on the target branch. In cases where changes can be applied from the source branch to the target branch without conflict, these changes will be applied, which is not what you want, since you already completed the entire merge via the merge-review branch.

Here's some more info on reviewing merge commits in git, from You've Been Haacked.


comments powered by Disqus