JSON Toolkit

Conflitti di merge Git nei file JSON: perché merge=union scrive JSON rotto

In sintesi: merge=union lascia passare JSON rotto e riporta comunque successo. Questo percorre un merge driver three-way consapevole delle chiavi per JSON, più tre abitudini che riducono i conflitti.

Intermedio~7 min di letturaPubblicato il 2026-10-30Aggiornato il 2026-10-30
Copertina per risolvere i conflitti di merge Git nei file JSON

In sintesi: standardizza serializzazione e ordine delle chiavi prima di discutere la strategia di merge; ogni conflitto che merge=union risparmia torna più tardi come virgola mancante, chiave duplicata o due documenti impilati, quindi i conflitti JSON appartengono a un merge driver three-way consapevole delle chiavi.

Due persone aggiungono due chiavi di traduzione nella stessa settimana: uno aggiunge tools.diff, l'altro aggiunge tools.schema. Le modifiche sono non correlate e semanticamente compatibili. Git riporta comunque un conflitto, e il blocco di conflitto contiene entrambe le righe:

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

La ragione è semplice. Entrambi i rami hanno inserito una nuova riga proprio prima della parentesi graffa di chiusura, e il merge three-way basato sulle righe di Git vede solo la posizione, non il significato. Non ha concetto di "queste sono due chiavi diverse". Quindi qualcuno legge la documentazione .gitattributes e copia una riga:

i18n.json merge=union

Il conflitto scompare. Ciò che costa è l'argomento di questo articolo.

1. Union non fa merge, mantiene le righe di entrambi i lati

merge=union è documentato chiaramente: esegue un merge three-way a livello di file per i file di testo, ma prende le righe da entrambe le versioni invece di lasciare i marker di conflitto. La documentazione segue con un avvertimento — non usarlo se non capisci le implicazioni. Quell'avvertimento non è boilerplate.

Union non capisce oggetti, chiavi o array. Capisce le righe. Ogni risultato qui sotto è stato riprodotto in un repository locale usando esattamente l'i18n.json sopra.

Scenario Driver predefinito merge=union Driver consapevole delle chiavi
Ogni lato aggiunge una chiave diversa Conflitto, manuale "Merged", virgola mancante, JSON invalido Unito, entrambe le chiavi mantenute
Entrambi i lati cambiano la stessa chiave Conflitto, manuale "Merged", chiave duplicata, l'ultima vince Exit code 1, percorsi in conflitto riportati
JSON su una riga, una modifica per lato Conflitto, scegli un intero file "Merged", due documenti impilati Unito, ma riformattato
Un lato elimina minify, l'altro modifica parse Conflitto, manuale "Merged", la chiave eliminata torna, virgola mancante Unito, eliminazione mantenuta, modifica applicata

Solo la colonna centrale fa restituire successo a git merge, e nessuno dei suoi output viene analizzato. È la parte controintuitiva di union su JSON: più è silenzioso, più è pericoloso.

2. Risultato uno: una virgola mancante, segnalata come merge pulito

Gli stessi due rami, ora con merge=union in vigore:

$ git merge feat-b
Auto-merging i18n.json
Merge made by the 'ort' strategy.
 i18n.json | 1 +
 1 file changed, 1 insertion(+)

Nessun conflitto, commit di merge creato. Il file appare così:

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

Non c'è virgola dopo "diff": "对比". Union ha mantenuto la riga dal nostro lato e la riga dal loro lato, ciascuna con la propria virgola finale, e la giuntura ha perso un separatore.

Analizzandolo:

$ python3 -c "import json;json.load(open('i18n.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 10 column 5

Questa è la parte peggiore: Git considera il merge riuscito, il passo di merge in CI passa, e la revisione del codice vede un diff verde di "1 insertion". Il fallimento aspetta il primo JSON.parse a runtime, quando ormai il merge è molto indietro.

3. Risultato due: una chiave duplicata che elimina silenziosamente dati

Il secondo esperimento usa una forma diversa: entrambi i lati modificano la stessa chiave, uno in "解析 / 格式化", l'altro in "解析器". Dopo union:

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

Questa volta il JSON è valido e JSON.parse non solleva nulla. Ma parse compare due volte. Per la semantica dei letterali oggetto l'ultima voce vince, quindi il valore finale è "解析器" — l'altra modifica viene scartata senza riga di log e senza errore.

Solo un controllo delle chiavi duplicate lo fa emergere:

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

Questo è più difficile da trovare del JSON invalido: il file si carica, la pagina viene renderizzata, e una stringa è tranquillamente tornata indietro. Settimane dopo qualcuno chiede perché una traduzione non è mai stata spedita.

4. Risultato tre: il JSON su una riga diventa due documenti

Alcuni file di configurazione sono minificati su una riga, per esempio gli artefatti di build. Non ci sono righe per union da unire, quindi entrambi i lati sono righe intere e il risultato le impila:

{"tools":{"parse":"解析","minify":"压缩","diff":"对比"},"version":1}
{"tools":{"parse":"解析","minify":"压缩"},"version":2}

JSON.parse riporta Unexpected non-whitespace character after JSON at position 63 — ha finito il primo documento e ne ha trovato un secondo. Git dice ancora "Merge made".

Il JSON minificato non dovrebbe essere affatto nel controllo di versione, ma una volta che lo è, union lo trasforma in due documenti non correlati.

5. Risultato quattro: la chiave eliminata torna

L'ultima forma è la più ingannevole. Un lato elimina minify; l'altro modifica il parse adiacente. Il driver predefinito riporta un conflitto; union produce:

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

Tre difetti in una volta: minify risorge, parse compare due volte, e la prima riga ha perso la virgola. Git riporta ancora "2 insertions" e un merge riuscito.

La lezione generale: in un'eliminazione, "mantieni le righe di entrambi i lati" è equivalente a annullare l'eliminazione. Qualsiasi squadra che ha usato union su JSON per un po' ha un insieme di impostazioni "quella l'avevamo eliminata" che vive tranquillamente nella storia.

6. L'approccio corretto: merge three-way per chiave

Git ti permette di assegnare un merge driver personalizzato a percorsi specifici. Il driver riceve tre percorsi di file: %O è l'antenato comune, %A è il nostro (il risultato viene scritto qui), %B è il loro. Poche dozzine di righe di Node bastano:

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);

Tre regole lo sostengono: identico su entrambi i lati, prendilo; un lato è uguale all'antenato, prendi l'altro; diverso su entrambi i lati è un vero conflitto. Tutto il resto è ricorsione negli oggetti semplici.

La registrazione richiede due passi — .gitattributes sceglie il driver per percorso, git config definisce il 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

Il primo può essere committato (viaggia con il codice e si applica in CI). Il secondo è configurazione locale e deve essere nel README — è qui che i driver personalizzati di solito si sgretolano.

Per il caso "ogni lato aggiunge una chiave diversa", il driver produce:

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

Exit code 0, JSON valido, entrambe le chiavi presenti.

Un effetto collaterale da conoscere: il driver scrive JSON.stringify(merged, null, 2), quindi riscrive la formattazione dell'intero file. Una configurazione minificata di 63 byte è tornata come 7 righe e 105 byte — stesso contenuto, forma diversa. Questo rende il driver adatto solo ai file sorgente mantenuti a mano. Il JSON minificato o generato non dovrebbe essere nel repository, e non dovrebbe usare questo driver, altrimenti ogni merge si trascinerà dietro rumore di formattazione.

7. Non riscrivere in caso di conflitto — questa trappola è più grande di quanto sembri

Quando entrambi i lati cambiano la stessa chiave, cosa dovrebbe fare il driver? La mossa intuitiva è "scrivi il nostro, esci con 1, lascia che Git riporti il conflitto". Testato, è pericoloso:

$ 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 riporta un conflitto, ma nel file non c'è nemmeno un marker di conflitto — il driver ha già scritto il suo risultato. Il contenuto sembra completamente normale, contenendo il valore del nostro lato. Un frettoloso git add i18n.json && git commit e la loro modifica è sparita, con nient'altro che "Merge conflict resolved" nella storia.

Quindi su un vero conflitto il driver non dovrebbe scrivere %A. Registra le voci non decise in un file di rapporto ed esci con 1:

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

Il valore è che git status si ferma su UU, quindi un umano deve agire — e non deve cercare <<<<<<< dentro dozzine di righe JSON. Il rapporto dice esattamente quali percorsi sono collisi.

Un'altra scoperta testata: git checkout --conflict=merge -- i18n.json non può ripristinare i marker di conflitto una volta registrato un driver personalizzato, perché invoca di nuovo il driver e produce comunque zero marker. Per riavere i marker devi temporaneamente git config --unset merge.json-merge.driver, oppure tirare su i tre stage con git show :1:file, :2:, :3:. Chi conta su "posso sempre rigenerare i marker" trova quella strada chiusa esattamente quando ne ha bisogno.

8. Tre abitudini che riducono i conflitti in anticipo

Il driver è una rete di sicurezza, non lo strumento principale. Il numero di conflitti è esso stesso riducibile.

Standardizza la serializzazione. Uno scrive indent=2, un altro indent=4, un terzo omette il newline finale. Ogni salvataggio riscrive l'intero file e il merge non ha nulla con cui lavorare. Fissa la configurazione del formattatore, o eseguilo in un hook pre-commit.

Mantieni stabile l'ordine delle chiavi. L'ordine delle chiavi è privo di significato per JSON ma molto reale per Git. Ordinare le chiavi prima di scrivere (ordina prima di JSON.stringify, o passa attraverso jq -S) mantiene una modifica in un hunk. È la stessa idea della normalizzazione prima del confronto in quando due documenti JSON differiscono — lì per il diff, qui per il commit.

Tieni i file generati fuori dal repository. JSON minificato, manifest di build con timestamp, lockfile — cambiano a ogni esecuzione. Versiona la sorgente, genera l'artefatto.

9. Lista di controllo

  • Se .gitattributes mappa JSON su merge=union, campiona la storia con git log -p per chiavi duplicate o errori di sintassi, poi sostituiscilo;
  • Dopo aver registrato un driver personalizzato, fai eseguire a tutti il passo git config, o mettilo in un make setup;
  • Il ramo di conflitto del driver non deve scrivere %A; emetti un rapporto invece;
  • Aggiungi un gate node -e "JSON.parse(...)" o jq empty in CI, così il JSON "valido ma sbagliato" viene intercettato dopo il merge e prima del deploy;
  • Aggiungi JSON minificato e file generati a .gitignore per rimuovere del tutto quella classe di conflitto.

10. FAQ

Quando merge=union è sicuro?

Solo per file append-only dove ogni riga è un record completo e l'ordine è privo di significato, come i log JSON Lines. Non appena una riga può essere modificata sul posto — aggiungere un campo a un record esistente — union produce due record contraddittori. È un confine di design, non una questione di usarlo con attenzione.

Un driver personalizzato può gestire gli array?

La versione sopra tratta gli array come atomici: se entrambi i lati modificano un array, riporta un conflitto. L'allineamento per indice riproduce il vecchio problema del diff testuale (inserisci un elemento e tutto si sposta), mentre l'allineamento per id richiede una chiave primaria concordata. I merge di array sono meglio lasciarli a un umano, o evitarli con una convenzione "gli array sono append-only".

E se il driver ha un bug?

Corrompe i file silenziosamente. Testa la prima versione in un repository di prova contro tre forme (ogni lato aggiunge una chiave, entrambi cambiano la stessa chiave, uno elimina mentre l'altro modifica) prima di rilasciarlo. Un driver rotto riscrive il file silenziosamente, il che è molto più difficile da notare di un marker di conflitto.

Perché non fare semplicemente il merge con una riga di jq?

jq -s '.[0] * .[1]' è un merge superficiale, il suo ordine delle chiavi e comportamento annidato necessitano di verifica caso per caso, e non ha un antenato — senza base non puoi distinguere "eliminato" da "invariato". Un merge three-way necessita di tre input.

Il rapporto di conflitto dovrebbe essere committato?

No. Aggiungilo a .gitignore; è un artefatto una tantum che scade quando il merge finisce.