·9 min read

What causes merge conflicts git can't see

Two PRs passed CI, GitHub called the merge clean, and the merged code did not compile. The conflicts git reports, and two real cases of the kind it cannot.

gitci-cdtestingclaude-code

Contents

GitHub told me the merge was clean, no conflicts. gh pr view said so in two words:

MERGEABLE CLEAN

The pull request's CI was green. The PR it was about to meet, already on main, had been green too. Both had touched the same file, so I merged the two into a scratch branch first, ran the typecheck, and got this:

error TS2304: Cannot find name 'ShapeId'.

Nothing was wrong with either PR. Something was wrong with the two of them together, and no tool in the chain was looking at them together. Two days later it happened again with three PRs and a different error, in a way I like a lot more. Between them they answer what causes merge conflicts better than the usual diagram of two arrows meeting at a file.

The project is a game builder I'm writing: a desktop app, TypeScript, a monorepo of several packages, about 1,600 tests at the time. For a few weeks I ran it with several Claude Code agents in parallel. Each took one issue on its own branch, opened its own PR and had to pass CI there. One session coordinated: reviewed, decided what could run at the same time, merged. That produces a lot of PRs branched from slightly different versions of main, which is the condition this post is about.

What causes merge conflicts in git

Git merges with three versions of each file: the common ancestor both branches started from, yours, and theirs. For each region of the file it asks who changed it since that ancestor. If one side did, git takes that side. If both did, differently, git stops and writes the <<<<<<< markers.

So every conflict git reports has one cause. Two branches changed the same lines, or lines touching each other, since they split. Most merge conflicts explained in tutorials are this, with a diagram.

The second case below had one, in its plainest form. Two branches each added a test fixture to the same file at the same point, so the two additions shared the comment line that opened them and the brace that closed them, and each added an import group at the top. Git saw two different insertions at one position and could not know the right order, or whether both belonged. Both did. Keeping both was the whole resolution.

A stylesheet the same two branches shared merged by itself, because their edits sat in different places. That is all git checks: where the lines are. It has no idea what they mean.

Why merge conflicts happen where git can't see them

A semantic conflict is two changes that are each correct against the code they were written on, and wrong together. One branch depends on something the other removed or redefined. They don't need to touch the same lines, or even the same file, so git merges them without a word and the result is broken.

A compiler catches some of these. Tests catch some more. Plenty slip past both. The only reliable way to see one is to build and test the merged tree, and in my setup nothing did that by default. Each PR's CI tested that PR on its own base.

Two green PRs, one TS2304

The first case, on 23 September. Two agents were working in the same source file at the same time. The code below is a simplified reconstruction with the names changed, not the project's own; the mechanism and the compiler output are the real ones. One agent added a function near line 390 that builds an edit:

export const moveShapeEdit = (
  shapeId: ShapeId,
  to: Point,
): ShapeEdit | undefined => {

The other was widening the type that describes a selection. As part of that it replaced the ShapeId import at the top of the file with ShapeRef. On its base, nothing else in the file used ShapeId any more, which its own green typecheck proves. Removing the import was correct.

The first PR merged. The second had been validated on a commit from before that. Then:

$ gh pr view 123 --json mergeable,mergeStateStatus -q '"\(.mergeable) \(.mergeStateStatus)"'
MERGEABLE CLEAN
$ git merge-tree $(git merge-base main pr123) main pr123 | grep -c "^<<<<<<<\|CONFLICT"
0

An import list at the top and a function hundreds of lines down are different regions, so git combined them. The combined file uses ShapeId and no longer imports it.

The fix was one line, the import put back. The assembled tree then passed the full check, 71 test files and 1,645 tests, and CI on the scratch branch before anything went to main.

The mistake was mine, and it was a scoping mistake. Before letting the two agents run at the same time I checked that they would not share a file, and I checked the wrong one: only one of them touched a neighbouring component, which was true and beside the point, since both had that source file in scope. I only noticed at review, when the second PR came in. The rule since then is that two branches sharing a file never merge one after the other against main. They go into an integration branch together, and the full gate runs there first.

Three PRs and an exhaustive switch

The rule paid for itself two days later, in a way it was not written for.

Three PRs were ready. Two of them shared a stylesheet and a test file, so they went into a batch branch. The third, a change to a data model in a separate package, shared no file with either. I merged it first, on its own, then assembled the other two on top of the new main. That is where the textual conflict described above came from. I resolved it, ran the gate, and got:

error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

The conflict I had built the batch for was fine. The error came from the PR I had already merged on its own because it shared no files.

One of the batched PRs added an editor panel. Part of it turns each value of a union that the other package exports into a sentence a person can read. It is written as a switch over that package's own exported union, with no default and a return type of string. Again a simplified reconstruction, with neutral names:

// in the model package
export type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

// in the editor package
export const describe = (shape: Shape): string => {
  switch (shape.kind) {
    case "circle":
      return `A circle with a radius of ${shape.radius}.`;
    case "square":
      return `A square, ${shape.side} on each side.`;
    // …one case for every member of the union, and nothing else
  }
};

The model PR added three members to that union, in the other package, the equivalent of triangle, ellipse and polygon. Against the new union the switch no longer covers every case, so the function can run off its end and return undefined, which string does not allow.

Each PR was green on its own. The editor panel had branched before the model change existed. The broken code existed only in the tree where all three met. And the file-overlap rule from the first case would not have flagged it, because the two PRs shared no file. They shared a type.

An exhaustive switch is a tripwire

None of that was luck. The function's own doc comment says what it is for. It is exhaustive over the union the other package owns, not over a list typed locally, so a member that package adds stops the app compiling until it is given a sentence. A lookup with a default would show a person the raw constant instead.

The obvious way to write that function is a map from code to sentence with a fallback for anything missing. It compiles forever. When a new code arrives, a user sees the generic fallback or the raw constant, and nothing anywhere goes red.

Written as a switch with no default, a change to the union in one package becomes a compile error in the package that reads it. A semantic conflict, the kind git cannot see, turned into one the compiler reports with a file and a line. Most semantic conflicts produce no error at all. A good part of the defence is writing code so that more of them do.

The fix was three case lines and three sentences. The assembled tree passed with 104 test files and 2,429 tests, then CI on the batch branch.

The fix that could not go on the branch

One wrinkle. The three new cases could not be committed to the editor panel's own branch: they name members that do not exist at that branch's base, so the fix would itself fail to compile there. It belonged only to the merged tree. The thing that had passed was the batch, not any one PR.

So I compared trees instead of commits. Merging main into the editor panel's branch raised the same textual conflict again, and rather than resolve it a second time by hand I took the batch's resolution and checked the result with git rev-parse <ref>^{tree}. After all three PRs landed:

main : abc1234def5678abc1234def5678abc1234def56
batch: abc1234def5678abc1234def5678abc1234def56

The commit hashes differ, because the history that led to each one differs. A tree hash only matches if every file is byte-identical, so main was exactly the tree CI had tested, not a near copy of it.

What GitHub's mergeable check says about merge conflicts

MERGEABLE means GitHub tried the merge and found no overlapping lines. CLEAN adds that the checks on the PR passed. Those checks ran on the PR's head as it was, against the base it had, and nothing re-ran them when main moved. Neither word says the result compiles.

GitHub has features aimed at this: a branch protection setting that requires a PR to be up to date with its base before merging, and merge queues, which test each PR merged on top of the ones ahead of it. I did not use either here, so I can't report how they behave. The batch branch did the same job by hand, and it caught both cases.

The same lesson as the check that passed while broken, from another angle. A green check is a statement about the tree it ran on. It says nothing about the tree you are about to create.

Takeaway

Git reports a conflict when two branches change the same lines. That is the whole of what causes merge conflicts in git, and it is the easy kind. The other kind is two changes that agree on text and disagree on meaning, and the only place it shows is the merged tree.

So build and test the merged tree before it becomes main. Treat two branches that share a file as one batch. And where one package reads another's list of cases, write it as a switch with no default, so that the day the list grows, the build says so instead of a user.

More posts

New writing when there is something worth saying. By email or by RSS, whichever you prefer.