Конфликты слияния 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 «1 insertion». Сбой ждёт до первого 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 — там для сравнения, здесь для коммита.
Держите сгенерированные файлы вне репозитория. Минифицированный JSON, манифесты сборки с метками времени, lock-файлы — они меняются при каждом запуске. Версионируйте исходник, генерируйте артефакт.
9. Контрольный список
- Если
.gitattributesотображает JSON наmerge=union, проберите историю черезgit log -pна дублирующиеся ключи или синтаксические ошибки, затем замените его; - После регистрации пользовательского драйвера заставьте всех выполнить шаг
git configили поместите его вmake setup; - Ветка конфликта драйвера не должна записывать
%A; вместо этого выдавайте отчёт; - Добавьте в CI шлюз
node -e "JSON.parse(...)"илиjq empty, чтобы «валидный, но неверный» JSON ловился после слияния и до развёртывания; - Добавьте минифицированный JSON и сгенерированные файлы в
.gitignore, чтобы полностью устранить этот класс конфликтов.
10. Вопросы и ответы
Когда merge=union безопасен?
Только для append-only файлов, где каждая строка — полная запись и порядок не имеет смысла, например логи JSON Lines. Как только строку можно редактировать на месте — добавляя поле к существующей записи — union производит две противоречивые записи. Это проектная граница, а не вопрос осторожного использования.
Может ли пользовательский драйвер обрабатывать массивы?
Версия выше трактует массивы как атомарные: если обе стороны меняют массив, он сообщает конфликт. Выравнивание по индексу воспроизводит старую проблему текстового diff (вставка одного элемента сдвигает всё), тогда как выравнивание по id требует согласованного первичного ключа. Слияния массивов лучше оставить человеку или предотвратить соглашением «массивы только дозаписываются».
Что если драйвер содержит баг?
Он тихо портит файлы. Тестируйте первую версию в черновом репозитории на трёх формах (каждая сторона добавляет ключ, обе меняют один ключ, одна удаляет, пока другая правит) до раскатки. Сломанный драйвер тихо переписывает файл, что гораздо труднее заметить, чем маркер конфликта.
Почему не слить просто одной строкой jq?
jq -s '.[0] * .[1]' — поверхностное слияние, его порядок ключей и вложенное поведение нужно проверять случай за случаем, и у него нет предка — без базы вы не можете отличить «удалено» от «не изменено». Трёхстороннему слиянию нужны три входа.
Стоит ли коммитить отчёт о конфликтах?
Нет. Добавьте его в .gitignore; это одноразовый артефакт, истекающий при завершении слияния.