How to revert a merge in git

2022-12-04
#howto

Note: this post assumes (1) you just did a squash merge (aka single-commit merge) and (2) you did not force-rebase beforehand.

If you're practicing continuous integration on your team, you likely run into broken merges all the time. When that happens, you want to be able to quickly revert back to a working commit, but how does one revert a merge commit?

It took me a while to find some good explanations online, so here's my two cents.

TLDR

  1. Run git revert -m 1 <merge-commit>
  2. Fix the problem on a new branch

Explanation

The -m 1 is necessary when reverting a merge commit, since it has two parents. This chooses to revert back to the previous commit on the first parent, the branch where you ran git merge from, which is usually the "trunk" (e.g. main).

Now, why don't we just keep working off the old branch?

While tempting, adding commits to the old branch can cause confusion if you plan on re-merging it later. This is because the reverting a merge will only undo the data changes introduced by that merge—it does not “unmerge” the branches in history.

In other words, git will still think the two branches were merged at that commit, so any previous changes in the old branch will be ignored if you try to re-merge it.

A simple example

Consider the picture below. You just merged your code from branch into main, but the merge caused something to break. So now you want to undo it.

Before:

                 v HEAD (good)
main   -- A ---- B
            \
branch        C --- D

After:

                        v HEAD (broken)
main   -- A ---- B ---- M
            \         /
              C --- D

You know that commit B was working fine, so you quickly revert the merge:

git revert -m 1 HEAD
                               v HEAD (good)
main   -- A ---- B ---- M ---- B'
            \         /
              C --- D

Great! You're back to a safe place. The main branch moves up to B', which is the same code as B. Now you go off to debug M.

Once you've figured out what caused it to break, you want to try and merge branch back into main, with the fix added on top. What do you do?

Re-merging your work

Let's say you did try to re-merge your work on the old branch. You found the problem and added a fix, commit E:

-- A ---- B ---- M ---- B'
     \         /
       C --- D ------------ E
                            ^ fixes the problem (probably)

It's tempting to just merge the old branch again, but if you do, your merge will only include the changes in E. It will not include C and D.

That's probably not what you want. You want to include your previous work and the new fix, all in one merge.

Here are some options instead.

Option 1: work off of the revert

This is the safest option.

Start with the reverted code at B' and add on your work from the previous branch.

This is similar to rebasing, but you don't destroy any history.

Here's what it looks like in our simple example, replaying the changes from branch in single-commit steps:

git checkout main # at B'
git checkout -b fix # switch to new branch
git cherry-pick C # creates C'
# test, make sure it works
git cherry-pick D # creates D'
# test again... woops, it broke!
# fix it, then commit D2
-- A ---- B ---- M ---- B'
     \         /          \
       C --- D              C' --- D2
             ^ branch               ^ fix (D had the bug)

In the example above, we started at the revert commit, which is B'. Then we start adding our previous work in small steps: first C, then D, etc. This way the bug is easier to narrow down.

If branch had a lot of commits, you might find it easier to replay all the commits at once and run git bisect instead:

git checkout main # at B'
git checkout -b fix # switch to new branch
# pick up everything from the old branch
git cherry-pick A..D
# find the bug
git bisect start
git bisect good main
git bisect bad fix
# etc etc

Gotta love git bisect.

Option 2: force rebase

If you're just doing this locally, it might be easier to rewrite history. In that case, you can simply force the old branch to start at A again, effectively "undoing" the merge.

git checkout branch
git rebase -f A
main   -- A ---- B ---- M ---- B'
          | \         /
          |   C --- D
branch    C' ---- D'
                  ^ HEAD

You can see the old branch was revived with a new set of commits, identical in data to the old branch.

Option 3: hard reset

When all else fails, you can always hard reset main back to before the merge:

git checkout main
git reset --hard B
                 v HEAD
main   -- A ---- B
            \ 
branch        C --- D

This one is nice because it takes you back to a simpler time, before all this merge revert nonsense!

But be careful when using these last two options on a public branch, as other people might be relying on that history for their own work.

References