JSON Toolkit

Конфлікти злиття Git у файлах JSON: чому merge=union пише зламаний JSON

Головне: merge=union пропускає зламаний JSON і все одно повідомляє про успіх. Розбирає драйвер тристороннього злиття JSON, що враховує ключі, плюс три звички, що скорочують конфлікти.

Середній~7 хв читанняОпубліковано 2026-10-30Оновлено 2026-10-30
Обкладинка статті про розвʼязання конфліктів злиття Git у файлах 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 «1 вставлення». Збій чекає до першого 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 стає двома документами

Деякі конфігураційні файли мініфіковано в один рядок, наприклад артефакти збірки. Для 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 усе одно повідомляє «2 insertions» і успішне злиття.

Загальний урок: під час видалення «зберегти рядки з обох сторін» рівноцінно скасуванню видалення. Будь-яка команда, що деякий час використовувала 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, третій пропускає завершальне переведення рядка. Кожне збереження переписує весь файл, і злиттю нема з чим працювати. Закріпіть конфігурацію форматувальника або запускайте її в хуку pre-commit.

Тримайте порядок ключів стабільним. Порядок ключів безглуздий для JSON, але цілком реальний для Git. Сортування ключів перед записом (сортуйте перед JSON.stringify або проженіть через jq -S) тримає одну редакцію в одному ханку. Це та сама ідея, що й нормалізація перед порівнянням у як різняться два документи JSON — там для diff, тут для коміту.

Тримайте згенеровані файли поза репозиторієм. Мініфікований JSON, маніфести збірки з часовими мітками, lockfiles — вони змінюються при кожному запуску. Версіонуйте джерело, генеруйте артефакт.

9. Чек-лист

  • Якщо .gitattributes відображає JSON на merge=union, перегляньте історію через git log -p на повторювані ключі чи синтаксичні помилки, потім замініть його;
  • Після реєстрації власного драйвера нехай усі виконають крок git config або покладіть його в make setup;
  • Гілка конфлікту драйвера не має писати %A; натомість видавайте звіт;
  • Додайте шлюз node -e "JSON.parse(...)" або jq empty у CI, щоб «валідний, але неправильний» JSON ловився після злиття й до розгортання;
  • Додайте мініфікований JSON і згенеровані файли в .gitignore, щоб повністю прибрати цей клас конфліктів.

10. Часті питання

Коли merge=union безпечний?

Лише для файлів, що тільки дописуються, де кожен рядок — повний запис, а порядок не має значення, як-от логи JSON Lines. Щойно рядок можна редагувати на місці — додати поле до наявного запису — union дає два суперечливі записи. Це межа дизайну, а не питання обережного використання.

Чи може власний драйвер обробляти масиви?

Наведена версія трактує масиви як атомарні: якщо обидві сторони змінюють масив, вона повідомляє конфлікт. Вирівнювання за індексом відтворює стару проблему текстового diff (вставте один елемент — і все зсувається), а вирівнювання за id потребує узгодженого первинного ключа. Злиття масивів краще залишити людині або запобігти конвенцією «масиви лише дописуються».

А якщо драйвер має баг?

Він тихо псує файли. Перевірте першу версію в чорновому репозиторії на трьох формах (кожна сторона додає ключ, обидві змінюють той самий ключ, одна видаляє, інша редагує), перш ніж розгортати. Зламаний драйвер мовчки переписує файл, що набагато важче помітити, ніж маркер конфлікту.

Чому не злити просто одним рядком jq?

jq -s '.[0] * .[1]' — поверхневе злиття, його порядок ключів і вкладена поведінка потребують перевірки випадок за випадком, і в нього немає предка — без бази ви не відрізните «видалено» від «не змінено». Тристороннє злиття потребує трьох входів.

Чи комітити звіт про конфлікти?

Ні. Додайте його в .gitignore; це одноразовий артефакт, що спливає, коли злиття закінчується.