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.

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
.gitattributesmapeia JSON paramerge=union, amostre o histórico comgit log -pem 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 ummake setup; - O ramo de conflito do driver não deve gravar
%A; emita um relatório; - Adicione um gate
node -e "JSON.parse(...)"oujq emptyno 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
.gitignorepara 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.