JSON Toolkit

Conflitos de merge do Git em arquivos JSON: por que merge=union grava JSON quebrado

Resumo: merge=union deixa JSON quebrado passar e ainda relata sucesso. Este artigo percorre um driver de merge de três vias ciente de chaves para JSON, além de três hábitos que reduzem conflitos.

Intermediário~7 min de leituraPublicado em 2026-10-30Atualizado em 2026-10-30
Capa de resolver conflitos de merge do Git em arquivos JSON

Resumo da ópera: padronize serialização e ordem de chaves antes de discutir estratégia de merge; todo conflito que o merge=union economiza volta depois como vírgula faltando, chave duplicada ou dois documentos empilhados, então conflitos JSON pertencem a um driver de merge de três vias ciente de chaves.

Duas pessoas adicionam duas chaves de tradução na mesma semana: uma adiciona tools.diff, a outra adiciona tools.schema. As mudanças não têm relação e são semanticamente compatíveis. O Git ainda relata um conflito, e o bloco de conflito contém as duas linhas:

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

O motivo é simples. As duas branches inseriram uma nova linha imediatamente antes da chave de fechamento, e o merge de três vias do Git, baseado em linhas, só enxerga posição, não significado. Ele não tem o conceito de "estas são duas chaves diferentes". Então alguém lê a documentação do .gitattributes e copia uma linha:

i18n.json merge=union

O conflito desaparece. O que ele custa é o assunto deste artigo.

1. Union não mescla, ele mantém as linhas dos dois lados

merge=union está documentado de forma clara: rode um merge de três vias em nível de arquivo para arquivos de texto, mas pegue linhas de duas versões em vez de deixar marcadores de conflito. A documentação segue com um aviso — não use isso se você não entende as implicações. Esse aviso não é texto de praxe.

Union não entende objetos, chaves ou arrays. Ele entende linhas. Todo resultado abaixo foi reproduzido em um repositório local usando exatamente o i18n.json acima.

Cenário Driver padrão merge=union Driver ciente de chaves
Cada lado adiciona uma chave diferente Conflito, manual "Mesclado", vírgula faltando, JSON inválido Mesclado, ambas as chaves mantidas
Os dois lados mudam a mesma chave Conflito, manual "Mesclado", chave duplicada, a última vence Código de saída 1, caminhos em conflito relatados
JSON de uma linha, uma mudança de cada lado Conflito, escolha um arquivo inteiro "Mesclado", dois documentos empilhados Mesclado, mas reformatado
Um lado exclui minify, o outro edita parse Conflito, manual "Mesclado", a chave excluída volta, vírgula faltando Mesclado, exclusão mantida, edição aplicada

Só a coluna do meio faz o git merge retornar sucesso, e nenhuma das suas saídas é analisável. Essa é a parte contraintuitiva do union em JSON: quanto mais silencioso, mais perigoso.

2. Resultado um: vírgula faltando, relatada como merge limpo

As mesmas duas branches, agora com merge=union em vigor:

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

Sem conflito, commit de merge criado. O arquivo fica assim:

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

Não há vírgula depois de "diff": "对比". Union manteve a linha do nosso lado e a linha do lado deles, cada uma carregando sua própria vírgula final, e a costura perdeu um separador.

Analisando-o:

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

Esta é a pior parte: o Git considera o merge bem-sucedido, a etapa de merge do CI passa, e a revisão de código vê um diff verde de "1 inserção". A falha espera até o primeiro JSON.parse em tempo de execução, quando o merge já ficou muito para trás.

3. Resultado dois: uma chave duplicada que descarta dados silenciosamente

O segundo experimento usa uma forma diferente: os dois lados modificam a mesma chave, um para "解析 / 格式化", o outro para "解析器". Após o union:

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

Desta vez o JSON é válido e JSON.parse não lança nada. Mas parse aparece duas vezes. Pela semântica de literal de objeto, a entrada posterior vence, então o valor final é "解析器" — a outra edição é descartada sem linha de log e sem erro.

Só uma checagem de chaves duplicadas o revela:

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

Isso é mais difícil de achar do que JSON inválido: o arquivo carrega, a página renderiza, e uma string reverteu silenciosamente. Semanas depois alguém pergunta por que uma tradução nunca foi publicada.

4. Resultado três: JSON de uma linha vira dois documentos

Alguns arquivos de configuração são minificados em uma única linha, por exemplo artefatos de build. Não há linhas para o union mesclar, então os dois lados são linhas inteiras e o resultado os empilha:

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

JSON.parse relata Unexpected non-whitespace character after JSON at position 63 — ele terminou o primeiro documento e encontrou um segundo. O Git de novo diz "Merge made".

JSON minificado não deveria estar no controle de versão de forma alguma, mas uma vez que está, o union o transforma em dois documentos sem relação.

5. Resultado quatro: a chave excluída volta

A última forma é a mais enganosa. Um lado exclui minify; o outro edita o parse adjacente. O driver padrão relata um conflito; o union produz:

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

Três defeitos de uma vez: minify é ressuscitado, parse aparece duas vezes, e a primeira linha perdeu a vírgula. O Git ainda relata "2 insertions" e um merge bem-sucedido.

A lição geral: em uma exclusão, "manter linhas dos dois lados" é equivalente a desfazer a exclusão. Qualquer equipe que tenha usado union em JSON por um tempo tem um conjunto de configurações de "nós excluímos isso" vivendo silenciosamente no histórico.

6. A abordagem correta: merge de três vias por chave

O Git permite atribuir um driver de merge personalizado a caminhos específicos. O driver recebe três caminhos de arquivo: %O é o ancestral comum, %A é o nosso (o resultado é gravado de volta aqui), %B é o deles. Algumas dezenas de linhas de Node bastam:

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

Três regras o sustentam: idêntico dos dois lados, pegue; um lado é igual ao ancestral, pegue o outro; diferir dos dois lados é um conflito real. Todo o resto é recursão em objetos simples.

O registro exige duas etapas — o .gitattributes escolhe o driver por caminho, o git config define o 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

O primeiro pode ser commitado (ele viaja com o código e se aplica no CI). O segundo é configuração local e precisa estar no README — é aqui que drivers personalizados normalmente desmoronam.

Para o caso "cada lado adiciona uma chave diferente", o driver produz:

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

Código de saída 0, JSON válido, ambas as chaves presentes.

Um efeito colateral que vale conhecer: o driver grava JSON.stringify(merged, null, 2), então ele reescreve a formatação do arquivo inteiro. Uma config minificada de 63 bytes voltou como 7 linhas e 105 bytes — mesmo conteúdo, forma diferente. Isso torna o driver adequado apenas a arquivos-fonte mantidos à mão. JSON minificado ou gerado não deveria estar no repositório, e não deveria usar este driver, ou todo merge arrastará ruído de formatação.

7. Não grave de volta em caso de conflito — esta armadilha é maior do que parece

Quando os dois lados mudam a mesma chave, o que o driver deve fazer? O movimento intuitivo é "gravar o nosso de volta, sair com 1, deixar o Git relatar o conflito". Testado, isso é perigoso:

$ 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

O Git relata um conflito, mas não há um único marcador de conflito no arquivo — o driver já gravou seu resultado. O conteúdo parece completamente normal, contendo o valor do nosso lado. Um git add i18n.json && git commit apressado e a mudança deles desapareceu, com nada além de "Merge conflict resolved" no histórico.

Então, em um conflito real, o driver não deve gravar %A. Registre as entradas indecididas em um arquivo de relatório e saia com 1:

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

O valor é que o git status para em UU, então um humano precisa agir — e não precisa caçar <<<<<<< dentro de dezenas de linhas JSON. O relatório diz exatamente quais caminhos colidiram.

Outra constatação testada: git checkout --conflict=merge -- i18n.json não consegue restaurar marcadores de conflito uma vez que um driver personalizado esteja registrado, porque ele invoca o driver novamente e ainda produz zero marcadores. Para recuperar os marcadores você precisa temporariamente fazer git config --unset merge.json-merge.driver, ou puxar os três estágios com git show :1:file, :2:, :3:. Quem conta com "sempre consigo regenerar os marcadores" descobre que esse caminho está fechado exatamente quando precisa dele.

8. Três hábitos que reduzem conflitos de antemão

O driver é uma rede de segurança, não a ferramenta principal. O número de conflitos é, ele mesmo, redutível.

Padronize a serialização. Uma pessoa escreve indent=2, outra indent=4, uma terceira omite a nova linha final. Toda gravação reescreve o arquivo inteiro e o merge não tem com o que trabalhar. Fixe a configuração do formatador, ou rode-o em um hook de pre-commit.

Mantenha a ordem das chaves estável. A ordem das chaves não tem significado para JSON mas é muito real para o Git. Ordenar chaves antes de gravar (ordene antes do JSON.stringify, ou passe por jq -S) mantém uma edição em um hunk. É a mesma ideia de normalizar antes de comparar em como dois documentos JSON diferem — lá para diff, aqui para commit.

Mantenha arquivos gerados fora do repositório. JSON minificado, manifests de build com timestamps, lockfiles — eles mudam a cada execução. Versione a fonte, gere o artefato.

9. Lista de verificação

  • Se o .gitattributes mapeia JSON para merge=union, amostre o histórico com git log -p em busca de chaves duplicadas ou erros de sintaxe, e depois substitua;
  • Depois de registrar um driver personalizado, faça todos rodarem a etapa git config, ou coloque-a em um make setup;
  • O ramo de conflito do driver não deve gravar %A; emita um relatório;
  • Adicione um gate node -e "JSON.parse(...)" ou jq empty no CI, para que JSON "válido mas errado" seja pego após o merge e antes do deploy;
  • Adicione JSON minificado e arquivos gerados ao .gitignore para eliminar essa classe de conflito por completo.

10. Perguntas frequentes

Quando merge=union é seguro?

Apenas para arquivos somente anexáveis em que cada linha é um registro completo e a ordem não tem significado, como logs JSON Lines. No momento em que uma linha pode ser editada no lugar — adicionar um campo a um registro existente — o union produz dois registros contraditórios. Essa é uma fronteira de design, não uma questão de usá-lo com cuidado.

Um driver personalizado consegue tratar arrays?

A versão acima trata arrays como atômicos: se os dois lados mudam um array, ele relata um conflito. O alinhamento por índice reproduz o velho problema de diff de texto (insira um item e tudo desliza), enquanto o alinhamento por id exige uma chave primária acordada. Merges de array são melhor deixados a um humano, ou evitados por uma convenção de "arrays são somente anexáveis".

E se o driver tiver um bug?

Ele corrompe arquivos silenciosamente. Teste a primeira versão em um repositório de rascunho contra três formas (cada lado adiciona uma chave, ambos mudam a mesma chave, um exclui enquanto o outro edita) antes de distribuí-lo. Um driver quebrado reescreve o arquivo silenciosamente, o que é muito mais difícil de perceber do que um marcador de conflito.

Por que não simplesmente mesclar com uma linha de jq?

jq -s '.[0] * .[1]' é um merge raso, sua ordem de chaves e comportamento aninhado precisam ser verificados caso a caso, e ele não tem ancestral — sem base você não consegue distinguir "excluído" de "inalterado". Um merge de três vias precisa de três entradas.

O relatório de conflito deve ser commitado?

Não. Adicione-o ao .gitignore; é um artefato pontual que expira quando o merge termina.