JSON Toolkit

تعارض‌های ادغام Git در فایل‌های JSON: چرا merge=union JSON شکسته می‌نویسد

جمع‌بندی: merge=union JSON شکسته را عبور می‌دهد و همچنان موفقیت گزارش می‌کند. این مقاله یک merge driver سه‌طرفه آگاه به کلید برای JSON را مرور می‌کند، به‌علاوه سه عادت که تعارض‌ها را کم می‌کند.

متوسطحدود 7 دقیقه مطالعهانتشار 2026-10-30به‌روزرسانی 2026-10-30
تصویر جلد حل تعارض‌های ادغام Git در فایل‌های JSON

جمع‌بندی: پیش از بحث راهبرد ادغام، سریالایز و ترتیب کلید را استاندارد کنید؛ هر تعارضی که merge=union نجات می‌دهد بعداً به‌صورت ویرگول گم‌شده، کلید تکراری یا دو سند روی‌هم‌انباشته برمی‌گردد، پس تعارض‌های JSON به merge driver سه‌طرفه آگاه به کلید تعلق دارند.

دو نفر در همان هفته دو کلید ترجمه اضافه می‌کنند: یکی tools.diff، دیگری tools.schema. تغییرات نامرتبط و از نظر معنایی سازگارند. Git همچنان تعارض گزارش می‌کند و بلوک تعارض هر دو خط را نگه می‌دارد:

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

دلیل ساده است. هر دو شاخه یک خط جدید درست پیش از آکولاد بسته درج کرده‌اند و ادغام سه‌طرفه خط‌محور Git فقط موقعیت می‌بیند نه معنا. مفهوم «این‌ها دو کلید متفاوت‌اند» را ندارد. پس کسی مستندات .gitattributes را می‌خواند و یک خط کپی می‌کند:

i18n.json merge=union

تعارض ناپدید می‌شود. هزینه‌اش موضوع این مقاله است.

۱. union ادغام نمی‌کند، خطوط هر دو طرف را نگه می‌دارد

merge=union ساده مستند شده: برای فایل‌های متنی یک ادغام سه‌طرفه در سطح فایل اجرا کن، اما به‌جای گذاشتن نشانگر تعارض، خطوط هر دو نسخه را بگیر. مستندات با هشدار ادامه می‌دهد — اگر پیامدها را نمی‌فهمید استفاده نکنید. آن هشدار متن تشریفاتی نیست.

union اشیا، کلیدها یا آرایه‌ها نمی‌فهمد. خطوط می‌فهمد. هر نتیجه زیر در یک مخزن محلی با همان i18n.json بالا بازتولید شد.

سناریو driver پیش‌فرض merge=union driver آگاه به کلید
هر طرف کلید متفاوتی اضافه می‌کند تعارض، دستی «ادغام شد»، ویرگول گم‌شده، JSON نامعتبر ادغام شد، هر دو کلید حفظ
هر دو طرف همان کلید را تغییر می‌دهند تعارض، دستی «ادغام شد»، کلید تکراری، آخری برنده کد خروج ۱، مسیرهای متعارض گزارش
JSON تک‌خطی، هر طرف یک تغییر تعارض، انتخاب کل فایل «ادغام شد»، دو سند روی‌هم ادغام شد، اما بازقالب‌بندی شده
یک طرف minify را حذف، دیگری parse را ویرایش تعارض، دستی «ادغام شد»، کلید حذف‌شده برمی‌گردد، ویرگول گم‌شده ادغام شد، حذف حفظ، ویرایش اعمال

فقط ستون میانی git merge را موفق برمی‌گرداند، و هیچ خروجی‌اش تجزیه نمی‌شود. این بخش ضدشهودی union روی JSON است: هر چه ساکت‌تر، خطرناک‌تر.

۲. نتیجه یک: ویرگول گم‌شده، گزارش‌شده به‌عنوان ادغام تمیز

همان دو شاخه، اکنون با 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 هنگام اجرا صبر می‌کند، و تا آن زمان ادغام خیلی عقب‌تر از شماست.

۳. نتیجه دو: کلید تکراری که داده را بی‌صدا می‌اندازد

آزمایش دوم شکل متفاوتی دارد: هر دو طرف همان کلید را تغییر می‌دهند، یکی به «解析 / 格式化»، دیگری به «解析器». پس از union:

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

این بار JSON معتبر است و JSON.parse چیزی نمی‌اندازد. اما parse دو بار ظاهر شده. بر اساس معناشناسی object literal ورودی بعدی برنده است، پس مقدار نهایی «解析器» است — ویرایش دیگر بدون خط لاگ و بدون خطا کنار گذاشته می‌شود.

فقط یک بررسی کلید تکراری آن را آشکار می‌کند:

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

یافتن این از JSON نامعتبر سخت‌تر است: فایل بار می‌شود، صفحه رندر می‌شود و یک رشته بی‌صدا برگشته. هفته‌ها بعد کسی می‌پرسد چرا ترجمه‌ای هرگز منتشر نشد.

۴. نتیجه سه: 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 آن را به دو سند نامرتبط تبدیل می‌کند.

۵. نتیجه چهار: کلید حذف‌شده برمی‌گردد

آخرین شکل فریبنده‌ترین است. یک طرف minify را حذف می‌کند؛ دیگری parse مجاور را ویرایش می‌کند. driver پیش‌فرض تعارض گزارش می‌کند؛ union تولید می‌کند:

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

سه نقص یکجا: minify زنده می‌شود، parse دو بار ظاهر می‌شود و خط اول ویرگولش را از دست داده. Git همچنان «۲ افزودن» و ادغام موفق گزارش می‌کند.

درس کلی: در یک حذف، «خطوط هر دو طرف را نگه دار» معادل بازگرداندن حذف است. هر تیمی که مدتی union روی JSON استفاده کرده باشد مجموعه‌ای از تنظیمات «آن را حذف کردیم» دارد که بی‌صدا در تاریخچه زندگی می‌کنند.

۶. رویکرد درست: ادغام سه‌طرفه بر اساس کلید

Git اجازه می‌دهد merge driver سفارشی به مسیرهای مشخص بدهید. driver سه مسیر فایل می‌گیرد: %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 driver را به ازای هر مسیر انتخاب می‌کند، 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

اولی می‌تواند کامیت شود (با کد سفر می‌کند و در CI اعمال می‌شود). دومی پیکربندی محلی است و باید در README باشد — اینجا معمولاً driverهای سفارشی از هم می‌پاشند.

برای حالت «هر طرف کلید متفاوتی اضافه می‌کند»، driver تولید می‌کند:

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

کد خروج ۰، JSON معتبر، هر دو کلید حاضر.

یک عارضه جانبی که باید بدانید: driver JSON.stringify(merged, null, 2) می‌نویسد، پس قالب‌بندی کل فایل را بازنویسی می‌کند. پیکربندی فشرده ۶۳ بایتی به ۷ خط و ۱۰۵ بایت برگشت — محتوای یکسان، شکل متفاوت. این driver را فقط مناسب فایل‌های منبع دست‌نگهداری‌شده می‌کند. JSON فشرده یا تولیدشده نباید در مخزن باشد و نباید از این driver استفاده کند، وگرنه هر ادغام نویز قالب‌بندی را با خود می‌کشد.

۷. هنگام تعارض بازنویسی نکنید — این تله بزرگ‌تر از آن است که به نظر می‌رسد

وقتی هر دو طرف همان کلید را تغییر می‌دهند، driver چه کند؟ حرکت شهودی «مال ما را بنویس، خروج ۱، بگذار 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 تعارض گزارش می‌کند، اما یک نشانگر تعارض هم در فایل نیست — driver از قبل نتیجه‌اش را نوشته. محتوا کاملاً نرمال به نظر می‌رسد و مقدار طرف ما را دارد. یک git add i18n.json && git commit شتاب‌زده و تغییر آن‌ها رفته، با تنها «Merge conflict resolved» در تاریخچه.

پس روی تعارض واقعی driver باید %A را ننویسد. ورودی‌های تصمیم‌نشده را در فایل گزارش ثبت و خروج ۱ کنید:

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

ارزشش این است که git status روی UU می‌ایستد تا انسان عمل کند — و لازم نیست داخل ده‌ها خط JSON دنبال <<<<<<< بگردد. گزارش دقیقاً می‌گوید کدام مسیرها برخورد کردند.

یک یافته آزمایش‌شده دیگر: git checkout --conflict=merge -- i18n.json نمی‌تواند نشانگرهای تعارض را بازگرداند وقتی driver سفارشی ثبت شده، چون driver را دوباره فراخوانی می‌کند و همچنان صفر نشانگر می‌دهد. برای بازگرداندن نشانگرها باید موقتاً git config --unset merge.json-merge.driver کنید، یا سه مرحله را با git show :1:file، :2:، :3: بیرون بکشید. هر کسی روی «همیشه می‌توانم نشانگرها را بازتولید کنم» حساب کند، دقیقاً وقتی لازمشان دارد آن جاده بسته است.

۸. سه عادت که تعارض‌ها را از پیش کم می‌کند

driver پشتیبان است، نه ابزار اصلی. تعداد تعارض‌ها خودش قابل کاهش است.

سریالایز را استاندارد کنید. یکی indent=2 می‌نویسد، دیگری indent=4، سومی خط جدید انتهایی را حذف می‌کند. هر ذخیره کل فایل را بازنویسی می‌کند و ادغام چیزی برای کار ندارد. پیکربندی قالب‌بند را سنجاق کنید، یا در hook پیش-کامیت اجرا کنید.

ترتیب کلید را پایدار نگه دارید. ترتیب کلید برای JSON بی‌معناست اما برای Git بسیار واقعی. مرتب کردن کلیدها پیش از نوشتن (مرتب قبل از JSON.stringify، یا لوله کردن در jq -S) یک ویرایش را به یک هانک نگه می‌دارد. همان ایده نرمال‌سازی پیش از مقایسه در چگونه دو سند JSON تفاوت دارند — آنجا برای diff، اینجا برای کامیت.

فایل‌های تولیدشده را از مخزن بیرون نگه دارید. JSON فشرده، مانیفست‌های ساخت با مهر زمانی، فایل‌های قفل — هر اجرا تغییر می‌کنند. منبع را نسخه‌بندی کنید، محصول را تولید.

۹. چک‌لیست

  • اگر .gitattributes JSON را به merge=union نگاشت کرده، تاریخچه را با git log -p برای کلید تکراری یا خطای نحوی نمونه‌گیری کنید، سپس جایگزینش کنید؛
  • پس از ثبت driver سفارشی، همه باید گام git config را اجرا کنند، یا آن را در make setup بگذارید؛
  • شاخه تعارض driver نباید %A بنویسد؛ به‌جایش گزارش بدهد؛
  • یک دروازه node -e "JSON.parse(...)" یا jq empty در CI اضافه کنید تا JSON «معتبر اما اشتباه» پس از ادغام و پیش از استقرار گرفته شود؛
  • JSON فشرده و فایل‌های تولیدشده را به .gitignore اضافه کنید تا آن دسته تعارض کاملاً حذف شود.

۱۰. پرسش‌های متداول

چه زمانی merge=union امن است؟

فقط برای فایل‌های فقط-افزودنی که هر خط یک رکورد کامل است و ترتیب معنا ندارد، مانند لاگ‌های JSON Lines. به‌محض اینکه یک خط بتواند درجا ویرایش شود — افزودن فیلد به رکورد موجود — union دو رکورد متناقض تولید می‌کند. این مرز طراحی است، نه مسئله استفاده محتاطانه.

آیا driver سفارشی می‌تواند آرایه‌ها را مدیریت کند؟

نسخه بالا آرایه‌ها را اتمیک می‌داند: اگر هر دو طرف آرایه را تغییر دهند، تعارض گزارش می‌کند. هم‌ترازی اندیس مشکل قدیمی diff متنی را بازتولید می‌کند (یک آیتم درج کن و همه جابه‌جا می‌شوند)، در حالی که هم‌ترازی id کلید اصلی توافق‌شده می‌خواهد. ادغام آرایه بهتر است به انسان سپرده شود، یا با قرارداد «آرایه‌ها فقط-افزودنی‌اند» پیشگیری شود.

اگر driver باگ داشته باشد چه؟

فایل‌ها را بی‌صدا خراب می‌کند. نسخه اول را در مخزن آزمایشی روی سه شکل (هر طرف کلید اضافه می‌کند، هر دو همان کلید را تغییر می‌دهند، یکی حذف و دیگری ویرایش می‌کند) آزمون کنید پیش از انتشار. driver خراب فایل را بی‌صدا بازنویسی می‌کند که بسیار سخت‌تر از نشانگر تعارض قابل توجه است.

چرا فقط با یک خط jq ادغام نکنیم؟

jq -s '.[0] * .[1]' ادغام سطحی است، ترتیب کلید و رفتار تودرتویش باید مورد به مورد بررسی شود و جد ندارد — بدون base نمی‌توانید «حذف شد» را از «تغییر نکرد» تشخیص دهید. ادغام سه‌طرفه سه ورودی می‌خواهد.

آیا گزارش تعارض باید کامیت شود؟

نه. به .gitignore اضافه‌اش کنید؛ محصول یک‌باره‌ای است که با پایان ادغام منقضی می‌شود.