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.
# 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.
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 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/ 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 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.
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-diffreview 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 option | Effect | When to use |
|---|---|---|
git merge-tree --write-tree A B | Merge in memory, print result tree, exit 1 on conflict | Previewing merges in CI or scripts |
--name-only (merge-tree) | List conflicted paths only | Quick conflict scan across many branches |
git commit-tree <tree> -p A -p B | Turn a merged tree into a testable commit | Building a preview merge in CI |
git show --remerge-diff <merge> | Diff a merge against Git’s automatic result | Reviewing conflict resolutions |
git log --remerge-diff --merges | Remerge diffs for a range of merges | Auditing resolutions over time |
refs/pull/<n>/merge | Forge’s synthetic merge of a PR into its base | Testing the merge result on each PR |
| Require branches up to date | Merge ref must be based on current main | Closing the stale-merge window |
| Merge queue | Builds and tests the exact final combination | High-traffic branches |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Both PRs green, main red after merging both | Semantic conflict between them | Test merge results; use a merge queue |
| CI tested a merge, main still broke | Merge ref was stale when it merged | Require up-to-date branches or a queue |
merge-tree --write-tree unknown option | Git older than 2.38 | Upgrade Git on runners |
| Merge commit contains code nobody reviewed | Edits made during conflict resolution | Review with --remerge-diff; require it in review |
| Bisect lands on a merge whose sides both pass | Interaction between the two sides | Test m^2 on m^1’s code; fix the interaction |
| Duplicate migrations reach main | Two branches picked the same number | Check 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.
Related Jump to heading
- Previewing Merges with git merge-tree — compute merges in memory, conflict-check every open branch, and test results before merging.
- Reviewing Conflict Resolutions with Remerge-Diff — see exactly what a person changed while resolving a merge.
- Catching Semantic Conflicts in CI — test the merged result, close the stale-merge window, and add cheap checks for common shapes.
- Merge Queues & Required Checks — the landing mechanism that builds the exact final combination.
- Conflict Prevention by Design — structural changes that remove whole classes of conflict.