JSON 檔案的 Git 衝突:union 會寫出壞 JSON,按 key 合併才對
結論:兩個分支各加一個文案鍵,Git 卻報衝突,於是有人加上 merge=union 一行了事。實測下來,union 會讓缺逗號的 JSON 通過合併、讓重複鍵靜默覆蓋、讓單行 JSON 變成兩份文件,而 Git 全程認為「合併成功」。這篇給出按 key 三方合併的驅動寫法,以及三個能讓衝突變少的前置習慣。

結論:先統一格式化引數與鍵序,再談合併;用 merge=union 省掉的衝突,最後都會以缺逗號、重複鍵或兩份文件的形式還回來,JSON 的衝突只該交給按 key 的三方合併驅動。
兩個人在同一周給多語言文案加了兩個鍵:一個加 tools.diff,一個加 tools.schema。兩邊互不干擾,語義上完全相容。合併時 Git 卻報了衝突,衝突塊裡躺著兩行:
<<<<<<< HEAD
"diff": "對比"
=======
"schema": "Schema 生成"
>>>>>>> feat-b
理由不複雜:兩個分支都在檔案末尾的 } 前插入了新行,Git 的行級三方合併只看位置,不看語義,它沒有「這是兩個不同的鍵」這個概念。於是有人翻到 .gitattributes 的說明文件,抄了一行:
i18n.json merge=union
衝突確實消失了。但這一行換來的是更貴的問題。
一、union 做的不是合併,是保留兩邊的行
merge=union 的行為在 Git 官方說明裡寫得很清楚:跑文字檔案的三方合併,但兩邊的行都保留,不產生衝突標記。文檔緊跟著一句警告——「不要在不理解後果的情況下使用」。這句警告不是客套。
union 不理解什麼是物件、什麼是鍵、什麼是陣列。它只認識行。下面三個結果都是在本地倉庫裡實跑出來的,倉庫結構就是上面那個 i18n.json。
| 場景 | 預設驅動 | merge=union |
按 key 的自定義驅動 |
|---|---|---|---|
| 兩邊各加一個不同的鍵 | 報衝突,需人工 | 合併「成功」,缺逗號,非法 JSON | 合併成功,兩個鍵都在 |
| 兩邊改同一個鍵為不同值 | 報衝突,需人工 | 合併「成功」,重複鍵,靜默取後者 | 退出碼 1,報告衝突路徑 |
| 單行壓縮 JSON,兩邊各改一處 | 報衝突,整檔案二選一 | 合併「成功」,兩份文件堆疊 | 合併成功,但會被重新縮進 |
一邊刪 minify、一邊改相鄰的 parse |
報衝突,需人工 | 合併「成功」,刪掉的鍵復活,還缺逗號 | 合併成功:刪掉的沒了,改動生效 |
四行裡只有中間一列會讓 git merge 返回成功,而它產出的 JSON 全部不能用。這就是 union 在 JSON 上最反直覺的地方:越安靜,越危險。最後一行尤其典型——本來要刪掉一個鍵,合併完它又回來了,還帶出一個重複鍵和一個語法錯誤。
二、結果一:缺一個逗號的 JSON,Git 說合併成功
還是那兩個分支,加上 merge=union 之後重新合併:
$ git merge feat-b
Auto-merging i18n.json
Merge made by the 'ort' strategy.
i18n.json | 1 +
1 file changed, 1 insertion(+)
沒有衝突,合併提交直接生成。檔案長這樣:
{
"tools": {
"parse": "解析",
"minify": "壓縮",
"diff": "對比"
"schema": "Schema 生成"
}
}
"diff": "對比" 後面沒有逗號。因為兩行各自帶著自己的逗號被拼到一起,其中一行的逗號在拼接時落到了錯誤的位置——更準確地說,union 保留了「我們這側的 "diff": "對比"」和「他們那側的 , 加 "schema": ...」,拼起來就少了一個分隔符。
解析一下:
$ python3 -c "import json;json.load(open('i18n.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 10 column 5
這才是最危險的地方:Git 認為合併成功,CI 的 git merge 步驟透過,代碼評審看到的是一個「新增 1 行」的綠色 diff。錯誤要等到執行時第一次 JSON.parse 才炸,而那時已經離合併地點很遠了。
三、結果二:重複鍵,合法但靜默丟資料
第二個實驗換一種衝突形狀:兩個分支修改同一個鍵的值,一個改成「解析 / 格式化」,另一個改成「解析器」。union 之後:
{
"tools": {
"parse": "解析 / 格式化",
"parse": "解析器",
"minify": "壓縮"
}
}
這次 JSON 是合法的,JSON.parse 不報錯。但鍵 parse 出現了兩次。按 ECMAScript 的物件字面量語義,後者覆蓋前者,最終取到「解析器」——「解析 / 格式化」這次修改被靜默丟棄,沒有任何日誌、沒有任何報錯。
用 Python 的 object_pairs_hook 才看得到重複鍵:
檢測到重複鍵: ['parse']
JSON.parse 成功,最終取值 parse = '解析器'
這比非法 JSON 更難查:檔案能加載,頁面能渲染,只有那一條文案回到了舊值。三周後有人發現「這個翻譯怎麼沒生效」,才會往回翻歷史。
四、結果三:單行 JSON 直接變成兩份文檔
有些配置檔案是壓縮輸出的單行 JSON,比如構建產物裡那份。這種檔案在 union 下沒有「行」可拼,兩邊各是一整行,結果就是把兩整行摞起來:
{"tools":{"parse":"解析","minify":"壓縮","diff":"對比"},"version":1}
{"tools":{"parse":"解析","minify":"壓縮"},"version":2}
JSON.parse 的報錯是 Unexpected non-whitespace character after JSON at position 63——它在第一份文檔結束後遇到了第二份。Git 同樣報「Merge made」。
單行 JSON 本來就不該進版本庫(它是生成物),但只要它進去了,union 會讓它變成兩份互不相干的說明文件。
五、結果四:刪掉的鍵又回來了
最後一種形狀最容易騙過人。一邊把 minify 刪掉,另一邊改的是相鄰的 parse。預設驅動老老實實報衝突,union 則直接給出:
{
"tools": {
"parse": "解析"
"parse": "解析器",
"minify": "壓縮"
}
}
三個毛病同時出現:本該消失的 minify 復活了、parse 出現兩次、第一行末尾少了逗號。Git 的判斷依然是「2 insertions」,合併成功。
這個案例說明一件事:union 的「保留兩邊的行」在刪除場景下等價於撤銷刪除。任何一個團隊只要用 union 管過一段時間 JSON,就必然有一批「明明刪了卻還在」的配置項散落在歷史裡。
六、正確做法:按 key 做三方合併
Git 支援給特定路徑指定自定義 merge driver。驅動收到三個檔案路徑:%O 是共同祖先,%A 是當前分支(結果寫回這裡),%B 是合入分支。寫一個幾十行的 Node 腳本就夠:
import { readFileSync, writeFileSync } from 'node:fs';
const [basePath, oursPath, theirsPath] = process.argv.slice(2);
const read = (filePath) => JSON.parse(readFileSync(filePath, 'utf8'));
const [base, ours, theirs] = [read(basePath), read(oursPath), read(theirsPath)];
const sortKeys = (value) => Array.isArray(value)
? value.map(sortKeys)
: value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortKeys(value[key])]))
: value;
const same = (left, right) => JSON.stringify(sortKeys(left)) === JSON.stringify(sortKeys(right));
const conflicts = [];
function merge3(baseValue, oursValue, theirsValue, path) {
if (same(oursValue, theirsValue)) return oursValue;
if (same(oursValue, baseValue)) return theirsValue;
if (same(theirsValue, baseValue)) return oursValue;
const allObjects = [oursValue, theirsValue, baseValue].every(
(value) => value && typeof value === 'object' && !Array.isArray(value),
);
if (allObjects) {
const keys = new Set([...Object.keys(baseValue ?? {}), ...Object.keys(oursValue), ...Object.keys(theirsValue)]);
const merged = {};
const has = (source, key) => Boolean(source) && Object.hasOwn(source, key);
for (const key of keys) {
const child = merge3(
has(baseValue, key) ? baseValue[key] : undefined,
has(oursValue, key) ? oursValue[key] : undefined,
has(theirsValue, key) ? theirsValue[key] : undefined,
path ? `${path}.${key}` : key,
);
if (child !== undefined) merged[key] = child;
}
return merged;
}
conflicts.push({ path: path || '(root)', ours: oursValue, theirs: theirsValue });
return oursValue;
}
const merged = merge3(base, ours, theirs);
if (conflicts.length > 0) {
writeFileSync('merge-conflicts.json', `${JSON.stringify({ file: oursPath, conflicts }, null, 2)}\n`, 'utf8');
console.error(`發現 ${conflicts.length} 處真實衝突,詳見 merge-conflicts.json`);
process.exit(1);
}
writeFileSync(oursPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
process.exit(0);
三條判斷是核心:兩邊相同直接用;一邊等於祖先就取另一邊;兩邊都改且不同才算衝突。剩下的就是對普通物件遞歸下去。
註冊方式有兩處,.gitattributes 裡指定路徑用哪個驅動,git config 裡定義驅動本身:
git config merge.json-merge.name "JSON 按鍵合併"
git config merge.json-merge.driver "node merge-json.mjs %O %A %B"
echo "i18n.json merge=json-merge" >> .gitattributes
第一處可以提交進倉庫(跟著代碼走,CI 也生效),第二處是本地配置,需要寫進 README 讓每個新同事執行一次——這是自定義驅動最常見的落地缺口。
兩個分支各加一個鍵的場景,驅動跑完的結果:
{
"tools": {
"parse": "解析",
"minify": "壓縮",
"diff": "對比",
"schema": "Schema 生成"
}
}
退出碼 0,JSON 合法,兩個鍵都在。
有一個副作用要提前知道:驅動輸出的是 JSON.stringify(merged, null, 2),所以它會重寫整個檔案的格式。實測把一份單行壓縮的 63 字節 JSON 合併後,輸出變成 7 行 105 字節——內容對,形態變了。這意味著驅動只適合管人手維護的原始檔案;壓縮過的、生成的 JSON 不該進版本庫,也不該套這個驅動,否則每次合併都會帶出一堆無意義的格式差異。
七、衝突時不要寫回檔案,這個坑比想像中大
同一鍵兩邊都改的場合,驅動該做什麼?直覺做法是「寫回 ours 再退出 1 讓 Git 報衝突」。實測下來這個做法很危險:
$ git merge conf-b
CONFLICT (content): Merge conflict in i18n.json
Automatic merge failed; fix conflicts and then commit the result.
$ grep -c '^<<<<<<<' i18n.json
0
Git 報了衝突,但檔案裡沒有任何衝突標記——因為驅動已經把合併結果寫進去了。檔案內容看起來完全正常,是 ours 側的值。此時一個手快的 git add i18n.json && git commit 就把 theirs 的修改徹底丟了,而且歷史上只留下一句「Merge conflict resolved」。
所以驅動在發現真實衝突時,不要寫 %A,把待裁決項落到一個報告檔案裡再退出 1:
{
"file": "i18n.json",
"conflicts": [
{ "path": "tools.parse", "ours": "解析 / 格式化", "theirs": "解析器" }
]
}
這樣做的價值在於:git status 會停在 UU,人必須處理;處理時不用在幾十行 JSON 裡找 <<<<<<<,直接看報告知道是哪幾個路徑打了架。
順帶說一個實測發現:git checkout --conflict=merge -- i18n.json 在啟用自定義驅動後無法重建衝突標記,因為它會再次調用驅動,輸出依然是 0 個標記。要恢復標記只能臨時 git config --unset merge.json-merge.driver 再 checkout,或者直接用 git show :1:file、:2:、:3: 取三份原文。指望「反正可以重建標記」的人,會在真正需要時發現這條路不通。
八、讓衝突變少的三個前置習慣
驅動是兜底,不是主要手段。衝突數量本身可以壓下來。
統一序列化參數。 同一個檔案,有人用 indent=2,有人用 indent=4,有人不寫末尾換行。每次保存都會產生整檔案重寫,合併沒有任何機會。給格式化工具定一個配置,或者加 pre-commit 鉤子統一跑一遍。
鍵保持穩定順序。 鍵序在 JSON 語義上無意義,但在 Git 眼裡是實打實的差異。寫入時排序(JSON.stringify 前對鍵排序,或用 jq -S),能讓同一次修改只產生一處 diff。這條和兩份 JSON 差在哪裡講的「先歸一化再比」是同一個道理,只是那裡用在對比,這裡用在提交。
生成物不要進版本庫。 單行壓縮的 JSON、帶時間戳的構建清單、鎖檔案——它們每次都在變。真要在倉庫裡管配置,就管原始檔案,產物由構建生成。
九、檢查清單
.gitattributes裡對 JSON 用了merge=union的,先git log -p抽查歷史裡有沒有出現重複鍵或語法錯誤,再換掉它;- 自定義驅動註冊後,讓團隊每個人都跑一次
git config,或者寫進倉庫的make setup; - 驅動的衝突分支不寫
%A,改輸出報告檔案; - CI 裡加一道
node -e "JSON.parse(...)"或jq empty,把「合法但語義錯」的 JSON 攔在合併之後、發布之前; - 單行 JSON 與生成物加進
.gitignore,斷掉這一類衝突。
十、常見問題
merge=union 在什麼場合是安全的?
只追加、一行一條完整記錄、行的順序不承載含義的檔案,比如 JSON Lines 日誌流。一旦某一行會被原地修改(比如往同一條記錄裡補一個欄位),union 就會造出兩條互相矛盾的記錄——這是它的設計邊界,不是使用姿勢問題。
自定義驅動能處理陣列嗎?
上面的寫法把陣列當成原子:兩邊都改了陣列就報衝突。按索引對齊會復現文本 diff 的老問題(插入一條就全錯位),按 id 對齊則需要約定主鍵欄位。陣列合併更適合人工判斷,或者先約定「陣列只追加」。
驅動寫錯了會不會把檔案弄壞?
會。所以第一版驅動先在臨時倉庫裡用 git merge 跑三種場景(各加一鍵、同鍵不同值、一邊刪一邊改),確認結果再推廣。驅動出錯時的表現是檔案被悄悄改寫,比衝突標記難發現得多。
為什麼不用 jq 一行合併?
jq -s '.[0] * .[1]' 是淺合併,鍵序與嵌套物件的行為需要逐層確認,且它拿不到三方信息——不知道哪邊是祖先,就分不清「刪掉」和「沒改」。三方合併必須有 base。
衝突報告檔案要不要提交?
不要。加進 .gitignore,它是一次性產物,隨合併結束作廢。
如何讓 JSON 檔案的合併衝突變少?
先統一序列化引數,給格式化工具定一個配置或加 pre-commit 鉤子,避免每次儲存都整檔案重寫;再讓鍵保持穩定順序,寫入時排序,使同一次修改只產生一處 diff。最後把單行壓縮的 JSON、構建清單、鎖檔案這類生成物排除出版本庫,只版本化原始檔案。