Hasta ahora hemos tenido suerte. Todas las fusiones del módulo han salido bien porque Ana y Bruno tocaban zonas distintas de los ficheros. Se acabó la suerte: en esta lección los dos van a modificar las mismas líneas de la misma función, cada uno por un motivo perfectamente razonable, y Git se va a detener a mitad de la fusión.

Los conflictos de fusión tienen mala fama, y es inmerecida. Un conflicto no es un error, no es una corrupción y no significa que hayas hecho nada mal. Es Git diciéndote, con toda la honestidad del mundo: "aquí hay dos cambios incompatibles sobre las mismas líneas y no tengo criterio para elegir; decide tú". La alternativa —que un algoritmo decidiera por su cuenta qué código de qué persona sobrevive— sería mucho peor.

Lo que sí genera angustia es la sensación de estar atrapado: el repositorio en un estado raro, unos símbolos extraños dentro del código y la duda de si acabas de romper algo. Esta lección elimina esa angustia. Al terminarla sabrás exactamente en qué estado está tu repositorio durante un conflicto, cómo leer lo que Git ha escrito en el fichero, cómo resolverlo y, si te agobias, cómo dar marcha atrás sin dejar rastro.

Contenido

  1. Por qué se produce un conflicto
  2. Provocando un conflicto de verdad
  3. Los marcadores de conflicto
  4. El estilo diff3 y zdiff3: ver también el ancestro
  5. Orientarse durante el conflicto: git status
  6. git diff durante un conflicto: las tres versiones
  7. Resolver y marcar como resuelto
  8. Atajos: --ours y --theirs por fichero
  9. Abortar: --abort y --quit
  10. git mergetool: herramientas gráficas
  11. Conflictos especiales: borrado frente a modificado
  12. git rerere: no resolver dos veces lo mismo

  1. Por qué se produce un conflicto

Recuerda la tabla de decisión de la fusión a tres bandas de la lección 03-03. Para cada trozo de fichero, Git compara tres versiones: la del ancestro común (base), la de tu rama (ours) y la de la rama que fusionas (theirs).

Cambió en ours Cambió en theirs Resultado
No No Se conserva la base
No Gana ours
No Gana theirs
Sí, igual Gana esa versión común
Sí, distinto CONFLICTO

Solo la última fila produce conflicto: las dos ramas cambiaron lo mismo de forma distinta.

Y hay que precisar qué significa "lo mismo", porque es la fuente de un miedo muy extendido:

  • Que dos personas toquen el mismo fichero NO produce conflicto. Si Ana edita la línea 10 y Bruno la 200, Git combina las dos sin decir nada.
  • Produce conflicto que toquen las mismas líneas, o líneas tan próximas que caen en el mismo trozo (hunk) del diff. Git trabaja con un margen de contexto de tres líneas, así que cambios separados por una o dos líneas también pueden chocar.

Además de los conflictos de contenido, existen los conflictos de árbol (tree conflicts), que afectan a la existencia o ubicación del fichero en lugar de a su interior:

Tipo Situación
Contenido Las dos ramas modifican las mismas líneas
Borrado/modificado Una rama borra el fichero, la otra lo modifica
Añadido/añadido Las dos ramas crean un fichero con el mismo nombre y distinto contenido
Renombrado/borrado Una rama lo renombra, la otra lo borra
Renombrado/renombrado Las dos lo renombran, con nombres distintos
Directorio/fichero Una rama crea informes/ y la otra un fichero llamado informes

Los de contenido son el 90 % de los casos y son lo que veremos primero. El de borrado/modificado, por ser el segundo más frecuente, tiene su apartado.

  1. Provocando un conflicto de verdad

Volvamos al proyecto. main está al día con todo lo integrado en las lecciones anteriores, y app.js contiene esta función, que pinta el listado de tareas en pantalla:

function pintarLista() {
  const lista = document.getElementById('lista-tareas');
  lista.innerHTML = '';
  tareas.forEach(function (t) {
    lista.appendChild(crearElementoTarea(t));
  });
  actualizarContador();
}

Ana abre una rama para mostrar las tareas ordenadas alfabéticamente:

git switch -c funcionalidad/orden-alfabetico main

Y modifica la función:

function pintarLista() {
  const lista = document.getElementById('lista-tareas');
  lista.innerHTML = '';
  const ordenadas = tareas.slice().sort(function (a, b) {
    return a.titulo.localeCompare(b.titulo);
  });
  ordenadas.forEach(function (t) {
    lista.appendChild(crearElementoTarea(t));
  });
  actualizarContador();
}
git commit -am "Mostrar las tareas ordenadas alfabéticamente"
[funcionalidad/orden-alfabetico a1e5c93] Mostrar las tareas ordenadas alfabéticamente
 1 file changed, 4 insertions(+), 1 deletion(-)

Bruno, mientras tanto, abre otra rama desde el mismo punto para arreglar algo que le han reportado: cuando no hay tareas, la pantalla queda en blanco y parece que la aplicación está rota.

git switch -c correccion/mensaje-lista-vacia main
function pintarLista() {
  const lista = document.getElementById('lista-tareas');
  lista.innerHTML = '';
  if (tareas.length === 0) {
    lista.innerHTML = '<li class="vacio">No hay tareas pendientes</li>';
    actualizarContador();
    return;
  }
  tareas.forEach(function (t) {
    lista.appendChild(crearElementoTarea(t));
  });
  actualizarContador();
}
git commit -am "Mostrar un mensaje cuando el listado está vacío"
[correccion/mensaje-lista-vacia 6d3f8b2] Mostrar un mensaje cuando el listado está vacío
 1 file changed, 5 insertions(+)
gitGraph
   commit id: "3b9e7d1"
   branch orden-alfabetico
   checkout orden-alfabetico
   commit id: "a1e5c93"
   checkout main
   branch mensaje-lista-vacia
   checkout mensaje-lista-vacia
   commit id: "6d3f8b2"

Los dos cambios son buenos y el proyecto los quiere los dos. Pero ambos insertan código justo en el mismo punto: entre lista.innerHTML = ''; y el forEach. Git no tiene forma de saber si el if de Bruno va antes o después del sort de Ana, ni si el forEach debe recorrer tareas u ordenadas.

Ana integra primero lo suyo, que va limpio:

git switch main
git merge --no-ff --no-edit funcionalidad/orden-alfabetico
Merge made by the 'ort' strategy.
 app.js | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

Y ahora, el de Bruno:

git merge --no-ff correccion/mensaje-lista-vacia
Auto-merging app.js
CONFLICT (content): Merge conflict in app.js
Automatic merge failed; fix conflicts and then commit the result.

Ahí está. Vamos a diseccionarlo.

  1. Los marcadores de conflicto

Lo primero que hay que entender: Git ha modificado tu fichero en disco. app.js ya no contiene código válido de JavaScript, sino código con anotaciones dentro:

function pintarLista() {
  const lista = document.getElementById('lista-tareas');
  lista.innerHTML = '';
<<<<<<< HEAD
  const ordenadas = tareas.slice().sort(function (a, b) {
    return a.titulo.localeCompare(b.titulo);
  });
  ordenadas.forEach(function (t) {
=======
  if (tareas.length === 0) {
    lista.innerHTML = '<li class="vacio">No hay tareas pendientes</li>';
    actualizarContador();
    return;
  }
  tareas.forEach(function (t) {
>>>>>>> correccion/mensaje-lista-vacia
    lista.appendChild(crearElementoTarea(t));
  });
  actualizarContador();
}

Los marcadores por defecto son tres:

Marcador Significado
<<<<<<< HEAD Empieza la versión de tu rama (ours). La etiqueta indica de dónde viene
======= Separador: termina ours, empieza theirs
>>>>>>> correccion/mensaje-lista-vacia Termina la versión de la otra rama (theirs), etiquetada con su nombre

Observaciones importantes que se pasan por alto:

  1. Solo la zona conflictiva lleva marcadores. Las tres primeras líneas de la función y las tres últimas están limpias: Git las fusionó sin problema. Un fichero de 400 líneas con un conflicto de 6 tiene 394 líneas ya resueltas.

  2. Un fichero puede tener varios bloques de conflicto, cada uno con su juego de marcadores. Hay que resolverlos todos.

  3. Los marcadores son texto normal. No hay nada mágico en ellos: puedes editarlos, borrarlos o dejarlos (si los dejas, git commit te avisará, pero puedes forzarlo y meter basura en el proyecto, así que revisa siempre).

  4. La etiqueta de la derecha es el nombre de la rama fusionada, lo que ayuda mucho cuando estás fusionando varias cosas seguidas y te has perdido.

Y aquí está el problema del formato por defecto: no ves lo que había antes. ¿El forEach sobre tareas de la versión de Bruno es un cambio suyo o simplemente el código original que él no tocó? Con este formato no puedes saberlo, y esa información es exactamente la que necesitas para resolver bien. Para eso está el apartado siguiente.

  1. El estilo diff3 y zdiff3: ver también el ancestro

Git puede mostrar tres secciones en lugar de dos, añadiendo la del ancestro común. Se controla con la opción merge.conflictStyle:

git config --global merge.conflictStyle diff3

Abortamos y repetimos la fusión para verlo:

git merge --abort
git merge --no-ff correccion/mensaje-lista-vacia

Ahora app.js contiene:

function pintarLista() {
  const lista = document.getElementById('lista-tareas');
  lista.innerHTML = '';
<<<<<<< HEAD
  const ordenadas = tareas.slice().sort(function (a, b) {
    return a.titulo.localeCompare(b.titulo);
  });
  ordenadas.forEach(function (t) {
||||||| 3b9e7d1
  tareas.forEach(function (t) {
=======
  if (tareas.length === 0) {
    lista.innerHTML = '<li class="vacio">No hay tareas pendientes</li>';
    actualizarContador();
    return;
  }
  tareas.forEach(function (t) {
>>>>>>> correccion/mensaje-lista-vacia
    lista.appendChild(crearElementoTarea(t));
  });
  actualizarContador();
}

El bloque nuevo, delimitado por ||||||| y etiquetado con el hash del ancestro común, contiene el código original. Y ahora la lectura es completamente distinta:

  • El ancestro tenía tareas.forEach(...).
  • Ana lo cambió por el bloque sort + ordenadas.forEach(...).
  • Bruno añadió el if delante y no tocó el forEach.

Con esa información, la resolución es evidente: hay que quedarse con las dos cosas, poniendo el if de Bruno primero y respetando el cambio de tareas a ordenadas de Ana.

Sin el ancestro, habrías tenido que deducirlo o consultar el historial. Con él, se ve de un vistazo.

zdiff3, todavía mejor

Desde Git 2.35 existe un estilo más: zdiff3 (por zealous diff3). Hace lo mismo que diff3 pero además saca fuera del conflicto las líneas comunes a los dos lados:

git config --global merge.conflictStyle zdiff3

En conflictos donde los dos lados comparten líneas al principio o al final del bloque, la zona conflictiva se reduce notablemente y solo queda dentro lo que de verdad discrepa.

Estilo Secciones Disponible desde Recomendación
merge 2 (ours, theirs) Siempre El de por defecto; el peor de los tres
diff3 3 (ours, base, theirs) Siempre Muy superior; adóptalo
zdiff3 3, con las comunes fuera Git 2.35 (2022) El mejor si tu versión lo soporta

Este es probablemente el consejo más rentable de toda la lección. Ponlo ahora mismo:

git config --global merge.conflictStyle zdiff3

Encaja con lo que ya configuraste en la lección 01-06 y no tiene ninguna contrapartida.

  1. Orientarse durante el conflicto: git status

Durante un conflicto, el repositorio está en un estado especial. git status es tu brújula y cambia por completo:

git status
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   app.js

no changes added to commit (use "git add" to commit)

Elementos clave:

  • You have unmerged paths: hay una fusión a medias.
  • Unmerged paths: sección nueva, distinta de "preparados" y "sin preparar". Aquí van los ficheros en conflicto.
  • both modified: el tipo de conflicto. Otros posibles: deleted by us, deleted by them, both added, added by us.
  • Git te recuerda las dos salidas: resolver y confirmar, o abortar.

En formato corto:

git status --short
UU app.js

UU significa unmerged en las dos columnas. La tabla completa de códigos de conflicto:

Código Significado
UU Modificado por los dos (both modified)
AA Añadido por los dos (both added)
DD Borrado por los dos
AU Añadido por nosotros, sin modificar por ellos
UA Añadido por ellos
DU Borrado por nosotros, modificado por ellos
UD Modificado por nosotros, borrado por ellos

Si solo quieres la lista de ficheros pendientes, sin ruido:

git diff --name-only --diff-filter=U
app.js

Es el comando que se usa en scripts y en atajos de editor para saltar de un conflicto a otro.

Qué hay dentro de .git en este momento

Merece la pena mirarlo una vez, porque desmitifica el estado:

ls .git/MERGE_HEAD .git/MERGE_MSG
.git/MERGE_HEAD  .git/MERGE_MSG
cat .git/MERGE_HEAD
6d3f8b2e4a9c1f5b7d3a8e2c6b4f9d1a5c7e3b8f

MERGE_HEAD guarda el commit que estás fusionando. Su existencia es lo que define "estar en una fusión": es por lo que git commit sabe que debe crear un commit con dos padres, y por lo que git merge --abort sabe qué deshacer. MERGE_MSG guarda el mensaje propuesto.

Y el índice (el área de preparación) también es especial ahora. Normalmente guarda una versión de cada fichero; durante un conflicto guarda tres, numeradas por etapas:

git ls-files -u
100644 8e1f5b3d7a2c9e4b6f1d8a3c5e7b2d9f4a6c8e1b 1	app.js
100644 2a7c9e4f1b6d8a3c5e2f7b9d4a1c6e8b3f5d7a2c 2	app.js
100644 9f3b7d1a5c8e2f6b4d9a1c7e3b5f8d2a6c4e9b1f 3	app.js
Etapa Versión
1 La del ancestro común (base)
2 La de tu rama (ours)
3 La de la rama fusionada (theirs)

Esas tres etapas son lo que hace posible todo lo que viene a continuación: los diffs especiales, --ours/--theirs y las herramientas gráficas. Cuando resuelves y haces git add, las tres etapas se sustituyen por una única versión normal, y así es como Git sabe que ese fichero ya está resuelto.

  1. git diff durante un conflicto: las tres versiones

git diff a secas, durante un conflicto, muestra un formato que no habías visto: el diff combinado.

git diff
diff --cc app.js
index 2a7c9e4,9f3b7d1..0000000
--- a/app.js
+++ b/app.js
@@@ -10,7 -10,11 +10,16 @@@ function pintarLista()
    const lista = document.getElementById('lista-tareas');
    lista.innerHTML = '';
++<<<<<<< HEAD
 +  const ordenadas = tareas.slice().sort(function (a, b) {
 +    return a.titulo.localeCompare(b.titulo);
 +  });
 +  ordenadas.forEach(function (t) {
++=======
+   if (tareas.length === 0) {
+     lista.innerHTML = '<li class="vacio">No hay tareas pendientes</li>';
+     actualizarContador();
+     return;
+   }
+   tareas.forEach(function (t) {
++>>>>>>> correccion/mensaje-lista-vacia
      lista.appendChild(crearElementoTarea(t));
    });
    actualizarContador();

Fíjate en que hay dos columnas de marcadores +/- en lugar de una, y en la cabecera @@@ con tres arrobas. Cada columna corresponde a un padre. Es un formato denso; en la práctica se usan más los diffs por parejas.

Para comparar el fichero en conflicto con cada una de las tres versiones:

# Frente a la versión del ancestro común (etapa 1)
git diff --base app.js

# Frente a la versión de tu rama (etapa 2)
git diff --ours app.js

# Frente a la versión de la rama fusionada (etapa 3)
git diff --theirs app.js

También puedes recuperar cualquiera de las tres versiones completas para verlas de forma aislada, con la sintaxis :<etapa>:<ruta>:

git show :1:app.js    # la del ancestro
git show :2:app.js    # la tuya
git show :3:app.js    # la de la otra rama

Esto es enormemente útil cuando el conflicto es grande y quieres leer cada versión entera sin marcadores, en lugar de intentar descifrar el fichero mezclado. Por ejemplo, para guardar la versión de Bruno en un fichero aparte y consultarla mientras editas:

git show :3:app.js > /tmp/version-bruno.js

  1. Resolver y marcar como resuelto

Resolver un conflicto es, simplemente, dejar el fichero como debe quedar. Ni más ni menos. No hay ningún comando mágico: se edita el fichero, se quitan los marcadores y se escribe el código correcto.

En nuestro caso, sabemos por el bloque ||||||| que hay que combinar las dos aportaciones. Ana edita app.js y lo deja así:

function pintarLista() {
  const lista = document.getElementById('lista-tareas');
  lista.innerHTML = '';
  if (tareas.length === 0) {
    lista.innerHTML = '<li class="vacio">No hay tareas pendientes</li>';
    actualizarContador();
    return;
  }
  const ordenadas = tareas.slice().sort(function (a, b) {
    return a.titulo.localeCompare(b.titulo);
  });
  ordenadas.forEach(function (t) {
    lista.appendChild(crearElementoTarea(t));
  });
  actualizarContador();
}

Sin marcadores. El if de Bruno va primero (si no hay tareas, no tiene sentido ordenarlas) y después el sort de Ana. Es código que no existía en ninguna de las dos ramas: es la síntesis que solo una persona podía hacer. Eso es exactamente lo que Git te estaba pidiendo.

Antes de dar nada por bueno, hay que probarlo. Un conflicto resuelto sin ejecutar el código es una apuesta:

# Comprobar que no queda ningún marcador olvidado
grep -n '^<<<<<<<\|^=======\|^>>>>>>>\|^|||||||' app.js
(sin salida)

Y a continuación, abrir la aplicación o pasar las pruebas.

Ahora se marca como resuelto:

git add app.js

git add sobre un fichero en conflicto tiene un significado especial: sustituye las tres etapas del índice por la versión del disco y declara el conflicto resuelto. No es "preparar un cambio"; es "he decidido, esta es la versión buena".

git status
On branch main
All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

Changes to be committed:
	modified:   app.js

All conflicts fixed but you are still merging: la fusión sigue en curso (MERGE_HEAD existe todavía), pero ya no hay nada bloqueado. Solo falta confirmar:

git commit

Git abre el editor con el mensaje que guardó en MERGE_MSG, ahora con una anotación adicional:

Merge branch 'correccion/mensaje-lista-vacia'

# Conflicts:
#	app.js
#
# It looks like you may be committing a merge.
# If this is not correct, please run
#	git update-ref -d MERGE_HEAD
# and try again.

Es una excelente costumbre describir cómo se resolvió, porque quien lea esto dentro de un año lo agradecerá:

Fusionar el mensaje de lista vacía

Conflicto en pintarLista(): la rama de Bruno añadía la comprobación
de listado vacío y la de Ana el orden alfabético, ambas en el mismo
punto. Se conservan las dos: primero la salida temprana si no hay
tareas, después la ordenación.
[main c2a8f1e] Fusionar el mensaje de lista vacía
git log --oneline --graph -5
*   c2a8f1e (HEAD -> main) Fusionar el mensaje de lista vacía
|\
| * 6d3f8b2 (correccion/mensaje-lista-vacia) Mostrar un mensaje cuando el listado está vacío
* |   e1f3a7b Merge branch 'funcionalidad/orden-alfabetico'
|\ \
| * | a1e5c93 (funcionalidad/orden-alfabetico) Mostrar las tareas ordenadas alfabéticamente
|/ /
* / 3b9e7d1 Añadir exportación del listado de tareas a CSV
|/

Y aquí es donde --cc, que vimos en la lección 03-03, cobra todo su sentido:

git show --cc c2a8f1e

Muestra solo las líneas que difieren de los dos padres, es decir, exactamente el código que Ana escribió a mano al resolver. Es la forma de auditar una resolución de conflicto sin leer todo el fichero.

  1. Atajos: --ours y --theirs por fichero

A veces no hay nada que sintetizar: una de las dos versiones es la correcta y punto. Típico en ficheros generados automáticamente, ficheros de bloqueo de dependencias o cuando sabes que el trabajo de un lado dejó obsoleto el del otro.

# Quedarse con la versión de tu rama, entera
git checkout --ours app.js

# Quedarse con la versión de la rama fusionada, entera
git checkout --theirs app.js

O con los comandos modernos que vimos en la lección 03-02:

git restore --ours app.js
git restore --theirs app.js

Estos comandos sobrescriben el fichero del disco con la etapa 2 o la 3 del índice, marcadores incluidos fuera. Después hay que marcarlo como resuelto igualmente:

git restore --theirs paquetes.lock
git add paquetes.lock

Dos avisos importantes:

  1. Cogen el fichero ENTERO, no solo la zona conflictiva. Si app.js tenía además cambios de la otra rama en otra parte del fichero que se habían fusionado bien, --ours los tira también. Úsalo solo cuando de verdad quieras una versión completa.

  2. No los confundas con -X ours/-X theirs de la lección anterior. Estos actúan fichero a fichero, durante un conflicto ya producido; aquellos actúan globalmente, antes de que el conflicto aparezca.

Un patrón muy práctico cuando hay varios ficheros y solo algunos son "automáticos":

# Resolver a mano los ficheros de código
vim app.js
git add app.js

# Los generados, con la versión entrante
git restore --theirs dist/bundle.js paquetes.lock
git add dist/bundle.js paquetes.lock

git commit

Y si prefieres empezar de cero un fichero que has estropeado editando:

git checkout --merge app.js

Restaura el fichero con los marcadores de conflicto originales, como estaba justo después de la fusión fallida.

  1. Abortar: --abort y --quit

Si te has perdido, si el conflicto es mucho mayor de lo que esperabas o si simplemente prefieres hacerlo en otro momento, se sale sin dejar rastro:

git merge --abort
(sin salida)
git status
On branch main
nothing to commit, working tree clean

Como si la fusión no hubiera existido. git merge --abort deshace todo: restaura el directorio de trabajo y el índice al estado anterior a git merge, y borra MERGE_HEAD y MERGE_MSG.

Es la operación más tranquilizadora de Git y conviene interiorizarla: durante un conflicto nunca estás atrapado. Siempre hay una tecla de escape.

Una precaución: si tenías cambios sin confirmar antes de empezar la fusión, --abort puede no ser capaz de recuperar el estado exacto. Por eso la recomendación de la lección 03-03 —fusionar con el directorio limpio— no es una manía.

Existe también un primo raro:

git merge --quit

--quit sale del estado de fusión (borra MERGE_HEAD) pero deja el directorio de trabajo y el índice tal como están, con los marcadores y todo. Es para casos muy concretos: quieres conservar el resultado a medias pero no quieres que Git cree un commit de fusión con dos padres. Si no sabes que lo necesitas, no lo necesitas: usa --abort.

Comando Estado de fusión Directorio de trabajo Cuándo
git merge --abort Se cancela Se restaura al estado previo Casi siempre
git merge --quit Se cancela Se deja como está Casos avanzados
git commit Se completa Se conserva lo resuelto Cuando has resuelto

  1. git mergetool: herramientas gráficas

Para conflictos grandes, editar marcadores a mano es incómodo. git mergetool abre una herramienta de tres o cuatro paneles: base, ours, theirs y resultado.

git mergetool
Merging:
app.js

Normal merge conflict for 'app.js':
  {local}: modified file
  {remote}: modified file
Hit return to start merge resolution tool (vimdiff):

Si no has configurado ninguna, Git elige la primera que encuentre instalada. Para fijar una:

# Meld (multiplataforma, muy recomendable para empezar)
git config --global merge.tool meld

# VS Code
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait --merge $REMOTE $LOCAL $BASE $MERGED'

# KDiff3
git config --global merge.tool kdiff3

# vimdiff
git config --global merge.tool vimdiff

Y una opción que casi todo el mundo acaba activando:

git config --global mergetool.keepBackup false

Sin ella, git mergetool deja ficheros *.orig con la versión conflictiva por todas partes, y luego hay que borrarlos a mano (o ignorarlos en .gitignore).

Las cuatro variables que la herramienta recibe, y que conviene entender para configurar la tuya:

Variable Contenido
$BASE La versión del ancestro común (etapa 1)
$LOCAL Tu versión (etapa 2, ours)
$REMOTE La versión entrante (etapa 3, theirs)
$MERGED El fichero de destino, donde escribes el resultado

Al guardar y salir de la herramienta, git mergetool marca el fichero como resuelto automáticamente (hace el git add por ti) y pasa al siguiente conflicto si lo hay.

Un apunte práctico: hoy la mayoría de editores modernos incluyen su propio resolvedor de conflictos que detecta los marcadores en el fichero y ofrece botones tipo "aceptar el actual / aceptar el entrante / aceptar los dos". Funcionan sobre el mismo fichero con marcadores que hemos visto, así que todo lo aprendido aquí se aplica igual. git mergetool sigue siendo útil cuando quieres los cuatro paneles con el ancestro a la vista.

  1. Conflictos especiales: borrado frente a modificado

El segundo tipo de conflicto más frecuente no ocurre dentro del fichero, sino sobre su existencia.

Situación: el proyecto tenía un fichero notas-internas.md con apuntes del principio del desarrollo. Ana decidió que estaba obsoleto y lo borró en su rama:

git switch -c limpieza/borrar-notas main
git rm notas-internas.md
git commit -m "Borrar las notas internas, ya obsoletas"

Bruno, que no lo sabía, lo actualizó en la suya:

git switch -c documentacion/actualizar-notas main
# … edita notas-internas.md …
git commit -am "Actualizar las notas internas con el nuevo flujo"

Ana fusiona lo suyo en main (sin problema) y luego lo de Bruno:

git merge documentacion/actualizar-notas
CONFLICT (modify/delete): notas-internas.md deleted in HEAD and modified in
documentacion/actualizar-notas.  Version documentacion/actualizar-notas of
notas-internas.md left in tree.
Automatic merge failed; fix conflicts and then commit the result.

Fíjate en dos cosas:

  1. El tipo de conflicto es modify/delete, no content.
  2. No hay marcadores dentro del fichero. No tendría sentido: el conflicto no es sobre su contenido, sino sobre si debe existir. Git ha dejado en el disco la versión de Bruno, como dice el mensaje.
git status
On branch main
You have unmerged paths.

Unmerged paths:
  (use "git add/rm <file>..." as appropriate to mark resolution)
	deleted by us:   notas-internas.md

deleted by us: nosotros (la rama en la que estamos) lo borramos, ellos lo modificaron. Git no puede decidir si el borrado era correcto o si las actualizaciones de Bruno lo justifican; eso depende de por qué se borró, y eso solo lo sabe una persona.

La resolución consiste en declarar qué debe pasar, y solo hay dos opciones:

# Opción A: mantener el borrado (el fichero ya no debe existir)
git rm notas-internas.md
notas-internas.md: needs merge
rm 'notas-internas.md'
# Opción B: conservar el fichero con los cambios de Bruno
git add notas-internas.md

En los dos casos, después:

git commit

Antes de decidir, conviene mirar qué contenían esos cambios, no vaya a ser que Bruno hubiera escrito algo que merece conservarse en otro sitio:

git show documentacion/actualizar-notas:notas-internas.md

Y si la respuesta es "sí merece la pena, pero no aquí", puedes rescatar el contenido a otro fichero antes de resolver.

Los demás conflictos de árbol

Se resuelven con la misma lógica: git add si el fichero debe existir con el contenido que tiene en disco, git rm si no debe existir.

CONFLICT (add/add): Merge conflict in config.json

Las dos ramas crearon el mismo fichero con contenido distinto. Aquí hay marcadores dentro (Git trata el vacío como base), así que se resuelve como un conflicto de contenido normal.

CONFLICT (rename/rename): Rename estilos.css->css/estilos.css in HEAD.
Rename estilos.css->assets/estilos.css in rama-b

Las dos ramas lo movieron a sitios distintos. Se decide la ubicación buena, se coloca el fichero ahí, se borran las otras copias y se marca todo con git add/git rm.

  1. git rerere: no resolver dos veces lo mismo

Un último apunte, para que sepas que existe cuando lo necesites.

rerere viene de reuse recorded resolution: "reutilizar la resolución registrada". Si lo activas, Git memoriza cómo resolviste cada conflicto y, cuando se le presenta el mismo conflicto otra vez, lo resuelve solo con tu decisión anterior.

git config --global rerere.enabled true

¿Cuándo se repite un conflicto? Más veces de lo que parece: cuando fusionas main en tu rama de trabajo cada pocos días, cuando abortas una fusión y la repites, cuando rehaces una rama con rebase varias veces (módulo 5), o cuando trabajas con ramas de larga duración.

Es una herramienta de uso avanzado y tiene sus matices —conviene revisar lo que resuelve por su cuenta, sobre todo si la resolución anterior fue apresurada—, así que aquí solo la mencionamos. Con saber que existe y que se activa con esa línea es suficiente por ahora.

Errores Comunes y Consejos

Error 1: dejar marcadores en el código. Confirmar un fichero con <<<<<<< dentro rompe el proyecto de forma espectacular. Git avisa si detecta marcadores al confirmar, pero no es infalible. Comprueba siempre antes de git add:

git diff --check
grep -rn '^<<<<<<<' .

Error 2: resolver "eligiendo un lado" sin pensar. El impulso de quedarse con --ours para acabar rápido destruye el trabajo del otro lado, incluidos los cambios que se habían fusionado bien en el resto del fichero. Lee el conflicto: en la mayoría de los casos la respuesta correcta es quedarse con las dos cosas, como en el ejemplo de esta lección.

Error 3: no probar el resultado. Un conflicto resuelto puede compilar y aun así estar mal: variables que quedan sin usar, funciones duplicadas, lógica que se ejecuta dos veces. Ejecuta la aplicación o las pruebas antes de confirmar.

Error 4: entrar en pánico y borrar el repositorio. Es más habitual de lo que debería. git merge --abort deja todo exactamente como estaba. No hay ninguna situación de conflicto de la que no se pueda salir.

Error 5: confundir --ours/--theirs durante un conflicto con -X ours/-X theirs. Los primeros actúan sobre un fichero concreto, con la fusión ya parada. Los segundos son una política global que se aplica antes. Y en un rebase, además, los papeles de ours y theirs se invierten respecto a lo que uno esperaría (lección 05-01).

Consejo 1: activa zdiff3 hoy mismo. Ver el ancestro común cambia por completo la calidad de tus resoluciones:

git config --global merge.conflictStyle zdiff3

Consejo 2: conflictos pequeños y frecuentes en lugar de grandes y raros. La mejor técnica para resolver conflictos es tener menos. Ramas de vida corta, integraciones frecuentes y traer main a tu rama cada pocos días convierten un conflicto de 300 líneas en cinco de tres.

Consejo 3: si el conflicto es enorme, aborta y estudia. Antes de pelearte con él, entiende por qué está ahí:

git merge --abort
git log --oneline main..la-otra-rama
git diff main...la-otra-rama --stat

A veces la conclusión es que hay que hablar con la otra persona antes de tocar nada. Esa también es una respuesta válida.

Consejo 4: documenta la resolución en el mensaje del commit de fusión. Un commit de fusión con conflictos contiene decisiones humanas que no están en ningún otro sitio. Explicar en tres líneas qué chocaba y qué se decidió ahorra arqueología a quien venga después.

Consejo 5: cuando toque decidir código ajeno, pregunta. Si el conflicto está en código que no conoces, resolverlo "a ojo" es una apuesta. Un mensaje de treinta segundos a quien lo escribió sale más barato que un fallo en producción.

Ejercicios

Ejercicio 1: provocar y resolver un conflicto de contenido

Monta un repositorio de pruebas, provoca un conflicto en el que las dos ramas modifiquen la misma línea, y resuélvelo combinando ambas aportaciones. Hazlo primero con el estilo de conflicto por defecto y después con diff3, y explica qué información aporta el segundo.

Ejercicio 2: el conflicto de borrado frente a modificado

Provoca un conflicto modify/delete y resuélvelo de las dos formas posibles (conservando el fichero y manteniendo el borrado). Antes de decidir, muestra el contenido que aportaba la rama que lo modificó.

Ejercicio 3: rescatar las tres versiones

En un conflicto de contenido, sin abrir ningún editor, guarda en /tmp las tres versiones del fichero (base, ours y theirs) por separado y compara la base con cada una de las otras dos. Después, aborta la fusión.

Soluciones

Solución 1:

mkdir /tmp/practica-conflicto && cd /tmp/practica-conflicto
git init -b main
cat > saludo.js <<'FIN'
function saludar(nombre) {
  return "Hola " + nombre;
}
FIN
git add . && git commit -m "Añadir la función de saludo"

# Rama A: añade signos de exclamación
git switch -c rama-a
cat > saludo.js <<'FIN'
function saludar(nombre) {
  return "¡Hola " + nombre + "!";
}
FIN
git commit -am "Añadir signos de exclamación"

# Rama B: añade el apellido
git switch main
git switch -c rama-b
cat > saludo.js <<'FIN'
function saludar(nombre, apellido) {
  return "Hola " + nombre + " " + apellido;
}
FIN
git commit -am "Incluir el apellido en el saludo"

# Fusionamos
git switch main
git merge rama-a
git merge rama-b
Auto-merging saludo.js
CONFLICT (content): Merge conflict in saludo.js

Con el estilo por defecto:

function saludar(nombre) {
<<<<<<< HEAD
  return "¡Hola " + nombre + "!";
=======
}
function saludar(nombre, apellido) {
  return "Hola " + nombre + " " + apellido;
>>>>>>> rama-b
}

Con diff3:

git merge --abort
git config merge.conflictStyle diff3
git merge rama-b
<<<<<<< HEAD
function saludar(nombre) {
  return "¡Hola " + nombre + "!";
||||||| 8a1d5c3
function saludar(nombre) {
  return "Hola " + nombre;
=======
function saludar(nombre, apellido) {
  return "Hola " + nombre + " " + apellido;
>>>>>>> rama-b

Qué aporta diff3: deja claro que la base era "Hola " + nombre, que la rama A solo añadió los signos de exclamación y que la rama B solo añadió el parámetro y el apellido. Sin el bloque de la base, había que deducirlo comparando mentalmente los dos lados y era fácil equivocarse sobre qué se había cambiado.

Resolución que combina las dos aportaciones:

cat > saludo.js <<'FIN'
function saludar(nombre, apellido) {
  return "¡Hola " + nombre + " " + apellido + "!";
}
FIN
grep -c '<<<<<<<' saludo.js
0
git add saludo.js
git commit -m "Fusionar el saludo con apellido y exclamaciones

Conflicto en saludar(): rama-a añadía los signos de exclamación y
rama-b el parámetro apellido. Se conservan ambos cambios."

Solución 2:

mkdir /tmp/practica-borrado && cd /tmp/practica-borrado
git init -b main
echo "Notas del proyecto" > notas.md
echo "contenido" > otro.txt
git add . && git commit -m "Base"

# Rama que borra
git switch -c borrar-notas
git rm notas.md
git commit -m "Borrar las notas obsoletas"

# Rama que modifica
git switch main
git switch -c actualizar-notas
echo "Notas del proyecto - revisión 2" > notas.md
git commit -am "Actualizar las notas"

# Fusión
git switch main
git merge borrar-notas          # fast-forward, sin problema
git merge actualizar-notas
CONFLICT (modify/delete): notas.md deleted in HEAD and modified in
actualizar-notas.  Version actualizar-notas of notas.md left in tree.
git status --short
DU notas.md

Antes de decidir, vemos qué aportaba la rama:

git show actualizar-notas:notas.md
Notas del proyecto - revisión 2

Opción A: mantener el borrado.

git rm notas.md
git commit -m "Fusionar actualizar-notas manteniendo el borrado de notas.md"
ls
otro.txt

Opción B: conservar el fichero (partiendo de git merge --abort y repitiendo).

git add notas.md
git commit -m "Fusionar actualizar-notas conservando notas.md actualizado"
cat notas.md
Notas del proyecto - revisión 2

La regla que resuelve todos los conflictos de árbol: git add si el fichero debe existir, git rm si no debe existir.

Solución 3:

Partiendo de un conflicto de contenido en curso sobre saludo.js:

git show :1:saludo.js > /tmp/base.js      # ancestro común
git show :2:saludo.js > /tmp/ours.js      # nuestra rama
git show :3:saludo.js > /tmp/theirs.js    # rama fusionada
diff /tmp/base.js /tmp/ours.js
2c2
<   return "Hola " + nombre;
---
>   return "¡Hola " + nombre + "!";
diff /tmp/base.js /tmp/theirs.js
1,2c1,2
< function saludar(nombre) {
<   return "Hola " + nombre;
---
> function saludar(nombre, apellido) {
>   return "Hola " + nombre + " " + apellido;

Los dos diffs, por separado y sin marcadores, muestran con total claridad qué hizo cada rama respecto al punto de partida. Es la técnica que salva los conflictos grandes: en lugar de leer un fichero mezclado, se leen los dos cambios por separado.

git merge --abort
git status
On branch main
nothing to commit, working tree clean

Todo como estaba.

Conclusión

Los conflictos han dejado de ser un misterio:

  • Un conflicto no es un error. Ocurre cuando las dos ramas cambiaron las mismas líneas de forma distinta desde el ancestro común. Que dos personas toquen el mismo fichero no basta: tienen que chocar en las mismas líneas.
  • Git escribe marcadores en el fichero: <<<<<<< abre tu versión, ======= separa y >>>>>>> cierra la entrante. Con merge.conflictStyle en diff3 o zdiff3 aparece además el bloque ||||||| con el contenido del ancestro, que es la información que de verdad permite resolver bien.
  • git status es la brújula: la sección Unmerged paths, el tipo de conflicto (both modified, deleted by us…) y los códigos cortos (UU, DU, AA). Internamente, el índice guarda tres etapas del fichero, accesibles con git show :1:, :2: y :3:, y MERGE_HEAD es lo que define que hay una fusión en curso.
  • Resolver es dejar el fichero como debe quedar y marcarlo con git add (o git rm si no debe existir). No hay comando mágico: hay una decisión humana, y a menudo la respuesta correcta es combinar las dos aportaciones.
  • Los atajos git checkout --ours/--theirs <fichero> (o git restore --ours/--theirs) sustituyen el fichero entero por una de las versiones. Útiles para ficheros generados; peligrosos si el fichero tenía además cambios bien fusionados.
  • Nunca estás atrapado: git merge --abort restaura el estado previo por completo. --quit sale de la fusión conservando el desorden, y es para casos avanzados.
  • git mergetool abre una herramienta de varios paneles con base, ours, theirs y resultado, y marca el fichero como resuelto al guardar.
  • Los conflictos de árbol (borrado/modificado, añadido/añadido, renombrados) no llevan marcadores: se resuelven declarando qué debe existir.
  • git rerere memoriza resoluciones y las reaplica cuando el mismo conflicto reaparece.

Lo que viene

El proyecto está en buena forma: main contiene el contador, el filtro, la exportación a CSV, el orden alfabético y el mensaje de lista vacía. Pero el repositorio de Ana empieza a estar hecho un desastre. Tiene ramas de funcionalidades ya integradas, ramas de experimentos abandonados, ramas cuyo nombre nadie recuerda y una que se llama simplemente prueba.

En la última lección del módulo, Gestión de Ramas, pondremos orden: listar las ramas con información útil (-v, --merged, --no-merged, formatos a medida ordenados por fecha), renombrar, borrar con seguridad (-d frente a -D) y entender exactamente qué significa que Git se niegue a borrar una rama. Veremos también las convenciones de nombres —qué caracteres admite Git y qué prefijos usa la gente— y cerraremos el módulo con el problema que llevamos toda la lección esquivando: todo esto ocurre en un único portátil.

Dominando Git: De Principiante a Avanzado

Módulo 1: Introducción a Git

Módulo 2: Operaciones Básicas de Git

Módulo 3: Ramas y Fusión

Módulo 4: Trabajando con Repositorios Remotos

Módulo 5: Operaciones Avanzadas de Git

Módulo 6: Herramientas y Técnicas de Git

Módulo 7: Estrategias de Colaboración y Flujo de Trabajo

Módulo 8: Mejores Prácticas y Consejos de Git

Módulo 9: Solución de Problemas y Depuración

Módulo 10: Git en el Mundo Real

© Copyright 2026. Todos los derechos reservados