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

สรุปสั้น: จัดมาตรฐานการทำให้เป็นอนุกรมและลำดับคีย์ก่อนจะคุยเรื่องกลยุทธ์การ 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 จบ