skip to content

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 differstextual conflictsemantic conflict
what causes itboth branches changed the same area of a fileone branch changed something the other one relies on
when you find outduring the merge, as conflict markersafter it: a failed build, a failed test or wrong behavior
what reports itgitthe 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.

ts
// 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.

ts
// 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.

ts
// 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.

ts
// 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).

ts
// 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.

examplethe compiler, on the merged codetests, on the merged codeeach branch’s CI, before the other lands
1. renamed fieldyes, when the name goes through the typewhen a test reaches the new lineno
2. changed signatureyes in TypeScript; when createUser is in a .js file, only when JSDoc @param tags declare its parameterswhen a test calls acceptInviteno
3. helper written twicenoa test title with punctuation, an accent or a leading spaceno
4. next id taken twicenoa test that the codes are uniqueno
5. stale-read revert, no mergewhen code reads the lost fieldwhen a test covers itn/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:

settingwhat it builds before the mergeits cost, and where it’s available
require branches to be up to date before mergingeach branch, after it takes in the latest basemore 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 queuethe pull request applied to the latest base and the pull requests queued ahead of itwhen 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:

ts
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.

  1. 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), and git log -p -- src/models/user.ts lists the commits that changed it, with their authors and diffs.
  2. 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.
  3. fix forward on the merged code: user.primaryEmail in the reminder job, a teamId passed in acceptInvite, one slug helper instead of two, a new number for whichever error code hasn’t shipped.
  4. 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):

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:

text
[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:

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