DEV Community

Cover image for GitHub Told Me I Had Merge Conflicts. Git Told Me I Didn't. They Were Both Right.
Rafael Costa
Rafael Costa

Posted on

GitHub Told Me I Had Merge Conflicts. Git Told Me I Didn't. They Were Both Right.

Last month I tried to merge main into stg. Routine sync. GitHub said: "Can't automatically merge". So I ran the same merge locally... and got a clean merge. Zero conflicts.

Same branches. Same commits. Different answer. I've been writing software for years and I genuinely did not know this could happen.

What followed was the kind of debugging session I recognize from physics more than from software: tracing a failure back through layers of structure until you hit the actual constraint that's doing the damage. Except the system wasn't a quantum lattice. It was git's commit graph. And the constraint wasn't an obvious one.

If you've ever been surprised by a merge conflict, or wondered how git's merge-base works, or just want to understand how branch flow design can create weird graph topologies, welcome to the story of twelve merge bases, a diamond-shaped DAG, and the fix that shouldn't have worked.

A quick crash course (the parts that matter)

If you already think in terms of DAGs and merge bases, skip to "Twelve Ancestors." If not, we'll cover three core concepts, and the rest of this article follows from them.

Concept 1: commits are snapshots with parent pointers.

A ← B ← C ← D
Enter fullscreen mode Exit fullscreen mode

Each commit stores a complete snapshot of your files and a pointer back to its parent. That's it. The whole history is a chain of these. Computer scientists call the resulting structure a directed acyclic graph - a DAG. Directed because pointers go one way. Acyclic because you can never follow them in a circle. Every problem I'm about to describe is a property of this graph.

A branch is just a sticky note pointing to a commit. main points to D. Branches are labels, not containers.

Concept 2: merges need a common ancestor.

Consider what happens when you branch off and both sides get new commits. Say we're creating a feature branch from main:

       ← E ← F     ← feature
      /
A ← B ← C ← D       ← main
Enter fullscreen mode Exit fullscreen mode

B is the last commit reachable by walking back parent pointers from both branch tips. Git calls this the merge base — "common" always means "common to the two branches being merged." That definition is load-bearing for everything below.

To merge, git diffs base → main and base → feature, then combines both diffs; this is a 3-way merge of two branch tips and one common ancestor:

        ← E ← F ──╮
       /           M  ← main
A ← B ← C ← D ────╯
Enter fullscreen mode Exit fullscreen mode

If both diffs touch the same line differently, that's a conflict. Everything else merges automatically.

You should notice that M, our merge commit, has two parents: D (main's old tip) and F (feature's tip). This is contrary to a regular commit which has a single parent. That means M is a descendant of both branches that were merged. This seems innocuous, but it's the single property that makes me writing this piece possible everything below work. It's really how ancestry flows between branches, why sequential merges stay clean, and why the diamond requires concurrency. One mechanism, three consequences.

Concept 3: git needs the newest common ancestor.

If git picks an old ancestor as the base, both diffs include changes the branches already agree on. False conflicts everywhere. The newest ancestor minimizes the diff — only what actually diverged shows up.

But what happens when there isn't a single newest?

Twelve Ancestors

Back to my problem.

git merge-base --all origin/main origin/stg | wc -l
12
Enter fullscreen mode Exit fullscreen mode

Twelve merge bases, not one.

When git finds multiple incomparable bases - none descending from any other - it can't just pick one. Its fallback: recursively merge them together into a synthetic virtual ancestor, then use that as the single base for the real merge. Not a real commit in your history, though. It's a temporary in-memory artifact. With twelve bases, that's a cascade of merges-within-merges before the intended one even starts.

Local git merged it cleanly. GitHub's server-side mergeability check didn't.

That was the first surprise. "GitHub runs the same merge I run locally" is close enough for everyday work, but not literally true. GitHub computes PR mergeability in the background using a test merge commit, and historically its server-side behavior diverged from local git often enough that GitHub migrated merges and rebases to merge-ort in 2023. In our case, the important fact wasn't the exact internal path — it was that the server-side check surfaced a graph-topology problem that local git could still resolve.

But twelve merge bases is not normal. Where did they come from?

How twelve diamonds form

Our branch flow had recently evolved into this:

main → working-branch           (branches always fork from main)
       working-branch → stg     (QA)
       working-branch → releases → main   (deploy)
Enter fullscreen mode Exit fullscreen mode

stg is a dead end, a parallel validation lane with nothing flowing out of it. Clean, one-directional pipeline. Working branches are always born from main, so their fork points sit on main's history.

Except two things used to happen that broke the one-directional rule.

First: we merged main back into stg to "stay in sync." Second - and this was the invisible one - developers (not me, pff, of course, cough cough) occasionally ran git merge origin/stg on their working branches to grab something from staging. That branch then shipped through releases into main, carrying stg-only ancestry with it.

The first puts main-only commits into stg's history. The second puts stg-only commits into main's ancestry. Bidirectional flow from intermediate branch states. When two branches each absorb the other's history like that, git calls it a criss-cross merge.

The minimal version

Strip away the branch flow. Two branches, two merges, one diamond:

Start:   main at M, stg at S (diverged from ancestor A)

Person 1: git checkout main && git merge origin/stg
          → M' (parents: M, S)

Person 2: git checkout stg && git merge origin/main
          → S' (parents: S, M)

(Both fetched before either pushed.)
Enter fullscreen mode Exit fullscreen mode

Now trace the common ancestors of M' and S':

  • M is reachable from M' (direct parent). Reachable from S' too (S' has M as its second parent, a cross-merge). Common ancestor.
  • S is reachable from S' (direct parent). Reachable from M' as well (M' has S as its second parent, the other cross-merge). Common ancestor.
  • M does not descend from S. S does not descend from M.
       M
      ╱ ╲
   S'    M'
      ╲ ╱
       S
Enter fullscreen mode Exit fullscreen mode

Two bases → diamond. That's it (;

The key: both merges see the other branch's pre-merge state. If Person 2 had fetched after Person 1 pushed, S' would descend from M' — single base, no diamond. Concurrency is the crucial ingredient.

Your first instinct might be: can't you create this with sequential direct merges? main→stg, then stg→main, then main→stg? No, because of the two-parent property from the crash course. Each merge commit descends from both tips. So when you merge stg→main, main's new tip descends from stg's current state. The next merge (main→stg) sees that result (a commit that already contains stg's history) and there's a single dominant ancestor. Always, no matter how many times you alternate.

How our branch flow produced this concurrency

In practice, nobody on our team was simultaneously merging in both directions. The working branch indirection turned sequential actions, spread across days or weeks, into graph-concurrent events. Here's the mechanism.

Start with stg at state S and main at state M. Both have diverged from their last common point, that is, neither descends from the other "in the near past". For all purposes now, they sit on parallel paths.

The contamination. A developer on branch-B runs git merge origin/stg to pull something from staging. That merge commit has two parents: branch-B's old tip and S. The second parent is the door: stg's entire history is now reachable from branch-B by walking that parent pointer. Branch-B now carries S in its ancestry.

    stg:   ─────── S ──────────────
                    │
              (git merge origin/stg)
                    │
    main:  ─── M ────── branch-B ──
Enter fullscreen mode Exit fullscreen mode

The backflow. Before branch-B ships, someone merges main → stg to "stay current." Same mechanism, opposite direction: BF's two parents are stg's old tip and M. Once more, second parent paves the way to disaster: main's history is now reachable from stg.

    stg:   ─── S ──────── BF
                          ╱
                   (main → stg)
                        ╱
    main:  ─── M ────────── branch-B (still developing)
Enter fullscreen mode Exit fullscreen mode

The carrier ships. Branch-B goes through releases into main.

    stg:   ─── S ──────── BF ─────── ...
                │          ╱
          (via branch-B)  (backflow)
                │        ╱
    main:  ─── M ────── MX ─────── ...
Enter fullscreen mode Exit fullscreen mode

MX descends from both M and S (through branch-B's merge of stg). The two-parent property created the bidirectional flow, and it's about to create the diamond too.

Now trace the common ancestors of stg and main:

  • M is reachable from stg. How? BF is a merge commit, its two parents are S and M. Walk stg's ancestry back to BF, then follow BF's second parent to M. That's the backflow's two-parent link doing the work. M is also reachable from main directly. Common ancestor.
  • S is reachable from main. How? MX descends from branch-B, and branch-B merged stg, again two parents, one of which is S. Walk main's ancestry back to MX → branch-B → S. That's the contamination's two-parent link. S is also reachable from stg directly. Common ancestor.

But M does not descend from S. And S does not descend from M. They're on parallel paths: M on main's history, S on stg's history.

None of this matters until someone tries to merge stg and main. That's the triggering event — git needs a single merge base, runs merge-base, and hits both S and M. Remember the filtering rule from the crash course: git only keeps the youngest common ancestors, dropping any that have a younger common ancestor descending from them. But neither S nor M can filter the other out, because neither descends from the other. So both survive, leaving Git stuck with two incomparable bases. It now has to fall back and recursively synthesize a virtual ancestor from S and M.

        M (reached via backflow)
       ╱  ╲
    stg    main
       ╲  ╱
        S (reached via branch-B)
Enter fullscreen mode Exit fullscreen mode

Two paths diverge and reconverge. Same diamond as the toy example, different wiring. The working branch captured stg's state weeks before the backflow captured main's state. Wall-clock sequential. But in the graph, each cross-merge referenced the other branch's pre-merge state. The branch indirection turned sequential actions into the same concurrent topology as two people merging "at the same time".

Each cycle, of course, adds more diamonds: a new backflow that references main before the latest carrier ships, or some new working branch that merged stg before the latest backflow... With six backflows over a few months, you can get twelve merge bases with none dominating the others.

In physics you'd call this a degeneracy — multiple states at the same energy level, no symmetry-breaking mechanism to select one. The DAG had the same problem: twelve ancestors at the same "depth," no easy descendancy relationship to break the tie.

A natural question if your team has a similar branch flow: does merging many feature branches into both stg and main create this problem? No. If branches fork from main and merge into both sides without pulling from stg first, the common ancestors are all fork points on main's linear history. Linear means each one descends from the last. Clear ordering, always one merge base.

    stg:   ── M₁ ───── M₂ ───── M₃ ───── M₄
             ╱         ╱         ╱         ╱
          feat-A    feat-B    feat-C    feat-D
           ╱         ╱         ╱         ╱
    main:  F₁ ── M₅ ── F₂ ── M₆ ── F₃ ── M₇ ── F₄ ── M₈

    Every feature forks from main and merges into both stg and main.
    Fork points are on main's history — each descends from the last.
    merge-base(stg, main) always has a single youngest. No diamonds.
Enter fullscreen mode Exit fullscreen mode

The diamond requires two ingredients: stg-only ancestry entering main (via a working branch that merged stg) plus main-only ancestry entering stg (via a backflow). And critically, each has to happen before the other's result is visible, so they reference mutual past states instead of the merged result. No bidirectional flow, no diamonds.

Why "just pick the newest" doesn't work

My first instinct: why doesn't git use timestamps to pick the most recent?

Because "newest" requires a total ordering, and the diamond creates commits that are incomparable. M1 was created February 25. M2 was created February 28. Neither descends from the other. They sit on parallel paths connected at the top and bottom of the diamond, but not to each other.

Git can only order commits along parent-child chains. Across parallel paths, there's no ordering. Asking "which is newer?" is like asking which is taller, the color blue or a Tuesday.

What git actually does

It doesn't pick a winner. It synthesizes one.

When merge-base returns S and M as incomparable youngest ancestors, git's ort strategy merges S and M into a virtual commit V, and to do that, it needs their common ancestor. Remember, S and M do share ancestry - the old fork point where stg was born from main, the one that was filtered out of the top-level search because S and M are younger. That older ancestor comes back one level down as the base for merging S against M.

So the cascade is:

  1. Top-level merge (stg into main): finds twelve youngest common ancestors, all incomparable.
  2. Recursive resolution: merge them pairwise. Each pairwise merge needs a base, and that base is an older ancestor that was "too old" for the top-level merge but exactly right for resolving the conflict between two of the twelve.
  3. At that lower level, the older ancestor is usually unambiguous: it predates the diamond-creating merges, so there's a single youngest. The recursive merge succeeds and produces a virtual result.
  4. V becomes the synthetic base for the real merge.

With twelve bases, this is why the operation is expensive! It's not twelve straightforward comparisons, but a cascade of merges-within-merges, each needing its own base resolution. Local git's ort handled that depth. GitHub's server-side path didn't.

The fix that shouldn't work

Here's where it got counterintuitive. The fix for twelve merge bases is... yet another merge!

git checkout stg
git merge origin/main
git push origin stg
Enter fullscreen mode Exit fullscreen mode

Wait: wasn't a main → stg merge the thing that caused this? Shouldn't this make it worse?

That was my reaction too. But look at the graph:

BEFORE: 12 merge bases, none dominating the others

    stg ──────────── ...          main ──────────── ...
         ╲        ╱                    ╲        ╱
         MB₁    MB₂                  MB₃    MB₄  ... MB₁₂
         (none is a descendant of any other — all 12 are "latest")


AFTER: git merge origin/main

               stg (pointer moves here)
                ↓
    ── ── ──── M (new merge commit)
              ╱ ╲
    (stg's      main-tip  ← this is now THE single merge base
   old head)       │
                (descendant of all 12 old MBs)
Enter fullscreen mode Exit fullscreen mode

Remember: a branch is just a pointer to a commit. The merge created M and moved stg to point at it. M has two parents: the commit stg used to point at, and main's tip. That makes main's tip reachable from both branches (a new common ancestor). And main's tip descends from all twelve old bases, because main's history contains all of them.

Here's the mechanism. git merge-base only returns the youngest common ancestors of the two branches. The rule: if a common ancestor has a descendant that's also a common ancestor of both branches, the older one is redundant - the younger one already contains everything the older one had - so it gets dropped.

The twelve old bases survived before because none descended from any other. Same depth, parallel paths, no way to filter. But the merge created main's tip as a new common ancestor that descends from all twelve. Now every one of them has a younger common ancestor below it. All twelve filtered out. Only main's tip survives.

It doesn't untangle the diamonds. It buries them.

And nothing is lost, which is where the first concept pays off: "Commits are snapshots, not diffs". Diffs are computed on the fly from whatever base git picks. Main's tip already contains all the content from the twelve old bases - it descends from all of them, so their content is baked into its snapshot. The burial doesn't discard data — it moves the comparison point forward, shrinking the diff to only the real divergence.

After pushing that merge, GitHub's "Can't automatically merge" disappeared.

The fix works because the criss-cross requires a cycle, content flowing through both directions. A single final merge without subsequent backflows creates a new dominant ancestor and stops. No cycle, no new diamond. It's a one-time symmetry-breaking intervention: you introduce a commit that's unambiguously "more recent" than all twelve, and the degeneracy lifts.

Cherry-pick vs merge

One thing clicked during this that I'd never really appreciated enough.

A merge connects two histories, due to the commit-with-two-parents property. Every future merge-base calculation has to account for that connection. A cherry-pick copies a diff as a new, independent commit. No parent link. Git doesn't know they're related (because, fundamentally, they aren't). The graphs stay disconnected with no impact on ancestry and no diamonds.

MERGE: main → stg (to get hotfix H)

    main:  A ── B ── H
                     │
    stg:   C ── D ── M (merge commit, H is a parent)
                    ╱
              parent link created
              → graphs are now connected
              → affects all future merge-base calculations
              → potential diamond


CHERRY-PICK: cherry-pick H onto stg

    main:  A ── B ── H

    stg:   C ── D ── H' (new commit, same diff, NO parent link)

              H and H' have identical content
              but git doesn't know they're related
              → graphs stay independent
              → no impact on ancestry
              → no diamonds
Enter fullscreen mode Exit fullscreen mode

A note on received wisdom: Raymond Chen's excellent "Stop cherry-picking, start merging" series documents how cherry-picks between branches that will eventually merge create time bombs — spurious conflicts, silent reversions, the works. But in a follow-up, he's explicit: "if the two branches never merge, then there's no need to get all fancy with your cherry-picking." Our stg is a dead end. Nothing flows out of it. Cherry-pick is the right tool precisely because the graphs should stay disconnected.

Rebase avoids merge commits, but it doesn't avoid ancestry changes. It replays your branch onto a chosen upstream. In this workflow, rebasing a working branch onto main is fine — your branch stays rooted in main's history. Rebasing onto stg would pull staging ancestry into the branch tip, which is exactly what we wanted to avoid. You also pay the price of rewriting history, but that's a separate tradeoff.

What changed

One merge to collapse existing damage. One flow change to prevent new damage.
A guiding principle to unite them all: stop backflowing with merges.

Working flow:     main → branch (fork)
                  branch → stg (QA, dead end)
                  branch → releases → main (deploy)

If stg needs a hotfix:  cherry-pick from main
Never:                  main → stg, stg → main, releases → stg
Enter fullscreen mode Exit fullscreen mode

The deeper realization was simpler and more uncomfortable: the bidirectional merges that created this mess weren't accidents. They were our process. "Merge main into stg to stay current" was something we did on purpose, routinely, because it seemed like good hygiene. The diamonds accumulated silently for months and nobody noticed... until GitHub's less-tolerant merge path surfaced what local git had been quietly papering over.

GitHub wasn't wrong. It was simply less forgiving. And that turned out to be substantially useful, since it forced us to see a graph topology problem that ort had been abstracting away!

When GitHub says "can't merge" and local git says clean... the question isn't who's right. Both are. They're evaluating the same graph in different contexts - local git running a direct merge in your repo, GitHub running a server-side mergeability check. Knowing that is the difference between debugging for ten minutes and debugging for a day.


The cheat sheet I wish I'd had:

You want to... Do this Not this
Update your branch git rebase origin/main git merge origin/stg or git rebase origin/stg (imports stg ancestry)
Test a feature branch → stg
Ship a feature branch → releases → main stg → releases
Get a hotfix into stg cherry-pick from main merge main → stg
GitHub says "can't merge" Test locally first Trust the UI
Check merge base health git merge-base --all A B | wc -l Assume it's 1

Top comments (0)