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:
- Builds a speculative branch: main + PR#412 + PR#418
- Runs CI on THAT
- 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.)