Cómo Resolver Conflictos de Fusión Difíciles en Git Paso a Paso

Resuelve conflictos de fusión complejos en Git leyendo las versiones nuestra, suya y base, manejando renombres, rebases, binarios y pruebas.

Cómo Resolver Conflictos de Fusión Difíciles en Git Paso a Paso

Un conflicto de fusión difícil en Git rara vez es difícil por los marcadores de conflicto en sí mismos. Es difícil porque debes preservar la intención de dos cambios diferentes al mismo tiempo. Una rama renombró una función mientras que otra cambió su comportamiento. Una rama movió un archivo mientras que otra lo editó. Una rama cambió una secuencia de migración de base de datos. Git puede mostrarte la superposición, pero no puede decidir qué debería significar el software después.

Cuando una fusión se detiene con conflictos, no empieces a eliminar marcadores inmediatamente. Primero, orientate.

git status

Git listará las rutas no fusionadas. Puede decir both modified, deleted by us, deleted by them, both added o algo similar. Esas frases te indican la forma del conflicto.

Si la fusión se siente incorrecta o no estás listo para resolverla, aborta antes de hacer más cambios:

git merge --abort

Para un conflicto de rebase, el equivalente es:

git rebase --abort

Eso no es un fracaso. Es un reinicio limpio al estado anterior a la operación, que a menudo es la jugada más inteligente cuando te das cuenta de que necesitas más contexto.

Lee el Conflicto como Tres Versiones

Un marcador de conflicto normal se ve así:

<<<<<<< HEAD
versión de la rama actual
=======
versión de la rama entrante
>>>>>>> feature-branch

Durante una fusión, HEAD es la rama que tenías activa cuando ejecutaste git merge. La parte inferior es la rama que se está fusionando.

Para conflictos difíciles, usa las tres etapas que Git mantiene en el índice:

git show :1:ruta/al/archivo   # ancestro común
git show :2:ruta/al/archivo   # nuestra
git show :3:ruta/al/archivo   # suya

El ancestro común es la versión desde la que ambas ramas comenzaron. Es útil porque muestra lo que cada rama realmente cambió. Sin él, puedes comparar dos versiones finales y perderte la razón detrás de ellas.

También puedes usar:

git diff
git diff --ours -- ruta/al/archivo
git diff --theirs -- ruta/al/archivo
git diff --base -- ruta/al/archivo

Aquí es donde muchos van demasiado rápido. El objetivo no es elegir "nuestra" o "suya" como un voto de lealtad al equipo. El objetivo es producir el archivo final correcto.

Un Flujo de Trabajo Manual Seguro

Usa esta rutina para cada archivo en conflicto:

  1. Abre el archivo y encuentra cada marcador de conflicto.
  2. Lee el código circundante, no solo las líneas marcadas.
  3. Revisa los commits que tocaron el archivo en ambas ramas.
  4. Edita el archivo hasta obtener la versión final deseada.
  5. Elimina todos los marcadores de conflicto.
  6. Ejecuta la prueba o verificación de compilación más pequeña relevante.
  7. Agrega el archivo al área de preparación.

Comandos útiles mientras haces eso:

git log --oneline --left-right --merge -- ruta/al/archivo
git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'

git diff --check detecta problemas de espacios en blanco sobrantes. git grep detecta marcadores de conflicto olvidados antes de que lleguen a CI.

Después de resolver un archivo:

git add ruta/al/archivo

Cuando todos los conflictos estén preparados:

git status
git commit

Durante un rebase, usa:

git rebase --continue

Cuando Ambas Ramas Cambiaron la Misma Función

Este es el caso común. Supongamos que una rama agrega validación y la otra renombra un parámetro:

<<<<<<< HEAD
function createUser(email) {
  return db.users.insert({ email });
}
=======
function createUser(rawEmail) {
  const email = rawEmail.trim().toLowerCase();
  return db.users.insert({ email });
}
>>>>>>> normalize-email

La respuesta correcta puede combinar ambas:

function createUser(rawEmail) {
  const email = rawEmail.trim().toLowerCase();
  return db.users.insert({ email });
}

Pero solo si los llamadores se actualizaron para pasar rawEmail, y solo si la normalización aún es deseada. Busca la función:

git grep -n 'createUser'

Los conflictos difíciles a menudo requieren revisar archivos cercanos. Un conflicto de firma de función en un archivo puede requerir actualizaciones en pruebas, rutas, tipos, simulaciones o documentación.

Conflictos de Renombre y Edición

Los conflictos de renombre son molestos porque el archivo que deseas puede no estar donde esperas. Comienza con el estado:

git status --short

Luego inspecciona la información de nombre-estado:

git diff --name-status --diff-filter=R

Si una rama renombró src/user.js a src/account.js y la otra editó src/user.js, normalmente quieres que el contenido editado se aplique a la nueva ruta. Una herramienta de fusión visual puede ayudar, pero el concepto es simple: preserva el renombre y preserva las ediciones significativas.

Después de decidir la ruta final, elimina la ruta obsoleta si es necesario y agrega la final:

git rm ruta/vieja.js
git add ruta/nueva.js

No agregues ambos archivos a menos que el proyecto final realmente deba contener ambos.

Eliminado por Nosotros o Eliminado por Ellos

Un conflicto de eliminar/modificar significa que una rama eliminó un archivo mientras que la otra lo cambió. Git no puede saber si la eliminación hizo que el cambio fuera irrelevante.

Si el archivo debe permanecer eliminado:

git rm ruta/al/archivo

Si el archivo debe permanecer, elige la versión que desees y agrégala:

git checkout --theirs ruta/al/archivo
git add ruta/al/archivo

o:

git checkout --ours ruta/al/archivo
git add ruta/al/archivo

Ten cuidado con --ours y --theirs durante un rebase. En un rebase, las etiquetas pueden sentirse invertidas porque Git está reproduciendo tus commits sobre otra base. Cuando no estés seguro, inspecciona las etapas:

git show :2:ruta/al/archivo
git show :3:ruta/al/archivo

Conflictos de Archivos Binarios

Git no puede fusionar la mayoría de los archivos binarios. Si dos ramas cambiaron la misma imagen, archivo, documento o activo compilado, debes elegir una versión o crear un nuevo archivo manualmente.

Para tomar nuestra versión:

git checkout --ours ruta/al/archivo.bin
git add ruta/al/archivo.bin

Para tomar su versión:

git checkout --theirs ruta/al/archivo.bin
git add ruta/al/archivo.bin

Si el binario es generado, la mejor respuesta puede ser regenerarlo desde la fuente después de resolver los archivos de texto. Si el binario es un activo de diseño o documento, habla con la persona que cambió el otro lado. Adivinar puede destruir trabajo.

Usa una Herramienta de Fusión Cuando el Archivo es Demasiado Difícil de Leer

Una buena herramienta de fusión muestra cuatro cosas: la versión base, tu versión, su versión y el resultado. Configura una que realmente te guste. Visual Studio Code es común:

git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'

Luego ejecuta:

git mergetool

Otros equipos prefieren Meld, KDiff3, Beyond Compare o herramientas integradas en IDE. La herramienta importa menos que entender las tres versiones. No hagas clic en "aceptar entrante" en un conflicto complejo solo para hacer desaparecer los marcadores rojos.

Después de usar un mergetool, verifica si hay archivos de respaldo como .orig:

git status --short

Puedes deshabilitar los archivos de respaldo del mergetool globalmente si no los deseas:

git config --global mergetool.keepBackup false

Las Opciones de Estrategia No Son Mágicas

Puedes ver consejos como:

git merge -X theirs feature

Esto no significa "reemplaza mi rama con feature". Significa que cuando la estrategia de fusión de Git ve fragmentos en conflicto, debe preferir el otro lado para esos fragmentos. Los cambios no conflictivos de ambas ramas aún se fusionan. Esto puede ser útil para archivos de bloqueo generados o conflictos de formato mecánico, pero es arriesgado para la lógica de negocio.

-X ours y -X theirs son opciones de estrategia. La estrategia de fusión ours es diferente:

git merge -s ours rama-vieja

Eso registra una fusión mientras mantiene el árbol actual. Es una herramienta especializada, a menudo utilizada para marcar una rama como fusionada sin tomar su contenido. No la uses para la resolución normal de conflictos a menos que estés muy seguro.

Conflictos de Rebase

Durante un rebase, Git reproduce los commits uno por uno. Eso significa que puedes resolver varios conflictos más pequeños en lugar de un gran conflicto de fusión.

El ciclo es:

git status
# editar archivos
git add archivo-resuelto
git rebase --continue

Si un commit que se está reproduciendo ya no es necesario porque la nueva base ya contiene el cambio, usa:

git rebase --skip

Usa skip con cuidado. Elimina ese commit de la rama con rebase. Lee el commit primero:

git show

Nuevamente, --ours y --theirs pueden ser confusos en un rebase. Inspecciona :2: y :3: cuando tengas dudas.

Prueba la Resolución, No Solo la Fusión

Una fusión puede estar sintácticamente resuelta y aún así ser incorrecta. Después de agregar archivos, ejecuta pruebas que toquen el área modificada. Para un conflicto de frontend, eso puede ser una verificación de tipos y una prueba de componente enfocada. Para un conflicto de backend, puede ser una prueba de servicio o verificación de migración. Para un conflicto de archivo de bloqueo, reinstala las dependencias y ejecuta el comando de verificación del gestor de paquetes.

Como mínimo:

git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'

Luego ejecuta la verificación específica del proyecto que habría detectado una mala combinación.

Reduce Futuros Conflictos

El mejor conflicto es el que nunca creas. Mantén las ramas de corta duración, haz rebase o fusión desde la rama principal regularmente, y evita mezclar cambios mecánicos con cambios de funcionalidad. Un PR solo de formato no debería también cambiar la lógica. Un movimiento de archivo no debería también reescribir el archivo si puedes evitarlo.

Para archivos que siempre son dolorosos, considera cambios de propiedad o estructura. Los archivos de configuración grandes, instantáneas generadas, archivos de bloqueo, listas de migración y registros de rutas centrales a menudo crean conflictos repetidos porque todos editan la misma área. A veces la solución es el proceso. A veces la solución es dividir el archivo o generarlo a partir de fuentes más pequeñas.

Usa .gitattributes para archivos que necesitan un comportamiento de fusión especial. Por ejemplo, algunos archivos de bloqueo generados pueden tener controladores de fusión específicos del gestor de paquetes. No inventes uno a la ligera, pero verifica si tu ecosistema tiene un controlador recomendado.

Los conflictos de fusión son parte trabajo técnico y parte comunicación. Si no entiendes la intención de la otra rama, pregunta. Diez minutos con el autor son más baratos que fusionar código silenciosamente que pasa las pruebas pero elimina la funcionalidad que estaban construyendo.

Archivos de Bloqueo, Migraciones y Otros Archivos de Alta Fricción

Algunos archivos entran en conflicto con más frecuencia porque muchas ramas editan la misma área pequeña. Los archivos de bloqueo de dependencias son un ejemplo común. Si dos ramas agregan paquetes, el conflicto del archivo de bloqueo puede ser técnicamente grande pero conceptualmente simple: regenéralo con el gestor de paquetes después de resolver el archivo de manifiesto.

Para un proyecto Node, eso podría significar resolver package.json, luego ejecutar el gestor de paquetes que posee el archivo de bloqueo:

npm install
# o pnpm install
# o yarn install

Luego agrega tanto el manifiesto como el archivo de bloqueo. No edites manualmente un archivo de bloqueo complejo a menos que entiendas su formato. El gestor de paquetes tiene menos probabilidades de cometer un error sutil en el gráfico de dependencias.

Las migraciones de base de datos requieren más cuidado. Si dos ramas crean migraciones con suposiciones de orden, aceptar ambos archivos puede no ser suficiente. Verifica las marcas de tiempo de la migración, los números de secuencia, las dependencias y si ambas migraciones modifican la misma tabla o datos. A veces la resolución correcta es una nueva migración de seguimiento que reconcilie las dos ramas.

Las instantáneas generadas y los archivos golden tienen el mismo patrón: resuelve primero el cambio de fuente, regenera la salida, luego revisa el diff generado. Si el diff generado es enorme, pregúntate si pertenece al mismo commit de fusión. Los cambios generados enormes pueden ocultar una mala resolución manual.

Cuando un conflicto abarca archivos, escribe el comportamiento final deseado antes de editar. Una nota corta como "mantener la nueva validación de la funcionalidad A, mantener el servicio renombrado de la funcionalidad B, regenerar los tipos del cliente" evita que resuelvas cada archivo localmente mientras pierdes el diseño general.

Para fusiones especialmente riesgosas, crea una rama temporal antes de comenzar:

git switch -c merge-test/main-con-feature
git merge feature

Si la resolución se vuelve desordenada, puedes abandonar la rama temporal sin perturbar tu rama original. Ese pequeño hábito hace que los conflictos difíciles sean menos estresantes porque siempre tienes un camino limpio de regreso.

Revisa la Fusión Final Como un Cambio Propio

Una resolución de conflicto es trabajo nuevo. Trátalo así en la revisión. El diff final debería mostrar no solo los cambios de ambas ramas, sino también cualquier código de unión que hayas escrito para hacerlos funcionar juntos. Si el commit de fusión es grande, explica la resolución en el mensaje del commit o en el comentario del pull request. Los revisores no deberían tener que hacer ingeniería inversa para entender por qué se eligió un lado.

Antes de enviar, compara el resultado final con ambos padres cuando sea posible:

git diff HEAD^1..HEAD -- ruta/al/archivo
git diff HEAD^2..HEAD -- ruta/al/archivo

Para una fusión no confirmada, inspecciona los cambios preparados:

git diff --cached

Busca eliminación accidental de pruebas, importaciones que ya no se usan, entradas de configuración duplicadas y rutas de código donde ambas ramas agregaron lógica similar bajo diferentes nombres. Estos son los errores que Git no puede identificar por ti.

Si el conflicto involucró comportamiento, agrega o actualiza una prueba que fallaría si hubieras elegido el lado equivocado. Esa prueba hace más que probar la fusión de hoy. Protege la decisión de ser deshecha en la próxima refactorización.