semantic merge conflicts: clean merge, broken code.
published
a semantic merge conflict is a merge that git completes with no conflict markers, but whose result is broken: it fails to compile, fails a test or does the wrong thing, though each branch worked alone. coding agents such as Claude Code, Codex and Cursor can cause them when one changes code a teammate’s agent is still writing against.
what is a semantic merge conflict?
git merges text. it compares each branch with the commit both started from and keeps every change only one side made. within a file, it stops to ask you when both sides made changes to the same area (git-merge). a semantic conflict sits outside that test. one branch changes what a name means or how a function is called, and the other branch keeps using the old meaning in lines nobody else touched.
| what differs | textual conflict | semantic conflict |
|---|---|---|
| what causes it | both branches changed the same area of a file | one branch changed something the other one relies on |
| when you find out | during the merge, as conflict markers | after it: a failed build, a failed test or wrong behavior |
| what reports it | git | the compiler, a test or a user |
the idea is older than coding agents. Martin Fowler described it in 2011 with a method renamed on one branch while a colleague’s branch added new calls to the old name (semantic conflict). in Phil Haack’s 2019 post, two branches move the same C# property to two different interfaces. git reports a conflict in one interface but says nothing about the move, which gmaster, a semantic merge tool, flags as a conflict (when git resolves changes it shouldn’t). Graphite’s guide calls the condition behind them merge skew: the number of commits on the trunk ahead of a change’s merge base (merge skew).
why parallel coding agents produce them
an agent writes against the code it read. when a teammate’s agent, on another branch or another laptop, changes that code afterwards, nothing in git or in the agent itself tells the first agent. an agent updates the callers it finds; the callers it can’t see, the ones the other agent is writing, stay on the old shape.
the mechanism has been measured on tasks built to trigger it. researchers at UIUC and Columbia had GPT-5.5 write code against real Django helpers while a scripted parallel change altered those helpers: in 97% of runs (105 of 108) the two changes each passed alone and merged without a textual conflict, then failed together (Xia, Wu and Park, Passes Alone, Fails Together). the benchmark’s scripted changes rename a helper, add a required argument or change a return type, close to examples 1 and 2 below. giving the agent a full description of the other, finished change recovered 82% of runs (89 of 108).
the authors say these constructed rates do not estimate how often the problem occurs in practice. on 834 runs over 417 real Django pull request pairs, which had already been through review, a GPT-5-class model showed interference in one, after the authors corrected their grading. how often it happens on a given team is not measured. what the studies of real agent pull requests did count is in how often AI coding agents cause merge conflicts.
semantic merge conflict examples from agents on one team
alice and bob each run a coding agent on one repository, in examples 1 to 4 on branches of their own. we ran every example in a scratch repository on 2026-10-16: the first four merge in git 2.39 without a conflict, the fifth needs no merge at all, and the compiler errors quoted are TypeScript 5.9’s.
1. a renamed field
alice’s agent renames email to primaryEmail on User and updates every use of it on her branch. bob’s agent, on his branch, adds a reminder job to a file that already imports User.
// src/models/user.ts, alice's branch
export interface User {
id: string;
primaryEmail: string; // was: email
name: string;
}
// src/jobs/reminders.ts, bob's branch: a new function in a file that imports User
import type { User } from '../models/user';
import { sendMail } from '../lib/mail';
export async function remindInactive(users: User[]) {
for (const user of users) {
await sendMail(user.email, 'still there?');
}
}git merges it: alice changed src/models/user.ts and the files that used email at the time, bob changed a file that didn’t, and no line changed on both sides. each branch type-checks alone.
caught by: the compiler, run on the merged code in a merge queue or on main: error TS2339: Property 'email' does not exist on type 'User'.
missed by: each branch’s CI before the other one lands, and the compiler too when the name travels as a string, such as a column in a SQL query or a key in a JSON body.
2. a changed signature
alice’s agent makes teamId a required argument of createUser and updates the one call it can see. bob’s agent adds a second handler to src/api/handlers.ts and copies the call it can see, which is the old one.
// src/models/user.ts, alice's branch: teamId is now required
export async function createUser(email: string, name: string, teamId: string): Promise<User> {
// ...
}
// src/api/handlers.ts, alice's branch: the call it could see, updated
export async function signUp(body: SignUpBody) {
return createUser(body.email, body.name, body.teamId);
}
// src/api/handlers.ts, bob's branch: a new handler below it
export async function acceptInvite(invite: Invite) {
return createUser(invite.email, invite.name);
}git merges it: alice changed a line inside signUp, bob added lines after it, and an unchanged line sits between them. the merged file has one call with three arguments and one with two.
caught by: the compiler on the merged code: error TS2554: Expected 3 arguments, but got 2.
missed by: the compiler when createUser itself is declared in a .js file without JSDoc @param tags, where teamId arrives as undefined: TypeScript treats the parameters of a JavaScript function as optional unless JSDoc @param tags declare them (type checking JavaScript files). in either language, the compiler also misses a change where the signature stays and the behavior moves, such as a flipped default. tests catch those only if one of them calls acceptInvite.
3. the same helper, written twice
alice’s agent needs a URL slug for links to posts and writes slugify. bob’s agent, building the page that finds a post by its slug, writes toSlug in another file. neither agent knew the other helper existed.
// src/lib/strings.ts, alice's branch
export function slugify(title: string): string {
return title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-');
}
// links: '/posts/' + slugify(post.title)
// src/utils/url.ts, bob's branch
export function toSlug(name: string): string {
return name.toLowerCase().replace(/\s+/g, '-');
}
// lookup: posts.find((p) => toSlug(p.title) === params.slug)git merges it: two new functions in two files. the merged code compiles, and a test with the title Hello world passes, since both helpers return hello-world. a post titled Q3 report: final gets the link q3-report-final and is looked up as q3-report:-final, so its link finds nothing.
caught by: a test that follows a link for a title with punctuation in it, or a reviewer who spots two slug helpers.
missed by: the compiler, and anything that matches changes by name, because the two helpers share none.
4. the same next id, taken twice
both agents add an error code. each reads the highest one, 1003, and takes the next.
// src/errors.ts, alice's branch: added at the end
export const ErrorCode = {
NotFound: 1001,
Forbidden: 1002,
RateLimited: 1003,
Conflict: 1004,
} as const;
// src/errors.ts, bob's branch: added next to Forbidden
export const ErrorCode = {
NotFound: 1001,
Forbidden: 1002,
InviteExpired: 1004,
RateLimited: 1003,
} as const;git merges it, because an unchanged line separates the two additions, and the merged object has two names for 1004. had both agents added their line at the end, git would have stopped with a conflict. numbered migrations break the same way: 0042_add_deleted_at.ts and 0042_add_team_id.ts are two new files, so git has nothing to merge at all.
caught by: a test that the codes are unique, or a migration runner that refuses two migrations with one number.
missed by: the compiler. TypeScript accepts a repeated value in an object, and in an enum.
5. no merge at all: an edit reverted by a stale read
two agents work in one checkout. bob’s agent reads src/models/user.ts, alice’s agent adds a field to it, and bob’s agent writes the whole file back from its older read. a whole-file write such as Claude Code’s Write replaces the file with the content it’s given and doesn’t merge (tools reference).
// what bob's agent read at 10:02
export interface User {
id: string;
email: string;
}
// what alice's agent wrote at 10:04
export interface User {
id: string;
email: string;
deletedAt: Date | null;
}
// what bob's agent wrote at 10:06, rebuilt from the 10:02 read
export interface User {
id: string;
email: string;
displayName: string;
}there is one branch, so git has nothing to compare. if alice’s change was committed first, bob’s next commit records the missing deletedAt as an ordinary edit, and its diff reads as one line replaced, deletedAt out and displayName in. if it wasn’t committed, git never saw it.
caught by: the compiler, if code already reads deletedAt; alice’s soft-delete tests, if her agent wrote them; a reviewer who reads a deleted line the task never asked for.
missed by: anything that compares branches. when the two agents in one checkout are one developer’s own, the fix is a worktree each, which the guide to git worktrees and agentmash, linked below, covers.
what catches each one
the compiler and the tests catch most of these, but only when they run on the merged code. CI that ran on each pull request before the other one landed passed both.
| example | the compiler, on the merged code | tests, on the merged code | each branch’s CI, before the other lands |
|---|---|---|---|
| 1. renamed field | yes, when the name goes through the type | when a test reaches the new line | no |
| 2. changed signature | yes in TypeScript; when createUser is in a .js file, only when JSDoc @param tags declare its parameters | when a test calls acceptInvite | no |
| 3. helper written twice | no | a test title with punctuation, an accent or a leading space | no |
| 4. next id taken twice | no | a test that the codes are unique | no |
| 5. stale-read revert, no merge | when code reads the lost field | when a test covers it | n/a: there is one branch |
why git, CI and worktrees miss them
git compares lines
git has no way to know that email in one file is the field another branch renamed. semantic merge tools parse the language instead. in 2019 the one in Haack’s post, gmaster, stepped in only when git had already reported a conflict in the file, and it didn’t yet handle a variable renamed on one branch while the other branch still used the old name.
CI on each branch, and merge skew
on GitHub, a pull_request workflow runs against the pull request’s merge branch, refs/pull/PULL_REQUEST_NUMBER/merge, so its tests do see the base branch as of that run. by default it runs when the pull request is opened, reopened or its head branch is updated (events that trigger workflows). a pull request that lands on the base after that run isn’t in it. that gap is the merge skew Graphite describes.
two GitHub settings close it at the merge:
| setting | what it builds before the merge | its cost, and where it’s available |
|---|---|---|
| require branches to be up to date before merging | each branch, after it takes in the latest base | more builds, and an update of each branch every time another one lands; a branch protection rule, which private repositories have on GitHub Pro, Team and Enterprise |
| merge queue | the pull request applied to the latest base and the pull requests queued ahead of it | when the required checks run on GitHub Actions, the workflow has to run on the merge_group event too; available in public repositories owned by an organization, and in private ones only on GitHub Enterprise Cloud |
bors-ng, an earlier bot for the same workflow, is archived and points to GitHub’s merge queue (bors-ng).
what this page says about GitHub, git, TypeScript, Node.js, Graphite and bors-ng is from their own docs and repositories, checked on 2026-10-16.
all of these find a semantic conflict at the merge, when the build or a test shows it, after bob’s agent has finished writing against the old shape. the other layers, in the order they act, are in how to prevent merge conflicts between AI agents.
worktrees
a worktree gives each agent its own checkout and its own branch on one machine, which ends example 5 for your own agents. examples 1 to 4 then show up at the merge, since each worktree is a branch that can’t see the others, and a teammate’s worktree on another laptop isn’t on your machine at all.
detecting semantic conflicts with unit tests
a test detects a semantic conflict only when it runs on the merged code and reaches the lines where the two changes meet. in example 3, a test with a plain title passes on the merged code; it takes a title the two helpers treat differently, such as one with punctuation, to show that they disagree.
the cheapest of these tests check an invariant of the whole codebase. this one fails on the merged ErrorCode from example 4, with 5 codes and 4 distinct values, so a merge queue that runs it stops there:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ErrorCode } from '../src/errors.ts';
test('error codes are unique', () => {
const codes = Object.values(ErrorCode);
assert.equal(new Set(codes).size, codes.length);
});on Node.js 22.18 or newer in the 22 line, or 23.6 or newer, in a package whose package.json sets "type": "module", node --test runs this file as it is: Node strips the types itself, and it needs the .ts extension in the import (TypeScript in Node.js). tsc accepts that extension with allowImportingTsExtensions when it only type-checks (noEmit); a build that emits JavaScript uses rewriteRelativeImportExtensions instead.
researchers have tried generating such tests. SAM, from Da Silva, Borba and colleagues, uses generated unit tests as partial specifications of the changes being merged; in the published version, its best configuration detected 9 of 29 semantic conflicts in their sample (detecting semantic conflicts with unit tests, Journal of Systems and Software, 2024). a static-analysis approach, with Borba among its authors again, reports higher recall and F1 than earlier methods built on dynamic analysis, and lower precision (detecting semantic conflicts using static analysis).
how to resolve a semantic merge conflict
git has already finished the merge, so there is no conflict to pick a side of. the fix is a new commit on the merged code.
- find the contract that moved. the compiler error or the failing test points at a line; find the file that defines what that line calls (
User,createUser), andgit log -p -- src/models/user.tslists the commits that changed it, with their authors and diffs. - decide which side moves. usually it’s the stale caller, since the other change was made on purpose; when the change itself was the mistake, revert it instead. a person reads both changes and decides, because neither agent saw the other’s.
- fix forward on the merged code:
user.primaryEmailin the reminder job, ateamIdpassed inacceptInvite, one slug helper instead of two, a new number for whichever error code hasn’t shipped. - add the test that would have caught it, and run the build and the tests on the merged result before the next pull request lands.
what agentmash’s symbol overlap covers
agentmash gives each developer’s coding agent a shared room per repository. most agents’ edits are reported to the room, and before an edit an agent is told when a teammate’s agent changed the same file inside the conflict window, 30 minutes by default, or on a branch that isn’t merged yet (a few agents hear it at another moment, as the limits below list). for semantic conflicts the part that matters is symbol overlap, which matches by name instead of by path (what counts as a conflict):
- after an edit reported by agentmash’s edit hook, such as Claude Code’s (not Cursor’s, and not a patch), the hook works out which exported declarations it changed, walking back from an edit inside a function body to the declaration that encloses it.
- before an edit, the hook sends what the file about to change imports and exports.
- the room matches one against the other by name, so a change to
createUsercan reach a file that importscreateUser, whatever directory it lives in (the limits are below).
the names are read on the developer’s machine, from the JavaScript and TypeScript family (.ts .tsx .mts .cts .js .jsx .mjs .cjs), with regexes and no parser. for symbol matching, the server gets the names, never the code. a match across files is reported only when it rests on a specific name: names like config or handler don’t count on their own.
in the docs’ two-terminal example, one machine plays alice and bob in one checkout, so alice’s change to createUser is already in bob’s working tree, and alice’s session is shown as ended. bob’s agent reads this before it edits src/api/handlers.ts:
[AgentMash: ACTIVE COLLISION] alice's agent has open work in src/models/user.ts (which src/api/handlers.ts depends on for createUser) — last write 1 min ago on your branch (feat/soft-delete), while working on 'rename createUser and add a deletedAt column to the user model' (+1/-1 lines). alice is not at the keyboard, and this is still a collision rather than a stale file: the overlap is confirmed at createUser, and a declaration someone else has already changed does not become safe to write against because its author stepped away. Their write is already in your working tree. Read src/models/user.ts before you finish this edit — you are about to write against createUser as it used to be, and it has moved. Leave createUser named and shaped as alice left it — that is what they just changed. If your task genuinely needs those same lines, change them and say so in your reply, so the overlap is visible. alice ended their session 20 min ago.when alice’s change is on her own branch, the usual case for examples 1 and 2, the level is PARALLEL EDIT (the five levels). in a match by name, such as createUser in a file that imports it and that alice’s agent didn’t touch, bob’s agent is told that its copy of src/models/user.ts still shows the old createUser, to assume createUser moved under it, to keep its change additive, and to say in its reply if it changes something alice touched. that helps the textual merge go through. a call written against the old signature still breaks the merged build, so the person reading the reply, or the build on the merged code, has to catch it. the agent learns who changed createUser, when and on which branch. it doesn’t get the new signature from the code, only what alice’s prompt said about it.
a symbol match looks back over the conflict window, 30 minutes by default. claims reach further: an agent can claim the paths it is about to change and name the exported names it expects to change or rename, and a teammate’s agent about to edit a file that imports one of them gets an [AgentMash: CLAIMED] note. on a branch of the agent’s own, a claim stands until that branch merges, a week at most; on the default branch it lasts two hours by default (eight at most) and ends with the session that made it. claims come through agentmash’s MCP tools. Codex, Cursor, GitHub Copilot and most other agents get them when a developer signs in, once the server is approved where the agent asks (Cursor asks when it restarts), and Claude Code gets them where the repository registers the MCP server, as below. a claim reaches importing files only when it lists the exported names (claims).
against the five examples:
- 1 and 2 are what symbol overlap is for:
src/jobs/reminders.tsimportsUser, andsrc/api/handlers.tsimportscreateUser. bob’s agent hears of it in one of two ways, and only when alice’s side comes first. by an edit: alice’s change is under 30 minutes old and her agent reports the names it changed (Claude Code’s edits do; a Codex patch and Cursor’s edits don’t). by a claim: her agent claimed the work and listedUserorcreateUser, which any agent with agentmash’s MCP tools can do. in example 2 the same-file match below can take the place of both. - 2 is also a same-file case: alice’s agent changed
src/api/handlers.tstoo, so the check finds her twice, by that file and bycreateUser. the room returns one match per teammate, the strongest and then the newest, so when her updated call insignUpcame after hercreateUserchange, bob’s agent is told about her edit tosrc/api/handlers.tsandsignUp, not aboutcreateUser. while her change is inside the window that comes at most once every ten minutes, then once a session until her branch merges (a week at most). that holds for any of her agents whose edits reach the room, which Continue’s and Zed’s don’t. - 3 has no shared file and no shared name, so nothing is said before the edit.
- 4 gets a same-file advisory for whichever agent edits second: it learns that the other’s branch changed
src/errors.ts, and that 1004 is taken only if the other’s prompt said so. numbered migrations are separate files, and bob’s is new, so the check before his agent writes it has no names to send. - 5 gets
ACTIVE COLLISIONwhen the two agents belong to two developers. if alice’s agent reported the names it changed (Claude Code’s edits do), the file’s own exportUserconfirms the overlap, and the level staysACTIVE COLLISIONfor the whole 30-minute window, even after she has gone. if her edit carried no names (a Codex patch, Cursor’s edits), it isACTIVE COLLISION, orSTALE READonce she has gone, while her last write is five minutes old or less, thenRECENT EDITuntil the window closes, which asks for a re-read only if the edit depends on her lines.ACTIVE COLLISIONandSTALE READtell bob’s agent to re-read the file and say “a stale old_string either fails to match or quietly reverts their change.” after the window, nothing is said. two agents of one developer get nothing from agentmash; that case is the worktree’s.
in the study above, the 82% came from giving the agent a full description of the finished change. agentmash’s advisory carries who, when, the branch, up to 200 characters of their prompt (none if they set AGENTMASH_HINTS=0), line counts and the names touched, not the change itself. the authors list a message of affected names as a comparison still to run, so what share of semantic conflicts a names-only warning helps an agent avoid is not measured.
to try it on your team’s agents, set up agentmash: someone who can push connects the repository once, and each developer signs in once on each machine. claims in Claude Code need the repository to carry agentmash’s MCP server entry, which npx agentmash init commits in .mcp.json (in a repository connected on the website, init stops and says how to commit one anyway), and each teammate then pulls and approves the server once. Codex runs no hook until each developer approves it once in /hooks.
what it doesn’t catch
- symbols are read from the JavaScript and TypeScript family only. Python, Go and Rust files are matched by path alone, so examples 1 and 2 in those languages get a warning only when the two agents share a file.
- a change reaches other files by name only when its author’s agent reports the names it changed, or a claim lists them. Claude Code’s edits report them. a patch (how Codex edits), Cursor’s edits and a file changed by a shell command carry no names to match, so unless a claim lists the names, they reach only the files they changed.
- the match runs from a changed declaration to the files that import it. when bob’s agent writes the new caller first and alice’s agent changes
Userafterwards, alice’s agent isn’t told about the caller, and bob’s agent hears of it only if it editssrc/jobs/reminders.tsagain, inside the window or while a claim of alice’s listsUser. - when the hook can’t place an edit inside one declaration, for example when its text occurs in two, the edit names none and is matched by path: 97 of 620 simulated edits on agentmash’s own source named nothing, and none named the wrong one. files over 512 KB aren’t read for names.
- names are matched without the module they come from, so a file that imports another
createUseris warned too, and a file’s check sends at most 60 names, its exports first. - it matches names, not meaning or signatures. a renamed local variable inside
createUserraises the same warning as a new required argument; the match can’t tell them apart, and only alice’s prompt, quoted in the warning, might. - members are reached through the name a file imports: alice’s rename of
User.emailis reported as a change toUser, with nothing finer. export * fromandexport =aren’t followed, so a re-export barrel breaks the chain between the file that defines a name and the file that imports it.- names shorter than three characters are dropped, a migration’s
upamong them. - the check before an edit reads the file as it is on disk. a new file has no imports yet, and an import that the edit itself adds isn’t part of that check.
- a cross-file match needs the teammate’s change inside the window, 30 minutes by default, or a claim that lists the name. older work on an unmerged branch is remembered for the file it changed, up to a week (less when the room keeps less history), and reaches files that import it only through such a claim.
- after an advisory for a file, the next ten minutes (outside strict mode) are quiet about the teammates it named, so a symbol change one of them makes in that time waits.
- it warns about teammates’ agents, never your own. two of your agents, in two worktrees or in one checkout, both report as you.
- not every agent hears the warning before the edit: Cline hears it just after, Antigravity at the start of each turn, Continue and Zed only when they ask the MCP tools, and an editor with no hooks is seen only when it commits, in a repository set up with
npx agentmash initandnpx agentmash git-hook install. the setup page says how each agent hears it, Devin’s and Junie’s included. - an advisory is not a lock: the edit goes ahead (in Cursor and the tools like it, on the agent’s second try).
AGENTMASH_STRICT=1blocks an edit into a teammate’s recent change instead, never on a claim or on older branch work. - a check that runs past its budget, 1500 ms by default, lets the edit go ahead unwarned.
- it doesn’t run your compiler or your tests. keep both on the merged code, in a merge queue where GitHub offers one or with up-to-date branches required. the full list is in known limitations.