JSON Toolkit

Git-Merge-Konflikte in JSON-Dateien: warum merge=union kaputtes JSON schreibt

Fazit: merge=union lässt kaputtes JSON durch und meldet dennoch Erfolg. Dieser Artikel führt durch einen schlüsselbewussten Drei-Wege-Merge-Treiber für JSON sowie drei Gewohnheiten, die Konflikte reduzieren.

Fortgeschritten~7 Min. LesezeitVeröffentlicht 2026-10-30Aktualisiert 2026-10-30
Titelbild für das Auflösen von Git-Merge-Konflikten in JSON-Dateien

Kurz gesagt: Standardisieren Sie Serialisierung und Schlüsselreihenfolge, bevor Sie über Merge-Strategie sprechen; jeder Konflikt, den merge=union spart, kommt später als fehlendes Komma, doppelter Schlüssel oder zwei gestapelte Dokumente zurück, JSON-Konflikte gehören also einem schlüsselbewussten Drei-Wege-Merge-Treiber.

Zwei Personen fügen in derselben Woche zwei Übersetzungsschlüssel hinzu: Der eine fügt tools.diff hinzu, der andere tools.schema. Die Änderungen sind unabhängig und semantisch kompatibel. Git meldet trotzdem einen Konflikt, und der Konfliktblock enthält beide Zeilen:

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

Der Grund ist einfach. Beide Branches fügten eine neue Zeile kurz vor der schließenden Klammer ein, und Gits zeilenbasierter Drei-Wege-Merge sieht nur Position, nicht Bedeutung. Es hat kein Konzept von „das sind zwei verschiedene Schlüssel“. Also liest jemand die .gitattributes-Dokumentation und kopiert eine Zeile:

i18n.json merge=union

Der Konflikt verschwindet. Was er kostet, ist das Thema dieses Artikels.

1. Union führt nicht zusammen, sondern behält die Zeilen beider Seiten

merge=union ist schlicht dokumentiert: einen Drei-Wege-Merge auf Dateiebene für Textdateien ausführen, aber Zeilen aus beiden Versionen nehmen, statt Konfliktmarker zu hinterlassen. Die Dokumentation folgt mit einer Warnung – nicht verwenden, wenn man die Auswirkungen nicht versteht. Diese Warnung ist kein Beiwerk.

Union versteht keine Objekte, Schlüssel oder Arrays. Es versteht Zeilen. Jedes Ergebnis unten wurde in einem lokalen Repository mit genau der obigen i18n.json reproduziert.

Szenario Standardtreiber merge=union Schlüsselbewusster Treiber
Jede Seite fügt einen anderen Schlüssel hinzu Konflikt, manuell „Zusammengeführt“, fehlendes Komma, ungültiges JSON Zusammengeführt, beide Schlüssel behalten
Beide Seiten ändern denselben Schlüssel Konflikt, manuell „Zusammengeführt“, doppelter Schlüssel, der letzte gewinnt Exit-Code 1, konfliktbehaftete Pfade gemeldet
Einzeiliges JSON, je eine Änderung pro Seite Konflikt, ganze Datei wählen „Zusammengeführt“, zwei Dokumente gestapelt Zusammengeführt, aber neu formatiert
Eine Seite löscht minify, die andere bearbeitet parse Konflikt, manuell „Zusammengeführt“, gelöschter Schlüssel kommt zurück, Komma fehlt Zusammengeführt, Löschung behalten, Bearbeitung angewendet

Nur die mittlere Spalte lässt git merge Erfolg zurückgeben, und keine ihrer Ausgaben parst. Das ist der kontraintuitive Teil von Union auf JSON: Je leiser es ist, desto gefährlicher ist es.

2. Ergebnis eins: ein fehlendes Komma, gemeldet als sauberer Merge

Dieselben zwei Branches, jetzt mit merge=union an Ort und Stelle:

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

Kein Konflikt, Merge-Commit erstellt. Die Datei sieht so aus:

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

Nach "diff": "对比" fehlt ein Komma. Union behielt die Zeile von unserer Seite und die Zeile von ihrer Seite, jede mit ihrem eigenen abschließenden Komma, und an der Nahtstelle ging ein Trennzeichen verloren.

Das zu parsen:

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

Das ist das Schlimmste: Git betrachtet den Merge als erfolgreich, der CI-Merge-Schritt besteht, und Code-Review sieht einen grünen Diff von „1 insertion“. Der Fehler wartet bis zum ersten JSON.parse zur Laufzeit, und dann liegt der Merge weit hinter Ihnen.

3. Ergebnis zwei: ein doppelter Schlüssel, der stillschweigend Daten verwirft

Das zweite Experiment verwendet eine andere Form: Beide Seiten ändern den selben Schlüssel, die eine zu „解析 / 格式化“, die andere zu „解析器“. Nach Union:

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

Diesmal ist das JSON gültig und JSON.parse wirft nichts. Aber parse erscheint zweimal. Gemäß Objektliteral-Semantik gewinnt der spätere Eintrag, der endgültige Wert ist also „解析器“ – die andere Bearbeitung wird ohne Logzeile und ohne Fehler verworfen.

Nur eine Prüfung auf doppelte Schlüssel macht das sichtbar:

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

Das ist schwerer zu finden als ungültiges JSON: Die Datei lädt, die Seite rendert, und ein String ist stillschweigend zurückgesetzt. Wochen später fragt jemand, warum eine Übersetzung nie ausgeliefert wurde.

4. Ergebnis drei: einzeiliges JSON wird zu zwei Dokumenten

Manche Konfigurationsdateien sind auf eine Zeile minifiziert, etwa Build-Artefakte. Es gibt keine Zeilen, die Union zusammenführen könnte, beide Seiten sind ganze Zeilen, und das Ergebnis stapelt sie:

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

JSON.parse meldet Unexpected non-whitespace character after JSON at position 63 – es hat das erste Dokument beendet und ein zweites gefunden. Git sagt wieder „Merge made“.

Minifiziertes JSON sollte gar nicht in der Versionskontrolle sein, aber einmal dort, macht Union daraus zwei unabhängige Dokumente.

5. Ergebnis vier: der gelöschte Schlüssel kommt zurück

Die letzte Form ist die täuschendste. Eine Seite löscht minify; die andere bearbeitet das benachbarte parse. Der Standardtreiber meldet einen Konflikt; Union erzeugt:

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

Drei Defekte auf einmal: minify ist wiederauferstanden, parse erscheint zweimal, und die erste Zeile hat ihr Komma verloren. Git meldet trotzdem „2 insertions“ und einen erfolgreichen Merge.

Die allgemeine Lehre: Bei einer Löschung entspricht „Zeilen von beiden Seiten behalten“ dem Rückgängigmachen der Löschung. Jedes Team, das Union eine Weile auf JSON verwendet hat, hat eine Reihe von „das haben wir doch gelöscht“-Einstellungen, die still in der Historie weiterleben.

6. Der richtige Ansatz: Drei-Wege-Merge nach Schlüssel

Git lässt Sie bestimmten Pfaden einen eigenen Merge-Treiber zuweisen. Der Treiber erhält drei Dateipfade: %O ist der gemeinsame Vorfahre, %A ist unserer (das Ergebnis wird hierher zurückgeschrieben), %B ist ihrer. Ein paar Dutzend Zeilen Node genügen:

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

Drei Regeln tragen es: Auf beiden Seiten identisch, nehmen; eine Seite entspricht dem Vorfahren, die andere nehmen; auf beiden Seiten unterschiedlich ist ein echter Konflikt. Alles andere ist Rekursion in einfache Objekte.

Die Registrierung braucht zwei Schritte – .gitattributes wählt den Treiber pro Pfad, git config definiert den Treiber:

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

Der erste Schritt kann committet werden (er reist mit dem Code und gilt in CI). Der zweite ist lokale Konfiguration und muss ins README – hier scheitern eigene Treiber meist.

Für den Fall „jede Seite fügt einen anderen Schlüssel hinzu“ erzeugt der Treiber:

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

Exit-Code 0, gültiges JSON, beide Schlüssel vorhanden.

Ein Nebeneffekt ist zu kennen: Der Treiber schreibt JSON.stringify(merged, null, 2), er schreibt also die Formatierung der ganzen Datei neu. Eine minifizierte Konfiguration von 63 Bytes kam als 7 Zeilen und 105 Bytes zurück – gleicher Inhalt, andere Form. Das macht den Treiber nur für von Hand gepflegte Quelldateien geeignet. Minifiziertes oder generiertes JSON sollte nicht im Repository sein und diesen Treiber nicht verwenden, sonst schleppt jeder Merge Formatierungsrauschen mit.

7. Bei Konflikt nicht zurückschreiben – diese Falle ist größer, als sie aussieht

Ändern beide Seiten denselben Schlüssel, was soll der Treiber tun? Der intuitive Schritt ist „unser Ergebnis zurückschreiben, mit 1 beenden, Git den Konflikt melden lassen“. Getestet ist das gefährlich:

$ 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 meldet einen Konflikt, aber die Datei enthält keinen einzigen Konfliktmarker – der Treiber hat sein Ergebnis bereits geschrieben. Der Inhalt sieht völlig normal aus und trägt den Wert unserer Seite. Ein hastiges git add i18n.json && git commit und ihre Änderung ist weg, mit nichts als „Merge conflict resolved“ in der Historie.

Bei einem echten Konflikt sollte der Treiber also %A nicht schreiben. Die unentschiedenen Einträge in eine Berichtsdatei schreiben und mit 1 beenden:

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

Der Wert ist, dass git status bei UU stehen bleibt, ein Mensch also handeln muss – und er muss nicht in Dutzenden JSON-Zeilen nach <<<<<<< suchen. Der Bericht sagt genau, welche Pfade kollidiert sind.

Ein weiterer getesteter Befund: git checkout --conflict=merge -- i18n.json kann Konfliktmarker nicht wiederherstellen, sobald ein eigener Treiber registriert ist, weil es den Treiber erneut aufruft und weiterhin null Marker liefert. Um Marker zurückzubekommen, müssen Sie vorübergehend git config --unset merge.json-merge.driver ausführen oder die drei Stufen mit git show :1:file, :2:, :3: holen. Wer darauf zählt, „die Marker kann ich immer neu erzeugen“, findet diesen Weg genau dann verschlossen, wenn er ihn braucht.

8. Drei Gewohnheiten, die Konflikte von vornherein reduzieren

Der Treiber ist eine Rückversicherung, kein Hauptwerkzeug. Die Zahl der Konflikte ist selbst reduzierbar.

Serialisierung standardisieren. Eine Person schreibt indent=2, eine andere indent=4, eine dritte lässt den abschließenden Zeilenumbruch weg. Jedes Speichern schreibt die ganze Datei neu, und der Merge hat nichts, womit er arbeiten kann. Legen Sie die Formatter-Konfiguration fest oder führen Sie sie in einem pre-commit-Hook aus.

Schlüsselreihenfolge stabil halten. Schlüsselreihenfolge ist für JSON bedeutungslos, für Git aber sehr real. Schlüssel vor dem Schreiben sortieren (vor JSON.stringify sortieren oder durch jq -S leiten) hält eine Bearbeitung in einem Hunk. Das ist dieselbe Idee wie das Normalisieren vor dem Vergleich in wie zwei JSON-Dokumente sich unterscheiden – dort zum Diffen, hier zum Committen.

Generierte Dateien aus dem Repository halten. Minifiziertes JSON, Build-Manifeste mit Timestamps, Lockdateien – sie ändern sich bei jedem Lauf. Versionieren Sie die Quelle, generieren Sie das Artefakt.

9. Checkliste

  • Wenn .gitattributes JSON auf merge=union abbildet, die Historie mit git log -p auf doppelte Schlüssel oder Syntaxfehler beproben und dann ersetzen;
  • Nach der Registrierung eines eigenen Treibers alle den git config-Schritt ausführen lassen oder ihn in ein make setup legen;
  • Der Konfliktzweig des Treibers darf %A nicht schreiben; stattdessen einen Bericht ausgeben;
  • In CI ein Gate node -e "JSON.parse(...)" oder jq empty ergänzen, damit „gültiges, aber falsches“ JSON nach dem Merge und vor dem Deployment erkannt wird;
  • Minifiziertes JSON und generierte Dateien in .gitignore aufnehmen, um diese Konfliktklasse ganz zu entfernen.

10. FAQ

Wann ist merge=union sicher?

Nur für Append-only-Dateien, bei denen jede Zeile ein vollständiger Datensatz ist und die Reihenfolge keine Bedeutung trägt, etwa JSON-Lines-Logs. Sobald eine Zeile in place bearbeitet werden kann – ein Feld zu einem bestehenden Datensatz hinzufügen –, erzeugt Union zwei widersprüchliche Datensätze. Das ist eine Design-Grenze, keine Frage des sorgfältigen Gebrauchs.

Kann ein eigener Treiber Arrays behandeln?

Die obige Version behandelt Arrays als atomar: Ändern beide Seiten ein Array, meldet sie einen Konflikt. Index-Ausrichtung reproduziert das alte Textdiff-Problem (ein Element einfügen und alles verschiebt sich), während ID-Ausrichtung einen vereinbarten Primärschlüssel erfordert. Array-Merges überlässt man besser einem Menschen oder verhindert sie durch eine Konvention „Arrays sind append-only“.

Was, wenn der Treiber einen Bug hat?

Er beschädigt Dateien stillschweigend. Testen Sie die erste Version in einem Scratch-Repository gegen drei Formen (jede Seite fügt einen Schlüssel hinzu, beide ändern denselben Schlüssel, eine löscht, während die andere bearbeitet), bevor Sie sie ausrollen. Ein kaputter Treiber schreibt die Datei stillschweigend neu, was weit schwerer zu bemerken ist als ein Konfliktmarker.

Warum nicht einfach mit einer Zeile jq mergen?

jq -s '.[0] * .[1]' ist ein flacher Merge, seine Schlüsselreihenfolge und sein verschachteltes Verhalten müssen Fall für Fall geprüft werden, und er hat keinen Vorfahren – ohne Basis können Sie „gelöscht“ nicht von „unverändert“ unterscheiden. Ein Drei-Wege-Merge braucht drei Eingaben.

Sollte der Konfliktbericht committet werden?

Nein. Nehmen Sie ihn in .gitignore auf; er ist ein einmaliges Artefakt, das mit dem Ende des Merges verfällt.