HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 842 min read

Merge Queues & Semantic Conflicts

Developer toolingdeveloper tooling / CI

14:02 — Two PRs, both green

PR #412 renames a helper: getUser(id) → fetchUser(id). CI green ✅

PR #418 adds a new endpoint that calls getUser(id). CI green ✅

Different files. Zero merge conflicts. Git is perfectly happy.

🕐 14:05 — You merge both. main is red.

TypeError: getUser is not a function

Nobody broke anything. Both PRs were tested honestly. And yet.

What actually happened

Your CI tested each PR against main-as-it-was-when-the-branch-started.

Nobody ever tested main-as-it-will-be-after-both-merge.

That's a SEMANTIC conflict: the text merges cleanly, the meaning doesn't. Git only diffs lines; it has no idea a symbol vanished. Classic triggers:

  • rename/signature change + new caller
  • new DB column used by one PR, migration reverted by another
  • a dependency bump in PR A that breaks PR B's usage
  • one PR removes a feature flag, another still reads it

Why it gets worse with team size

The chance any merge lands on stale base grows with concurrent PRs and CI duration. 10 devs × a 20-minute pipeline ≈ your base is stale most of the day. "Green PR" stops meaning "green main."

🚦 The fix: a merge queue (GitHub merge queue, Mergify, Bors, Zuul)

Instead of merging your branch, you enqueue it. The queue:

  1. Builds a speculative branch: main + PR#412 + PR#418
  2. Runs CI on THAT
  3. Merges only if it passes

Now the thing tested is the thing that ships.

Speculation (the part people miss)

A naive queue is serial: N PRs × 20 min = painful. Real queues test batches optimistically in parallel:

[main+A]  [main+A+B]  [main+A+B+C]

If all pass → all merge, one pipeline's wall-clock. If B's batch fails, the queue BISECTS: A merges, B is ejected with a comment, C is retested on main+A. You get parallel throughput and correctness.

Three things that trip people up

  • "Require branches up to date before merging" is NOT a merge queue. It forces you to rebase and re-run CI — but only against main at that instant. Two people doing it at once still race. It reduces the window; it doesn't close it.
  • Flaky tests are lethal here. A 2% flake per job across a 5-PR batch ≈ 10% chance of an ejection storm, and the queue kicks innocent PRs. Fix flakes before you turn a queue on.
  • The queue only protects what CI covers. If nothing imports that renamed helper in a test, the speculative build is green too. A queue guarantees "main passes CI," never "main works."

Cheap version if you have no queue today

Add a job that tests the MERGE RESULT, not the branch tip:

git fetch origin main
git merge --no-edit origin/main   # fail loudly on textual conflict
npm test

GitHub's pull_request event already gives you a merge commit (refs/pull/N/merge) — but it's computed when the PR last changed, so re-run it on a schedule or on main pushes, not just on push-to-branch.

Your exercise

Open your repo's last 3 red-main incidents. For each ask: was it a textual conflict git could have caught, or a semantic one? Then measure your real exposure — median PR CI duration × PRs merged per day. If that product is anywhere near your working hours, you're shipping untested combinations daily.

Bonus: what kind of change is a merge queue still blind to, even with perfect tests? (Hint: two PRs that are individually correct and jointly correct at merge time — but conflict at runtime, in production data.)