JSON Toolkit

Konflik cantum Git dalam fail JSON: mengapa merge=union menulis JSON rosak

Inti pati: merge=union membiarkan JSON rosak lalu dan masih melaporkan kejayaan. Ini menelusuri pemacu cantum tiga hala peka kunci untuk JSON, serta tiga tabiat yang mengurangkan konflik.

Pertengahan~7 min bacaanDiterbitkan 2026-10-30Dikemas kini 2026-10-30
Kulit untuk menyelesaikan konflik cantum Git dalam fail JSON

Kesimpulan utama: standardkan pensirian dan susunan kunci sebelum membincangkan strategi cantum; setiap konflik yang dijimatkan merge=union kembali kemudian sebagai koma hilang, kunci pendua, atau dua dokumen bertindan, jadi konflik JSON tergolong dalam pemacu cantum tiga hala peka kunci.

Dua orang menambah dua kunci terjemahan dalam minggu yang sama: seorang menambah tools.diff, yang lain menambah tools.schema. Perubahan itu tidak berkaitan dan serasi dari segi semantik. Git masih melaporkan konflik, dan blok konflik memegang kedua-dua baris:

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

Alasannya mudah. Kedua-dua cabang menyisipkan baris baharu tepat sebelum pendakap tutup, dan cantum tiga hala berasaskan baris Git hanya melihat kedudukan, bukan makna. Ia tiada konsep "ini dua kunci berbeza". Jadi seseorang membaca dokumentasi .gitattributes dan menyalin satu baris:

i18n.json merge=union

Konflik itu hilang. Apa kosnya ialah subjek artikel ini.

1. Union tidak mencantum, ia menyimpan baris kedua-dua belah

merge=union didokumenkan secara jelas: jalankan cantum peringkat fail tiga hala untuk fail teks, tetapi ambil baris daripada kedua-dua versi dan bukannya meninggalkan penanda konflik. Dokumentasi diikuti dengan amaran — jangan gunakan ini jika anda tidak memahami implikasinya. Amaran itu bukan teks standard.

Union tidak memahami objek, kunci, atau tatasusunan. Ia memahami baris. Setiap hasil di bawah dihasilkan semula dalam repositori setempat menggunakan tepat i18n.json di atas.

Senario Pemacu lalai merge=union Pemacu peka kunci
Setiap pihak menambah kunci berbeza Konflik, manual "Digabung", koma hilang, JSON tidak sah Digabung, kedua-dua kunci dikekalkan
Kedua-dua pihak mengubah kunci sama Konflik, manual "Digabung", kunci pendua, yang terakhir menang Kod keluar 1, laluan berkonflik dilaporkan
JSON satu baris, satu perubahan setiap pihak Konflik, pilih seluruh fail "Digabung", dua dokumen bertindan Digabung, tetapi diformat semula
Satu pihak memadam minify, yang lain menyunting parse Konflik, manual "Digabung", kunci dipadam kembali, koma hilang Digabung, pemadaman dikekalkan, suntingan digunakan

Hanya lajur tengah membuat git merge memulangkan kejayaan, dan tiada outputnya dihurai. Itulah bahagian berlawanan dengan intuisi union pada JSON: semakin senyap, semakin berbahaya.

2. Hasil satu: koma hilang, dilaporkan sebagai cantum bersih

Cabang yang sama, kini dengan merge=union di tempatnya:

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

Tiada konflik, komit cantum dicipta. Fail kelihatan seperti ini:

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

Tiada koma selepas "diff": "对比". Union menyimpan baris daripada pihak kita dan baris daripada pihak mereka, masing-masing membawa koma hujungnya sendiri, dan sambungan itu kehilangan satu pemisah.

Menghurainya:

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

Inilah bahagian paling teruk: Git menganggap cantum berjaya, langkah cantum CI lulus, dan semakan kod melihat diff hijau "1 insertion". Kegagalan menunggu sehingga JSON.parse pertama semasa masa jalan, dan pada masa itu cantum sudah jauh di belakang anda.

3. Hasil dua: kunci pendua yang menggugurkan data secara senyap

Eksperimen kedua menggunakan bentuk berbeza: kedua-dua pihak mengubah kunci yang sama, seorang kepada "解析 / 格式化", yang lain kepada "解析器". Selepas union:

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

Kali ini JSON sah dan JSON.parse tidak melontar apa-apa. Tetapi parse muncul dua kali. Mengikut semantik literal objek, entri kemudian menang, jadi nilai akhir ialah "解析器" — suntingan yang lain dibuang tanpa baris log dan tanpa kesalahan.

Hanya semakan kunci pendua mendedahkannya:

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

Ini lebih sukar ditemui daripada JSON tidak sah: fail dimuatkan, halaman dipaparkan, dan satu string secara senyap kembali asal. Beberapa minggu kemudian seseorang bertanya mengapa terjemahan tidak pernah dihantar.

4. Hasil tiga: JSON satu baris menjadi dua dokumen

Sesetengah fail konfigurasi dikecilkan menjadi satu baris, contohnya artifak binaan. Tiada baris untuk union cantum, jadi kedua-dua belah ialah baris penuh dan hasilnya menindan 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 menyelesaikan dokumen pertama dan menemui yang kedua. Git sekali lagi berkata "Merge made".

JSON yang dikecilkan tidak sepatutnya berada dalam kawalan versi sama sekali, tetapi sebaik ia berada di sana, union menukarnya menjadi dua dokumen tidak berkaitan.

5. Hasil empat: kunci yang dipadam kembali semula

Bentuk terakhir paling menipu. Satu pihak memadam minify; yang lain menyunting parse bersebelahan. Pemacu lalai melaporkan konflik; union menghasilkan:

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

Tiga kecacatan sekali gus: minify dibangkitkan, parse muncul dua kali, dan baris pertama kehilangan komanya. Git masih melaporkan "2 insertions" dan cantum berjaya.

Pelajaran umum: dalam pemadaman, "kekalkan baris daripada kedua-dua belah" setara dengan membatalkan pemadaman. Mana-mana pasukan yang menggunakan union pada JSON untuk seketika mempunyai satu set tetapan "kami memadam itu" yang secara senyap hidup dalam sejarah.

6. Pendekatan betul: cantum tiga hala mengikut kunci

Git membenarkan anda menetapkan pemacu cantum tersuai kepada laluan tertentu. Pemacu menerima tiga laluan fail: %O ialah nenek moyang sepunya, %A ialah milik kita (hasil ditulis semula di sini), %B ialah milik mereka. Beberapa puluh 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 peraturan menyokongnya: sama pada kedua-dua belah, ambil; satu pihak sama dengan nenek moyang, ambil yang lain; berbeza pada kedua-dua belah ialah konflik sebenar. Selebihnya ialah rekursi ke dalam objek biasa.

Pendaftaran mengambil dua langkah — .gitattributes memilih pemacu per laluan, git config mentakrifkan pemacu:

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 boleh dikomit (ia bergerak dengan kod dan terpakai dalam CI). Yang kedua ialah konfigurasi setempat dan perlu berada dalam README — di sinilah pemacu tersuai biasanya gagal.

Untuk kes "setiap pihak menambah kunci berbeza", pemacu menghasilkan:

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

Kod keluar 0, JSON sah, kedua-dua kunci hadir.

Satu kesan sampingan yang perlu diketahui: pemacu menulis JSON.stringify(merged, null, 2), jadi ia menulis semula pemformatan keseluruhan fail. Konfigurasi 63 bait yang dikecilkan kembali sebagai 7 baris dan 105 bait — kandungan sama, bentuk berbeza. Itu menjadikan pemacu sesuai untuk fail sumber yang diselenggara tangan sahaja. JSON yang dikecilkan atau dijana tidak sepatutnya berada dalam repositori, dan tidak sepatutnya menggunakan pemacu ini, atau setiap cantum akan menyeret bunyi pemformatan.

7. Jangan tulis balik pada konflik — perangkap ini lebih besar daripada kelihatan

Apabila kedua-dua pihak mengubah kunci yang sama, apa yang patut dilakukan pemacu? Langkah intuitif ialah "tulis milik kita balik, keluar 1, biar 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 tiada satu pun penanda konflik dalam fail — pemacu sudah menulis hasilnya. Kandungan kelihatan normal sepenuhnya, memegang nilai pihak kita. Satu git add i18n.json && git commit terburu-buru dan perubahan mereka hilang, dengan hanya "Merge conflict resolved" dalam sejarah.

Jadi pada konflik sebenar pemacu tidak sepatutnya menulis %A. Rekod entri yang belum diputuskan dalam fail laporan dan keluar 1:

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

Nilainya ialah git status berhenti pada UU, jadi manusia mesti bertindak — dan mereka tidak perlu memburu <<<<<<< di dalam berpuluh baris JSON. Laporan itu menyatakan tepat laluan mana yang berlanggar.

Satu lagi penemuan yang diuji: git checkout --conflict=merge -- i18n.json tidak dapat memulihkan penanda konflik sebaik pemacu tersuai didaftarkan, kerana ia memanggil pemacu sekali lagi dan masih menghasilkan sifar penanda. Untuk mendapatkan semula penanda anda mesti sementara git config --unset merge.json-merge.driver, atau tarik tiga peringkat dengan git show :1:file, :2:, :3:. Sesiapa yang bergantung pada "saya sentiasa boleh menjana semula penanda" mendapati jalan itu tertutup tepat apabila mereka memerlukannya.

8. Tiga tabiat yang mengurangkan konflik dari awal

Pemacu ialah jaring keselamatan, bukan alat utama. Bilangan konflik sendiri boleh dikurangkan.

Standardkan pensirian. Seorang menulis indent=2, yang lain indent=4, yang ketiga meninggalkan baris baharu hujung. Setiap simpanan menulis semula keseluruhan fail dan cantum tiada apa untuk digunakan. Tetapkan konfigurasi pemformat, atau jalankannya dalam hook pra-komit.

Kekalkan susunan kunci stabil. Susunan kunci tidak bermakna kepada JSON tetapi sangat nyata kepada Git. Isih kunci sebelum menulis (isih sebelum JSON.stringify, atau salurkan melalui jq -S) memastikan satu suntingan menjadi satu hunk. Ini idea sama seperti menormalkan sebelum membandingkan dalam bagaimana dua dokumen JSON berbeza — di sana untuk diff, di sini untuk komit.

Jauhkan fail terjana daripada repositori. JSON dikecilkan, manifes binaan dengan cap masa, fail kunci — ia berubah pada setiap larian. Versikan sumber, jana artifak.

9. Senarai semak

  • Jika .gitattributes memetakan JSON kepada merge=union, sampel sejarah dengan git log -p untuk kunci pendua atau kesalahan sintaks, kemudian gantikannya;
  • Selepas mendaftarkan pemacu tersuai, pastikan semua orang menjalankan langkah git config, atau letakkan dalam make setup;
  • Cabang konflik pemacu tidak boleh menulis %A; keluarkan laporan sebaliknya;
  • Tambah gerbang node -e "JSON.parse(...)" atau jq empty dalam CI, supaya JSON "sah tetapi salah" ditangkap selepas cantum dan sebelum penghantaran;
  • Tambah JSON dikecilkan dan fail terjana ke .gitignore untuk membuang kelas konflik itu sepenuhnya.

10. FAQ

Bila merge=union selamat?

Hanya untuk fail tambah-sahaja di mana setiap baris ialah rekod lengkap dan susunan tidak membawa makna, seperti log JSON Lines. Sebaik satu baris boleh disunting di tempatnya — menambah medan pada rekod sedia ada — union menghasilkan dua rekod bercanggah. Itu sempadan reka bentuk, bukan soal menggunakannya dengan berhati-hati.

Bolehkah pemacu tersuai mengendalikan tatasusunan?

Versi di atas melayan tatasusunan sebagai atom: jika kedua-dua pihak mengubah tatasusunan, ia melaporkan konflik. Penjajaran indeks menghasilkan semula masalah diff teks lama (sisipkan satu item dan semuanya beralih), manakala penjajaran id memerlukan kunci utama yang dipersetujui. Cantum tatasusunan lebih baik diserahkan kepada manusia, atau dicegah oleh konvensyen "tatasusunan ialah tambah-sahaja".

Bagaimana jika pemacu mempunyai bug?

Ia merosakkan fail secara senyap. Uji versi pertama dalam repositori calit terhadap tiga bentuk (setiap pihak menambah kunci, kedua-duanya mengubah kunci sama, satu memadam manakala yang lain menyunting) sebelum melancarkannya. Pemacu rosak menulis semula fail secara senyap, yang jauh lebih sukar disedari daripada penanda konflik.

Mengapa tidak cantum dengan satu baris jq sahaja?

jq -s '.[0] * .[1]' ialah cantum cetek, susunan kunci dan tingkah laku bersarangnya perlu disemak kes demi kes, dan ia tiada nenek moyang — tanpa asas anda tidak boleh membezakan "dipadam" daripada "tidak berubah". Cantum tiga hala memerlukan tiga input.

Patutkah laporan konflik dikomit?

Tidak. Tambahkannya ke .gitignore; ia artifak sekali pakai yang luput apabila cantum berakhir.