Semantic merge conflicts, the kind Git merges clean
A semantic merge conflict is a merge that Git completes with no complaint while the combined code still breaks. It is also called a logical merge conflict. Git compares lines, not meaning, so when two branches change different lines that depend on each other, Git merges them and leaves you a build that fails or tests that go red. This guide explains how a logical merge conflict happens, shows a real one, and lists ways to catch it.
The short answer
Git merges text. Two branches that never touch the same line always merge cleanly, even when one renames a function the other calls, or removes a field the other reads. The merge succeeds, the compiler or the test suite does not. There is no Git flag that catches this, because the clash is in meaning, not in the lines. You find a semantic conflict only by building and testing the merged code, not either side alone.
Textual conflict vs semantic conflict
A textual conflict is the one everyone knows. Two branches edit the same lines, Git cannot pick a side, and it stops the merge with conflict markers for you to resolve. A semantic conflict is the quiet one. The branches edit different lines, so Git has nothing to stop on, yet the two changes contradict each other.
Textual conflict
Both branches edit the same lines. Git stops and asks which version to keep.
Semantic conflict
The branches edit different lines that depend on each other. Git merges them, and the code is wrong.
The textual conflict is loud and cheap. Git shows it right away, and you fix it before the merge finishes. The semantic conflict is silent and dear. It lands on main looking healthy, and the break shows up later in a build log or a failing test, often for someone who did not write either change.
Why Git cannot see it
Git's merge works on hunks of text. For each file it takes the common ancestor and both sides, and it combines the line ranges that changed. If the changed ranges do not overlap, the merge is clean by definition. Git never parses the code, so it does not know that a function one branch renamed is called by the other, or that a constant one branch deleted is read three files away. Meaning lives above the lines, and Git does not go there.
This is a property of the merge, not a bug. The same holds for every line-based tool, including a rebase, a cherry-pick, and git merge-tree. A clean result from any of them means the lines fit. It does not mean the code works.
Common shapes of a logical merge conflict
The same few patterns come up again and again. Each one has one branch changing a contract and another branch relying on the old one.
- Renamed or reordered argumentOne branch changes a function's parameters, another adds a call with the old ones.
- Deleted field or constantOne branch removes it, another branch's new code still reads it.
- Same key added twiceBoth branches add the same field to one object or map, a hundred lines apart.
- Same name, new behaviourOne branch changes what a value means, another branch uses the old meaning.
- Relocated definitionOne branch moves a helper to a new module, another imports it from the old path.
Signature change
Removed symbol
Duplicate add
Changed meaning
Moved code
A real one that did not build
Here is a semantic conflict from two real pull requests. The pair is #72 and #73 in firecrawl-mcp-server, a server for the Firecrawl web scraping API. An agent opened both with the same title, and each adds a maxAge option to the same object, the scrape tool's input schema in src/index.ts. One adds it after timeout, the other after location, about a hundred lines apart.
timeout timeout: {
type: 'number',
...
},
+ maxAge: {
+ type: 'number',
+ default: 0,
+ },
actions: {location description: 'Location settings for scraping',
},
+ maxAge: {
+ type: 'number',
+ description: 'Maximum age in milliseconds ...',
+ },
},
required: ['url'],Git sees two separate hunks and merges them. The merged file then holds two maxAge keys in one object literal, and TypeScript refuses it.
src/index.ts(198,7): error TS1117: An object literal cannot have
multiple properties with the same name.
The project compiles with tsc in its prepare script, so npm ci fails on the merge. Each pull request passes on its own, and so does the base. A reviewer who reads either diff sees a correct change. You can replay it in a minute.
git init -q firecrawl && cd firecrawl
git fetch --filter=blob:none https://github.com/mendableai/firecrawl-mcp-server \
refs/pull/72/head:pr72 refs/pull/73/head:pr73
git merge-tree --write-tree pr72 pr73 # exit status 0, a clean merge
git show <tree id>:src/index.ts | grep -n "maxAge: {"
How to catch a semantic conflict
There is one reliable way. Build and test the merged code, not either branch alone. Merge into a throwaway worktree and run the real suite there.
git worktree add --detach ../combo main
cd ../combo
git merge --no-edit feature-branch && npm ci && npm test
cd - && git worktree remove --force ../combo
git merge-tree --write-tree merges in memory first and tells you if the lines even fit, with exit 0 for clean and 1 for a textual conflict. That is a fast gate, yet it is still line based, so a clean exit is only the start. A strong type checker catches some of these at compile time, and good tests catch more. A changelog or a comment catches none, so the suite is what you lean on.
The cost is that you have to do it for every combination. Two branches make one pair. Five make ten. Every new commit makes its pairs out of date, so by hand the checks pile up. This is why semantic conflicts slip through on busy repositories even when every branch is green on its own.
Why AI agents make this worse
Semantic conflicts predate AI. They get more common when many changes land at once, and that is exactly how agent work arrives. Xu et al. studied 33,596 agent pull requests and found 79.4% were open at the same time as another agent pull request in the same repository (arXiv 2607.04697). Overlapping changes are the setting a logical conflict needs.
We measured how often the overlap turns into a clash. We replayed 733 pairs of real overlapping agent pull requests and merged each pair. Git flagged a textual conflict in 179 of them, about one in four. Of the clean merges we could test on both sides and on the merge, one no longer built, the firecrawl pair above. One in 44 is a small sample, yet it is a real semantic conflict from real agents that Git accepted and the compiler rejected. The method is in How often do AI agent pull requests break each other?
What gitta does about it
gitta is built around this exact gap. It watches the branches and worktrees on a repository, and it compares them by structure, not by line. It parses each changed file with Tree-sitter, finds the functions, fields and constants each side changed, and follows what each side's new code uses up to three steps away. When one branch changes something another branch relies on, gitta warns before the merge.
gitta init --gate "npm ci && npm test"
gitta check feature-branch --with main # PASSES, BREAKS or DOES_NOT_MERGE
gitta uses no AI model, so it behaves the same every time. You merge with git merge, git pull or git push, as always. gitta runs the gate on the combined code inside that merge and stops main from moving when the tests fail. On the firecrawl pair, with no tests it warned that both sides edited the same object. With the gate it merged the pair, ran the install and reported the TS1117 error. For the parallel-agent version of this problem, read Why git worktrees don't prevent agent conflicts and How to review AI-generated pull requests.
Questions about semantic merge conflicts
What is a semantic merge conflict?
It is a merge that Git completes cleanly while the combined code is wrong. The two branches change different lines, so Git has nothing to stop on, but the changes contradict each other in meaning. The break shows up as a failed build or a failing test after the merge, not as a conflict marker.
What is the difference between a logical and a textual merge conflict?
They are two names for the two kinds. A textual conflict is when both branches edit the same lines and Git stops the merge. A logical, or semantic, conflict is when they edit different lines that depend on each other, so Git merges them and the code breaks afterward. Logical and semantic mean the same thing here.
Why does Git not catch semantic conflicts?
Git merges text, line by line. If the changed lines do not overlap, the merge is clean by definition. Git never parses the code, so it cannot know that a renamed function is called elsewhere or that a deleted field is still read. The clash is in meaning, which Git does not look at.
How do I detect a logical merge conflict?
Build and test the merged code, not either branch alone. Merge both sides into a spare worktree and run your normal test command there. A type checker catches some at compile time and tests catch more. There is no Git flag for it, because it is not a line problem.
Can a clean git merge still break the build?
Yes, and that is the whole point of a semantic conflict. A clean merge means the lines fit together, not that the code works. The firecrawl example on this page merged with exit status 0 and then failed to compile.