Git 語意衝突的核心問題,是兩個分支各自成立的假設,在合併後不再相容。 沒有衝突標記,只代表文字合併沒有留下待裁決項目,不代表呼叫契約、成員完整性與執行結果都正確。遇到方法搬移與介面調整交錯的變更,應先確認共同祖先,再追蹤新位置,最後驗證整合後的程式。

本文針對跨檔案重構的合併審查,不討論補丁交換、部分暫存或團隊分支政策。範例使用完全合成的標籤函式,只在新建的離線測試資料夾執行,不需要連線到任何服務。技術結論來自開發筆記的通用觀念、Git 官方原始文件,以及本文實際執行的最小反例;不沿用筆記中的專案識別、事件數量或環境設定。

為何合併成功仍會破壞呼叫契約

文字合併處理的是檔案內容與開發歷史,不會替應用程式判定所有行為是否相容。當一邊縮減函式參數,另一邊在搬移後的呼叫點保留舊參數,兩邊可能沒有文字交集,整合結果卻無法執行。

把「衝突」拆成兩層會更容易理解。第一層是 Git 需要人工處理的文字衝突;第二層是程式組合後違反契約的語意衝突。兩者可以同時發生,也可以只發生第二層。介面更動與呼叫點位於不同檔案時,沒有未合併路徑是合理結果,不是 Git 忘記顯示錯誤。

本文實驗的共同祖先提供一個具有預設參數的 label 函式,呼叫端原本省略參數。介面分支移除可選參數,但保留原本的回傳值,所以原呼叫端仍能通過測試。重構分支搬移呼叫端,並明確傳入與預設值相同的文字;它對舊介面也合法。兩個分支各自通過,合併後卻出現「無參數函式收到參數」的錯誤。

這個反例刻意區分三種證據:文字整合完成、語法可編譯、契約可執行。Python 的位元組碼編譯不會執行這個呼叫,因此不能把它當作完整型別檢查。本文沒有測試其他語言的編譯器,也不推論所有工具鏈都會在相同階段報錯。

先用共同祖先分開兩邊的變更意圖

共同祖先提供兩個分支開始分歧時的比較基準,能協助區分原有成員、新增行為與刻意刪除。審查時應分別閱讀兩邊相對基準的差異,而不是直接把檔案消失判定為資料遺失或重構完成。

在本文的簡單歷史裡,git merge-base --all api-change refactor 應回傳唯一的起始提交。接著比較基準中的提供者、介面分支中的提供者,以及重構分支中的新呼叫端。這樣才知道參數移除與呼叫搬移來自不同變更,不能靠選擇其中一份檔案同時滿足兩個目的。

共同祖先不一定只有一個。 Git 官方文件明確指出,部分交錯合併歷史可能具有多個最佳共同祖先。因此本文程式只對自行建立的簡單分支圖斷言數量為一;若套用到不同歷史,應重新評估,不能把第一個輸出視為普遍唯一的真相。

成員清單也只能當作審查入口。相同名稱可能對應不同參數、不同型別或不同責任;不同名稱也可能是同一行為的搬移。實務審查應建立「原位置、新位置、簽章變更、預期行為、對應測試」的對照表,再用語言工具確認引用關係。文字搜尋方便縮小範圍,但不足以證明沒有間接呼叫。

搬移方法後要追蹤責任,不只追蹤檔名

跨檔案重構的驗收重點是原有責任是否在新位置延續,以及新增變更是否重新套用到真正的執行入口。檔案名稱相同或差異行數變少,都不能單獨證明功能保留,也不能取代呼叫契約的測試。

遇到大段移除與另一邊的修改互相交錯時,先列出受影響的公開介面與直接呼叫端,再查看新結構如何安排行為。若某段原始碼確實搬走,修正應落在新位置;若它是刻意刪除的功能,則需要刪除理由與依賴清單。不能只為了消除差異而把舊實作重新貼回去,否則可能讓已移除的責任重新出現。

也不要把「採用某一邊」當作完整解法。它至多決定某個檔案的初始內容,無法保證其他檔案中的新呼叫都已跟上。本文不提供整檔覆蓋指令作為通用處方,而是先證明應保留哪些契約,再做最小修正。範例只將新位置的呼叫改成無參數形式,維持兩邊共同要求的回傳行為。

審查記錄可以簡短,但必須能回答責任問題:哪一個契約被修改?哪一個入口會觸發它?哪個測試原本應通過?合併後為什麼失敗?修正之後究竟保留哪個預期?這比只註記「解完衝突」更能支援後續維護。

驗證與重現:建立乾淨合併仍失敗的反例

本實驗在全新離線資料夾建立兩個可獨立通過測試的分支,證明它們能完成文字合併,卻在執行契約檢查時失敗。最後只修正搬移後的呼叫點,確認相同行為恢復,並檢查合併提交仍保留兩個父提交。

固定前置條件: Windows、Git for Windows 2.38.1.windows.1、Python 3.11.15,兩個程式都可由命令列啟動,暫存目錄可寫入。這是本次實驗的版本釘選,不是新安裝的版本建議。程式遇到不同版本會停止;不要刪掉版本檢查後仍宣稱使用相同基準。跨平台或其他版本的執行結果,本文沒有驗證。

  1. 將下方完整程式儲存為 semantic_merge_lab.py。執行 git --version 與 python --version,確認符合上述固定版本;使用一般 python,不要加上會停用斷言的最佳化選項。
  2. 執行 python semantic_merge_lab.py。檢查輸出的 branchChecks 兩項皆為 true,且 check1.mergeBaseMatchesOriginal 為 true。程式會自行建立全新測試資料夾,不要求你切換進既有專案。
  3. 查看 check2:mergeExit 與 compileExit 應為 0,unmergedPaths 應為 0,但 runtimeExit 必須非零且 runtimeError 必須為 TypeError。這是預期失敗的反例,不是整個實驗未通過。
  4. 查看 check3:修正後 repairedExit 應為 0,stdout 應為 contract-ok,父提交數量應為 2,工作區應乾淨。缺少任一條件、出現未預期的指令錯誤,或主程式非零退出,都應視為驗證未完成。
"""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))

程式只在新資料夾中建立合成檔案與本地提交,未設定遠端。提交識別採用虛構的 Sandbox 與 sandbox,不使用個人資料;設定只對該次子程序生效。它隔離外部 Git 設定、使用空的 hooks 目錄,並保留測試資料夾供自行檢查,不自動清除其他檔案。

實際測試結果與證據邊界

排程環境已實際執行完整程式,得到乾淨文字合併、成功語法編譯與失敗執行檢查的組合;修正呼叫端後,相同契約檢查通過。這支持本文的有限結論,但不是所有重構案例、作業系統或工具版本的相容性保證。

本次使用 git version 2.38.1.windows.1 與 Python 3.11.15。兩個分支各自通過;共同祖先符合起始提交;合併退出碼為 0,未合併路徑為 0,編譯退出碼為 0,錯誤版本的執行退出碼為 1 並出現 TypeError。修正版退出碼為 0,輸出 contract-ok,合併提交有兩個父提交,工作區乾淨。

這些值是實際執行結果,不是效能測量或成功率。本文沒有計時、沒有比較合併策略速度,也沒有推估團隊可以節省多少工時。範例證明的是「可能發生」與「此修正符合合成契約」,不是「所有語意衝突都會被這份程式找出」。

需要特別留意,語法檢查通過只涵蓋列出的檔案;契約檢查只覆蓋 render 的回傳值。真實的開發測試專案若還有其他輸入、錯誤分支或狀態變化,必須另外設計案例。不能把本範例的通過條件直接擴大成整個系統的品質聲明。

審查清單:讓合併驗收對齊行為

合併驗收應同時檢查歷史、檔案狀態與可觀察行為,並保留失敗案例作為修正的對照。只記錄工作區乾淨會漏掉契約問題,只記錄測試通過又可能無法說明哪些變更被保留,因此需要兩類證據相互補足。

  • 歷史證據: 確認分支端點與共同祖先,分開描述兩邊的改動。遇到多個共同祖先時,停止套用本文的單一基準假設。
  • 結構證據: 逐一記錄成員是搬移、改名、改簽章還是刻意刪除,不拿行數相近當作完整性證明。
  • 契約證據: 在整合結果上執行測試,不能只引用合併之前兩個分支的綠燈。讓測試觸及移動後的入口,而非只測未被使用的舊函式。
  • 反例證據: 保留一個會使舊呼叫失敗的負向案例,確認檢查真的能識別問題;再對照修正後的正向結果。
  • 範圍證據: 清楚寫出固定版本、輸入範圍與未測項目。測試不完整時降低結論強度,而不是擴大承諾。

如果要在工程審查中追蹤改善,建議觀察「涉及介面變更的合併是否附帶整合後測試證據」與「合併後發現的契約回歸件數」。這些是待建立的品質指標,不是本文已取得的績效;衡量目的在改善驗收覆蓋,不是拿測試數量替代測試品質。

官方依據、適用範圍與結論

本文以官方文件確認合併歷史、共同祖先與位元組碼編譯的定義,再以固定版本實驗檢查具體行為。筆記提供問題背景,文件提供工具語意,測試提供反例;三者用途不同,不能把其中任何一項當作全部驗證。

結論:Git 合併成功不是行為相容性的證明。 跨重構整合時,先看共同祖先與責任搬移,再驗證新入口所使用的契約。從本文的離線反例開始,為你的測試專案建立一份能指出「失敗在哪裡、修正保留什麼」的合併驗收紀錄。