Xung đột merge Git trên tệp JSON: vì sao merge=union ghi ra JSON hỏng
Kết luận: merge=union để JSON hỏng lọt qua mà vẫn báo thành công. Bài viết đi qua một merge driver ba chiều nhận biết khoá cho JSON, cùng ba thói quen giúp giảm xung đột.

Tóm lại: hãy chuẩn hoá cách tuần tự hoá và thứ tự khoá trước khi bàn đến chiến lược merge; mỗi xung đột mà merge=union "tiết kiệm" sẽ quay lại sau này dưới dạng dấu phẩy thiếu, khoá trùng, hoặc hai tài liệu xếp chồng, nên xung đột JSON thuộc về một merge driver ba chiều nhận biết khoá.
Hai người thêm hai khoá dịch trong cùng một tuần: một người thêm tools.diff, người kia thêm tools.schema. Các thay đổi không liên quan và tương thích về ngữ nghĩa. Git vẫn báo xung đột, và khối xung đột chứa cả hai dòng:
<<<<<<< HEAD
"diff": "对比"
=======
"schema": "Schema 生成"
>>>>>>> feat-b
Lý do đơn giản. Cả hai nhánh đều chèn một dòng mới ngay trước dấu đóng ngoặc nhọn, và merge ba chiều dựa trên dòng của Git chỉ thấy vị trí, không thấy ý nghĩa. Nó không có khái niệm "đây là hai khoá khác nhau". Thế là ai đó đọc tài liệu .gitattributes và sao chép một dòng:
i18n.json merge=union
Xung đột biến mất. Cái giá của nó chính là chủ đề bài này.
1. Union không hợp nhất, nó giữ dòng của cả hai bên
merge=union được tài liệu mô tả thẳng thắn: chạy merge ba chiều ở mức tệp cho tệp văn bản, nhưng lấy dòng từ cả hai phiên bản thay vì để lại dấu xung đột. Tài liệu tiếp theo bằng một cảnh báo — đừng dùng nếu bạn không hiểu hệ quả. Cảnh báo đó không phải câu rập khuôn.
Union không hiểu object, khoá hay mảng. Nó hiểu dòng. Mọi kết quả dưới đây đều được tái hiện trong một kho mã cục bộ dùng đúng i18n.json phía trên.
| Tình huống | Driver mặc định | merge=union |
Driver nhận biết khoá |
|---|---|---|---|
| Mỗi bên thêm một khoá khác nhau | Xung đột, làm tay | "Đã merge", thiếu dấu phẩy, JSON không hợp lệ | Đã merge, giữ cả hai khoá |
| Cả hai bên đổi cùng một khoá | Xung đột, làm tay | "Đã merge", khoá trùng, cái sau thắng | Mã thoát 1, báo các đường dẫn xung đột |
| JSON một dòng, mỗi bên đổi một chỗ | Xung đột, chọn cả tệp | "Đã merge", hai tài liệu xếp chồng | Đã merge, nhưng bị định dạng lại |
Một bên xoá minify, bên kia sửa parse |
Xung đột, làm tay | "Đã merge", khoá đã xoá quay lại, thiếu dấu phẩy | Đã merge, giữ việc xoá, áp dụng sửa |
Chỉ cột giữa làm git merge trả về thành công, và không đầu ra nào của nó phân tích được. Đó là phần phản trực giác của union trên JSON: nó càng im lặng thì càng nguy hiểm.
2. Kết quả một: thiếu dấu phẩy, mà được báo là merge sạch
Vẫn hai nhánh đó, giờ có merge=union:
$ git merge feat-b
Auto-merging i18n.json
Merge made by the 'ort' strategy.
i18n.json | 1 +
1 file changed, 1 insertion(+)
Không xung đột, commit merge được tạo. Tệp trông như sau:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比"
"schema": "Schema 生成"
}
}
Không có dấu phẩy sau "diff": "对比". Union giữ dòng của phía chúng ta và dòng của phía họ, mỗi dòng mang dấu phẩy cuối riêng, và đường nối mất một dấu phân cách.
Phân tích nó:
$ python3 -c "import json;json.load(open('i18n.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 10 column 5
Đây là phần tệ nhất: Git coi merge thành công, bước merge trong CI qua, và review mã thấy một diff xanh "1 insertion". Lỗi chờ đến lần JSON.parse đầu tiên lúc chạy, lúc đó merge đã ở rất xa phía sau bạn.
3. Kết quả hai: khoá trùng âm thầm làm mất dữ liệu
Thí nghiệm thứ hai dùng hình dạng khác: cả hai bên sửa cùng một khoá, một bên thành "解析 / 格式化", bên kia thành "解析器". Sau union:
{
"tools": {
"parse": "解析 / 格式化",
"parse": "解析器",
"minify": "压缩"
}
}
Lần này JSON hợp lệ và JSON.parse không báo gì. Nhưng parse xuất hiện hai lần. Theo ngữ nghĩa object literal, mục sau thắng, nên giá trị cuối là "解析器" — thay đổi kia bị bỏ mà không có dòng log, không có lỗi.
Chỉ kiểm tra khoá trùng mới phát hiện được:
duplicate keys detected: ['parse']
JSON.parse succeeded, final parse value = '解析器'
Điều này khó tìm hơn JSON không hợp lệ: tệp tải được, trang hiển thị, và một chuỗi âm thầm quay về giá trị cũ. Nhiều tuần sau ai đó hỏi vì sao một bản dịch chưa bao giờ được phát hành.
4. Kết quả ba: JSON một dòng biến thành hai tài liệu
Một số tệp cấu hình bị nén thành một dòng, ví dụ sản phẩm build. Không có dòng nào để union hợp nhất, nên hai bên đều là những dòng trọn vẹn và kết quả xếp chồng chúng:
{"tools":{"parse":"解析","minify":"压缩","diff":"对比"},"version":1}
{"tools":{"parse":"解析","minify":"压缩"},"version":2}
JSON.parse báo Unexpected non-whitespace character after JSON at position 63 — nó đọc xong tài liệu đầu và gặp tài liệu thứ hai. Git vẫn nói "Merge made".
JSON nén lẽ ra không nên nằm trong kiểm soát phiên bản, nhưng một khi đã nằm, union biến nó thành hai tài liệu không liên quan.
5. Kết quả bốn: khoá đã xoá quay lại
Hình dạng cuối cùng là kiểu lừa dối nhất. Một bên xoá minify; bên kia sửa parse liền kề. Driver mặc định báo xung đột; union tạo ra:
{
"tools": {
"parse": "解析"
"parse": "解析器",
"minify": "压缩"
}
}
Ba lỗi cùng lúc: minify sống lại, parse xuất hiện hai lần, và dòng đầu mất dấu phẩy. Git vẫn báo "2 insertions" và merge thành công.
Bài học chung: trong một lần xoá, "giữ dòng của cả hai bên" tương đương với hoàn tác việc xoá. Nhóm nào dùng union trên JSON một thời gian đều có một bộ thiết lập "chúng ta đã xoá cái đó" âm thầm sống trong lịch sử.
6. Cách đúng: merge ba chiều theo khoá
Git cho phép gán một merge driver tuỳ chỉnh cho những đường dẫn cụ thể. Driver nhận ba đường dẫn tệp: %O là tổ tiên chung, %A là của chúng ta (kết quả ghi lại vào đây), %B là của họ. Vài chục dòng Node là đủ:
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);
Ba quy tắc mang nó đi: hai bên giống nhau thì lấy; một bên bằng tổ tiên thì lấy bên kia; hai bên khác nhau là xung đột thật. Phần còn lại chỉ là đệ quy vào object phẳng.
Đăng ký cần hai bước — .gitattributes chọn driver theo đường dẫn, git config định nghĩa 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
Bước đầu có thể commit (nó đi cùng mã và áp dụng trong CI). Bước hai là cấu hình cục bộ và cần nằm trong README — đây thường là chỗ driver tuỳ chỉnh sụp đổ.
Với trường hợp "mỗi bên thêm một khoá khác nhau", driver tạo ra:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比",
"schema": "Schema 生成"
}
}
Mã thoát 0, JSON hợp lệ, cả hai khoá đều có.
Một hệ quả cần biết: driver ghi JSON.stringify(merged, null, 2), nên nó viết lại định dạng của cả tệp. Một cấu hình nén 63 byte trả về thành 7 dòng và 105 byte — cùng nội dung, khác hình dạng. Điều đó khiến driver chỉ phù hợp với tệp nguồn được bảo trì bằng tay. JSON nén hoặc sinh tự động không nên nằm trong kho mã, và không nên dùng driver này, nếu không mỗi lần merge sẽ kéo theo nhiễu định dạng.
7. Đừng ghi đè khi xung đột — cái bẫy này lớn hơn vẻ ngoài
Khi cả hai bên đổi cùng một khoá, driver nên làm gì? Động tác trực giác là "ghi lại phía của chúng ta, thoát 1, để Git báo xung đột". Đã thử, và nó nguy hiểm:
$ 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 báo xung đột, nhưng tệp không có một dấu xung đột nào — driver đã ghi kết quả của nó. Nội dung trông hoàn toàn bình thường, chứa giá trị của phía chúng ta. Một lệnh git add i18n.json && git commit vội vàng là thay đổi của họ biến mất, lịch sử chỉ còn "Merge conflict resolved".
Vậy nên khi có xung đột thật, driver không nên ghi %A. Ghi các mục chưa quyết định vào một tệp báo cáo và thoát 1:
{
"file": "i18n.json",
"conflicts": [
{ "path": "tools.parse", "ours": "解析 / 格式化", "theirs": "解析器" }
]
}
Giá trị ở đây là git status dừng ở UU, nên con người buộc phải hành động — và họ không phải đi tìm <<<<<<< trong hàng chục dòng JSON. Báo cáo nói chính xác đường dẫn nào va nhau.
Một phát hiện đã kiểm chứng nữa: git checkout --conflict=merge -- i18n.json không thể khôi phục dấu xung đột khi đã đăng ký driver tuỳ chỉnh, vì nó gọi lại driver và vẫn cho ra zero dấu. Để lấy lại dấu, bạn phải tạm thời git config --unset merge.json-merge.driver, hoặc lấy ba giai đoạn bằng git show :1:file, :2:, :3:. Ai trông cậy vào "tôi luôn có thể tái tạo dấu xung đột" sẽ thấy con đường đó đóng đúng lúc họ cần.
8. Ba thói quen giúp giảm xung đột từ đầu
Driver là lưới chắn, không phải công cụ chính. Số xung đột tự nó có thể giảm.
Chuẩn hoá cách tuần tự hoá. Một người viết indent=2, người khác indent=4, người thứ ba bỏ dòng mới cuối tệp. Mỗi lần lưu đều viết lại cả tệp và merge không còn gì để làm. Hãy ghim cấu hình formatter, hoặc chạy nó trong pre-commit hook.
Giữ thứ tự khoá ổn định. Thứ tự khoá vô nghĩa với JSON nhưng rất thật với Git. Sắp xếp khoá trước khi ghi (sắp trước JSON.stringify, hoặc đẩy qua jq -S) giữ một lần sửa trong một hunk. Đây cùng ý tưởng với việc chuẩn hoá trước khi so sánh trong khi hai tài liệu JSON khác nhau — ở đó để diff, ở đây để commit.
Giữ tệp sinh tự động ra khỏi kho mã. JSON nén, manifest build có timestamp, lockfile — chúng đổi mỗi lần chạy. Hãy phiên bản hoá nguồn, sinh ra sản phẩm.
9. Danh sách kiểm tra
- Nếu
.gitattributesánh xạ JSON sangmerge=union, hãy lấy mẫu lịch sử bằnggit log -pđể tìm khoá trùng hoặc lỗi cú pháp, rồi thay thế nó; - Sau khi đăng ký driver tuỳ chỉnh, yêu cầu mọi người chạy bước
git config, hoặc đặt nó vàomake setup; - Nhánh xung đột của driver không được ghi
%A; hãy phát ra báo cáo thay vào đó; - Thêm cổng kiểm tra
node -e "JSON.parse(...)"hoặcjq emptyvào CI, để JSON "hợp lệ nhưng sai" bị bắt sau merge và trước khi triển khai; - Thêm JSON nén và tệp sinh tự động vào
.gitignoređể loại bỏ hoàn toàn loại xung đột này.
10. FAQ
Khi nào merge=union an toàn?
Chỉ với tệp chỉ ghi thêm, nơi mỗi dòng là một bản ghi trọn vẹn và thứ tự không mang ý nghĩa, như log JSON Lines. Ngay khi một dòng có thể bị sửa tại chỗ — thêm trường vào bản ghi hiện có — union sinh ra hai bản ghi mâu thuẫn. Đó là ranh giới thiết kế, không phải chuyện dùng cẩn thận hơn.
Driver tuỳ chỉnh có xử lý được mảng không?
Bản trên coi mảng là nguyên tử: nếu cả hai bên đổi một mảng, nó báo xung đột. Căn theo chỉ số tái hiện vấn đề text-diff cũ (chèn một mục là mọi thứ dịch đi), còn căn theo id cần một khoá chính được thoả thuận. Merge mảng tốt hơn là để con người, hoặc ngăn bằng quy ước "mảng chỉ được ghi thêm".
Nếu driver có lỗi thì sao?
Nó làm hỏng tệp một cách âm thầm. Hãy kiểm thử bản đầu trong một kho mã nháp với ba hình dạng (mỗi bên thêm một khoá, cả hai đổi cùng khoá, một bên xoá còn bên kia sửa) trước khi triển khai. Driver hỏng viết lại tệp lặng lẽ, khó phát hiện hơn nhiều so với dấu xung đột.
Vì sao không merge bằng một dòng jq?
jq -s '.[0] * .[1]' là merge nông, thứ tự khoá và hành vi lồng của nó cần kiểm tra từng trường hợp, và nó không có tổ tiên — không có base thì không phân biệt được "đã xoá" với "không đổi". Merge ba chiều cần ba đầu vào.
Có nên commit báo cáo xung đột không?
Không. Hãy thêm nó vào .gitignore; nó là sản phẩm một lần, hết giá trị khi merge kết thúc.