Skip to content
// blog

How to revert a merge commit (and re-merge the branch later)

theBGuy 8 min read
On this page

The notification feature merged on Friday. By Monday morning it has been emailing customers on every status poll instead of every status change, and your support inbox is paying for it. The merge sits on main, and main is pushed: teammates have pulled it, CI has built it. Deleting the merge from history would mean rewriting a branch other people stand on, which is a worse incident than the one you have. (Caught before anyone pulled it, a local merge can simply be reset away; this one is published.)

Git’s undo for published history moves forward instead: git revert writes a new commit that does the opposite of an old one. Nothing already recorded changes, so there is nothing for your teammates to trip over. Here is the history as Monday finds it:

$ git log --oneline --graph
*   e1c284b Merge branch 'feature-notify'
|\  
| * 413cd96 Send order status emails
* | a137798 Lower request timeout
|/  
* 9ca49dd Initial order flow

So: revert the merge at the top.

$ git revert e1c284b
error: commit e1c284b384600dfae455e4076fc55cea987de0e1 is a merge but no -m option was given.
fatal: revert failed

Reverting an ordinary commit never asks a question like this. An ordinary commit has one parent, so “undo what it changed” has one meaning: the difference between the commit and that parent, applied in reverse. A merge commit has at least two, and the difference depends on which one you measure from. Git refuses to pick for you.

One commit, two parents

$ git log -1 e1c284b
commit e1c284b384600dfae455e4076fc55cea987de0e1
Merge: a137798 413cd96
Author: Dev <dev@example.com>
Date:   Fri Sep 11 16:40:00 2026 -0400

    Merge branch 'feature-notify'

The Merge: line names the parents in order. a137798 is parent 1: where HEAD stood when you ran git merge, the tip of main just before the feature came in. 413cd96 is parent 2, the tip of the branch you merged. A forge’s merge button assigns the same roles, since it merges your branch into the target: the target branch’s tip is parent 1 there too. (A squash or rebase merge is different: it writes no merge commit, its single-parent commits revert without any -m, and because the branch’s own commits never entered main’s ancestry, the re-merge trap below is not yours either.)

-m answers Git’s question by number. git revert -m 1 says: treat parent 1 as the line to keep, and undo what the merge brought in from the other side — everything that distinguishes the merge’s tree from that parent, hand-made conflict resolutions included. git diff e1c284b^1 e1c284b previews what the revert will take out. On main, -m 1 is almost always the answer.

-m 2 is the other answer, and on main it backfires: it keeps the branch’s side and undoes what came in from parent 1, stripping out your own mainline’s work since the two lines split. In this history it would put the timeout back to its old value and leave the notification code in place, the exact opposite of the goal. (Already ran it? That bad revert is an ordinary commit — revert it and the tree is back where you started.)

What -m 1 undoes

Revert refuses to start if the index is dirty or the files it will touch carry unstaged edits, so commit or stash anything in flight first (and git stash pop once the revert lands). Then:

$ git revert --no-edit -m 1 e1c284b
[main 1141f0a] Revert "Merge branch 'feature-notify'"
 Date: Mon Sep 14 09:12:00 2026 -0400
 2 files changed, 1 insertion(+), 9 deletions(-)
 delete mode 100644 notify.js

One new commit, and the merge’s contribution is gone: the file the branch added is deleted outright, the file it edited is restored. What the revert left alone is just as deliberate:

$ cat app.js
export function placeOrder(cart) {
  return submit(cart);
}
$ cat config.js
export const TIMEOUT_MS = 3000;

app.js is back to calling submit directly, and config.js still carries the timeout work main did on its own after the branches split. Reverting with -m 1 undoes one side of one merge and leaves everything around it alone. And since it is ordinary new history, you can push it to the shared branch without ceremony.

A revert can also conflict, exactly like a merge, when later commits touch the lines it is taking out. It pauses the repository mid-operation (“You are currently reverting commit …” in git status) and ends with git revert --continue or git revert --abort. (There is also --skip; with a single commit being reverted it is just --abort by another name.)

The inbox calms down. The duplicates stop. Weeks pass.

Already up to date

The notification feature is wanted again, batched behind a digest this time. The branch still exists, so the first instinct is to merge it back:

$ git merge feature-notify
Already up to date.

Git is answering a different question than the one you asked. Merge decides what to bring over by ancestry: the commits reachable from the branch but not from main. And 413cd96 is reachable from main, through the merge you reverted. The revert undid that merge’s changes; it did not un-happen the merge. As far as ancestry is concerned, the notification commit already arrived, and there is nothing left to bring.

Half a feature arrives

It gets quieter than that. The branch’s author keeps working and adds a digest helper, one new commit touching one new file. Time to merge once more:

$ git merge --no-edit feature-notify
Merge made by the 'ort' strategy.
 digest.js | 3 +++
 1 file changed, 3 insertions(+)
 create mode 100644 digest.js

A clean merge, no conflicts. Look at what it built:

$ ls
app.js
config.js
digest.js
$ cat app.js
export function placeOrder(cart) {
  return submit(cart);
}

The new commit came over; nothing the revert removed came back. notify.js is still missing and placeOrder still calls submit bare, so main now holds a digest helper for a notification feature that is not there. A re-merge brings only the commits added since the first merge. Everything the revert took out stays out, and no conflict warns you about any of it. (If the branch’s new commits had edited notify.js itself, you would have hit a modify/delete conflict instead — louder, but the same cause.)

Reverting the revert

The changes need a way back in, and ancestry has closed the normal door. Two moves work; one popular one does not.

The move that keeps the story in history: revert the revert.

$ git revert --no-edit 1141f0a
[main 32151ae] Reapply "Merge branch 'feature-notify'"
 Date: Fri Oct 2 15:25:00 2026 -0400
 2 files changed, 9 insertions(+), 1 deletion(-)
 create mode 100644 notify.js

Git titles it for you: since 2.43, a revert of a revert is a Reapply (older releases stack Revert "Revert …" instead). The tree is whole again:

$ cat app.js
import { sendStatusEmail } from "./notify.js";

export function placeOrder(cart) {
  const order = submit(cart);
  sendStatusEmail(order);
  return order;
}
$ ls
app.js
config.js
digest.js
notify.js

And the log now reads as what actually happened: merged, reverted, re-merged for the digest, reapplied.

$ git log --oneline --graph
* 32151ae Reapply "Merge branch 'feature-notify'"
*   b2cde5f Merge branch 'feature-notify'
|\  
| * 26bf5d2 Add a daily digest option
* | 1141f0a Revert "Merge branch 'feature-notify'"
* | e1c284b Merge branch 'feature-notify'
|\| 
| * 413cd96 Send order status emails
* | a137798 Lower request timeout
|/  
* 9ca49dd Initial order flow

The order is flexible here: reverting the revert first and merging the branch after lands the same tree. What a plain merge cannot do is resurrect content ancestry already counts as delivered; the reapply commit is what puts it back.

The second move that works is giving the changes a new identity. git cherry-pick 413cd96 onto a fresh branch copies the change as a new commit with a new hash. Ancestry has never seen that hash, so it merges like any other new work. A branch that carried several commits needs the whole run copied (git cherry-pick <oldest>^..<tip>), or the commits you skip stay exactly as gone as they were.

The move that does not work is the one that looks most natural: rebase the branch and merge again. git rebase main selects what to replay with the same ancestry test the merge used, commits reachable from the branch but not from main. That set is empty here, so the rebase has nothing to replay and simply fast-forwards the branch. You get the same “Already up to date” you started with.

Or don’t do any of this

A revert that conflicts pauses the whole repository, and that pause is the part GitDesktop is built to pick up. It reads the paused state from the repository itself (the same REVERT_HEAD and sequencer records git status reads), so it does not need to have started the operation to manage it. Run your -m 1 revert in whatever terminal you like; if it stops on conflicts, the Changes view grows a warning strip reading “Reverting · 2 conflicts” and counts down as you resolve, “Continue revert” stays unavailable until every conflict is resolved and staged, and Abort sits behind a confirm that spells out exactly what it abandons. Because Git records which operation is paused, the strip calls a revert a revert rather than a generic conflict.

For ordinary commits the app starts the revert itself: right-click a commit in History, “Revert changes in commit”, and a confirm that is this post in one sentence — “history keeps both and nothing already recorded is rewritten.” A merge commit keeps you in the terminal with -m, and the strip meets you there if it goes sideways.

Undo, in Git, is more history. The next merge will read all of it.

Written while building GitDesktop.

A desktop Git client for GitHub, GitLab and Bitbucket — free and open source, with AI you control or hide entirely.