JSON Toolkit

התנגשויות מיזוג Git בקבצי JSON: למה merge=union כותב JSON שבור

שורה תחתונה: merge=union מכניס JSON שבור ועדיין מדווח על הצלחה. הסבר על driver מיזוג תלת-כיווני מודע למפתחות עבור JSON, ועוד שלושה הרגלים שמצמצמים התנגשויות.

בינוניכ-7 דקות קריאהפורסם 2026-10-30עודכן 2026-10-30
שער עבור פתרון התנגשויות מיזוג Git בקבצי JSON

שורה תחתונה: תקננו את הסריאליזציה וסדר המפתחות לפני שאתם דנים באסטרטגיית מיזוג; כל התנגשות ש-merge=union חוסך חוזרת אחר כך כפסיק חסר, מפתח כפול או שני מסמכים מוערמים, ולכן התנגשויות JSON שייכות ל-driver מיזוג תלת-כיווני מודע למפתחות.

שני אנשים מוסיפים שני מפתחות תרגום באותו שבוע: אחד מוסיף tools.diff, השני מוסיף tools.schema. השינויים לא קשורים וסמנטית תואמים. Git עדיין מדווח על התנגשות, ובלוק ההתנגשות מחזיק את שתי השורות:

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

הסיבה פשוטה. שני הענפים הכניסו שורה חדשה ממש לפני הסוגר המסולסל, והמיזוג התלת-כיווני מבוסס השורות של Git רואה רק מיקום, לא משמעות. אין לו מושג של "אלה שני מפתחות שונים". אז מישהו קורא את התיעוד של .gitattributes ומעתיק שורה אחת:

i18n.json merge=union

ההתנגשות נעלמת. מה שהיא עולה הוא נושא המאמר הזה.

1. union לא ממזג, הוא שומר את השורות של שני הצדדים

merge=union מתועד בפשטות: הרץ מיזוג תלת-כיווני ברמת קובץ עבור קבצי טקסט, אבל קח שורות משתי הגרסאות במקום להשאיר סמני התנגשות. התיעוד ממשיך באזהרה — אל תשתמשו בזה אם לא הבנתם את ההשלכות. האזהרה הזו אינה נוסחה ריקה.

Union לא מבין אובייקטים, מפתחות או מערכים. הוא מבין שורות. כל תוצאה למטה שוחזרה במאגר מקומי תוך שימוש בדיוק ב-i18n.json שלמעלה.

תרחיש driver ברירת מחדל merge=union driver מודע למפתחות
כל צד מוסיף מפתח אחר התנגשות, ידני "מוזג", פסיק חסר, 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(+)

אין התנגשות, נוצר 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 מחשיב את המיזוג כמוצלח, שלב המיזוג ב-CI עובר, וסקירת קוד רואה diff ירוק של "1 insertion". הכשל מחכה ל-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 הסמוך. ה-driver של ברירת המחדל מדווח על התנגשות; union מייצר:

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

שלושה פגמים בבת אחת: minify קם לתחייה, parse מופיע פעמיים, והשורה הראשונה איבדה את הפסיק. Git עדיין מדווח "2 insertions" ומיזוג מוצלח.

הלקח הכללי: במחיקה, "שמור שורות משני הצדדים" שקול לביטול המחיקה. כל צוות שהשתמש ב-union על JSON זמן מה מחזיק סט הגדרות "מחקנו את זה" שחי בשקט בהיסטוריה.

6. הגישה הנכונה: מיזוג תלת-כיווני לפי מפתח

Git מאפשר להקצות 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

את הראשון אפשר לבצע commit (הוא נוסע עם הקוד וחל ב-CI). השני הוא תצורה מקומית וצריך להיות ב-README — כאן drivers מותאמים בדרך כלל מתפרקים.

עבור המקרה "כל צד מוסיף מפתח אחר", ה-driver מייצר:

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

קוד יציאה 0, JSON חוקי, שני המפתחות קיימים.

צד השפעה אחד שכדאי להכיר: ה-driver כותב JSON.stringify(merged, null, 2), ולכן הוא משכתב את הפורמט של הקובץ כולו. תצורה ממוזערת של 63 בייטים חזרה כ-7 שורות ו-105 בייטים — אותו תוכן, צורה אחרת. זה הופך את ה-driver למתאים רק לקבצי מקור שמתוחזקים ביד. JSON ממוזער או מיוצר לא אמור להיות במאגר, ולא להשתמש ב-driver הזה, אחרת כל מיזוג יגרור איתו רעש פורמט.

7. אל תכתבו בחזרה בהתנגשות — המלכודת הזו גדולה ממה שהיא נראית

כששני הצדדים משנים את אותו מפתח, מה ה-driver צריך לעשות? המהלך האינטואיטיבי הוא "כתוב את שלנו בחזרה, צא עם 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 מדווח על התנגשות, אבל אין אפילו סמן התנגשות אחד בקובץ — ה-driver כבר כתב את התוצאה שלו. התוכן נראה לגמרי רגיל, ומחזיק את הערך של הצד שלנו. git add i18n.json && git commit אחד בהול והשינוי שלהם נעלם, עם כלום מלבד "Merge conflict resolved" בהיסטוריה.

לכן בהתנגשות אמיתית ה-driver לא צריך לכתוב %A. רשמו את הערכים שלא הוכרעו בקובץ דוח וצאו עם 1:

{
  "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:. כל מי שמסתמך על "תמיד אפשר לייצר מחדש את הסמנים" מגלה שהדרך הזו סגורה בדיוק כשהוא צריך אותה.

8. שלושה הרגלים שמצמצמים התנגשויות מראש

ה-driver הוא רשת ביטחון, לא הכלי הראשי. מספר ההתנגשויות עצמו ניתן להקטנה.

תקננו את הסריאליזציה. אחד כותב indent=2, אחר indent=4, שלישי משמיט את תו השורה הנגרר. כל שמירה משכתבת את הקובץ כולו ולמיזוג אין עם מה לעבוד. קבעו את תצורת הפורמט, או הריצו אותה ב-pre-commit hook.

שמרו על סדר מפתחות יציב. סדר מפתחות חסר משמעות ל-JSON אבל מאוד ממשי ל-Git. מיון מפתחות לפני הכתיבה (למיין לפני JSON.stringify, או להעביר דרך jq -S) משאיר עריכה אחת ב-hunk אחד. זה אותו רעיון כמו נרמול לפני השוואה בכיצד שני מסמכי JSON נבדלים — שם ל-diff, כאן ל-commit.

הרחיקו קבצים מיוצרים מהמאגר. JSON ממוזער, manifests של בנייה עם חותמות זמן, lockfiles — הם משתנים בכל ריצה. נהלו גרסאות של המקור, ייצרו את הארטיפקט.

9. צ'קליסט

  • אם .gitattributes ממפה JSON ל-merge=union, דגמו את ההיסטוריה עם git log -p לחיפוש מפתחות כפולים או שגיאות תחביר, ואז החליפו אותו;
  • אחרי רישום driver מותאם, ודאו שכולם מריצים את שלב ה-git config, או הכניסו אותו ל-make setup;
  • ענף ההתנגשות של ה-driver לא חייב לכתוב %A; הפיקו דוח במקום;
  • הוסיפו שער node -e "JSON.parse(...)" או jq empty ב-CI, כך ש-JSON "חוקי אבל שגוי" נתפס אחרי המיזוג ולפני הפריסה;
  • הוסיפו JSON ממוזער וקבצים מיוצרים ל-.gitignore כדי להסיר את סוג ההתנגשות הזה לחלוטין.

10. FAQ

מתי merge=union בטוח?

רק לקבצי append-only שבהם כל שורה היא רשומה שלמה ולסדר אין משמעות, כמו לוגי JSON Lines. ברגע שאפשר לערוך שורה במקום — להוסיף שדה לרשומה קיימת — union מייצר שתי רשומות סותרות. זה גבול תכנוני, לא עניין של שימוש זהיר.

האם driver מותאם יכול לטפל במערכים?

הגרסה שלמעלה מתייחסת למערכים כאטומיים: אם שני הצדדים משנים מערך, היא מדווחת על התנגשות. יישור לפי אינדקס משחזר את בעיית ה-text diff הישנה (הכנס פריט אחד והכול זז), בעוד יישור לפי id דורש מפתח ראשי מוסכם. מוטב להשאיר מיזוגי מערכים לבן אדם, או למנוע אותם במוסכמת "מערכים הם append-only".

מה אם ל-driver יש באג?

הוא משחית קבצים בשקט. בדקו את הגרסה הראשונה במאגר ניסוי מול שלוש צורות (כל צד מוסיף מפתח, שניהם משנים את אותו מפתח, אחד מוחק והשני עורך) לפני שאתם מגלגלים אותו. driver שבור משכתב את הקובץ בשקט, וזה הרבה יותר קשה להבחין מסמן התנגשות.

למה לא פשוט למזג עם שורה אחת של jq?

jq -s '.[0] * .[1]' הוא מיזוג רדוד, וסדר המפתחות וההתנהגות המקוננת שלו צריכים בדיקה מקרה-מקרה, ואין לו אב — בלי base אי אפשר להבחין בין "נמחק" ל"לא השתנה". מיזוג תלת-כיווני צריך שלושה קלטים.

האם דוח ההתנגשות צריך לעבור commit?

לא. הוסיפו אותו ל-.gitignore; הוא ארטיפקט חד-פעמי שפג כשהמיזוג מסתיים.