JSON 工具箱

JSON 檔案的 Git 衝突:union 會寫出壞 JSON,按 key 合併才對

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

進階約 7 分鐘發佈於 2026-10-30更新於 2026-10-30
JSON 檔案 Git 合併衝突封面

結論:先統一格式化引數與鍵序,再談合併;用 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、構建清單、鎖檔案這類生成物排除出版本庫,只版本化原始檔案。