JSON Toolkit

Conflits de fusion Git dans les fichiers JSON : pourquoi merge=union écrit du JSON cassé

L’essentiel : merge=union laisse passer du JSON cassé et rapporte quand même un succès. Ceci détaille un pilote de fusion à trois voies conscient des clés pour JSON, plus trois habitudes qui réduisent les conflits.

Intermédiaire~7 min de lecturePublié le 2026-10-30Mis à jour le 2026-10-30
Illustration de la résolution des conflits de fusion Git dans les fichiers JSON

L'essentiel : standardisez la sérialisation et l'ordre des clés avant de discuter de stratégie de fusion ; chaque conflit que merge=union évite revient plus tard sous forme de virgule manquante, de clé dupliquée ou de deux documents empilés, donc les conflits JSON appartiennent à un pilote de fusion à trois voies conscient des clés.

Deux personnes ajoutent deux clés de traduction la même semaine : l'une ajoute tools.diff, l'autre ajoute tools.schema. Les changements sont sans rapport et sémantiquement compatibles. Git signale quand même un conflit, et le bloc de conflit contient les deux lignes :

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

La raison est simple. Les deux branches ont inséré une nouvelle ligne juste avant l'accolade fermante, et la fusion à trois voies de Git, basée sur les lignes, ne voit que la position, pas le sens. Il n'a aucun concept de « ce sont deux clés différentes ». Alors quelqu'un lit la documentation .gitattributes et copie une ligne :

i18n.json merge=union

Le conflit disparaît. Ce qu'il coûte est le sujet de cet article.

1. Union ne fusionne pas, il garde les lignes des deux côtés

merge=union est documenté clairement : exécuter une fusion de fichiers à trois voies pour les fichiers texte, mais prendre les lignes des deux versions au lieu de laisser des marqueurs de conflit. La documentation poursuit avec un avertissement — ne pas utiliser ceci si vous ne comprenez pas les implications. Cet avertissement n'est pas du remplissage.

Union ne comprend ni objets, ni clés, ni tableaux. Il comprend les lignes. Chaque résultat ci-dessous a été reproduit dans un dépôt local en utilisant exactement le i18n.json ci-dessus.

Scénario Pilote par défaut merge=union Pilote conscient des clés
Chaque côté ajoute une clé différente Conflit, manuel « Fusionné », virgule manquante, JSON invalide Fusionné, les deux clés conservées
Les deux côtés changent la même clé Conflit, manuel « Fusionné », clé dupliquée, la dernière gagne Code de sortie 1, chemins en conflit signalés
JSON sur une ligne, un changement de chaque côté Conflit, choisir un fichier entier « Fusionné », deux documents empilés Fusionné, mais reformaté
Un côté supprime minify, l'autre modifie parse Conflit, manuel « Fusionné », la clé supprimée revient, virgule manquante Fusionné, suppression conservée, modification appliquée

Seule la colonne du milieu fait renvoyer un succès à git merge, et aucune de ses sorties ne parse. C'est la partie contre-intuitive d'union sur JSON : plus c'est silencieux, plus c'est dangereux.

2. Résultat un : une virgule manquante, rapportée comme une fusion propre

Mêmes deux branches, maintenant avec merge=union en place :

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

Pas de conflit, commit de fusion créé. Le fichier ressemble à ceci :

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

Il n'y a pas de virgule après "diff": "对比". Union a gardé la ligne de notre côté et la ligne de leur côté, chacune portant sa virgule finale, et la jointure a perdu un séparateur.

En le parsant :

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

C'est le pire aspect : Git considère la fusion réussie, l'étape de fusion de la CI passe, et la revue de code voit un diff vert d'« 1 insertion ». La défaillance attend le premier JSON.parse à l'exécution, moment auquel la fusion est loin derrière vous.

3. Résultat deux : une clé dupliquée qui supprime silencieusement des données

La deuxième expérience utilise une autre forme : les deux côtés modifient la même clé, l'un en « 解析 / 格式化 », l'autre en « 解析器 ». Après union :

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

Cette fois le JSON est valide et JSON.parse ne lève rien. Mais parse apparaît deux fois. Selon la sémantique de littéral d'objet, l'entrée tardive gagne, donc la valeur finale est « 解析器 » — l'autre modification est écartée sans ligne de journal ni erreur.

Seule une vérification des clés dupliquées le révèle :

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

C'est plus difficile à trouver que du JSON invalide : le fichier se charge, la page s'affiche, et une chaîne a silencieusement réverté. Des semaines plus tard, quelqu'un demande pourquoi une traduction n'a jamais été livrée.

4. Résultat trois : un JSON sur une ligne devient deux documents

Certains fichiers de configuration sont minifiés sur une seule ligne, par exemple des artefacts de build. Il n'y a pas de lignes à fusionner pour union, donc les deux côtés sont des lignes entières et le résultat les empile :

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

JSON.parse rapporte Unexpected non-whitespace character after JSON at position 63 — il a terminé le premier document et en a trouvé un second. Git dit encore « Merge made ».

Le JSON minifié ne devrait pas être sous contrôle de version du tout, mais une fois qu'il y est, union le transforme en deux documents sans rapport.

5. Résultat quatre : la clé supprimée revient

La dernière forme est la plus trompeuse. Un côté supprime minify ; l'autre modifie le parse adjacent. Le pilote par défaut signale un conflit ; union produit :

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

Trois défauts à la fois : minify est ressuscité, parse apparaît deux fois, et la première ligne a perdu sa virgule. Git rapporte encore « 2 insertions » et une fusion réussie.

La leçon générale : dans une suppression, « garder les lignes des deux côtés » équivaut à annuler la suppression. Toute équipe qui a utilisé union sur JSON pendant un certain temps a un ensemble de réglages « nous avions supprimé ça » qui vivent discrètement dans l'historique.

6. La bonne approche : fusion à trois voies par clé

Git permet d'assigner un pilote de fusion personnalisé à des chemins spécifiques. Le pilote reçoit trois chemins de fichier : %O est l'ancêtre commun, %A est le nôtre (le résultat est réécrit ici), %B est le leur. Quelques dizaines de lignes de Node suffisent :

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

Trois règles le portent : identique des deux côtés, prendre ; un côté égale l'ancêtre, prendre l'autre ; différent des deux côtés est un vrai conflit. Tout le reste est de la récursion dans des objets ordinaires.

L'enregistrement se fait en deux étapes — .gitattributes choisit le pilote par chemin, git config définit le pilote :

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

Le premier peut être committé (il voyage avec le code et s'applique en CI). Le second est une configuration locale et doit figurer dans le README — c'est là que les pilotes personnalisés s'effondrent généralement.

Pour le cas « chaque côté ajoute une clé différente », le pilote produit :

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

Code de sortie 0, JSON valide, les deux clés présentes.

Un effet secondaire à connaître : le pilote écrit JSON.stringify(merged, null, 2), donc il réécrit le formatage de tout le fichier. Une configuration minifiée de 63 octets revient en 7 lignes et 105 octets — même contenu, forme différente. Cela rend le pilote adapté uniquement aux fichiers source maintenus à la main. Le JSON minifié ou généré ne devrait pas être dans le dépôt, et ne devrait pas utiliser ce pilote, sinon chaque fusion traînera du bruit de formatage.

7. Ne réécrivez pas en cas de conflit — ce piège est plus gros qu'il n'y paraît

Quand les deux côtés changent la même clé, que devrait faire le pilote ? Le réflexe intuitif est « réécrire le nôtre, sortir 1, laisser Git signaler le conflit ». Testé, c'est dangereux :

$ 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 signale un conflit, mais il n'y a pas un seul marqueur de conflit dans le fichier — le pilote a déjà écrit son résultat. Le contenu semble complètement normal, contenant la valeur de notre côté. Un git add i18n.json && git commit hâtif et leur changement est parti, avec rien d'autre que « Merge conflict resolved » dans l'historique.

Donc sur un vrai conflit, le pilote ne devrait pas écrire %A. Enregistrez les entrées non décidées dans un fichier de rapport et sortez 1 :

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

L'intérêt est que git status s'arrête sur UU, donc un humain doit agir — et il n'a pas à chercher <<<<<<< dans des dizaines de lignes JSON. Le rapport dit exactement quels chemins se sont heurtés.

Une autre constatation testée : git checkout --conflict=merge -- i18n.json ne peut pas restaurer les marqueurs de conflit une fois un pilote personnalisé enregistré, car il invoque de nouveau le pilote et donne toujours zéro marqueur. Pour récupérer des marqueurs, il faut temporairement git config --unset merge.json-merge.driver, ou récupérer les trois étapes avec git show :1:file, :2:, :3:. Quiconque compte sur « je peux toujours régénérer les marqueurs » trouve cette route fermée exactement quand il en a besoin.

8. Trois habitudes qui réduisent les conflits à l'avance

Le pilote est un filet de sécurité, pas l'outil principal. Le nombre de conflits est lui-même réductible.

Standardisez la sérialisation. Une personne écrit indent=2, une autre indent=4, une troisième omet le retour à la ligne final. Chaque sauvegarde réécrit tout le fichier et la fusion n'a rien avec quoi travailler. Fixez la configuration du formateur, ou exécutez-le dans un hook de pré-commit.

Gardez l'ordre des clés stable. L'ordre des clés n'a aucun sens pour JSON mais est très réel pour Git. Trier les clés avant d'écrire (trier avant JSON.stringify, ou passer par jq -S) garde une modification dans un seul hunk. C'est la même idée que normaliser avant de comparer dans comment deux documents JSON diffèrent — là pour le diff, ici pour le commit.

Gardez les fichiers générés hors du dépôt. JSON minifié, manifestes de build avec horodatages, lockfiles — ils changent à chaque exécution. Versionnez la source, générez l'artefact.

9. Liste de vérification

  • Si .gitattributes mappe JSON sur merge=union, échantillonnez l'historique avec git log -p pour des clés dupliquées ou des erreurs de syntaxe, puis remplacez-le ;
  • Après avoir enregistré un pilote personnalisé, faites exécuter l'étape git config par tout le monde, ou mettez-la dans un make setup ;
  • La branche conflit du pilote ne doit pas écrire %A ; émettez un rapport à la place ;
  • Ajoutez une barrière node -e "JSON.parse(...)" ou jq empty en CI, pour que le JSON « valide mais faux » soit attrapé après fusion et avant déploiement ;
  • Ajoutez le JSON minifié et les fichiers générés à .gitignore pour éliminer entièrement cette classe de conflit.

10. FAQ

Quand merge=union est-il sûr ?

Uniquement pour les fichiers en ajout seul où chaque ligne est un enregistrement complet et où l'ordre n'a aucun sens, comme les journaux JSON Lines. Dès qu'une ligne peut être modifiée sur place — ajouter un champ à un enregistrement existant — union produit deux enregistrements contradictoires. C'est une frontière de conception, pas une question de l'utiliser avec précaution.

Un pilote personnalisé peut-il gérer les tableaux ?

La version ci-dessus traite les tableaux comme atomiques : si les deux côtés changent un tableau, elle signale un conflit. L'alignement par index reproduit l'ancien problème du diff texte (insérez un élément et tout se décale), tandis que l'alignement par id exige une clé primaire convenu. Les fusions de tableaux sont mieux laissées à un humain, ou évitées par une convention « les tableaux sont en ajout seul ».

Et si le pilote a un bug ?

Il corrompt silencieusement les fichiers. Testez la première version dans un dépôt de travail sur trois formes (chaque côté ajoute une clé, les deux changent la même clé, l'un supprime pendant que l'autre modifie) avant de la déployer. Un pilote cassé réécrit le fichier en silence, ce qui est bien plus difficile à remarquer qu'un marqueur de conflit.

Pourquoi ne pas simplement fusionner avec une ligne de jq ?

jq -s '.[0] * .[1]' est une fusion superficielle, son ordre de clés et son comportement imbriqué doivent être vérifiés au cas par cas, et il n'a pas d'ancêtre — sans base, vous ne pouvez pas distinguer « supprimé » de « inchangé ». Une fusion à trois voies a besoin de trois entrées.

Le rapport de conflit doit-il être committé ?

Non. Ajoutez-le à .gitignore ; c'est un artefact ponctuel qui expire quand la fusion se termine.