JSON Toolkit

Konflik merge Git pada file JSON: mengapa merge=union menulis JSON rusak

Intinya: merge=union membiarkan JSON rusak lolos dan tetap melaporkan sukses. Artikel ini menelusuri driver three-way merge yang sadar key untuk JSON, plus tiga kebiasaan yang mengurangi konflik.

Menengah±7 menit bacaDiterbitkan 2026-10-30Diperbarui 2026-10-30
Sampul untuk menyelesaikan konflik merge Git pada file JSON

Intinya: standarkan serialisasi dan urutan key sebelum membahas strategi merge; setiap konflik yang dihemat merge=union kembali kemudian sebagai koma yang hilang, key duplikat, atau dua dokumen bertumpuk, jadi konflik JSON adalah urusan driver three-way merge yang sadar key.

Dua orang menambahkan dua key terjemahan pada minggu yang sama: satu menambahkan tools.diff, yang lain menambahkan tools.schema. Perubahannya tidak berhubungan dan kompatibel secara semantik. Git tetap melaporkan konflik, dan blok konflik memuat kedua baris:

<<<<<<< HEAD
    "diff": "对比"
=======
    "schema": "Schema 生成"
>>>>>>> feat-b

Sebabnya sederhana. Kedua branch menyisipkan baris baru tepat sebelum kurung kurawal penutup, dan three-way merge berbasis baris Git hanya melihat posisi, bukan makna. Ia tidak punya konsep "ini dua key berbeda". Jadi seseorang membaca dokumentasi .gitattributes dan menyalin satu baris:

i18n.json merge=union

Konfliknya hilang. Apa yang menjadi biayanya adalah subjek artikel ini.

1. Union tidak melakukan merge, ia mempertahankan baris kedua sisi

merge=union didokumentasikan dengan jelas: jalankan three-way merge tingkat file untuk file teks, tetapi ambil baris dari kedua versi alih-alih meninggalkan conflict marker. Dokumentasinya diikuti peringatan — jangan pakai ini jika Anda tidak memahami implikasinya. Peringatan itu bukan basa-basi.

Union tidak memahami object, key, atau array. Ia memahami baris. Setiap hasil di bawah direproduksi di repositori lokal memakai i18n.json yang persis seperti di atas.

Skenario Driver default merge=union Driver sadar key
Setiap sisi menambahkan key berbeda Konflik, manual "Merged", koma hilang, JSON tidak valid Merged, kedua key dipertahankan
Kedua sisi mengubah key yang sama Konflik, manual "Merged", key duplikat, yang terakhir menang Exit code 1, path konflik dilaporkan
JSON satu baris, satu perubahan tiap sisi Konflik, pilih satu file utuh "Merged", dua dokumen bertumpuk Merged, tetapi diformat ulang
Satu sisi menghapus minify, sisi lain mengedit parse Konflik, manual "Merged", key yang dihapus kembali, koma hilang Merged, penghapusan dipertahankan, edit diterapkan

Hanya kolom tengah yang membuat git merge mengembalikan sukses, dan tidak ada outputnya yang bisa di-parse. Itu bagian kontraintuitif union pada JSON: semakin sunyi, semakin berbahaya.

2. Hasil satu: koma yang hilang, dilaporkan sebagai merge bersih

Dua branch yang sama, sekarang dengan merge=union terpasang:

$ git merge feat-b
Auto-merging i18n.json
Merge made by the 'ort' strategy.
 i18n.json | 1 +
 1 file changed, 1 insertion(+)

Tidak ada konflik, merge commit dibuat. File tampak seperti ini:

{
  "tools": {
    "parse": "解析",
    "minify": "压缩",
    "diff": "对比"
    "schema": "Schema 生成"
  }
}

Tidak ada koma setelah "diff": "对比". Union mempertahankan baris dari sisi kami dan baris dari sisi mereka, masing-masing membawa koma di akhir barisnya, dan sambungannya kehilangan satu pemisah.

Mem-parsing-nya:

$ python3 -c "import json;json.load(open('i18n.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 10 column 5

Ini bagian terburuknya: Git menganggap merge berhasil, langkah merge CI lolos, dan code review melihat diff hijau "1 insertion". Kegagalan menunggu sampai JSON.parse pertama saat runtime, pada saat merge sudah jauh di belakang Anda.

3. Hasil dua: key duplikat yang diam-diam membuang data

Eksperimen kedua memakai bentuk berbeda: kedua sisi memodifikasi key yang sama, satu menjadi "解析 / 格式化", yang lain menjadi "解析器". Setelah union:

{
  "tools": {
    "parse": "解析 / 格式化",
    "parse": "解析器",
    "minify": "压缩"
  }
}

Kali ini JSON valid dan JSON.parse tidak melempar apa pun. Tetapi parse muncul dua kali. Menurut semantik object literal entri yang lebih akhir menang, jadi nilai akhirnya "解析器" — edit lainnya dibuang tanpa baris log dan tanpa error.

Hanya pemeriksaan key duplikat yang memunculkannya:

duplicate keys detected: ['parse']
JSON.parse succeeded, final parse value = '解析器'

Ini lebih sulit ditemukan daripada JSON tidak valid: file termuat, halaman terender, dan satu string diam-diam kembali ke nilai lama. Beberapa minggu kemudian seseorang bertanya mengapa terjemahan tidak pernah terkirim.

4. Hasil tiga: JSON satu baris menjadi dua dokumen

Sebagian file konfigurasi diminifikasi menjadi satu baris, misalnya artefak build. Tidak ada baris untuk di-merge oleh union, jadi kedua sisi adalah baris utuh dan hasilnya menumpuk mereka:

{"tools":{"parse":"解析","minify":"压缩","diff":"对比"},"version":1}
{"tools":{"parse":"解析","minify":"压缩"},"version":2}

JSON.parse melaporkan Unexpected non-whitespace character after JSON at position 63 — ia selesai membaca dokumen pertama dan menemukan dokumen kedua. Git lagi-lagi berkata "Merge made".

JSON terminifikasi seharusnya tidak ada di version control sama sekali, tetapi begitu ia ada, union mengubahnya menjadi dua dokumen yang tidak berhubungan.

5. Hasil empat: key yang dihapus kembali

Bentuk terakhir adalah yang paling menipu. Satu sisi menghapus minify; sisi lain mengedit parse yang berdekatan. Driver default melaporkan konflik; union menghasilkan:

{
  "tools": {
    "parse": "解析"
    "parse": "解析器",
    "minify": "压缩"
  }
}

Tiga cacat sekaligus: minify dibangkitkan kembali, parse muncul dua kali, dan baris pertama kehilangan komanya. Git tetap melaporkan "2 insertions" dan merge yang berhasil.

Pelajaran umumnya: dalam sebuah penghapusan, "pertahankan baris dari kedua sisi" setara dengan membatalkan penghapusan. Tim mana pun yang sudah memakai union pada JSON cukup lama memiliki sekumpulan pengaturan "kami sudah menghapus itu" yang diam-diam hidup di riwayat.

6. Pendekatan yang benar: three-way merge berdasarkan key

Git mengizinkan Anda menetapkan merge driver kustom untuk path tertentu. Driver menerima tiga path file: %O adalah common ancestor, %A adalah milik kita (hasil ditulis kembali ke sini), %B adalah milik mereka. Beberapa lusin baris Node sudah cukup:

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} real conflict(s), see merge-conflicts.json`);
  process.exit(1);
}

writeFileSync(oursPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
process.exit(0);

Tiga aturan yang menopangnya: identik di kedua sisi, ambil itu; satu sisi sama dengan ancestor, ambil yang lain; berbeda di kedua sisi adalah konflik nyata. Sisanya adalah rekursi ke plain object.

Pendaftaran butuh dua langkah — .gitattributes memilih driver per path, git config mendefinisikan driver:

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

Yang pertama bisa di-commit (ia ikut bersama kode dan berlaku di CI). Yang kedua adalah konfigurasi lokal dan perlu ada di README — di sinilah driver kustom biasanya runtuh.

Untuk kasus "setiap sisi menambahkan key berbeda", driver menghasilkan:

{
  "tools": {
    "parse": "解析",
    "minify": "压缩",
    "diff": "对比",
    "schema": "Schema 生成"
  }
}

Exit code 0, JSON valid, kedua key ada.

Satu efek samping yang perlu diketahui: driver menulis JSON.stringify(merged, null, 2), jadi ia menulis ulang format seluruh file. Konfigurasi terminifikasi 63 byte kembali sebagai 7 baris dan 105 byte — isi sama, bentuk berbeda. Itu membuat driver hanya cocok untuk file sumber yang dipelihara manual. JSON terminifikasi atau yang dihasilkan seharusnya tidak ada di repositori, dan tidak boleh memakai driver ini, atau setiap merge akan menyeret noise format.

7. Jangan menulis balik saat konflik — jebakan ini lebih besar dari kelihatannya

Ketika kedua sisi mengubah key yang sama, apa yang harus dilakukan driver? Gerakan intuitif adalah "tulis milik kita kembali, exit 1, biarkan Git melaporkan konflik". Setelah diuji, itu berbahaya:

$ 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 melaporkan konflik, tetapi tidak ada satu pun conflict marker di file — driver sudah menulis hasilnya. Kontennya tampak sepenuhnya normal, memegang nilai sisi kita. Satu git add i18n.json && git commit yang terburu-buru dan perubahan mereka hilang, dengan hanya "Merge conflict resolved" di riwayat.

Jadi pada konflik nyata driver sebaiknya tidak menulis %A. Catat entri yang belum diputuskan di file laporan dan exit 1:

{
  "file": "i18n.json",
  "conflicts": [
    { "path": "tools.parse", "ours": "解析 / 格式化", "theirs": "解析器" }
  ]
}

Nilainya adalah git status berhenti di UU, sehingga manusia harus bertindak — dan mereka tidak perlu berburu <<<<<<< di dalam puluhan baris JSON. Laporan menyebutkan tepat path mana yang bertabrakan.

Satu temuan lain yang teruji: git checkout --conflict=merge -- i18n.json tidak bisa memulihkan conflict marker begitu driver kustom terdaftar, karena ia memanggil driver lagi dan tetap menghasilkan nol marker. Untuk mendapatkan marker kembali Anda harus sementara git config --unset merge.json-merge.driver, atau mengambil ketiga stage dengan git show :1:file, :2:, :3:. Siapa pun yang mengandalkan "saya selalu bisa meregenerasi marker" mendapati jalan itu tertutup tepat ketika mereka membutuhkannya.

8. Tiga kebiasaan yang mengurangi konflik sejak awal

Driver adalah jaring pengaman, bukan alat utama. Jumlah konflik itu sendiri bisa dikurangi.

Standarkan serialisasi. Satu orang menulis indent=2, yang lain indent=4, yang ketiga melewatkan newline di akhir. Setiap save menulis ulang seluruh file dan merge tidak punya apa pun untuk bekerja. Kunci konfigurasi formatter, atau jalankan di pre-commit hook.

Jaga urutan key tetap stabil. Urutan key tidak bermakna bagi JSON tetapi sangat nyata bagi Git. Mengurutkan key sebelum menulis (urutkan sebelum JSON.stringify, atau pipe melalui jq -S) menjaga satu edit menjadi satu hunk. Ini ide yang sama dengan normalisasi sebelum membandingkan di bagaimana dua dokumen JSON berbeda — di sana untuk diffing, di sini untuk committing.

Jauhkan file yang dihasilkan dari repositori. JSON terminifikasi, manifest build dengan timestamp, lockfile — mereka berubah setiap kali jalan. Versikan sumbernya, hasilkan artefaknya.

9. Checklist

  • Jika .gitattributes memetakan JSON ke merge=union, sampel riwayat dengan git log -p untuk key duplikat atau syntax error, lalu gantikan;
  • Setelah mendaftarkan driver kustom, minta semua orang menjalankan langkah git config, atau taruh di make setup;
  • Cabang konflik driver tidak boleh menulis %A; keluarkan laporan sebagai gantinya;
  • Tambahkan gate node -e "JSON.parse(...)" atau jq empty di CI, agar JSON "valid tetapi salah" tertangkap setelah merge dan sebelum deploy;
  • Tambahkan JSON terminifikasi dan file yang dihasilkan ke .gitignore untuk menghapus kelas konflik itu sepenuhnya.

10. FAQ

Kapan merge=union aman?

Hanya untuk file append-only di mana setiap baris adalah record lengkap dan urutan tidak bermakna, seperti log JSON Lines. Begitu sebuah baris bisa diedit di tempatnya — menambahkan field ke record yang ada — union menghasilkan dua record yang bertentangan. Itu batas desain, bukan soal memakainya dengan hati-hati.

Bisakah driver kustom menangani array?

Versi di atas memperlakukan array sebagai atomik: jika kedua sisi mengubah array, ia melaporkan konflik. Penyelarasan indeks mereproduksi masalah text-diff lama (sisipkan satu item dan semuanya bergeser), sementara penyelarasan id membutuhkan primary key yang disepakati. Merge array lebih baik diserahkan kepada manusia, atau dicegah dengan konvensi "array bersifat append-only".

Bagaimana jika driver punya bug?

Ia merusak file secara diam-diam. Uji versi pertamanya di repositori percobaan terhadap tiga bentuk (setiap sisi menambahkan key, kedua sisi mengubah key yang sama, satu menghapus sementara yang lain mengedit) sebelum menggulirkannya. Driver yang rusak menulis ulang file dalam diam, yang jauh lebih sulit disadari daripada conflict marker.

Mengapa tidak langsung merge dengan satu baris jq?

jq -s '.[0] * .[1]' adalah shallow merge, urutan key dan perilaku bersarangnya perlu diperiksa per kasus, dan ia tidak punya ancestor — tanpa base Anda tidak bisa membedakan "dihapus" dari "tidak berubah". Three-way merge butuh tiga input.

Apakah laporan konflik sebaiknya di-commit?

Tidak. Tambahkan ke .gitignore; ia artefak sekali pakai yang kedaluwarsa ketika merge berakhir.