تعارضات دمج Git في ملفات JSON: لماذا يكتب merge=union JSON معطوباً
الخلاصة: merge=union يسمح بمرور JSON معطوب ويظل يبلّغ عن النجاح. يشرح هذا دليل دمج ثلاثي واعٍ بالمفاتيح لـJSON، إضافة إلى ثلاث عادات تقلّل التعارضات.

الخلاصة: وحّد التسلسل وترتيب المفاتيح قبل مناقشة استراتيجية الدمج؛ فكل تعارض يوفره merge=union يعود لاحقاً كفاصلة مفقودة، أو مفتاح مكرر، أو وثيقتين متراكمتين، لذا تعارضات JSON تنتمي إلى دليل دمج ثلاثي واعٍ بالمفاتيح.
يضيف شخصان مفتاحَي ترجمة في الأسبوع نفسه: أحدهما يضيف tools.diff، والآخر يضيف tools.schema. التغييرات غير مرتبطة ومتوافقة دلالياً. ومع ذلك يبلّغ Git عن تعارض، وكتلة التعارض تحمل السطرين:
<<<<<<< HEAD
"diff": "对比"
=======
"schema": "Schema 生成"
>>>>>>> feat-b
السبب بسيط. أدخل الفرعان سطراً جديداً قبل القوس الختامي مباشرةً، ودمج Git الثلاثي القائم على الأسطر يرى الموضع لا المعنى. ليس لديه مفهوم "هذان مفتاحان مختلفان". فيقرأ أحدهم وثائق .gitattributes وينسخ سطراً واحداً:
i18n.json merge=union
يختفي التعارض. وما يكلّفه هو موضوع هذه المقالة.
1. Union لا يدمج، بل يُبقي أسطر الطرفين
merge=union موثّق بوضوح: شغّل دمجاً ثلاثياً على مستوى الملف للنصوص، لكن خذ الأسطر من النسختين بدلاً من ترك علامات التعارض. تتبع الوثائق تحذيراً — لا تستخدم هذا إن لم تفهم التبعات. ذلك التحذير ليس كلاماً إنشائياً.
Union لا يفهم الكائنات ولا المفاتيح ولا المصفوفات. إنه يفهم الأسطر. وكل نتيجة أدناه أُعيد إنتاجها في مستودع محلي باستخدام ملف i18n.json نفسه أعلاه.
| السيناريو | الدليل الافتراضي | merge=union |
دليل واعٍ بالمفاتيح |
|---|---|---|---|
| كل طرف يضيف مفتاحاً مختلفاً | تعارض، يدوي | "مدموج"، فاصلة مفقودة، JSON غير صالح | مدموج، مع الاحتفاظ بالمفتاحين |
| كل الطرفين يغيّران المفتاح نفسه | تعارض، يدوي | "مدموج"، مفتاح مكرر، الأخير يفوز | رمز خروج 1، المسارات المتعارضة مبلّغ عنها |
| JSON أحادي السطر، تغيير واحد لكل طرف | تعارض، اختر ملفاً كاملاً | "مدموج"، وثيقتان متراكمتان | مدموج، لكن بعد إعادة تنسيق |
طرف يحذف minify والآخر يعدّل parse |
تعارض، يدوي | "مدموج"، المفتاح المحذوف يعود، والفاصلة مفقودة | مدموج، الحذف محفوظ والتعديل مطبّق |
العمود الأوسط وحده يجعل git merge يعيد النجاح، ولا شيء من خرجه يُحلّل. ذلك هو الجانب المناقض للحدس في union على JSON: كلما كان أكثر هدوءاً، كان أخطر.
2. النتيجة الأولى: فاصلة مفقودة، مبلّغ عنها كدمج نظيف
الفرعان نفسهما، الآن مع merge=union في مكانه:
$ git merge feat-b
Auto-merging i18n.json
Merge made by the 'ort' strategy.
i18n.json | 1 +
1 file changed, 1 insertion(+)
لا تعارض، وأُنشئ التزام دمج. يبدو الملف هكذا:
{
"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 الدمج ناجحاً، وتمر خطوة دمج CI، وترى المراجعة diffاً أخضر بـ"إدراج واحد". ينتظر الفشل حتى أول JSON.parse في وقت التشغيل، وعندها يكون الدمج قد تركك بعيداً.
3. النتيجة الثانية: مفتاح مكرر يُسقط بيانات بصمت
التجربة الثانية تستخدم شكلاً مختلفاً: يعدّل الطرفان المفتاح نفسه، أحدهما إلى "解析 / 格式化" والآخر إلى "解析器". بعد union:
{
"tools": {
"parse": "解析 / 格式化",
"parse": "解析器",
"minify": "压缩"
}
}
هذه المرة JSON صالح وJSON.parse لا يرمي شيئاً. لكن parse يظهر مرتين. حسب دلالات object literal يفوز المدخل اللاحق، لذا القيمة النهائية "解析器" — ويُهمل التعديل الآخر بلا سطر سجل ولا خطأ.
فحص المفاتيح المكررة وحده يكشفه:
duplicate keys detected: ['parse']
JSON.parse succeeded, final parse value = '解析器'
هذا أصعب في الاكتشاف من JSON غير الصالح: يُحمّل الملف، وتُعرض الصفحة، ويرتد نص واحد بهدوء. بعد أسابيع يسأل أحدهم لماذا لم تُشحن ترجمة قط.
4. النتيجة الثالثة: JSON أحادي السطر يصبح وثيقتين
بعض ملفات الإعدادات مضغوطة إلى سطر واحد، مثل مخرجات البناء. لا توجد أسطر ليدمجها union، لذا فالطرفان سطران كاملان والنتيجة تكدّسهما:
{"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 المضغوط لا ينبغي أن يكون في التحكم بالإصدارات إطلاقاً، لكن بمجرد أن يكون، يحوّله union إلى وثيقتين غير مرتبطتين.
5. النتيجة الرابعة: المفتاح المحذوف يعود
الشكل الأخير هو الأكثر خداعاً. طرف يحذف minify؛ والآخر يعدّل parse المجاور. الدليل الافتراضي يبلّغ عن تعارض؛ وunion يُنتج:
{
"tools": {
"parse": "解析"
"parse": "解析器",
"minify": "压缩"
}
}
ثلاثة عيوب في آن واحد: minify يُبعث من جديد، وparse يظهر مرتين، والسطر الأول فقد فاصلته. ويظل Git يبلّغ عن "إدراجين" ودمج ناجح.
الدرس العام: في الحذف، "الإبقاء على أسطر الطرفين" يساوي إلغاء الحذف. وأي فريق استخدم union على JSON لوقت ما لديه مجموعة إعدادات "حذفنا ذلك" تعيش بصمت في التاريخ.
6. النهج الصحيح: دمج ثلاثي بالمفاتيح
يتيح Git تعيين دليل دمج مخصص لمسارات محددة. يتلقى الدليل ثلاثة مسارات ملفات: %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);
ثلاث قواعد تحمله: متطابق على الطرفين، فخذه؛ طرف يساوي السلف، فخذ الآخر؛ مختلف على الطرفين تعارض حقيقي. وكل ما عدا ذلك عودية في الكائنات العادية.
التسجيل يتم بخطوتين — .gitattributes يختار الدليل لكل مسار، وgit config يعرّف الدليل:
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
الأول يمكن التزامه (يسافر مع الكود ويُطبَّق في CI). والثاني إعداد محلي ويحتاج أن يكون في README — وهنا عادةً تنهار الأدلة المخصصة.
لحالة "كل طرف يضيف مفتاحاً مختلفاً"، يُنتج الدليل:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比",
"schema": "Schema 生成"
}
}
رمز الخروج 0، JSON صالح، المفتاحان موجودان.
أثر جانبي يجب معرفته: يكتب الدليل JSON.stringify(merged, null, 2)، لذا يعيد كتابة تنسيق الملف كله. إعداد مضغوط بـ63 بايتاً عاد 7 أسطر و105 بايتات — المحتوى نفسه، الشكل مختلف. ذلك يجعل الدليل مناسباً لملفات المصدر المُصانة يدوياً فقط. JSON المضغوط أو المولّد لا ينبغي أن يكون في المستودع، ولا أن يستخدم هذا الدليل، وإلا فكل دمج سيسحب معه ضوضاء تنسيق.
7. لا تكتب مرة أخرى عند التعارض — هذا الفخ أكبر مما يبدو
عندما يغيّر الطرفان المفتاح نفسه، ماذا يجب أن يفعل الدليل؟ الحركة البديهية هي "اكتب طرفنا مرة أخرى، اخرج بـ1، ودع Git يبلّغ عن التعارض". جُرّب ذلك، وهو خطر:
$ 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 عن تعارض، لكن لا توجد علامة تعارض واحدة في الملف — فقد كتب الدليل نتيجته بالفعل. يبدو المحتوى طبيعياً تماماً، حاملاً قيمة طرفنا. git add i18n.json && git commit واحد متسرع ويختفي تغييرهم، ولا شيء في التاريخ سوى "Merge conflict resolved".
لذا عند تعارض حقيقي يجب أن لا يكتب الدليل %A. سجّل المدخلات غير المحسومة في ملف تقرير واخرج بـ1:
{
"file": "i18n.json",
"conflicts": [
{ "path": "tools.parse", "ours": "解析 / 格式化", "theirs": "解析器" }
]
}
القيمة هي أن git status يتوقف عند UU، فيجب على إنسان أن يتصرف — ولا يتعين عليه البحث عن <<<<<<< داخل عشرات أسطر JSON. يحدد التقرير المسارات التي تصادمت بالضبط.
اكتشاف آخر مُختبَر: git checkout --conflict=merge -- i18n.json لا يستطيع استعادة علامات التعارض بمجرد تسجيل دليل مخصص، لأنه يستدعي الدليل مرة أخرى وما زال ينتج صفر علامات. لاستعادة العلامات يجب أن تنفّذ مؤقتاً git config --unset merge.json-merge.driver، أو تسحب المراحل الثلاث بـgit show :1:file و:2: و:3:. أي شخص يعتمد على "يمكنني دائماً إعادة توليد العلامات" يجد ذلك الطريق مغلقاً بالضبط عندما يحتاجه.
8. ثلاث عادات تقلّل التعارضات مسبقاً
الدليل هو شبكة أمان لا الأداة الرئيسية. وعدد التعارضات نفسه قابل للتقليل.
وحّد التسلسل. شخص يكتب indent=2 وآخر indent=4 وثالث يحذف السطر الجديد الأخير. كل حفظ يعيد كتابة الملف كله وليس لدى الدمج ما يعمل به. ثبّت إعداد المنسّق، أو شغّله في hook قبل الالتزام.
أبقِ ترتيب المفاتيح مستقراً. ترتيب المفاتيح بلا معنى لـJSON لكنه واقع جداً لـGit. ترتيب المفاتيح قبل الكتابة (رتّب قبل JSON.stringify، أو مرّر عبر jq -S) يُبقي التعديل الواحد في كتلة واحدة. إنها الفكرة نفسها كالتطبيع قبل المقارنة في كيف تختلف وثيقتا JSON — هناك للـdiffing، وهنا للالتزام.
أبقِ الملفات المولّدة خارج المستودع. JSON المضغوط، وبيانات بناء بطوابع زمنية، وملفات الأقفال — كلها تتغير في كل تشغيل. أصدِر المصدر، وولّد الناتج.
9. قائمة الفحص
- إذا كان
.gitattributesيربط JSON بـmerge=union، فاعتمد التاريخ بـgit log -pبحثاً عن مفاتيح مكررة أو أخطاء صياغة، ثم استبدله؛ - بعد تسجيل دليل مخصص، اطلب من الجميع تشغيل خطوة
git config، أو ضعها فيmake setup؛ - يجب ألا يكتب فرع التعارض في الدليل
%A؛ بل يُصدر تقريراً؛ - أضف بوابة
node -e "JSON.parse(...)"أوjq emptyفي CI، حتى يُكتشف JSON "صالح لكن خاطئ" بعد الدمج وقبل النشر؛ - أضف JSON المضغوط والملفات المولّدة إلى
.gitignoreلإزالة ذلك النوع من التعارض بالكامل.
10. FAQ
متى يكون merge=union آمناً؟
فقط للملفات ذات الإلحاق فقط حيث كل سطر سجل كامل والترتيب بلا معنى، مثل سجلات JSON Lines. بمجرد أن يمكن تحرير سطر في مكانه — إضافة حقل إلى سجل موجود — يُنتج union سجلين متعارضين. ذلك حد تصميمي، لا مسألة استخدامه بحذر.
هل يمكن لدليل مخصص التعامل مع المصفوفات؟
النسخة أعلاه تعامل المصفوفات كذرّية: إذا غيّر الطرفان مصفوفة، فإنها تبلّغ عن تعارض. محاذاة الفهرس تعيد مشكلة diff النصوص القديمة (أدرج عنصراً وكل شيء يزحف)، بينما محاذاة id تتطلب مفتاحاً رئيسياً متفقاً عليه. دمج المصفوفات أفضل تركه لإنسان، أو منعه باتفاقية "المصفوفات إلحاق فقط".
ماذا لو كان في الدليل خلل؟
يفسد الملفات بصمت. اختبر النسخة الأولى في مستودع تجريبي على ثلاثة أشكال (كل طرف يضيف مفتاحاً، كلاهما يغيّر المفتاح نفسه، أحدهما يحذف والآخر يعدّل) قبل طرحه. الدليل المعطوب يعيد كتابة الملف بصمت، وهو أصعب ملاحظة من علامة تعارض.
لماذا لا أدمج بسطر واحد من jq؟
jq -s '.[0] * .[1]' دمج سطحي، وترتيب مفاتيحه وسلوكه المتداخل يحتاجان فحصاً حالة بحالة، وليس له سلف — بلا قاعدة لا يمكنك التمييز بين "محذوف" و"غير متغير". الدمج الثلاثي يحتاج ثلاثة مدخلات.
هل يجب التزام تقرير التعارض؟
لا. أضفه إلى .gitignore؛ إنه ناتج لمرة واحدة ينتهي حين ينتهي الدمج.