JSON 工具箱

JSON 文件的 Git 冲突:union 会写出坏 JSON,按 key 合并才对

结论:两个分支各加一个文案键,Git 却报冲突,于是有人加上 merge=union 一行了事。实测下来,union 会让缺逗号的 JSON 通过合并、让重复键静默覆盖、让单行 JSON 变成两份文档,而 Git 全程认为「合并成功」。这篇给出按键三方合并的驱动写法,以及三个能让冲突变少的前置习惯。

进阶约 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、构建清单、锁文件这类生成物排除出版本库,只版本化源文件。