Konflikty slučování v Git u souborů JSON: proč merge=union vytváří neplatný JSON
Shrnutí: merge=union propustí neplatný JSON a přesto hlásí úspěch. Procházíme merge driver pro JSON se znalostí klíčů a tři návyky, které konflikty sníží.

Stručně: než začnete diskutovat o strategii slučování, standardizujte serializaci a pořadí klíčů; každý konflikt, který merge=union ušetří, se později vrátí jako chybějící čárka, duplicitní klíč nebo dva naskládané dokumenty, takže konflikty JSON patří merge driveru se znalostí klíčů.
Dva lidé přidají ve stejném týdnu dva překladové klíče: jeden přidá tools.diff, druhý tools.schema. Změny jsou nesouvisející a sémanticky kompatibilní. Git přesto hlásí konflikt a konfliktní blok drží oba řádky:
<<<<<<< HEAD
"diff": "对比"
=======
"schema": "Schema 生成"
>>>>>>> feat-b
Důvod je jednoduchý. Obě větve vložily nový řádek těsně před uzavírací závorku a třícestné slučování Git založené na řádcích vidí jen pozici, nikoli význam. Nemá pojem „tohle jsou dva různé klíče“. Někdo tedy přečte dokumentaci .gitattributes a zkopíruje jeden řádek:
i18n.json merge=union
Konflikt zmizí. Co to stojí, je téma tohoto článku.
1. Union neslučuje, drží řádky obou stran
merge=union je zdokumentováno jasně: pro textové soubory spusť třícestné slučování na úrovni souboru, ale ber řádky z obou verzí, místo abys nechal konfliktní značky. Dokumentace pokračuje varováním – nepoužívej to, pokud nerozumíš důsledkům. To varování není fráze.
Union nerozumí objektům, klíčům ani polím. Rozumí řádkům. Každý výsledek níže byl reprodukován v lokálním repozitáři přesně s i18n.json výše.
| Scénář | Výchozí driver | merge=union |
Driver se znalostí klíčů |
|---|---|---|---|
| Každá strana přidá jiný klíč | Konflikt, ručně | „Sloučeno“, chybí čárka, neplatný JSON | Sloučeno, oba klíče zachovány |
| Obě strany změní stejný klíč | Konflikt, ručně | „Sloučeno“, duplicitní klíč, vyhraje poslední | Návratový kód 1, hlášeny konfliktní cesty |
| Jednořádkový JSON, každá strana jedna změna | Konflikt, volba celého souboru | „Sloučeno“, dva dokumenty naskládané | Sloučeno, ale přeformátováno |
Jedna strana smaže minify, druhá upraví parse |
Konflikt, ručně | „Sloučeno“, smazaný klíč se vrátí, chybí čárka | Sloučeno, smazání zachováno, úprava aplikována |
Jen prostřední sloupec nechá git merge vrátit úspěch a žádný jeho výstup se neparsuje. To je na union u JSON to protiintuitivní: čím tišší, tím nebezpečnější.
2. Výsledek první: chybějící čárka, hlášená jako čisté sloučení
Stejné dvě větve, nyní s merge=union:
$ git merge feat-b
Auto-merging i18n.json
Merge made by the 'ort' strategy.
i18n.json | 1 +
1 file changed, 1 insertion(+)
Žádný konflikt, merge commit vytvořen. Soubor vypadá takto:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比"
"schema": "Schema 生成"
}
}
Za "diff": "对比" chybí čárka. Union udržel řádek z naší strany i řádek z jejich strany, každý s vlastní koncovou čárkou, a šev ztratil jeden oddělovač.
Parsování:
$ python3 -c "import json;json.load(open('i18n.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 10 column 5
Toto je nejhorší část: Git považuje sloučení za úspěšné, krok sloučení v CI projde a revize kódu vidí zelený diff „1 insertion“. Selhání počká až do prvního JSON.parse za běhu, kdy je sloučení dávno za vámi.
3. Výsledek druhý: duplicitní klíč, který tiše zahodí data
Druhý experiment používá jiný tvar: obě strany upraví stejný klíč, jedna na „解析 / 格式化“, druhá na „解析器“. Po union:
{
"tools": {
"parse": "解析 / 格式化",
"parse": "解析器",
"minify": "压缩"
}
}
Tentokrát je JSON platný a JSON.parse nic nevyhodí. Ale parse se objevuje dvakrát. Podle sémantiky objektového literálu vyhraje pozdější položka, takže výsledná hodnota je „解析器“ – druhá úprava je zahozena bez řádku logu a bez chyby.
Odhalí to jen kontrola duplicitních klíčů:
duplicate keys detected: ['parse']
JSON.parse succeeded, final parse value = '解析器'
To se hledá hůř než neplatný JSON: soubor se načte, stránka se vykreslí a jeden řetězec se tiše vrátil. O týdny později se někdo ptá, proč se překlad nikdy nedoručil.
4. Výsledek třetí: jednořádkový JSON se stane dvěma dokumenty
Některé konfigurační soubory jsou zminifikované do jednoho řádku, například build artefakty. Pro union nejsou žádné řádky ke sloučení, takže obě strany jsou celé řádky a výsledek je naskládá:
{"tools":{"parse":"解析","minify":"压缩","diff":"对比"},"version":1}
{"tools":{"parse":"解析","minify":"压缩"},"version":2}
JSON.parse hlásí Unexpected non-whitespace character after JSON at position 63 – dokončil první dokument a našel druhý. Git znovu říká „Merge made“.
Zminifikovaný JSON by vůbec neměl být ve verzovacím systému, ale jakmile je, union z něj udělá dva nesouvisející dokumenty.
5. Výsledek čtvrtý: smazaný klíč se vrátí
Poslední tvar je nejklamnější. Jedna strana smaže minify; druhá upraví sousední parse. Výchozí driver hlásí konflikt; union produkuje:
{
"tools": {
"parse": "解析"
"parse": "解析器",
"minify": "压缩"
}
}
Tři vady najednou: minify je vzkříšen, parse se objevuje dvakrát a první řádek ztratil čárku. Git stále hlásí „2 insertions“ a úspěšné sloučení.
Obecné ponaučení: u smazání je „udržet řádky z obou stran“ ekvivalentní vrácení smazání. Každý tým, který union u JSON používal delší dobu, má sadu nastavení „to jsme smazali“, která tiše žijí dál v historii.
6. Správný přístup: třícestné slučování podle klíčů
Git vám umožňuje přiřadit vlastní merge driver konkrétním cestám. Driver dostane tři cesty souborů: %O je společný předek, %A je náš (výsledek se zapisuje sem), %B je jejich. Několik desítek řádků Node stačí:
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);
Nesou ho tři pravidla: shodné na obou stranách, vezmi to; jedna strana se rovná předkovi, vezmi druhou; liší se na obou stranách, je skutečný konflikt. Vše ostatní je rekurze do obyčejných objektů.
Registrace má dva kroky – .gitattributes vybere driver pro cestu, git config definuje 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
První lze commitovat (cestuje s kódem a uplatní se v CI). Druhé je lokální konfigurace a musí být v README – tady vlastní drivery obvykle selhávají.
Pro případ „každá strana přidá jiný klíč“ driver produkuje:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比",
"schema": "Schema 生成"
}
}
Návratový kód 0, platný JSON, oba klíče přítomny.
Jeden vedlejší účinek k zapamatování: driver zapisuje JSON.stringify(merged, null, 2), takže přepisuje formátování celého souboru. Zminifikovaná 63bajtová konfigurace se vrátila jako 7 řádků a 105 bajtů – stejný obsah, jiný tvar. To činí driver vhodným jen pro ručně udržované zdrojové soubory. Zminifikovaný nebo generovaný JSON by neměl být v repozitáři a neměl by tento driver používat, jinak každé sloučení potáhne šum formátování.
7. Nezapisujte zpět při konfliktu – tato past je větší, než vypadá
Když obě strany změní stejný klíč, co má driver udělat? Intuitivní tah je „zapiš naše, ukonči s 1, nech Git hlásit konflikt“. Otestováno, to je nebezpečné:
$ 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 hlásí konflikt, ale v souboru není ani jedna konfliktní značka – driver už zapsal svůj výsledek. Obsah vypadá zcela normálně a drží hodnotu naší strany. Jedno ukvapené git add i18n.json && git commit a jejich změna je pryč, s ničím jiným než „Merge conflict resolved“ v historii.
Takže při skutečném konfliktu driver nesmí zapsat %A. Zaznamenejte nerozhodnuté položky do report souboru a ukončete s 1:
{
"file": "i18n.json",
"conflicts": [
{ "path": "tools.parse", "ours": "解析 / 格式化", "theirs": "解析器" }
]
}
Hodnotou je, že git status se zastaví na UU, takže člověk musí jednat – a nemusí hledat <<<<<<< uvnitř desítek řádků JSON. Report přesně říká, které cesty kolidovaly.
Ještě jedno otestované zjištění: git checkout --conflict=merge -- i18n.json neumí obnovit konfliktní značky, jakmile je registrovaný vlastní driver, protože znovu vyvolá driver a stále vrací nula značek. Abyste značky dostali zpět, musíte dočasně git config --unset merge.json-merge.driver, nebo vytáhnout tři fáze pomocí git show :1:file, :2:, :3:. Každý, kdo počítá s „značky vždy znovu vygeneruji“, zjistí, že ta cesta je uzavřená právě když ji potřebuje.
8. Tři návyky, které konflikty snižují předem
Driver je záchranná síť, nikoli hlavní nástroj. Počet konfliktů je sám redukovatelný.
Standardizujte serializaci. Jeden píše indent=2, druhý indent=4, třetí vynechává koncový nový řádek. Každé uložení přepíše celý soubor a sloučení nemá s čím pracovat. Zafixujte konfiguraci formátovače, nebo ho spusťte v háčku pre-commit.
Držte stabilní pořadí klíčů. Pořadí klíčů je pro JSON bezvýznamné, ale pro Git velmi skutečné. Řazení klíčů před zápisem (seřadit před JSON.stringify, nebo prohnat přes jq -S) udrží jednu úpravu v jednom hunk. Je to stejná myšlenka jako normalizace před srovnáváním v jak se dva dokumenty JSON liší – tam pro diff, tady pro commit.
Držte generované soubory mimo repozitář. Zminifikovaný JSON, build manifesty s časovými razítky, lockfiles – mění se při každém běhu. Verzujte zdroj, generujte artefakt.
9. Kontrolní seznam
- Pokud
.gitattributesmapuje JSON namerge=union, prohlédněte historii pomocígit log -pna duplicitní klíče nebo syntaktické chyby a poté to nahraďte; - Po registraci vlastního driveru ať všichni spustí krok
git config, nebo ho dejte domake setup; - Konfliktní větev driveru nesmí zapisovat
%A; vydejte místo toho report; - Přidejte do CI bránu
node -e "JSON.parse(...)"nebojq empty, aby se „platný, ale špatný“ JSON zachytil po sloučení a před nasazením; - Přidejte zminifikovaný JSON a generované soubory do
.gitignore, čímž tuto třídu konfliktů zcela odstraníte.
10. Časté dotazy
Kdy je merge=union bezpečné?
Jen pro soubory jen s připojováním, kde každý řádek je úplný záznam a pořadí nemá význam, například logy JSON Lines. Jakmile lze řádek upravit na místě – přidání pole do existujícího záznamu – union produkuje dva protichůdné záznamy. To je designová hranice, nikoli věc opatrného používání.
Umí vlastní driver zpracovat pole?
Verze výše považuje pole za atomická: pokud obě strany změní pole, hlásí konflikt. Zarovnání podle indexu reprodukuje starý problém textového diffu (vložte jednu položku a všechno se posune), zatímco zarovnání podle id vyžaduje dohodnutý primární klíč. Slučování polí je lepší nechat člověku, nebo ho předejít konvencí „pole jsou jen s připojováním“.
Co když má driver chybu?
Tiše poškozuje soubory. První verzi otestujte ve scratch repozitáři na třech tvarech (každá strana přidá klíč, obě změní stejný klíč, jedna smaže zatímco druhá upraví), než ji zavedete. Rozbitý driver přepisuje soubor tiše, což je mnohem těžší postřehnout než konfliktní značku.
Proč nesloučit prostě jedním řádkem jq?
jq -s '.[0] * .[1]' je mělké sloučení, jeho pořadí klíčů a chování u vnoření je třeba kontrolovat případ od případu, a nemá předka – bez základu nedokážete rozlišit „smazáno“ od „nezměněno“. Třícestné sloučení potřebuje tři vstupy.
Má se report konfliktů commitovat?
Ne. Přidejte ho do .gitignore; je to jednorázový artefakt, který vyprší, když sloučení skončí.