JSON ファイルの Git マージ競合:merge=union が壊れた JSON を書き出す理由
要点:merge=union は壊れた JSON を通し、しかも成功と報告します。キーを理解する JSON 用の三方向マージドライバと、競合を減らす三つの習慣を解説します。

要点:マージ戦略を論じる前にシリアライズとキー順を標準化しましょう。merge=union が節約する競合は、後でカンマ欠落、重複キー、二つの文書の積み重ねとして戻ってきます。JSON の競合はキーを理解する三方向マージドライバに属します。
二人が同じ週に翻訳キーを二つ追加します。一人は tools.diff、もう一人は tools.schema。変更は無関係で意味的に互換です。それでも Git は競合を報告し、競合ブロックに両方の行が入ります。
<<<<<<< HEAD
"diff": "比較"
=======
"schema": "Schema 生成"
>>>>>>> feat-b
理由は単純です。両方のブランチが閉じ波括弧の直前に新しい行を挿入し、Git の行ベースの三方向マージは位置しか見ず、意味を見ません。「これらは二つの異なるキーだ」という概念がありません。そこで誰かが .gitattributes のドキュメントを読み、一行をコピーします。
i18n.json merge=union
競合は消えます。その代償が本記事の主題です。
1. union はマージせず、両側の行を保つ
merge=union は明白に文書化されています。テキストファイルに対してファイルレベルの三方向マージを実行するが、競合マーカーを残す代わりに両方のバージョンから行を取る、と。ドキュメントは続けて警告します。影響を理解していなければ使わないこと。その警告は定型文ではありません。
union はオブジェクト、キー、配列を理解しません。行を理解します。以下のすべての結果は、まさに上の i18n.json を使ってローカルリポジトリで再現しました。
| シナリオ | 既定のドライバ | merge=union |
キー対応ドライバ |
|---|---|---|---|
| 各側が異なるキーを追加 | 競合、手動 | 「マージ済み」、カンマ欠落、不正な JSON | マージ済み、両方のキーを保持 |
| 両側が同じキーを変更 | 競合、手動 | 「マージ済み」、重複キー、最後が勝つ | 終了コード 1、競合パスを報告 |
| 一行 JSON、各側が一つずつ変更 | 競合、ファイル全体を選ぶ | 「マージ済み」、二つの文書が積み重なる | マージ済みだが再整形 |
片側が minify を削除、他側が parse を編集 |
競合、手動 | 「マージ済み」、削除したキーが戻り、カンマ欠落 | マージ済み、削除を保ち編集を適用 |
中央の列だけが git merge に成功を返し、その出力はどれもパースできません。これが JSON に対する union の直感に反する点です。静かであるほど危険です。
2. 結果その一:カンマ欠落が「クリーンなマージ」として報告される
同じ二つのブランチで、今度は 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 はこちら側の行と相手側の行を保ち、それぞれが自分の末尾カンマを運びましたが、継ぎ目で区切りが一つ失われました。
パースすると:
$ python3 -c "import json;json.load(open('i18n.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 10 column 5
ここが最悪の部分です。Git はマージを成功とみなし、CI のマージステップが通り、コードレビューは「1 行追加」の緑の差分を見ます。失敗は実行時の最初の JSON.parse まで待ち、その頃にはマージは遠い過去です。
3. 結果その二:データを静かに落とす重複キー
二つ目の実験は異なる形を使います。両側が同じキーを変更し、片方は「解析 / フォーマット」、もう片方は「パーサー」にします。union の後:
{
"tools": {
"parse": "解析 / フォーマット",
"parse": "パーサー",
"minify": "圧縮"
}
}
今度の JSON は正しく、JSON.parse は何も投げません。しかし parse が二度現れます。オブジェクトリテラルの意味論により後ろのエントリが勝ち、最終値は「パーサー」です。もう一方の編集はログ行もエラーもなく破棄されます。
重複キーチェックだけがそれを表面化させます。
duplicate keys detected: ['parse']
JSON.parse succeeded, final parse value = 'パーサー'
これは不正な JSON より見つけにくいです。ファイルは読み込まれ、ページは描画され、一つの文字列が静かに巻き戻ります。数週間後、誰かがなぜ翻訳が出荷されなかったのか尋ねます。
4. 結果その三:一行 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 はそれを二つの無関係な文書に変えます。
5. 結果その四:削除したキーが戻る
最後の形が最も欺瞞的です。片側が minify を削除し、他側が隣の parse を編集します。既定のドライバは競合を報告し、union はこう生みます。
{
"tools": {
"parse": "解析"
"parse": "パーサー",
"minify": "圧縮"
}
}
三つの欠陥が一度に。minify が復活し、parse が二度現れ、最初の行はカンマを失いました。Git はそれでも「2 insertions」と成功したマージを報告します。
一般的な教訓:削除において「両側の行を保つ」は削除を取り消すことと等価です。JSON に union を長く使ったチームには、「あれは削除したはず」という設定が履歴に静かに生きている一団があります。
6. 正しい方法:キーによる三方向マージ
Git は特定のパスに独自のマージドライバを割り当てられます。ドライバは三つのファイルパスを受け取ります。%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 key merge"
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 バイトの圧縮設定が 7 行 105 バイトで戻りました。内容は同じ、形は違います。そのためドライバは手で保守するソースファイルにだけ向きます。圧縮された JSON や生成された JSON はリポジトリに入れるべきではなく、このドライバも使うべきではありません。さもないとすべてのマージが整形の雑音を引き連れます。
7. 競合時に書き戻さない。この罠は見た目より大きい
両側が同じキーを変更したとき、ドライバはどうすべきか。直感は「こちら側を書き戻し、終了コード 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 は競合を報告しますが、ファイルには競合マーカーが一つもありません。ドライバがすでに結果を書いているのです。内容はまったく正常に見え、こちら側の値を持っています。慌てて git add i18n.json && git commit を一度すれば相手の変更は消え、履歴には「Merge conflict resolved」しか残りません。
ですから本当の競合ではドライバは %A を書くべきではありません。未決定のエントリをレポートファイルに記録し、終了コード 1 で終わります。
{
"file": "i18n.json",
"conflicts": [
{ "path": "tools.parse", "ours": "解析 / フォーマット", "theirs": "パーサー" }
]
}
価値は git status が UU で止まることです。人間が行動しなければならず、しかも数十行の JSON の中で <<<<<<< を探す必要がありません。レポートがどのパスが衝突したかを正確に告げます。
もう一つの検証済みの発見:独自ドライバを登録すると、git checkout --conflict=merge -- i18n.json は競合マーカーを復元できません。ドライバを再度呼び出し、依然としてマーカーがゼロになるからです。マーカーを取り戻すには一時的に git config --unset merge.json-merge.driver するか、git show :1:file、:2:、:3: で三つのステージを取り出します。「いつでもマーカーを再生成できる」と当てにしている人は、まさに必要なときにその道が閉じていることに気づきます。
8. 競合を事前に減らす三つの習慣
ドライバは最後の砦で、主役ではありません。競合の数自体を減らせます。
シリアライズを標準化する。 一人は indent=2、別の人は indent=4、三人目は末尾改行を省く。保存のたびにファイル全体が書き直され、マージに使える材料がありません。フォーマッタ設定を固定するか、pre-commit フックで実行します。
キー順を安定させる。 キー順は JSON には無意味ですが、Git には非常に現実的です。書く前にキーを並べ替える(JSON.stringify の前にソートするか、jq -S を通す)ことで、一つの編集が一つのハンクに収まります。二つの JSON 文書の差分の取り方で比較前に正規化するのと同じ発想で、あちらは差分のため、こちらはコミットのためです。
生成ファイルをリポジトリの外に保つ。 圧縮された JSON、タイムスタンプ付きのビルドマニフェスト、ロックファイル。これらは実行のたびに変わります。ソースをバージョン管理し、成果物は生成します。
9. チェックリスト
.gitattributesが JSON をmerge=unionにマップしているなら、git log -pで履歴をサンプリングし、重複キーや構文エラーを探してから置き換える。- カスタムドライバを登録したら、全員が
git configの手順を実行するか、make setupに入れる。 - ドライバの競合分岐は
%Aを書いてはならない。代わりにレポートを出す。 - CI に
node -e "JSON.parse(...)"かjq emptyのゲートを足し、「正しいが間違っている」JSON をマージ後・デプロイ前に捕まえる。 - 圧縮された JSON と生成ファイルを
.gitignoreに入れ、その種の競合を完全に除去する。
10. FAQ
merge=union はいつ安全ですか?
各行が完全なレコードで順序が意味を持たない追記専用ファイルに限ります。たとえば JSON Lines のログです。行をインプレースで編集できるようになった瞬間(既存レコードへのフィールド追加など)、union は矛盾する二つのレコードを生みます。それは利用の注意ではなく、設計上の境界です。
カスタムドライバは配列を扱えますか?
上のバージョンは配列を原子的に扱います。両側が配列を変更したら競合を報告します。インデックス整列は古いテキスト差分の問題(1 件挿入で全部ずれる)を再現し、id 整列は合意された主キーを必要とします。配列のマージは人間に任せるか、「配列は追記専用」という慣習で防ぐ方がよいです。
ドライバにバグがあったら?
ファイルを静かに壊します。最初のバージョンはスクラッチリポジトリで三つの形(各側がキー追加、両側が同じキー変更、片側削除と他側編集)に対してテストしてから展開してください。壊れたドライバはファイルを無言で書き直し、競合マーカーよりずっと気づきにくいです。
なぜ jq の一行でマージしないのですか?
jq -s '.[0] * .[1]' は浅いマージで、キー順とネストの振る舞いをケースごとに確認する必要があり、祖先がありません。base がなければ「削除」と「未変更」を区別できません。三方向マージには三つの入力が必要です。
競合レポートはコミットすべきですか?
いいえ。.gitignore に追加してください。マージが終われば失効する一度きりの成果物です。