JSON Toolkit

Conflict การ merge Git ในไฟล์ JSON: ทำไม merge=union จึงเขียน JSON ที่พัง

สรุปสำคัญ: merge=union ปล่อย JSON ที่พังผ่านไปและยังรายงานว่าสำเร็จ บทความนี้ไล่ดู three-way merge driver ที่เข้าใจคีย์สำหรับ JSON พร้อมนิสัยสามข้อที่ลด conflict

ระดับกลางอ่านประมาณ 7 นาทีเผยแพร่ 2026-10-30อัปเดต 2026-10-30
ภาพปกการแก้ conflict การ merge Git ในไฟล์ JSON

สรุปสั้น: จัดมาตรฐานการทำให้เป็นอนุกรมและลำดับคีย์ก่อนจะคุยเรื่องกลยุทธ์การ merge; conflict ทุกครั้งที่ merge=union ประหยัดไว้จะกลับมาในภายหลังเป็นจุลภาคที่หายไป คีย์ซ้ำ หรือเอกสารสองฉบับซ้อนกัน ดังนั้น conflict ของ JSON ควรใช้ three-way merge driver ที่เข้าใจคีย์

คนสองคนเพิ่มคีย์การแปลสองตัวในสัปดาห์เดียวกัน: คนหนึ่งเพิ่ม tools.diff อีกคนเพิ่ม tools.schema การเปลี่ยนแปลงไม่เกี่ยวข้องกันและเข้ากันได้เชิงความหมาย Git ยังรายงาน conflict และบล็อก conflict มีทั้งสองบรรทัด:

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

เหตุผลง่ายมาก ทั้งสองกิ่งแทรกบรรทัดใหม่ก่อนวงเล็บปิด และ three-way merge ตามบรรทัดของ Git เห็นเพียงตำแหน่ง ไม่เห็นความหมาย มันไม่มีแนวคิดเรื่อง "นี่คือสองคีย์ที่ต่างกัน" ดังนั้นมีคนอ่านเอกสาร .gitattributes แล้วลอกมาหนึ่งบรรทัด:

i18n.json merge=union

Conflict หายไป สิ่งที่มันแลกมาคือหัวข้อของบทความนี้

1. Union ไม่ได้ merge มันเก็บบรรทัดของทั้งสองฝั่ง

merge=union ถูกอธิบายไว้ตรง ๆ: รัน three-way merge ระดับไฟล์สำหรับไฟล์ข้อความ แต่เก็บบรรทัดจากทั้งสองเวอร์ชันแทนที่จะทิ้งเครื่องหมาย conflict เอกสารตามด้วยคำเตือน — อย่าใช้สิ่งนี้หากคุณไม่เข้าใจผลกระทบ คำเตือนนั้นไม่ใช่ถ้อยคำสำเร็จรูป

Union ไม่เข้าใจออบเจ็กต์ คีย์ หรืออาร์เรย์ มันเข้าใจบรรทัด ทุกผลลัพธ์ด้านล่างถูกจำลองใน repository ภายในเครื่องโดยใช้ i18n.json ข้างบนเป๊ะ ๆ

สถานการณ์ Driver เริ่มต้น merge=union Driver ที่เข้าใจคีย์
แต่ละฝั่งเพิ่มคีย์ต่างกัน Conflict แก้ด้วยมือ "Merged" จุลภาคหาย JSON ไม่ถูกต้อง Merged คีย์ทั้งสองอยู่
ทั้งสองฝั่งแก้คีย์เดียวกัน Conflict แก้ด้วยมือ "Merged" คีย์ซ้ำ อันหลังชนะ Exit code 1 รายงาน path ที่ขัดแย้ง
JSON บรรทัดเดียว แก้ฝั่งละอย่าง Conflict เลือกทั้งไฟล์ "Merged" สองเอกสารซ้อนกัน Merged แต่จัดรูปแบบใหม่
ฝั่งหนึ่งลบ minify อีกฝั่งแก้ parse Conflict แก้ด้วยมือ "Merged" คีย์ที่ลบกลับมา จุลภาคหาย Merged การลบอยู่ การแก้ถูกใช้

เฉพาะคอลัมน์กลางที่ทำให้ git merge คืนค่าสำเร็จ และไม่มีเอาต์พุตใดของมันแยกวิเคราะห์ได้ นั่นคือส่วนที่ขัดกับสัญชาตญาณของ union บน JSON: ยิ่งเงียบ ยิ่งอันตราย

2. ผลลัพธ์ที่หนึ่ง: จุลภาคหาย แต่รายงานว่า merge สะอาด

สองกิ่งเดิม ตอนนี้มี merge=union:

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

ไม่มี conflict merge commit ถูกสร้าง ไฟล์มีหน้าตาแบบนี้:

{
  "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 ถือว่า merge สำเร็จ ขั้นตอน merge ใน CI ผ่าน และ code review เห็น diff สีเขียวของ "1 insertion" ความล้มเหลวรอจนถึง JSON.parse ครั้งแรกตอนรัน ซึ่งตอนนั้น merge อยู่ไกลข้างหลังคุณแล้ว

3. ผลลัพธ์ที่สอง: คีย์ซ้ำที่ทิ้งข้อมูลอย่างเงียบ ๆ

การทดลองที่สองใช้รูปร่างอื่น: ทั้งสองฝั่งแก้คีย์ เดียวกัน ฝั่งหนึ่งเป็น "解析 / 格式化" อีกฝั่งเป็น "解析器" หลัง union:

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

ครั้งนี้ JSON ถูกต้องและ JSON.parse ไม่โยนอะไร แต่ parse ปรากฏสองครั้ง ตามความหมายของ object literal รายการหลังชนะ ค่าสุดท้ายคือ "解析器" — การแก้อีกฝั่งถูกทิ้งโดยไม่มี log และไม่มี error

มีเพียงการตรวจคีย์ซ้ำที่เปิดเผยมัน:

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

อันนี้หายากกว่า JSON ที่ไม่ถูกต้อง: ไฟล์โหลดได้ หน้าเรนเดอร์ และหนึ่งสตริงย้อนกลับอย่างเงียบ ๆ หลายสัปดาห์ต่อมามีคนถามว่าทำไมการแปลไม่เคยถูกปล่อย

4. ผลลัพธ์ที่สาม: JSON บรรทัดเดียวกลายเป็นสองเอกสาร

ไฟล์คอนฟิกบางไฟล์ถูก minify เป็นบรรทัดเดียว เช่น build artifact ไม่มีบรรทัดให้ union merge ดังนั้นทั้งสองฝั่งเป็นบรรทัดเต็มและผลลัพธ์ซ้อนกัน:

{"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 ที่ minify แล้วไม่ควรอยู่ใน version control เลย แต่เมื่อมันอยู่ union เปลี่ยนมันเป็นสองเอกสารที่ไม่เกี่ยวข้องกัน

5. ผลลัพธ์ที่สี่: คีย์ที่ถูกลบกลับมา

รูปร่างสุดท้ายหลอกลวงที่สุด ฝั่งหนึ่งลบ minify; อีกฝั่งแก้ parse ที่อยู่ติดกัน Driver เริ่มต้นรายงาน conflict; union สร้าง:

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

ข้อบกพร่องสามอย่างพร้อมกัน: minify ฟื้นคืนชีพ parse ปรากฏสองครั้ง และบรรทัดแรกสูญเสียจุลภาค Git ยังรายงาน "2 insertions" และ merge สำเร็จ

บทเรียนทั่วไป: ในการลบ "เก็บบรรทัดจากทั้งสองฝั่ง" เทียบเท่ากับ การย้อนการลบ ทีมใดที่ใช้ union กับ JSON มาระยะหนึ่งจะมีชุดการตั้งค่า "เราลบสิ่งนั้นไปแล้ว" ที่แอบอยู่ในประวัติ

6. แนวทางที่ถูกต้อง: three-way merge ตามคีย์

Git ให้คุณกำหนด merge driver แบบกำหนดเองให้ path ที่ระบุได้ Driver ได้รับ path ไฟล์สามค่า: %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} real conflict(s), see merge-conflicts.json`);
  process.exit(1);
}

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

กฎสามข้อพามันไป: เหมือนกันทั้งสองฝั่ง ให้เอา; ฝั่งหนึ่งเท่ากับบรรพบุรุษ ให้เอาอีกฝั่ง; ต่างกันทั้งสองฝั่งคือ conflict จริง ที่เหลือคือการเรียกซ้ำเข้าไปในออบเจ็กต์ plain

การลงทะเบียนมีสองขั้น — .gitattributes เลือก driver ตาม path, git config นิยาม 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

อันแรก commit ได้ (มันเดินทางไปกับโค้ดและมีผลใน CI) อันที่สองคือคอนฟิกภายในเครื่องและต้องอยู่ใน README — นี่คือจุดที่ driver แบบกำหนดเองมักพัง

สำหรับกรณี "แต่ละฝั่งเพิ่มคีย์ต่างกัน" driver สร้าง:

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

Exit code 0, JSON ถูกต้อง, คีย์ทั้งสองอยู่

ผลข้างเคียงหนึ่งอย่างที่ควรรู้: driver เขียน JSON.stringify(merged, null, 2) ดังนั้นมัน เขียนการจัดรูปแบบทั้งไฟล์ใหม่ คอนฟิก minified 63 ไบต์กลับมาเป็น 7 บรรทัดและ 105 ไบต์ — เนื้อหาเดิม รูปร่างต่าง นั่นทำให้ driver เหมาะกับไฟล์ซอร์สที่ดูแลด้วยมือเท่านั้น JSON ที่ minify หรือสร้างอัตโนมัติไม่ควรอยู่ใน repository และไม่ควรใช้ driver นี้ มิฉะนั้นทุก merge จะลากเสียงรบกวนจากการจัดรูปแบบมาด้วย

7. อย่าเขียนกลับเมื่อเกิด conflict — กับดักนี้ใหญ่กว่าที่เห็น

เมื่อทั้งสองฝั่งแก้คีย์เดียวกัน driver ควรทำอะไร? การเคลื่อนที่ตามสัญชาตญาณคือ "เขียนของเรากลับ exit 1 ปล่อยให้ Git รายงาน conflict" เมื่อทดสอบแล้วมันอันตราย:

$ 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 รายงาน conflict แต่ไม่มีเครื่องหมาย conflict แม้แต่ตัวเดียวในไฟล์ — driver เขียนผลลัพธ์ไปแล้ว เนื้อหาดูปกติโดยสมบูรณ์ โดยมีค่าของฝั่งเรา git add i18n.json && git commit หนึ่งครั้งอย่างรีบร้อนแล้วการเปลี่ยนแปลงของเขาหายไป เหลือเพียง "Merge conflict resolved" ในประวัติ

ดังนั้นเมื่อ conflict จริง driver ไม่ควรเขียน %A บันทึกรายการที่ยังไม่ได้ตัดสินในไฟล์รายงานและ exit 1:

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

คุณค่าคือ git status หยุดที่ UU ดังนั้นมนุษย์ต้องลงมือ — และไม่ต้องตามหา <<<<<<< ในบรรทัด JSON หลายสิบรายงานบอกตรง ๆ ว่า path ใดชนกัน

การค้นพบที่ทดสอบแล้วอีกข้อ: git checkout --conflict=merge -- i18n.json กู้เครื่องหมาย conflict ไม่ได้ เมื่อลงทะเบียน driver แบบกำหนดเองแล้ว เพราะมันเรียก driver อีกครั้งและยังให้เครื่องหมายศูนย์ตัว การจะเอาเครื่องหมายกลับมาต้อง git config --unset merge.json-merge.driver ชั่วคราว หรือดึงสามสเตจด้วย git show :1:file, :2:, :3: ใครที่พึ่ง "ฉันสร้างเครื่องหมายใหม่ได้เสมอ" จะพบว่าถนนปิดพอดีตอนที่ต้องการ

8. นิสัยสามข้อที่ลด conflict ล่วงหน้า

Driver เป็นตาข่ายรองรับ ไม่ใช่เครื่องมือหลัก จำนวน conflict ลดได้เอง

จัดมาตรฐานการทำให้เป็นอนุกรม คนหนึ่งเขียน indent=2 อีกคน indent=4 คนที่สามไม่ใส่ขึ้นบรรทัดใหม่ท้าย ทุกการบันทึกเขียนทั้งไฟล์ใหม่และ merge ไม่มีอะไรให้ทำ ตรึงคอนฟิกฟอร์แมตเตอร์ หรือรันใน pre-commit hook

รักษาลำดับคีย์ให้มั่นคง ลำดับคีย์ไม่มีความหมายต่อ JSON แต่มีจริงมากต่อ Git การเรียงคีย์ก่อนเขียน (เรียงก่อน JSON.stringify หรือส่งผ่าน jq -S) ทำให้การแก้หนึ่งครั้งอยู่หนึ่ง hunk นี่คือแนวคิดเดียวกับการ normalize ก่อนเปรียบเทียบใน เมื่อเอกสาร JSON สองฉบับต่างกัน — ที่นั่นเพื่อ diff ที่นี่เพื่อ commit

เก็บไฟล์ที่สร้างอัตโนมัติออกจาก repository JSON ที่ minify แล้ว build manifest ที่มี timestamp lockfile — พวกมันเปลี่ยนทุกครั้งที่รัน ให้ทำเวอร์ชันซอร์ส สร้าง artifact

9. รายการตรวจสอบ

  • หาก .gitattributes แมป JSON ไปยัง merge=union ให้สุ่มดูประวัติด้วย git log -p หาคีย์ซ้ำหรือ syntax error แล้วแทนที่;
  • หลังลงทะเบียน driver แบบกำหนดเอง ให้ทุกคนรันขั้น git config หรือใส่มันใน make setup;
  • สาขา conflict ของ driver ต้องไม่เขียน %A; ให้สร้างรายงานแทน;
  • เพิ่ม gate node -e "JSON.parse(...)" หรือ jq empty ใน CI เพื่อจับ JSON "ถูกต้องแต่ผิด" หลัง merge และก่อน deploy;
  • เพิ่ม JSON ที่ minify แล้วและไฟล์ที่สร้างอัตโนมัติใน .gitignore เพื่อกำจัด conflict ประเภทนั้นทั้งหมด

10. FAQ

merge=union ปลอดภัยเมื่อใด?

เฉพาะไฟล์แบบ append-only ที่แต่ละบรรทัดเป็นเรกคอร์ดสมบูรณ์และลำดับไม่มีความหมาย เช่น log JSON Lines ทันทีที่บรรทัดหนึ่งสามารถถูกแก้ในที่ — เพิ่มฟิลด์ในเรกคอร์ดที่มีอยู่ — union จะสร้างสองเรกคอร์ดที่ขัดแย้งกัน นั่นคือขอบเขตการออกแบบ ไม่ใช่เรื่องของการใช้อย่างระมัดระวัง

Driver แบบกำหนดเองจัดการอาร์เรย์ได้หรือไม่?

เวอร์ชันข้างบนถืออาร์เรย์เป็นอะตอม: หากทั้งสองฝั่งแก้อาร์เรย์ มันรายงาน conflict การจับคู่ตามดัชนีสร้างปัญหาของ text diff เดิม (แทรกหนึ่งรายการแล้วทุกอย่างเลื่อน) ขณะที่การจับคู่ตาม id ต้องมีคีย์หลักที่ตกลงกัน การ merge อาร์เรย์ควรปล่อยให้มนุษย์ หรือป้องกันด้วยธรรมเนียม "อาร์เรย์เป็น append-only"

ถ้า driver มีบั๊กจะเกิดอะไรขึ้น?

มันทำลายไฟล์อย่างเงียบ ๆ ทดสอบเวอร์ชันแรกใน repository ทดลองกับสามรูปร่าง (แต่ละฝั่งเพิ่มคีย์, ทั้งสองแก้คีย์เดียวกัน, ฝั่งหนึ่งลบอีกฝั่งแก้) ก่อนเปิดใช้จริง Driver ที่พังเขียนไฟล์ใหม่เงียบ ๆ ซึ่งสังเกตยากกว่าเครื่องหมาย conflict มาก

ทำไมไม่ merge ด้วย jq บรรทัดเดียว?

jq -s '.[0] * .[1]' เป็น shallow merge ลำดับคีย์และพฤติกรรมการซ้อนต้องตรวจทีละกรณี และมันไม่มีบรรพบุรุษ — หากไม่มี base คุณแยก "ถูกลบ" จาก "ไม่เปลี่ยน" ไม่ได้ three-way merge ต้องมีอินพุตสามอย่าง

ควร commit รายงาน conflict หรือไม่?

ไม่ ให้เพิ่มใน .gitignore; มันเป็น artifact ใช้ครั้งเดียวที่หมดอายุเมื่อ merge จบ