A few days ago, I stumbled upon a thread talking about the trade-offs of squash-merging a PR VS creating a merge commit. One of the topics of discussion was whether or not reverting merge commits was possible. Here’s the thread in question:
There seemed to be some confusion surrounding the subject which isn't surprising. Indeed, while reverting a merge commit is definitely possible, it can have unintended consequences that are a pain to deal with. Specifically, once you’ve reverted the merge, re-introducing the changes from your PR is not straightforward. Having struggled with this a few times myself, I thought I’d do a small write-up to clear things up!
By the end of this post, you should know how to revert merge commits, and more importantly, how to re-introduce your changes once you’re done with your fix. This assumes basic knowledge of git.
Let’s dive in!
What’s in a merge commit
First things first, we need to clarify a few things about merge commits, this will be important later on. When you’re merging a branch, say a feature branch into master, git does basically 3 things :
- Determine the closest common ancestor of the two branches. This allows git to compute the diff between the two branches, and detect eventual conflicts.
- Create a commit containing the changes from both branches. This is the merge commit
- Add the last commits of the branches you’re merging as ancestors to the merge commit. This means every merge commit has two ancestors.
Once the merging is done, you’ll end up with something like this:
So far so good. But now let’s say you missed something. Something big. The buggy-feature
branch above unexpectedly introduces a critical bug in production.
Ideally, you would be able to quickly narrow down the issue to one commit and deploy a hotfix to resolve the issue. However sometimes you simply cannot pinpoint which changes broke something, and you can’t afford to let the bug sit in production much longer.
In this sort of situation, you might want to revert the merge and sort out the rest later. Let’s see how to do that!
Reverting the merge commit
First of all, you can definitely revert a merge commit. It just works a bit differently than a regular commit. Indeed, if you try to revert it using git revert 181c8d1
you’ll be greeted by the following error message:
The revert failed, and what’s this “-m option” stuff about?
Well, remember, merge commits have two ancestors. Thus, git must know which ancestor it should use as a reference to compute the diffs you want to remove. The content of the revert commit will differ depending on which ancestor you’re reverting to. And that’s what the -m
option is for. Let’s see how it works.
The -m
option takes the index of the ancestor you want to use as a reference. It can be either 1
or 2
. Most of the time, if you’re reverting a merge commit from a PR into main, you want to revert to the previous main commit which means you’ll want -m 1
.
If you want to be sure tho, just use git show
on the merge commit:
Notice the “Merge:” line. It indicates both ancestors. When you pass -m 1
or -m 2
to the revert command, it tells git to use the first or second ancestor as listed on this line.
Now you’ve reverted the merge, and production is working again. You can breathe. Seriously just take the time to breathe, it does wonder for your health. Not breathing is strongly discouraged by most medical practitioners.
This is not over though. Once you’ve determined what’s wrong with your branch, you’ll probably want to add a commit to fix it and re-merge it into the master. And this is where things start to get hairy.
Re-introducing the feature after the fix
Why re-merging doesn’t work
Now with production up, you had time to figure out what's wrong with your branch. You’ve added a commit to fix that, and you’re ready to re-merge the branch. If you do so, you should end up with the following git history :
This looks fine at first, but you’ll probably quickly notice something is wrong again and might even create a new bug. Why is that? Well if you inspect the content of the master branch, you’ll discover that the content of the commits prior to the fix, Good Commit #1
and Buggy Commit #1
is missing. This is probably not what you want.
To understand why the commits are missing, we need to discuss what git does when you revert a merge commit :
- First, it looks at the difference between the merge commit and the ancestor you provide using the
-m
option - then, it inverts the diff and commits the result. This is the “revert” commit.
What it doesn’t do, however, is delete the meta-data that indicates that the feature branch has already been merged. In the example above, this means, that Buggy
commit is still an ancestor of the first merge commits. Which means that when you re-merge your branch, the following things happen:
- Git determines the closest common ancestor. Since the first merge is still in history, the closest common ancestor here is
Buggy Commit
. - Git determines the difference. In this case, it creates a diff between the
Revert commit
andBuggy Commit
. - Git creates the merge commits, with two ancestors.
Good Commit #1
(and Buggy Commit #1
) are part of the master branch history now. They cannot be included again.
What can you do then? Well, the git documentation isn’t really optimistic about the subject …
Reverting a merge commit declares that you will never want the tree changes brought in by the merge
… but there are solutions! Let’s look at a few of them!
How to “re-merge” your PR
Re-merging directly doesn’t do the trick, so we’ll have to be clever to force git to accept the changes from the branch. To do so they are a few alternatives
Reverting the revert
This is the most straightforward way to do it. To make your changes part of the master again, you can simply revert the revert commit. This will make it so the initial revert never happened, with both reverts canceling each other out. This might sound a bit confusing, so here’s what it looks like with our example :
Once you’ve done that you can simply merge the fix you’ve made to your PR and be done
Be careful tho, between the instant you revert the revert and the moment you merge the fix, the bug will be re-introduced. So if you’re using some kind of automation to auto-deploy pushes made to the main branch, be sure to perform this operation, before deploying. Otherwise, you risk re-deploying the bug.
This method is described in more detail in the official git how-to. (The not-so-obvious location of this doc might explain some of the confusion around this subject)
Cherry-Picking and Rebasing
Another alternative is starting a new branch from the main, and cherry pick the commit from your previous branch, including the fix. This will change the hash of the commit, so it won’t detect them in the branch history. (This also works using rebase --on-to
, but not rebase
alone as it will automatically use the closes common ancestor, which again will by “Buggy Commit #1”). If all goes well you should end up with something like this.
The commits from “buggy-feature” are duplicated with a different hash and can be “re-merged” into master.
Conclusion
Hope this clears things up! I think the main thing to take away here is that reverting a merge commit is very painful. Even tho they are alternatives, you might still end up with a lot of conflict resolution, especially if other people are working on the same repo. You could always ask your coworkers to stop working for a bit while you’re sorting this out, but this isn’t ideal either, especially on a big project.
What should you do then? Well, Linus explains it best:
If you find a problem that got merged into the main tree, rather than revert the merge, try really hard to bisect the problem down into the branch you merged, and just fix it, or try to revert the individual commit that caused it.