JSON Toolkit

Konflikty scalania Git w plikach JSON: dlaczego merge=union zapisuje zepsuty JSON

Podsumowanie: merge=union przepuszcza zepsuty JSON i nadal zgłasza sukces. Ten artykuł omawia sterownik trójdrożnego scalania JSON świadomy kluczy oraz trzy nawyki, które redukują konflikty.

Średniozaawansowany~7 min czytaniaOpublikowano 2026-10-30Zaktualizowano 2026-10-30
Okładka artykułu o rozwiązywaniu konfliktów scalania Git w plikach JSON

Najważniejsze: ustandaryzuj serializację i kolejność kluczy, zanim zaczniesz rozmawiać o strategii scalania; każdy konflikt, który merge=union oszczędza, wraca później jako brakujący przecinek, zduplikowany klucz albo dwa sklejone dokumenty, więc konflikty JSON należą do trójdrożnego sterownika scalania świadomego kluczy.

Dwie osoby dodają dwa klucze tłumaczeń w tym samym tygodniu: jedna dodaje tools.diff, druga dodaje tools.schema. Zmiany są niepowiązane i semantycznie zgodne. Git nadal zgłasza konflikt, a blok konfliktu trzyma obie linie:

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

Powód jest prosty. Obie gałęzie wstawiły nową linię tuż przed zamykającym nawiasem, a trójdrożne scalanie Git oparte na liniach widzi tylko pozycję, nie znaczenie. Nie ma pojęcia „to są dwa różne klucze”. Więc ktoś czyta dokumentację .gitattributes i kopiuje jedną linię:

i18n.json merge=union

Konflikt znika. Ile to kosztuje, jest tematem tego artykułu.

1. Union nie scala, zachowuje linie obu stron

merge=union jest udokumentowane wprost: uruchom trójdrożne scalanie na poziomie plików dla plików tekstowych, ale bierz linie z obu wersji zamiast zostawiać znaczniki konfliktu. Dokumentacja dodaje ostrzeżenie — nie używaj tego, jeśli nie rozumiesz konsekwencji. To ostrzeżenie to nie boilerplate.

Union nie rozumie obiektów, kluczy ani tablic. Rozumie linie. Każdy poniższy wynik został odtworzony w lokalnym repozytorium przy użyciu dokładnie powyższego i18n.json.

Scenariusz Domyślny sterownik merge=union Sterownik świadomy kluczy
Każda strona dodaje inny klucz Konflikt, ręcznie „Scalone”, brakujący przecinek, niepoprawny JSON Scalone, oba klucze zachowane
Obie strony zmieniają ten sam klucz Konflikt, ręcznie „Scalone”, zduplikowany klucz, ostatni wygrywa Kod wyjścia 1, zgłoszone konfliktowe ścieżki
JSON jednoliniowy, jedna zmiana po każdej stronie Konflikt, wybierz cały plik „Scalone”, dwa dokumenty sklejone Scalone, ale przeformatowane
Jedna strona usuwa minify, druga edytuje parse Konflikt, ręcznie „Scalone”, usunięty klucz wraca, brakuje przecinka Scalone, usunięcie zachowane, edycja zastosowana

Tylko środkowa kolumna sprawia, że git merge zwraca sukces, a żadne z jej wyjść się nie parsuje. To jest sprzeczna z intuicją część union na JSON: im ciszej, tym niebezpieczniej.

2. Wynik pierwszy: brakujący przecinek zgłoszony jako czyste scalanie

Te same dwie gałęzie, teraz z merge=union w miejscu:

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

Brak konfliktu, commit scalania utworzony. Plik wygląda tak:

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

Nie ma przecinka po "diff": "对比". Union zachował linię z naszej strony i linię z ich strony, każdą z własnym przecinkiem końcowym, a szew zgubił jeden separator.

Parsowanie tego:

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

To jest najgorsza część: Git uznaje scalanie za udane, etap scalania w CI przechodzi, a przegląd kodu widzi zielony diff „1 insertion”. Awaria czeka do pierwszego JSON.parse w czasie wykonania, gdy scalanie jest już dawno za tobą.

3. Wynik drugi: zduplikowany klucz, który po cichu gubi dane

Drugi eksperyment używa innego kształtu: obie strony modyfikują ten sam klucz, jedna na „解析 / 格式化”, druga na „解析器”. Po union:

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

Tym razem JSON jest poprawny i JSON.parse nic nie zgłasza. Ale parse występuje dwa razy. Zgodnie z semantyką literału obiektowego wygrywa późniejszy wpis, więc końcowa wartość to „解析器” — druga edycja zostaje odrzucona bez linii logu i bez błędu.

Dopiero kontrola zduplikowanych kluczy to ujawnia:

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

To trudniejsze do znalezienia niż niepoprawny JSON: plik się ładuje, strona renderuje, a jeden łańcuch po cichu wrócił do starej wartości. Tydzień później ktoś pyta, dlaczego tłumaczenie nigdy nie trafiło na produkcję.

4. Wynik trzeci: jednoliniowy JSON staje się dwoma dokumentami

Niektóre pliki konfiguracyjne są zminifikowane do jednej linii, na przykład artefakty budowania. Nie ma linii, które union mógłby scalić, więc obie strony są całymi liniami, a wynik je skleja:

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

JSON.parse zgłasza Unexpected non-whitespace character after JSON at position 63 — skończył pierwszy dokument i znalazł drugi. Git znowu mówi „Merge made”.

Zminifikowany JSON w ogóle nie powinien trafiać do kontroli wersji, ale gdy już tam jest, union zamienia go w dwa niepowiązane dokumenty.

5. Wynik czwarty: usunięty klucz wraca

Ostatni kształt jest najbardziej zwodniczy. Jedna strona usuwa minify; druga edytuje sąsiedni parse. Domyślny sterownik zgłasza konflikt; union produkuje:

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

Trzy defekty naraz: minify zostaje wskrzeszony, parse występuje dwa razy, a pierwsza linia zgubiła przecinek. Git nadal zgłasza „2 insertions” i udane scalanie.

Ogólna lekcja: przy usunięciu „zachowaj linie obu stron” jest równoważne cofnięciu usunięcia. Każdy zespół, który używał union na JSON-ie przez jakiś czas, ma zestaw ustawień „usunęliśmy to”, które po cichu żyją w historii.

6. Właściwe podejście: trójdrożne scalanie po kluczach

Git pozwala przypisać własny sterownik scalania do konkretnych ścieżek. Sterownik otrzymuje trzy ścieżki plików: %O to wspólny przodek, %A to nasza strona (wynik jest tu zapisywany z powrotem), %B to ich strona. Kilkadziesiąt linii Node wystarczy:

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

Trzy reguły to niosą: identyczne po obu stronach — weź to; jedna strona równa się przodkowi — weź drugą; różne po obu stronach to prawdziwy konflikt. Wszystko inne to rekurencja do zwykłych obiektów.

Rejestracja wymaga dwóch kroków — .gitattributes wybiera sterownik per ścieżka, git config definiuje sterownik:

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

Pierwszy można commitować (podróżuje z kodem i działa w CI). Drugi to konfiguracja lokalna i musi trafić do README — właśnie tutaj własne sterowniki zwykle się rozpadają.

Dla przypadku „każda strona dodaje inny klucz” sterownik produkuje:

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

Kod wyjścia 0, poprawny JSON, oba klucze obecne.

Jedna skutek uboczny do poznania: sterownik zapisuje JSON.stringify(merged, null, 2), więc przepisuje formatowanie całego pliku. Zminifikowana konfiguracja 63 bajtów wróciła jako 7 linii i 105 bajtów — ta sama treść, inny kształt. To czyni sterownik pasującym wyłącznie do plików źródłowych utrzymywanych ręcznie. Zminifikowany lub generowany JSON nie powinien być w repozytorium i nie powinien używać tego sterownika, inaczej każde scalanie wlecze szum formatowania.

7. Nie zapisuj z powrotem przy konflikcie — ta pułapka jest większa, niż wygląda

Gdy obie strony zmieniają ten sam klucz, co powinien zrobić sterownik? Intuicyjny ruch to „zapisz naszą stronę z powrotem, exit 1, pozwól Git zgłosić konflikt”. Przetestowane, to niebezpieczne:

$ 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 zgłasza konflikt, ale w pliku nie ma ani jednego znacznika konfliktu — sterownik już zapisał swój wynik. Treść wygląda całkowicie normalnie, trzymając wartość z naszej strony. Jeden pośpieszny git add i18n.json && git commit i ich zmiana znika, a w historii zostaje tylko „Merge conflict resolved”.

Więc przy prawdziwym konflikcie sterownik nie powinien zapisywać %A. Zapisz nieustalone wpisy do pliku raportu i zakończ z kodem 1:

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

Wartość polega na tym, że git status zatrzymuje się na UU, więc człowiek musi działać — i nie musi szukać <<<<<<< w dziesiątkach linii JSON. Raport mówi dokładnie, które ścieżki się zderzyły.

Jeszcze jedno przetestowane odkrycie: git checkout --conflict=merge -- i18n.json nie potrafi przywrócić znaczników konfliktu, gdy zarejestrowany jest własny sterownik, bo wywołuje sterownik ponownie i nadal daje zero znaczników. Aby odzyskać znaczniki, musisz tymczasowo wykonać git config --unset merge.json-merge.driver albo pobrać trzy etapy przez git show :1:file, :2:, :3:. Każdy, kto liczy na „zawsze mogę zregenerować znaczniki”, odkrywa, że ta droga jest zamknięta dokładnie wtedy, gdy jej potrzebuje.

8. Trzy nawyki, które z góry redukują konflikty

Sterownik jest siatką bezpieczeństwa, nie głównym narzędziem. Liczba konfliktów sama w sobie jest redukowalna.

Ustandaryzuj serializację. Jedna osoba pisze indent=2, druga indent=4, trzecia pomija końcową nową linię. Każdy zapis przepisuje cały plik i scalanie nie ma z czym pracować. Przypnij konfigurację formatera albo uruchamiaj go w haku pre-commit.

Utrzymuj stabilną kolejność kluczy. Kolejność kluczy jest bez znaczenia dla JSON, ale bardzo realna dla Git. Sortowanie kluczy przed zapisem (sort przed JSON.stringify albo przepuszczenie przez jq -S) utrzymuje jedną edycję w jednym hunk. To ta sama idea co normalizacja przed porównaniem w jak różnią się dwa dokumenty JSON — tam dla diffu, tu dla commitu.

Trzymaj generowane pliki poza repozytorium. Zminifikowany JSON, manifesty budowania ze znacznikami czasu, lockfile — zmieniają się przy każdym uruchomieniu. Wersjonuj źródło, generuj artefakt.

9. Lista kontrolna

  • Jeśli .gitattributes mapuje JSON na merge=union, przeszukaj historię przez git log -p pod kątem zduplikowanych kluczy lub błędów składni, a potem zastąp to;
  • Po zarejestrowaniu własnego sterownika każ każdemu wykonać krok git config albo umieść go w make setup;
  • Gałąź konfliktu sterownika nie może zapisywać %A; zamiast tego emituj raport;
  • Dodaj bramkę node -e "JSON.parse(...)" lub jq empty w CI, aby „poprawny, ale zły” JSON był łapany po scalaniu i przed wdrożeniem;
  • Dodaj zminifikowany JSON i generowane pliki do .gitignore, aby całkowicie usunąć tę klasę konfliktów.

10. FAQ

Kiedy merge=union jest bezpieczne?

Tylko dla plików append-only, gdzie każda linia jest kompletnym rekordem i kolejność jest bez znaczenia, takich jak logi JSON Lines. Gdy tylko linia może być edytowana w miejscu — dodanie pola do istniejącego rekordu — union produkuje dwa sprzeczne rekordy. To granica projektowa, a nie kwestia ostrożnego używania.

Czy własny sterownik potrafi obsłużyć tablice?

Powyższa wersja traktuje tablice atomowo: jeśli obie strony zmieniają tablicę, zgłasza konflikt. Wyrównanie po indeksie odtwarza stary problem diffu tekstowego (wstaw jedno element i wszystko się przesuwa), a wyrównanie po id wymaga uzgodnionego klucza głównego. Scalanie tablic lepiej zostawić człowiekowi albo zapobiec konwencją „tablice są append-only”.

Co jeśli sterownik ma błąd?

Po cichu psuje pliki. Przetestuj pierwszą wersję w repozytorium próbnym na trzech kształtach (każda strona dodaje klucz, obie zmieniają ten sam klucz, jedna usuwa a druga edytuje) przed wdrożeniem. Zepsuty sterownik po cichu przepisuje plik, co jest znacznie trudniejsze do zauważenia niż znacznik konfliktu.

Dlaczego nie scalić po prostu jedną linią jq?

jq -s '.[0] * .[1]' to płytkie scalanie, jego kolejność kluczy i zachowanie zagnieżdżenia wymagają sprawdzenia przypadek po przypadku, a nie ma przodka — bez base nie rozróżnisz „usunięte” od „niezmienione”. Trójdrożne scalanie wymaga trzech wejść.

Czy raport konfliktu powinien być commitowany?

Nie. Dodaj go do .gitignore; to jednorazowy artefakt, który traci ważność po zakończeniu scalania.