The short version
A merge queue serialises merges and re-runs your checks on the combined result. That only helps if the checks would have caught the bug. Get the oracle right first, then the queue is worth having.
- Our api-v2 CI runs zero unit tests, so a green tick there proves syntax and nothing else.
- Ten pull requests landed in two days. Master broke twice, and two separate repair PRs exist to prove it.
- The legacy
git merge-treeexits 0 on a real conflict, so the popular pre-check reports clean. - GitLab documents the failure mode in its own merge-trains page, and Fowler named it in 2011.
Every merge queue, merge train, and speculative-merge scheme rests on an assumption that almost nobody writes down: that the checks it re-runs would actually catch the problem. Serialise the merges, rebuild on the combined result, re-run CI, and the queue promises your trunk stays green. Take away a trustworthy signal and all you have bought is a slower way to break master with better paperwork.
I do not get to be smug about this. Our own Laravel API’s CI pipeline runs no PHPUnit at all. It lints, it builds, it checks style. A green tick on a pull request there tells you the code parses. It does not tell you the code works, and for years nobody minded, because one human merged one branch at a time and ran the suite locally first.
Then we started landing branches from parallel sessions, and the gap stopped being theoretical.
Related reading
Those parallel sessions each ran in their own git worktree. What a git worktree does not isolate covers what that buys you and what it quietly leaves shared. The viral cheat codes I tested covers the wider set of Claude Code features people reach for first.
What a merge queue actually assumes
Strip a merge queue down and it does two things. It puts merges in a line so no two land at the same instant, and it re-runs your test suite against the combination rather than against each branch alone. Both are good. Neither invents information.
The second one is the interesting half, because it is a direct answer to a specific bug class. Martin Fowler named that class semantic conflict back in August 2011, defining it as a situation where two people “make changes which can be safely merged on a textual level but cause the program to behave differently”. Git is content with the merge. The program is not.
GitLab states the consequence plainly in its own merge trains documentation, which is a blunt thing for a vendor to publish about the feature it is selling:
A merged results pipeline does not account for other merge requests that merge around the same time. Two merge requests can each pass their own pipeline, but their combined changes can still conflict. If both merge, the target branch can break, even though every pipeline succeeded.
Note the modal verbs. Can conflict, can break. That is the accurate form and I would keep it that way.
Here is the bit the vendor pages leave implicit. “Re-run the pipeline on the combined result” is only a fix if the pipeline is a real oracle. If your pipeline never executes the unit tests, then running it against one branch, or against eight branches stacked in a train, returns the same answer: green. A queue multiplies the value of a good test suite. It multiplies nothing at all when there is no suite in the loop.
Ten pull requests, two days, master red twice
I would rather show you our own scar tissue than a hypothetical.
Across 27 and 28 July, ten pull requests merged into our api-v2 master branch. Nine were feature work from a long-running email-template job, reviewed individually, each one green. Every one of them touched the same family of Blade templates and the same test files. The tenth was a repair.
Two of them collided like this. One PR sentence-cased a label in the email footer. Another replaced the signed-route calls that generated the link under that same label. They merged five hours and thirty-five minutes apart, in that order, so neither author and neither reviewer could have seen the other’s change in their own diff. Both were green. Together they were not.
That is not the only one. A merge resolution on a third PR silently reverted a deletion that the PR itself had made, so it shipped with its own guard red. Because CI runs no PHPUnit, nothing objected. Master carried failing tests, and the only reason anyone noticed is that a human ran the suite locally afterwards.
The receipts are two pull requests whose entire reason for existing is repair. One is titled “repair two MJML guards left red on master by the C1 digest merge”. The other, merged the next day, is titled “repair 5 MJML tests left red on master by the 07-27/28 merge train”. The first of those landed at 13:39 on the 28th, five minutes after the day’s first feature PR, with five more still queued behind it. So the repair went in mid-train and the train broke master again anyway.
The snag here is what a merge queue would have done for us. Very little. It would have serialised ten merges that were already serialised, rebuilt each on the combined tree, re-run a pipeline that does not execute the failing tests, and reported green ten times. The bug was never a race. It was a missing oracle, and no amount of queueing manufactures one.
Does your conflict check tell you the truth?
If you are going to simulate merges before they happen, the tooling has a trap in it that I think is worth more attention than the queue itself.
git merge-tree has two modes, chosen by argument count with no flag and, importantly, no runtime warning. Pass two branches and you get the modern mode. Pass three and you get the legacy one, which the man page marks deprecated in its synopsis but which prints nothing to tell you at run time. Elijah Newren shipped the modern mode’s contract in the commit message itself back in 2022: exit 0 for clean, 1 for conflicts.
Measured on git 2.50.1 against a purpose-built repository with a real same-line conflict:
| Invocation | Outcome | Exit |
|---|---|---|
merge-tree --write-tree A C | actually clean | 0 |
merge-tree --write-tree A B | real conflict | 1 |
merge-tree <base> A B (legacy) | the same real conflict | 0 |
merge-tree --write-tree with 3 args | usage error | 129 |
| unrelated histories | fatal, refuses | 128 |
The third row is the one that matters. I also ran the legacy form on a clean pair and on a modify/delete pair, and it returned 0 for all three: clean, content conflict, and modify/delete alike. Its exit code carries no information whatsoever.
The modify/delete case is nastier still. On that pair, the legacy form emitted zero conflict markers and printed output that reads like an ordinary deletion, opening with “removed in remote” and a clean diff hunk. So the widely-copied recipe of running the legacy form and grepping for <<<<<<< reports clean on a real conflict, with a zero exit code agreeing. The top-voted answer on the relevant Stack Overflow question recommends exactly the legacy three-argument form, with no guidance at all on how to interpret what comes back. It was posted in 2019, it is not the accepted answer, and its score sits one point above a newer answer recommending the modern form.
Mind you, I only learned this because I published the wrong version of it first, in an internal runbook, and had to go back and fix it.
One more trap for anyone batching checks. --stdin looks like the natural way to run an N-by-N pairwise matrix, and it inverts on you twice over. I fed it one clean pair and one conflicting pair in a single batch, and the process exit status came back 0. The per-merge integers came back 1 for the clean merge and 0 for the conflicting one, which is backwards from the exit-code convention you just learned. Git documents both, in the same man page, a few paragraphs apart:
When
--stdinis passed, the return status is 0 for both successful and conflicted merges
The integer status is: 0: merge had conflicts / 1: merge was clean
Read the exit code in --stdin mode and you learn nothing. Read the integer while assuming it matches the exit convention and your matrix reports the precise opposite of the truth.
Squash a stack base and you manufacture conflicts
Stacked branches are how parallel agents naturally produce work, and they interact badly with the merge button most teams press.
When a pull request is squash-merged, its commits are replaced by one new commit with a different hash. Everything stacked above it still refers to the old, unsquashed history. The git-spice project documents this in its own limitations page under the heading “Squash-merges restack the upstack”, and is careful to say those branches “need to be restacked” rather than claiming conflicts are inevitable.
In our case they were not inevitable, they were measured. One PR in the middle of a stack of six merged clean as a merge commit and every child stayed clean. Simulated as a squash, it conflicted with all five children, across two to six files each. Same code, same day, opposite outcome, decided purely by which button gets pressed. Both prior PRs in that series had been squashed, so the repo’s own habit pointed at the wrong choice.
git cherry will not save you here either, because its equivalence test does not see through a squash. Patch IDs handle a one-to-one rewrite and give up on many-to-one.
Build the oracle before you buy the queue
The research on this is older and better than the tooling debate suggests. Brun, Holmes, Ernst and Notkin measured it in Proactive Detection of Collaboration Conflicts at ESEC/FSE 2011, across nine open-source projects’ histories through February 2010. Their headline: conflicts are “the norm, rather than the exception”, they “persist, on average, 10 days”, and they are “often higher-order”. That last word is the payload. Of all the conflicts they found, 33% were build or test conflicts that the version control system reported as clean merges. Textual conflicts, the only kind git can see, were the other 67%.
Two caveats I would want if I were reading this. The same authors revised the 10-day figure down to a 3.2-day mean in their 2013 journal version, using the same corpus and a changed method, so quote the 10 days as the 2011 result it is. And the 67/33 split was computed over only three of the nine projects, the ones with runnable test suites.
Which brings me to the counter-evidence, because there is some and it points the other way from where most people expect.
Mergify published a State of Merge Queues 2026 report on 27 July, drawing on roughly 160 organisations and 153,000 merges that passed through its own queue over a rolling 90-day window. Pull requests carrying an AI-assistance signature on their commits rode in a failed merge-queue batch 1.9% of the time, against 4.4% for those without one. Mergify calls that the broken-main rate, and two qualifications come attached to it. The queue caught these before they reached main, so nothing actually broke. And the metric counts batch membership rather than blame: in its own words, riding “in a failed batch” means a PR “rode in a batch that broke, not that it caused the break”.
It is observational vendor data, self-selected twice, and Mergify volunteers the confounder itself: “The teams that keep the AI footer on their commits may be more disciplined in ways we can’t see.” Its AI detection only sees tools that stamp a commit, mostly Claude Code, so it calls its own AI figures a floor. Read it carefully and what it measures is teams, not authors: the ones running a real queue over a real suite catch things, whoever or whatever wrote the patch.
After sitting with all of it, my order of operations is unglamorous. Put the tests in CI first, even a slow subset, because a queue that re-runs nothing re-runs nothing eight times. Verify merges pairwise with merge-tree --write-tree and read the exit code rather than grepping for markers. Merge stack bases as merge commits. Then, once the signal is real, buy the queue, and it will earn its keep.
The other half of this problem sits at the start rather than the end, in what the parallel workspaces themselves quietly share. I wrote that up separately in what a git worktree does not isolate, which is where the same ten pull requests came from.





