Git semantic merge conflicts arise when independently reasonable changes combine into an invalid program. A merge can complete without unresolved paths while a moved caller still uses a contract that another branch has removed. The useful acceptance question is therefore not merely whether Git finished, but whether the integrated program preserves the intended behavior of both changes.
This guide addresses refactoring across files and changes to function contracts. It is not a tutorial on exchanging patches, selecting individual changes for a commit, or designing a branching policy. Its example is deliberately small: a synthetic label function, two local branches, and a contract check. Everything runs inside a newly created offline sandbox, without contacting a service or modifying an existing repository.
The development note behind this article supplies the general problem: code movement can separate a changed interface from the call sites that need to follow it. Project identifiers, incident quantities, and environment details are intentionally omitted. The technical conclusions below combine that general observation with official documentation and an executed, version-pinned counterexample.
Why a clean merge can produce an invalid program
A textual merge reconciles file content and development history; it does not establish every behavioral relationship between files. If one branch removes a function parameter while another moves a caller that supplies that parameter, Git may have no overlapping text to resolve, even though executing the combined program fails.
There are two different failure categories. A textual conflict leaves an unresolved choice for the person integrating the branches. A semantic conflict violates an application contract after those changes are combined. Either category can occur without the other. No conflict markers is evidence about the textual result, not a statement about the validity of every call in the program.
The lab starts with a function named label that accepts an optional argument. Its initial caller uses the default. One branch removes the optional argument and returns the same label directly; the original caller still works. The other branch moves the caller to a new file and supplies the original default value explicitly; that is valid against the original function. Both branches pass their own check.
After merging, the moved caller supplies an argument to a function that accepts none. The failure crosses a file boundary and a branch boundary. Accepting either file in isolation cannot answer whether the pair is compatible. This is why the acceptance test must execute the integrated entry point rather than rely on the earlier branch results.
Python also makes an important limit visible: compiling these files to bytecode does not execute the incompatible call. The example therefore separates textual completion, successful compilation, and successful execution. It does not claim that another language or compiler would discover the problem at the same stage; those toolchains were not tested here.
Use the merge base to reconstruct each branch's intent
The merge base gives reviewers a shared historical reference for separating an inherited member from a new addition or an intentional removal. Compare each branch against that reference before deciding that missing text represents lost functionality. A file-level difference is a starting point for investigation, not a complete explanation of intent.
In the controlled history below, git merge-base --all api-change refactor identifies the original commit. The lab checks the original provider, the changed provider, and the moved caller at their respective commits. Those comparisons establish where the incompatible assumptions came from: one branch changed the accepted inputs, while the other retained an older calling convention in a new location.
Do not generalize the lab's single-base assertion to every repository. Git's official documentation explains that a pair of commits can have more than one best common ancestor, including some histories with criss-cross merges. The program deliberately creates a simple graph and asserts the base expected for that graph. A different graph needs a separate interpretation rather than silently taking one result as universally authoritative.
For a larger refactor, create a small responsibility map. Record the original member, its new location, any signature change, the required behavior, and the test that covers it. Names alone are insufficient: identical names can have different contracts, and renamed members can preserve the same responsibility. Text search helps locate candidates, but it does not prove the absence of indirect references or every possible call path.
Follow the moved behavior instead of choosing a whole file
The acceptance target is preserved responsibility at the new execution point, not a familiar filename or a reassuring line count. First determine what each branch intended to retain, change, or remove. Then apply the smallest compatible adjustment where the behavior actually lives after refactoring, and test that integrated path.
When one branch removes a large section and another edits it, the obvious temptation is to choose an entire side and move on. That can establish a starting version of a file, but it cannot validate the relationships between that file and newly introduced callers. This guide deliberately avoids presenting a whole-file replacement command as a universal resolution strategy.
Instead, distinguish movement from removal. A moved responsibility needs its local changes transferred to the new location. An intentional removal needs a reason and a review of its dependencies. Reintroducing old code simply because it appears in a comparison can accidentally revive behavior that the refactor meant to retire. Conversely, accepting a removal without accounting for a new requirement can discard useful work.
In the lab, the smallest valid adjustment is to stop supplying the removed argument in the moved caller. The expected label remains unchanged, so the repair preserves the observable requirement common to both branches. That conclusion depends on the synthetic contract. In another program, removing an argument might discard meaningful behavior; the appropriate repair would then require a different design and different tests.
A review note should name the changed contract, the affected entry point, the reason the integrated version failed, and the behavior retained by the repair. “Conflicts resolved” does not capture those decisions. A concise explanation connected to executable evidence is more useful than a long account of which merge-editor buttons were pressed.
Verification: reproduce a clean merge that fails its contract
The following lab creates two branches that each pass an executable check, then combines them without unresolved paths. It observes a failed contract call despite successful bytecode compilation, repairs only the moved caller, and verifies the restored behavior and two-parent merge history. All changes are confined to a new offline sandbox.
Fixed prerequisites: Windows, Git for Windows 2.38.1.windows.1, Python 3.11.15, both commands available, and a writable temporary directory. These are the versions used for this experiment, not recommendations for a new installation. The program stops on a version mismatch. Do not remove the checks and describe the result as an execution against the same pinned baseline. Other platforms and versions were not exercised.
- Save the complete program below as
semantic_merge_lab.py. Rungit --versionandpython --version, and confirm the exact prerequisites. Run normal Python without-O, because the program uses assertions for its acceptance conditions. - Run
python semantic_merge_lab.py. Inspect the JSON output: bothbranchChecksvalues must betrue, andcheck1.mergeBaseMatchesOriginalmust betrue. The program creates its own repository; do not run its internal commands against an existing project. - Inspect
check2. ExpectmergeExitandcompileExitto equal0, andunmergedPathsto equal0. At the same time,runtimeExitmust be nonzero andruntimeErrormust beTypeError. That failed application check is the intended negative control, not an unexpected failure of the overall lab. - Inspect
check3. RequirerepairedExitof0,stdoutofcontract-ok,mergeParentCountof2, and a clean working tree. A missing condition, an unexpected command error, or a nonzero exit from the main lab means the verification did not complete.
"""Offline-only merge contract lab. Creates a new sandbox; no existing repo is changed."""
import json
import os
from pathlib import Path
import platform
import subprocess
import sys
import tempfile
assert platform.python_version() == '3.11.15', 'Use Python 3.11.15'
assert not sys.flags.optimize, 'Run without -O'
version = subprocess.check_output(['git', '--version'], text=True).strip()
assert version == 'git version 2.38.1.windows.1', 'Use Git for Windows 2.38.1.windows.1'
root = Path(tempfile.mkdtemp(prefix='semantic-merge-lab-'))
repo = root / 'repo'
repo.mkdir()
empty = root / 'empty-config'
empty.write_text('', encoding='utf-8')
hooks = root / 'empty-hooks'
hooks.mkdir()
env = {k: v for k, v in os.environ.items()
if not k.startswith('GIT_') and not k.startswith('PYTHON')}
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=str(empty),
PYTHONDONTWRITEBYTECODE='1', PYTHONIOENCODING='utf-8')
def run(args, expected=0):
r = subprocess.run(args, cwd=repo, env=env, text=True,
capture_output=True, encoding='utf-8')
if expected is not None and r.returncode != expected:
raise RuntimeError(f'Unexpected exit {r.returncode}: {args[0]}')
return r
def git(*args):
return run(['git', '-c', 'user.name=Sandbox', '-c', 'user.email=sandbox',
'-c', 'commit.gpgsign=false', '-c', 'core.autocrlf=false',
'-c', f'core.hooksPath={hooks}', *args])
def write(name, body):
(repo / name).write_text(body, encoding='utf-8', newline='\n')
def commit(message):
git('add', '--all')
git('commit', '-m', message)
def test():
return run([sys.executable, '-B', 'check.py'], expected=None)
git('init', '-b', 'base')
write('.gitignore', '__pycache__/\n')
write('provider.py', "def label(name='base'):\n return name\n")
write('old_caller.py', 'from provider import label\n\ndef render():\n return label()\n')
write('check.py', "from old_caller import render\nassert render() == 'base'\nprint('contract-ok')\n")
commit('base contract')
base = git('rev-parse', 'HEAD').stdout.strip()
assert test().returncode == 0
git('switch', '-c', 'api-change')
write('provider.py', "def label():\n return 'base'\n")
commit('remove optional argument')
api = git('rev-parse', 'HEAD').stdout.strip()
api_passed = test().returncode == 0
assert api_passed
git('switch', '-c', 'refactor', 'base')
git('mv', 'old_caller.py', 'moved_caller.py')
write('moved_caller.py', "from provider import label\n\ndef render():\n return label('base')\n")
write('check.py', "from moved_caller import render\nassert render() == 'base'\nprint('contract-ok')\n")
commit('move caller and make argument explicit')
refactor = git('rev-parse', 'HEAD').stdout.strip()
refactor_passed = test().returncode == 0
assert refactor_passed
bases = git('merge-base', '--all', 'api-change', 'refactor').stdout.split()
assert bases == [base]
assert "name='base'" in git('show', f'{base}:provider.py').stdout
assert 'def label()' in git('show', f'{api}:provider.py').stdout
assert "label('base')" in git('show', f'{refactor}:moved_caller.py').stdout
git('switch', 'api-change')
merge = git('merge', '--no-ff', '--no-commit', 'refactor')
unmerged = git('diff', '--name-only', '--diff-filter=U').stdout.strip()
assert not unmerged
compiled = run([sys.executable, '-m', 'py_compile',
'provider.py', 'moved_caller.py', 'check.py'])
broken = test()
assert broken.returncode != 0 and 'TypeError' in broken.stderr
write('moved_caller.py', 'from provider import label\n\ndef render():\n return label()\n')
fixed = test()
assert fixed.returncode == 0 and fixed.stdout.strip() == 'contract-ok'
commit('integrate contract with moved caller')
assert not git('status', '--porcelain').stdout.strip()
parents = git('rev-list', '--parents', '-n', '1', 'HEAD').stdout.split()[1:]
assert parents == [api, refactor]
print(json.dumps({'git': version, 'python': platform.python_version(),
'branchChecks': {'apiChangePassed': api_passed, 'refactorPassed': refactor_passed},
'check1': {'mergeBaseMatchesOriginal': bases == [base], 'mergeBaseCount': len(bases)},
'check2': {'mergeExit': merge.returncode, 'unmergedPaths': 0,
'compileExit': compiled.returncode, 'runtimeExit': broken.returncode,
'runtimeError': 'TypeError'},
'check3': {'repairedExit': fixed.returncode, 'stdout': fixed.stdout.strip(),
'mergeParentCount': len(parents), 'cleanWorktree': True},
'scope': 'new offline sandbox only; no remote configured'}, indent=2))
The code creates only synthetic files and local commits. It does not configure a remote. The identity strings Sandbox and sandbox are fictional labels, and the Git settings apply only to child processes. The lab isolates external Git configuration and uses an empty hooks directory so unrelated local settings do not silently alter the demonstration. It leaves the newly created sandbox available for inspection rather than automatically deleting other files.
Executed results and the limits of the evidence
The complete lab was executed in the scheduler environment, reproducing successful textual integration and compilation alongside a failed runtime contract check. Changing the moved call restored the expected behavior. This establishes the counterexample on the stated baseline; it does not certify every refactor, operating system, language, or Git version.
The actual tools reported git version 2.38.1.windows.1 and Python 3.11.15. Each branch passed before integration, and the merge base matched the original commit. The merge returned 0 with no unresolved paths. Bytecode compilation also returned 0, while the broken contract check returned 1 and identified TypeError. After repair, the check returned 0 and printed contract-ok. The final commit had two parents, and the working tree was clean.
These are observed outcomes, not timing results, a success rate, or a benchmark. No merge-strategy performance comparison was run, and no estimate of engineering time saved follows from this experiment. The supported statement is narrow: this clean textual merge combined incompatible calling assumptions, and this specific adjustment restored the synthetic contract.
The coverage limit matters just as much as the successful result. Compilation covered the named files, and the executable check covered the value returned by render. Additional inputs, alternate paths, and state changes need their own checks in a development test project. Passing this small example is not a substitute for testing an entire application.
A review checklist focused on integrated behavior
A useful merge review combines history, file-state evidence, and executable behavior. Historical evidence explains what was combined; a negative control demonstrates that the check can detect the incompatibility; the repaired check establishes the intended result. None of these alone justifies treating every affected path as verified.
- History: identify branch endpoints and the merge base, and state each branch's intended change independently. Reassess the method if the history has multiple best common ancestors.
- Structure: classify affected members as moved, renamed, changed, or intentionally removed. Do not use similar line counts as proof of completeness.
- Contract: run checks on the integrated result. Passing results from the two pre-merge branches do not establish that their combination works.
- Negative control: preserve a case that fails with the outdated calling convention, then demonstrate that the intended repair makes the same behavior pass.
- Scope: record tool versions, covered inputs, and untested cases. Narrow the conclusion when coverage is limited rather than upgrading a small test into a broad assurance.
For an engineering review process, useful measures could include whether interface-changing merges carry post-integration test evidence and how many contract regressions are discovered after integration. Those are proposed quality indicators, not reported achievements or measured improvements from this article. Their purpose is to improve the relevance of acceptance checks, not to reward the number of tests regardless of what they cover.
Official references and the practical conclusion
The official sources establish how Git describes merged history and common ancestors, and what Python bytecode compilation does. The local experiment supplies the concrete behavioral evidence. The development note provides the general problem framing; it is not treated as proof of every command, compatibility claim, or possible outcome.
- Git v2.38.1: git-merge source documentation supports the history-integration model, pre-merge checks, and the documented boundary of conflict handling.
- Git v2.38.1: git-merge-base source documentation defines best common ancestors and explains why some histories can have more than one merge base.
- Python 3.11: py_compile documentation describes compiling source files to bytecode and command-line failure reporting. This maintained documentation branch is not a claim that the experiment used its newest patch release; the executed interpreter remains pinned to
3.11.15.
A successful Git merge is not a behavioral compatibility guarantee. Trace changed contracts through moved code, compare both branches with their shared history, and test the entry point in the integrated result. Start with this offline counterexample, then build a merge acceptance record that states what failed, what was repaired, and which behavior the evidence actually covers.