JSON फ़ाइलों में Git मर्ज कॉन्फ़्लिक्ट: merge=union टूटी JSON क्यों लिखता है
मुख्य बात: merge=union टूटी JSON को गुज़ार देता है और फिर भी सफलता बताता है। यह JSON के लिए कुंजी-जागरूक three-way मर्ज ड्राइवर और कॉन्फ़्लिक्ट घटाने वाली तीन आदतों से गुज़रता है।

मुख्य निष्कर्ष: मर्ज रणनीति पर चर्चा से पहले सीरियलाइज़ेशन और कुंजी क्रम को मानकीकृत करें; merge=union जो हर कॉन्फ़्लिक्ट बचाता है, वह बाद में गायब कॉमा, दोहराई गई कुंजी, या दो ढेर किए दस्तावेज़ों के रूप में लौटता है, इसलिए JSON कॉन्फ़्लिक्ट कुंजी-जागरूक three-way मर्ज ड्राइवर के हक़दार हैं।
दो लोग एक ही सप्ताह में दो अनुवाद कुंजियाँ जोड़ते हैं: एक tools.diff जोड़ता है, दूसरा tools.schema। बदलाव असंबंधित और अर्थ में संगत हैं। Git फिर भी कॉन्फ़्लिक्ट बताता है, और कॉन्फ़्लिक्ट ब्लॉक में दोनों पंक्तियाँ हैं:
<<<<<<< HEAD
"diff": "对比"
=======
"schema": "Schema 生成"
>>>>>>> feat-b
कारण सरल है। दोनों शाखाओं ने क्लोज़िंग ब्रेस से ठीक पहले एक नई पंक्ति डाली, और Git का पंक्ति-आधारित three-way मर्ज केवल स्थिति देखता है, अर्थ नहीं। उसमें "ये दो अलग कुंजियाँ हैं" की कोई अवधारणा नहीं। तो कोई .gitattributes दस्तावेज़ पढ़ता है और एक पंक्ति कॉपी कर लेता है:
i18n.json merge=union
कॉन्फ़्लिक्ट ग़ायब। इसकी कीमत क्या है, यह इस लेख का विषय है।
1. Union मर्ज नहीं करता, वह दोनों पक्षों की पंक्तियाँ रखता है
merge=union सीधे दस्तावेज़ित है: टेक्स्ट फ़ाइलों के लिए three-way फ़ाइल-स्तरीय मर्ज चलाएँ, लेकिन कॉन्फ़्लिक्ट मार्कर छोड़ने के बजाय दोनों संस्करणों से पंक्तियाँ लें। दस्तावेज़ के बाद एक चेतावनी है — यदि आप निहितार्थ नहीं समझते तो इसका उपयोग न करें। वह चेतावनी बेकार की औपचारिकता नहीं है।
Union ऑब्जेक्ट, कुंजियाँ, या ऐरे नहीं समझता। वह पंक्तियाँ समझता है। नीचे हर परिणाम ऊपर वाली ठीक i18n.json का उपयोग करके स्थानीय रिपॉज़िटरी में दोहराया गया।
| परिदृश्य | डिफ़ॉल्ट ड्राइवर | merge=union |
कुंजी-जागरूक ड्राइवर |
|---|---|---|---|
| हर पक्ष अलग कुंजी जोड़ता है | कॉन्फ़्लिक्ट, मैनुअल | "मर्ज हुआ", गायब कॉमा, अवैध JSON | मर्ज हुआ, दोनों कुंजियाँ सुरक्षित |
| दोनों पक्ष वही कुंजी बदलते हैं | कॉन्फ़्लिक्ट, मैनुअल | "मर्ज हुआ", दोहराई कुंजी, अंतिम जीतता है | exit कोड 1, कॉन्फ़्लिक्टिंग पथ बताए गए |
| एक-पंक्ति JSON, हर पक्ष एक बदलाव | कॉन्फ़्लिक्ट, पूरी फ़ाइल चुनें | "मर्ज हुआ", दो दस्तावेज़ ढेर | मर्ज हुआ, लेकिन पुनः फ़ॉर्मेटेड |
एक पक्ष minify हटाता है, दूसरा parse संपादित करता है |
कॉन्फ़्लिक्ट, मैनुअल | "मर्ज हुआ", हटाई कुंजी लौटी, कॉमा गायब | मर्ज हुआ, हटाना सुरक्षित, संपादन लागू |
केवल बीच वाला कॉलम git merge को सफलता लौटाता है, और उसका कोई भी आउटपुट पार्स नहीं होता। JSON पर union का यही उल्टा हिस्सा है: यह जितना शांत है, उतना ही ख़तरनाक है।
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 मर्ज चरण पास हो जाता है, और कोड समीक्षा "1 insertion" का हरा diff देखती है। विफलता runtime पर पहले JSON.parse तक इंतज़ार करती है, तब तक मर्ज बहुत पीछे छूट चुका होता है।
3. परिणाम दो: दोहराई गई कुंजी जो चुपचाप डेटा गिरा देती है
दूसरा प्रयोग अलग आकार का उपयोग करता है: दोनों पक्ष वही कुंजी संशोधित करते हैं, एक "解析 / 格式化" में, दूसरा "解析器" में। Union के बाद:
{
"tools": {
"parse": "解析 / 格式化",
"parse": "解析器",
"minify": "压缩"
}
}
इस बार JSON वैध है और JSON.parse कुछ नहीं फेंकता। लेकिन parse दो बार आता है। ऑब्जेक्ट लिटरल सेमांटिक्स के अनुसार बाद वाली प्रविष्टि जीतती है, इसलिए अंतिम मान "解析器" है — दूसरा संपादन बिना लॉग पंक्ति और बिना त्रुटि के त्याग दिया जाता है।
केवल दोहराई-कुंजी जाँच इसे सामने लाती है:
duplicate keys detected: ['parse']
JSON.parse succeeded, final parse value = '解析器'
इसे अवैध JSON से ढूँढना कठिन है: फ़ाइल लोड होती है, पेज रेंडर होता है, और एक स्ट्रिंग चुपचाप वापस बदल जाती है। हफ़्तों बाद कोई पूछता है कि एक अनुवाद कभी क्यों नहीं गया।
4. परिणाम तीन: एक-पंक्ति JSON दो दस्तावेज़ बन जाती है
कुछ कॉन्फ़िग फ़ाइलें एक पंक्ति में minified होती हैं, उदाहरण के लिए बिल्ड आर्टिफ़ैक्ट। 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" कहता है।
Minified JSON संस्करण नियंत्रण में होना ही नहीं चाहिए, लेकिन एक बार होने पर, union इसे दो असंबंधित दस्तावेज़ों में बदल देता है।
5. परिणाम चार: हटाई कुंजी लौट आती है
आख़िरी आकार सबसे धोखेबाज़ है। एक पक्ष minify हटाता है; दूसरा बगल वाला parse संपादित करता है। डिफ़ॉल्ट ड्राइवर कॉन्फ़्लिक्ट बताता है; union पैदा करता है:
{
"tools": {
"parse": "解析"
"parse": "解析器",
"minify": "压缩"
}
}
एक साथ तीन दोष: minify पुनर्जीवित, parse दो बार, और पहली पंक्ति ने अपना कॉमा खो दिया। Git फिर भी "2 insertions" और सफल मर्ज बताता है।
सामान्य सबक: हटाने में, "दोनों पक्षों की पंक्तियाँ रखना" हटाने को पूर्ववत करने के बराबर है। जिस भी टीम ने JSON पर union कुछ समय उपयोग किया है, उसके पास इतिहास में चुपचाप जीवित ऐसी "हमने इसे हटा दिया था" सेटिंग्स हैं।
6. सही दृष्टिकोण: कुंजी से three-way मर्ज
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 生成"
}
}
Exit कोड 0, वैध JSON, दोनों कुंजियाँ मौजूद।
जानने लायक एक साइड इफ़ेक्ट: ड्राइवर JSON.stringify(merged, null, 2) लिखता है, इसलिए यह पूरी फ़ाइल की फ़ॉर्मेटिंग फिर से लिखता है। एक minified 63-बाइट config 7 पंक्तियों और 105 बाइट के रूप में लौटा — वही सामग्री, अलग आकार। यह ड्राइवर को केवल हाथ से बनाए रखी स्रोत फ़ाइलों के लिए फ़िट बनाता है। Minified या जनरेट की गई JSON रिपॉज़िटरी में नहीं होनी चाहिए, और इस ड्राइवर का उपयोग नहीं करनी चाहिए, अन्यथा हर मर्ज फ़ॉर्मेटिंग शोर घसीटेगा।
7. कॉन्फ़्लिक्ट पर वापस न लिखें — यह जाल दिखने से बड़ा है
जब दोनों पक्ष वही कुंजी बदलते हैं, ड्राइवर को क्या करना चाहिए? सहज कदम है "हमारा वापस लिखो, exit 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 नहीं लिखना चाहिए। अनिर्णीत प्रविष्टियाँ एक रिपोर्ट फ़ाइल में दर्ज करें और exit 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, तीसरा अनुगामी न्यूलाइन छोड़ देता है। हर सेव पूरी फ़ाइल फिर से लिखता है और मर्ज के पास काम करने को कुछ नहीं बचता। फ़ॉर्मेटर config पिन करें, या इसे pre-commit hook में चलाएँ।
कुंजी क्रम स्थिर रखें। कुंजी क्रम JSON के लिए अर्थहीन है लेकिन Git के लिए बहुत वास्तविक। लिखने से पहले कुंजियाँ सॉर्ट करना (JSON.stringify से पहले सॉर्ट करें, या jq -S से पाइप करें) एक संपादन को एक hunk में रखता है। यह वही विचार है जो दो JSON दस्तावेज़ कैसे भिन्न होते हैं में तुलना से पहले सामान्यीकरण का है — वहाँ diff के लिए, यहाँ commit के लिए।
जनरेट की गई फ़ाइलें रिपॉज़िटरी से बाहर रखें। Minified JSON, टाइमस्टैम्प वाले build manifests, lockfiles — वे हर रन पर बदलते हैं। स्रोत को संस्करण दें, आर्टिफ़ैक्ट उत्पन्न करें।
9. चेकलिस्ट
- यदि
.gitattributesJSON कोmerge=unionपर मैप करता है, तो दोहराई गई कुंजियों या सिंटैक्स त्रुटियों के लिएgit log -pसे इतिहास का नमूना लें, फिर उसे बदलें; - कस्टम ड्राइवर पंजीकृत करने के बाद, सभी को
git configचरण चलाने दें, या इसेmake setupमें डालें; - ड्राइवर की कॉन्फ़्लिक्ट शाखा
%Aनहीं लिखनी चाहिए; इसके बजाय रिपोर्ट जारी करें; - CI में
node -e "JSON.parse(...)"याjq emptyगेट जोड़ें, ताकि "वैध लेकिन ग़लत" JSON मर्ज के बाद और डिप्लॉय से पहले पकड़ी जाए; - Minified JSON और जनरेट की गई फ़ाइलें
.gitignoreमें जोड़ें ताकि उस प्रकार का कॉन्फ़्लिक्ट पूरी तरह हटे।
10. FAQ
merge=union कब सुरक्षित है?
केवल append-only फ़ाइलों के लिए जहाँ हर पंक्ति एक पूर्ण रिकॉर्ड है और क्रम का कोई अर्थ नहीं, जैसे JSON Lines लॉग। जैसे ही कोई पंक्ति मौजूदा जगह संपादित हो सके — मौजूदा रिकॉर्ड में फ़ील्ड जोड़ना — union दो विरोधाभासी रिकॉर्ड पैदा करता है। यह डिज़ाइन सीमा है, सावधानी से उपयोग करने का मामला नहीं।
क्या कस्टम ड्राइवर ऐरे संभाल सकता है?
ऊपर वाला संस्करण ऐरे को परमाणु मानता है: यदि दोनों पक्ष ऐरे बदलते हैं, यह कॉन्फ़्लिक्ट बताता है। सूचकांक मिलान पुरानी टेक्स्ट-diff समस्या दोहराता है (एक आइटम डालो और सब खिसक जाता है), जबकि id मिलान के लिए सहमत प्राथमिक कुंजी चाहिए। ऐरे मर्ज मनुष्य को सौंपना बेहतर है, या "ऐरे append-only हैं" परंपरा से रोकें।
अगर ड्राइवर में बग हो?
यह चुपचाप फ़ाइलें भ्रष्ट करता है। रोल आउट से पहले एक स्क्रैच रिपॉज़िटरी में तीन आकारों (हर पक्ष कुंजी जोड़ता है, दोनों वही कुंजी बदलते हैं, एक हटाता है जबकि दूसरा संपादित करता है) के विरुद्ध पहला संस्करण परखें। टूटा ड्राइवर फ़ाइल चुपचाप फिर से लिखता है, जिसे कॉन्फ़्लिक्ट मार्कर से कहीं अधिक कठिन पहचानना है।
बस jq की एक पंक्ति से मर्ज क्यों न करें?
jq -s '.[0] * .[1]' एक shallow मर्ज है, इसका कुंजी क्रम और नेस्टेड व्यवहार मामले-दर-मामले जाँचना पड़ता है, और इसमें कोई पूर्वज नहीं — base के बिना आप "हटाया गया" और "अपरिवर्तित" अलग नहीं कर सकते। Three-way मर्ज को तीन इनपुट चाहिए।
क्या कॉन्फ़्लिक्ट रिपोर्ट कमिट की जानी चाहिए?
नहीं। इसे .gitignore में जोड़ें; यह एक-बार का आर्टिफ़ैक्ट है जो मर्ज ख़त्म होते ही समाप्त हो जाता है।