Conflictos de merge de Git en archivos JSON: por qué merge=union escribe JSON roto
En resumen: merge=union deja pasar JSON roto y aun así informa éxito. Recorre un driver de merge a tres bandas consciente de las claves para JSON, más tres hábitos que reducen los conflictos.

Conclusión: estandariza la serialización y el orden de claves antes de hablar de estrategia de merge; cada conflicto que merge=union ahorra vuelve después como una coma que falta, una clave duplicada o dos documentos apilados, así que los conflictos de JSON pertenecen a un driver de merge a tres bandas consciente de las claves.
Dos personas añaden dos claves de traducción en la misma semana: una añade tools.diff, la otra añade tools.schema. Los cambios no tienen relación y son semánticamente compatibles. Git sigue informando un conflicto, y el bloque de conflicto contiene ambas líneas:
<<<<<<< HEAD
"diff": "对比"
=======
"schema": "Schema 生成"
>>>>>>> feat-b
La razón es simple. Ambas ramas insertaron una línea nueva justo antes de la llave de cierre, y el merge a tres bandas de Git, basado en líneas, solo ve posición, no significado. No tiene concepto de "estas son dos claves distintas". Así que alguien lee la documentación de .gitattributes y copia una línea:
i18n.json merge=union
El conflicto desaparece. Lo que cuesta es el tema de este artículo.
1. Union no fusiona, conserva las líneas de ambos lados
merge=union está documentado sin rodeos: ejecuta un merge a tres bandas a nivel de archivo para archivos de texto, pero toma líneas de ambas versiones en lugar de dejar marcadores de conflicto. La documentación continúa con una advertencia: no lo uses si no entiendes las implicaciones. Esa advertencia no es texto de relleno.
Union no entiende objetos, claves ni arrays. Entiende líneas. Cada resultado de abajo se reprodujo en un repositorio local usando exactamente el i18n.json anterior.
| Escenario | Driver por defecto | merge=union |
Driver consciente de claves |
|---|---|---|---|
| Cada lado añade una clave distinta | Conflicto, manual | "Fusionado", coma que falta, JSON inválido | Fusionado, ambas claves conservadas |
| Ambos lados cambian la misma clave | Conflicto, manual | "Fusionado", clave duplicada, gana la última | Código de salida 1, rutas en conflicto informadas |
| JSON de una sola línea, un cambio por lado | Conflicto, elegir un archivo entero | "Fusionado", dos documentos apilados | Fusionado, pero reformateado |
Un lado elimina minify, el otro edita parse |
Conflicto, manual | "Fusionado", la clave eliminada vuelve, falta la coma | Fusionado, eliminación conservada, edición aplicada |
Solo la columna central hace que git merge devuelva éxito, y ninguna de sus salidas se analiza correctamente. Esa es la parte contraintuitiva de union sobre JSON: cuanto más silencioso es, más peligroso es.
2. Resultado uno: una coma que falta, informada como merge limpio
Las mismas dos ramas, ahora con merge=union en su sitio:
$ git merge feat-b
Auto-merging i18n.json
Merge made by the 'ort' strategy.
i18n.json | 1 +
1 file changed, 1 insertion(+)
Sin conflicto, commit de merge creado. El archivo queda así:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比"
"schema": "Schema 生成"
}
}
No hay coma después de "diff": "对比". Union conservó la línea de nuestro lado y la línea del suyo, cada una con su propia coma final, y la costura perdió un separador.
Analizándolo:
$ python3 -c "import json;json.load(open('i18n.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 10 column 5
Esto es lo peor: Git considera el merge exitoso, el paso de merge en CI pasa, y la revisión de código ve un diff verde de "1 insertion". El fallo espera hasta el primer JSON.parse en tiempo de ejecución, momento en el que el merge ya queda muy atrás.
3. Resultado dos: una clave duplicada que descarta datos en silencio
El segundo experimento usa una forma distinta: ambos lados modifican la misma clave, uno a "解析 / 格式化", el otro a "解析器". Tras union:
{
"tools": {
"parse": "解析 / 格式化",
"parse": "解析器",
"minify": "压缩"
}
}
Esta vez el JSON es válido y JSON.parse no lanza nada. Pero parse aparece dos veces. Según la semántica de los literales de objeto, gana la entrada posterior, así que el valor final es "解析器": la otra edición se descarta sin línea de log y sin error.
Solo una comprobación de claves duplicadas lo saca a la luz:
duplicate keys detected: ['parse']
JSON.parse succeeded, final parse value = '解析器'
Esto es más difícil de encontrar que un JSON inválido: el archivo carga, la página se renderiza, y una cadena vuelve atrás en silencio. Semanas después alguien pregunta por qué una traducción nunca se publicó.
4. Resultado tres: el JSON de una sola línea se convierte en dos documentos
Algunos archivos de configuración se minifican a una sola línea, por ejemplo artefactos de build. No hay líneas para que union haga merge, así que ambos lados son líneas enteras y el resultado las apila:
{"tools":{"parse":"解析","minify":"压缩","diff":"对比"},"version":1}
{"tools":{"parse":"解析","minify":"压缩"},"version":2}
JSON.parse informa Unexpected non-whitespace character after JSON at position 63: terminó el primer documento y encontró un segundo. Git vuelve a decir "Merge made".
El JSON minificado no debería estar en control de versiones en absoluto, pero una vez que está, union lo convierte en dos documentos sin relación.
5. Resultado cuatro: la clave eliminada vuelve
La última forma es la más engañosa. Un lado elimina minify; el otro edita el parse adyacente. El driver por defecto informa un conflicto; union produce:
{
"tools": {
"parse": "解析"
"parse": "解析器",
"minify": "压缩"
}
}
Tres defectos a la vez: minify resucita, parse aparece dos veces, y la primera línea perdió su coma. Git sigue informando "2 insertions" y un merge exitoso.
La lección general: en una eliminación, "conservar líneas de ambos lados" equivale a deshacer la eliminación. Cualquier equipo que haya usado union sobre JSON durante un tiempo tiene un conjunto de ajustes "eso lo eliminamos" viviendo en silencio en el historial.
6. El enfoque correcto: merge a tres bandas por clave
Git te permite asignar un driver de merge personalizado a rutas específicas. El driver recibe tres rutas de archivo: %O es el ancestro común, %A es el nuestro (el resultado se escribe de vuelta aquí), %B es el suyo. Unas pocas docenas de líneas de Node bastan:
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);
Tres reglas lo sostienen: idéntico en ambos lados, tómalo; un lado igual al ancestro, toma el otro; distinto en ambos lados es un conflicto real. Todo lo demás es recursión en objetos planos.
El registro requiere dos pasos: .gitattributes elige el driver por ruta, git config define el 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
El primero se puede versionar (viaja con el código y se aplica en CI). El segundo es configuración local y debe estar en el README: aquí es donde normalmente se desmoronan los drivers personalizados.
Para el caso "cada lado añade una clave distinta", el driver produce:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比",
"schema": "Schema 生成"
}
}
Código de salida 0, JSON válido, ambas claves presentes.
Un efecto secundario que conviene conocer: el driver escribe JSON.stringify(merged, null, 2), así que reescribe el formato de todo el archivo. Una configuración minificada de 63 bytes volvió como 7 líneas y 105 bytes: mismo contenido, forma distinta. Eso hace que el driver solo encaje en archivos fuente mantenidos a mano. El JSON minificado o generado no debería estar en el repositorio, y no debería usar este driver, o cada merge arrastrará ruido de formato.
7. No escribir de vuelta en conflicto: esta trampa es más grande de lo que parece
Cuando ambos lados cambian la misma clave, ¿qué debe hacer el driver? El movimiento intuitivo es "escribir el nuestro de vuelta, salir con 1, dejar que Git informe el conflicto". Probado, eso es peligroso:
$ 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 informa un conflicto, pero no hay ni un solo marcador de conflicto en el archivo: el driver ya escribió su resultado. El contenido parece completamente normal, con el valor de nuestro lado. Un git add i18n.json && git commit apresurado y el cambio del otro desaparece, sin nada más que "Merge conflict resolved" en el historial.
Así que ante un conflicto real el driver no debe escribir %A. Registra las entradas sin decidir en un archivo de informe y sale con 1:
{
"file": "i18n.json",
"conflicts": [
{ "path": "tools.parse", "ours": "解析 / 格式化", "theirs": "解析器" }
]
}
El valor es que git status se detiene en UU, así que un humano debe actuar, y no tiene que cazar <<<<<<< dentro de docenas de líneas JSON. El informe dice exactamente qué rutas colisionaron.
Otro hallazgo probado: git checkout --conflict=merge -- i18n.json no puede restaurar los marcadores de conflicto una vez registrado un driver personalizado, porque vuelve a invocar el driver y sigue produciendo cero marcadores. Para recuperar los marcadores debes hacer temporalmente git config --unset merge.json-merge.driver, o extraer las tres etapas con git show :1:file, :2:, :3:. Quien cuenta con "siempre puedo regenerar los marcadores" descubre que ese camino está cerrado exactamente cuando lo necesita.
8. Tres hábitos que reducen los conflictos de antemano
El driver es una red de seguridad, no la herramienta principal. El número de conflictos es en sí mismo reducible.
Estandariza la serialización. Una persona escribe indent=2, otra indent=4, una tercera omite el salto de línea final. Cada guardado reescribe todo el archivo y el merge no tiene con qué trabajar. Fija la configuración del formateador, o ejecútalo en un hook de pre-commit.
Mantén estable el orden de claves. El orden de claves no tiene significado para JSON pero es muy real para Git. Ordenar las claves antes de escribir (ordenar antes de JSON.stringify, o pasar por jq -S) mantiene una edición en un solo hunk. Es la misma idea que normalizar antes de comparar en cómo difieren dos documentos JSON: allí para hacer diff, aquí para commitear.
Mantén los archivos generados fuera del repositorio. JSON minificado, manifiestos de build con timestamps, lockfiles: cambian en cada ejecución. Versiona la fuente, genera el artefacto.
9. Lista de comprobación
- Si
.gitattributesmapea JSON amerge=union, muestrea el historial congit log -pen busca de claves duplicadas o errores de sintaxis, y luego reemplázalo; - Después de registrar un driver personalizado, haz que todos ejecuten el paso de
git config, o ponlo en unmake setup; - La rama de conflicto del driver no debe escribir
%A; en su lugar, emite un informe; - Añade una compuerta
node -e "JSON.parse(...)"ojq emptyen CI, para que el JSON "válido pero incorrecto" se detecte después del merge y antes del despliegue; - Añade el JSON minificado y los archivos generados a
.gitignorepara eliminar esa clase de conflicto por completo.
10. FAQ
¿Cuándo es seguro merge=union?
Solo para archivos de solo anexado donde cada línea es un registro completo y el orden no tiene significado, como los logs JSON Lines. En cuanto una línea pueda editarse in situ, añadir un campo a un registro existente, union produce dos registros contradictorios. Esa es una frontera de diseño, no una cuestión de usarlo con cuidado.
¿Un driver personalizado puede manejar arrays?
La versión anterior trata los arrays como atómicos: si ambos lados cambian un array, informa un conflicto. La alineación por índice reproduce el viejo problema del diff de texto (inserta un elemento y todo se desplaza), mientras que la alineación por id requiere una clave primaria acordada. Los merges de arrays se dejan mejor a un humano, o se evitan con una convención de "los arrays son de solo anexado".
¿Y si el driver tiene un bug?
Corrompe archivos en silencio. Prueba la primera versión en un repositorio de pruebas contra tres formas (cada lado añade una clave, ambos cambian la misma clave, uno elimina mientras el otro edita) antes de desplegarlo. Un driver roto reescribe el archivo en silencio, lo que es mucho más difícil de notar que un marcador de conflicto.
¿Por qué no hacer el merge simplemente con una línea de jq?
jq -s '.[0] * .[1]' es una fusión superficial, su orden de claves y su comportamiento con anidamiento deben comprobarse caso por caso, y no tiene ancestro: sin base no puedes distinguir "eliminado" de "sin cambios". Un merge a tres bandas necesita tres entradas.
¿El informe de conflictos debe versionarse?
No. Añádelo a .gitignore; es un artefacto de una sola vez que caduca cuando termina el merge.