Semantic Conflicts & Merge Verification Jump to heading

Git reports a conflict when two branches change the same lines. It says nothing when two branches change different lines in ways that break each other: one branch renames a function while another adds a new call to the old name; one changes a default timeout while another adds code that relies on the old value; one removes a database column while another starts writing to it. These semantic conflicts merge cleanly, pass each branch’s own CI, and fail only on the combined code — often in production. A second, related blind spot is the conflict resolution itself: whatever someone typed while resolving a conflict lands in a merge commit that most review tools never show. This part of Conflict Resolution & Safe Merge Operations covers the tools that close both gaps: previewing merges before they happen, inspecting resolutions after they happen, and testing the merged result rather than each side alone.

Prerequisites Jump to heading

Why Clean Merges Break Jump to heading

A three-way merge compares each side with the merge base and combines the two sets of textual changes. It has no model of what the code means. If the two sets touch different lines, the merge succeeds — even when the second set depends on something the first set changed.

Two green branches, one red mergeBranch A renames charge() to charge_card() and updates every caller it knows about. Branch B, created earlier, adds a new call to charge(). Each branch passes its own CI. Their merge is textually clean, but the combined code calls a function that no longer exists.each side is green; the merge is redmainBMbranch A: renameBA1branch B: new callBB1B2merge resultfailsno lines overlap, so Git sees nothing to stop for Two green branches, one red mergeBranch A renames charge() to charge_card() and updates every caller it knows about. Branch B, created earlier, adds a new call to charge(). Each branch passes its own CI. Their merge is textually clean, but the combined code calls a function that no longer exists.each side is green; the merge is redmainBMbranch A: renameBA1branch B: new callBB1B2merge resultfailsno lines overlap, so Git sees nothing to stop for
# Branch A (merged first): renames the function and its known callers
def charge_card(account, amount): ...

# Branch B (merged second): written against the old name, in a different file
def renew_subscription(account):
    charge(account, account.plan.price)     # NameError after the merge

The classic forms are renamed or removed symbols, changed function signatures, changed defaults or configuration semantics, schema changes, and duplicate registrations — two branches each adding a migration with the same number, or a route with the same path. All of them merge without a single conflict marker.

A Taxonomy of Semantic Conflicts Jump to heading

Naming the kinds of semantic conflict helps, because each kind has a different cheapest defence. Six account for nearly all the incidents teams report.

Removed or renamed symbols. One branch deletes or renames a function, class, configuration key or environment variable; another adds a new use of the old name. In compiled or type-checked code this is a build error on the merged tree. In dynamic code it is a runtime error on the first call, which may be days later. A type checker or an import-everything smoke test on the merge result catches most of them.

Changed signatures. One branch adds a required parameter or changes a return type; another adds a call written against the old shape. Like renames, these are type errors when types exist, and test failures when the new call is exercised.

Changed defaults and semantics. One branch changes what a function does when called the same way — a timeout from thirty seconds to five, a sort from stable to unstable, a currency from minor units to major units. Another branch adds code that relied on the old behaviour. Nothing structural breaks. Only a test that exercises the new caller against the new behaviour can see it, which is why these are the most expensive class.

Duplicate registrations. Two branches each add a database migration with the next free number, a route at the same path, a feature flag with the same name, or a plugin with the same identifier. Each branch is internally consistent; the merge has two of something that must be unique. A listing-and-deduplication check on the merged tree catches all of them in milliseconds.

Schema and data-contract drift. One branch removes or renames a column, field or message attribute; another starts reading or writing it. These often slip past tests that use fixtures generated from the old schema. Contract tests and migration checks on the merged result are the defence.

Ordering and initialisation. One branch reorders middleware or start-up steps; another adds a step that assumes the old order. These are rare and subtle, and usually caught only by end-to-end tests.

Semantic conflicts by cost to catchDuplicate registrations are caught by trivial listing checks. Removed symbols and changed signatures are caught by type checking the merged code. Schema drift needs contract tests. Changed defaults and ordering assumptions need integration or end-to-end tests that exercise the interaction directly.cheapest defence firstDuplicate registrationsuniq -d on the merged treeRemoved symbols, signaturestype check the merge resultSchema and contract driftcontract tests on the mergeChanged defaults, orderingintegration and end-to-end testscover the top two layers with seconds of CI and the bottom two with deliberate tests Semantic conflicts by cost to catchDuplicate registrations are caught by trivial listing checks. Removed symbols and changed signatures are caught by type checking the merged code. Schema drift needs contract tests. Changed defaults and ordering assumptions need integration or end-to-end tests that exercise the interaction directly.cheapest defence firstDuplicate registrationsuniq -d on the merged treeRemoved symbols, signaturestype check the merge resultSchema and contract driftcontract tests on the mergeChanged defaults, orderingintegration and end-to-end testscover the top two layers with seconds of CI and the bottom two with deliberate tests

Step 1 — Test the Merge Result, Not the Branch Jump to heading

The most effective single change is to run CI on the commit that will actually exist after merging, rather than on the pull request’s head. Hosted forges expose this as a synthetic merge ref for pull requests, and merge queues build it explicitly.

# GitHub Actions: pull_request events check out the merge commit by default
on: pull_request
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # ref: refs/pull/<n>/merge — the merged result
      - run: make test
# Verification: CI's checkout is a merge of the PR into its base, not the PR head
git log -1 --format='%h parents: %p'      # two parents in the job's log

The merge ref is computed when the pull request is updated, not when it merges. If main moves afterwards, the tested merge is stale. Requiring branches to be up to date, or using a merge queue that builds the final combination, closes that window; the trade-offs are in merge queues and required checks. The full CI pattern is in catching semantic conflicts in CI.

Step 2 — Preview Merges Without Touching the Working Tree Jump to heading

Before merging — or to check many pairs of branches at once — compute the merge in memory. git merge-tree --write-tree performs a real merge with the default strategy, writes the resulting tree to the object store, and reports conflicts, all without a checkout.

git merge-tree --write-tree main feature/renewals
# 3f1c9a2e...   <- tree ID of the merged result (clean merge)

git merge-tree --write-tree --name-only main feature/payments-v2
# 7a0b2c4d...
# src/payments/client.py            <- conflicted path
echo "exit: $?"                     # 1 when there are conflicts
Previewing a merge in memorymerge-tree reads the two branch tips and their merge base, performs the merge in the object database, writes the result tree, and reports whether it conflicted. Nothing is checked out and no branch moves, so it is safe to run on a server or across hundreds of branch pairs.Two tipsmain, featuremerge-tree--write-treeResult treein object storeExit code0 clean / 1 conflictBuild itcommit-tree + CIa clean preview is only the textual answer — building the tree gives the semantic one Previewing a merge in memorymerge-tree reads the two branch tips and their merge base, performs the merge in the object database, writes the result tree, and reports whether it conflicted. Nothing is checked out and no branch moves, so it is safe to run on a server or across hundreds of branch pairs.Two tipsmain, featuremerge-tree--write-treeResult treein object storeExit code0 clean / 1 conflictBuild itcommit-tree + CIa clean preview is only the textual answer — building the tree gives the semantic one

A clean preview is not a verdict; it is the input to one. Turn the tree into a commit with git commit-tree and run your tests on it, and you have tested a merge that has not happened yet. The details, including scanning every open branch against main, are in previewing merges with git merge-tree.

Step 3 — Review What Happened Inside Merge Commits Jump to heading

When a merge does conflict, the person resolving it writes new code into the merge commit. Most diff views of a merge show either nothing (combined diff hides cleanly merged hunks) or everything (diff against one parent shows the whole other branch). --remerge-diff shows exactly what the resolver changed relative to what Git would have produced automatically.

git show --remerge-diff 5a9e3c1
git log --remerge-diff --merges --since=2.weeks -- src/payments/
Three ways to look at a merge commitA diff against the first parent shows everything the other branch brought, which buries the resolution. The default combined diff hides hunks that merged cleanly and is hard to read. The remerge diff shows only what the resolver changed relative to Git's own automatic merge.ShowsGood fordiff HEAD^1 HEADthe whole incoming branchwhat the merge broughtgit show (--cc)overlapping hunks onlyquick glancegit show --remerge-diffthe resolver's own editsreviewing resolutionsremerge-diff is the only view that answers 'what did a human add here?' Three ways to look at a merge commitA diff against the first parent shows everything the other branch brought, which buries the resolution. The default combined diff hides hunks that merged cleanly and is hard to read. The remerge diff shows only what the resolver changed relative to Git's own automatic merge.ShowsGood fordiff HEAD^1 HEADthe whole incoming branchwhat the merge broughtgit show (--cc)overlapping hunks onlyquick glancegit show --remerge-diffthe resolver's own editsreviewing resolutionsremerge-diff is the only view that answers 'what did a human add here?'

Resolutions are where unreviewed code most often enters a codebase, and also where many semantic conflicts are fixed, sometimes wrongly. Making them reviewable is covered in reviewing conflict resolutions with remerge-diff.

Step 4 — Catch Classes of Semantic Conflict Early Jump to heading

Some semantic conflicts have recognisable shapes that a cheap check catches before tests run: duplicate migration numbers, duplicate route paths, references to symbols removed on the base branch. Run these checks on the merge result.

# Duplicate migration identifiers in the merged tree
git ls-tree -r --name-only "$MERGE_TREE" db/migrations/ |
  sed -n 's#.*/\([0-9]\{4,\}\)_.*#\1#p' | sort | uniq -d

# Symbols deleted on main that the branch still references
git diff --unified=0 "$(git merge-base main HEAD)" main -- '*.py' |
  sed -n 's/^-def \([a-zA-Z_][a-zA-Z0-9_]*\).*/\1/p' |
  while read -r fn; do git grep -n "\b$fn(" HEAD -- '*.py' && echo "^ still calls removed $fn"; done
Common semantic conflicts and their cheap checksRenamed or removed symbols are caught by a type checker or a grep on the merged tree. Duplicate migrations and routes are caught by listing identifiers and finding duplicates. Changed defaults and behaviour need tests that exercise the interaction, which no static check can replace.Removed symboltype check / grepon merge resultDuplicate IDsmigrations, routesuniq -dSignature changecompiler / type checkChanged defaultintegration testsonlystatic checks are fast and catch the common shapes; tests catch the rest Common semantic conflicts and their cheap checksRenamed or removed symbols are caught by a type checker or a grep on the merged tree. Duplicate migrations and routes are caught by listing identifiers and finding duplicates. Changed defaults and behaviour need tests that exercise the interaction, which no static check can replace.Removed symboltype check / grepon merge resultDuplicate IDsmigrations, routesuniq -dSignature changecompiler / type checkChanged defaultintegration testsonlystatic checks are fast and catch the common shapes; tests catch the rest

Team Rollout Jump to heading

Introducing merge verification is mostly a matter of changing what CI checks out and what is required before merging. A staged rollout avoids surprising anyone.

Teams usually find that the first two items catch most of the problem. The merge queue matters on busy branches, and the remerge-diff comment matters on teams that merge main into feature branches rather than rebasing.

Rolling out merge verificationThe first week audits which pipelines test heads instead of merges. The second switches pull request builds to merge refs and adds the merge-sanity job. Busy branches get a merge queue next, followed by remerge-diff comments and targeted integration tests drawn from past incidents.Audithead vs merge checkoutweek 1Merge refs+ merge-sanity jobweek 2Merge queuebusy branches onlyweek 3Remerge-diffPR commentweek 4Interaction testsfrom incidentsongoingthe first two weeks deliver most of the benefit Rolling out merge verificationThe first week audits which pipelines test heads instead of merges. The second switches pull request builds to merge refs and adds the merge-sanity job. Busy branches get a merge queue next, followed by remerge-diff comments and targeted integration tests drawn from past incidents.Audithead vs merge checkoutweek 1Merge refs+ merge-sanity jobweek 2Merge queuebusy branches onlyweek 3Remerge-diffPR commentweek 4Interaction testsfrom incidentsongoingthe first two weeks deliver most of the benefit

Integration with Adjacent Workflows Jump to heading

Merge verification sits between the tools that produce changes and the tools that land them. Its boundaries with neighbouring topics are worth stating precisely.

  • Merge queues decide when the final combination is built. Verification decides what is checked on it. A queue that runs only unit tests on the candidate merge leaves semantic conflicts to be found later; see merge queues and required checks.
  • rerere replays conflict resolutions automatically. That makes --remerge-diff review more important, not less, because a replayed resolution was never looked at in its new context; see rerere conflict automation.
  • Bisect finds which commit broke something. When the culprit is a merge whose two sides are each fine, the problem is a semantic conflict, and the tools on this page explain it; see bisecting across merges with --first-parent.
  • Conflict prevention reduces textual conflicts by structure. Some of the same changes — fragment files, sorted lists — also remove classes of semantic conflict such as duplicate registrations; see conflict prevention by design.

Configuration Reference Jump to heading

Command or optionEffectWhen to use
git merge-tree --write-tree A BMerge in memory, print result tree, exit 1 on conflictPreviewing merges in CI or scripts
--name-only (merge-tree)List conflicted paths onlyQuick conflict scan across many branches
git commit-tree <tree> -p A -p BTurn a merged tree into a testable commitBuilding a preview merge in CI
git show --remerge-diff <merge>Diff a merge against Git’s automatic resultReviewing conflict resolutions
git log --remerge-diff --mergesRemerge diffs for a range of mergesAuditing resolutions over time
refs/pull/<n>/mergeForge’s synthetic merge of a PR into its baseTesting the merge result on each PR
Require branches up to dateMerge ref must be based on current mainClosing the stale-merge window
Merge queueBuilds and tests the exact final combinationHigh-traffic branches

Troubleshooting Jump to heading

SymptomLikely causeFix
Both PRs green, main red after merging bothSemantic conflict between themTest merge results; use a merge queue
CI tested a merge, main still brokeMerge ref was stale when it mergedRequire up-to-date branches or a queue
merge-tree --write-tree unknown optionGit older than 2.38Upgrade Git on runners
Merge commit contains code nobody reviewedEdits made during conflict resolutionReview with --remerge-diff; require it in review
Bisect lands on a merge whose sides both passInteraction between the two sidesTest m^2 on m^1’s code; fix the interaction
Duplicate migrations reach mainTwo branches picked the same numberCheck the merged tree for duplicate IDs

Frequently Asked Questions Jump to heading

Isn’t a semantic conflict just a missing test? Jump to heading

Partly. Tests catch semantic conflicts only if they run on the merged code and exercise the interaction. Most teams run tests on each branch separately, which is exactly the configuration that cannot see the problem. Testing the merge result fixes the configuration; good integration tests fix the coverage.

Do merge queues make this topic unnecessary? Jump to heading

They solve the timing problem — the tested combination is the one that lands. They do not decide what is tested, and they do not show what a person wrote while resolving conflicts. The checks and reviews on this page still apply inside a queue.

How expensive is testing every merge result? Jump to heading

No more expensive than testing every pull request head, which most teams already do. The merge ref replaces the head as the thing being tested. The extra cost appears only when the base moves often, which is what merge queue batching is for.

Can merge-tree previews run on the forge’s server side? Jump to heading

They need only the repository’s objects, so any job with a clone can run them. Because they never touch a working tree, they are safe to run in parallel across many branches on one clone.

What is the single highest-value change if we only do one thing? Jump to heading

Make pull request CI test the merge result rather than the branch head, and keep it current by requiring branches to be up to date. That one configuration change turns most semantic conflicts from production incidents into failed checks.