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
- Por qué se produce un conflicto
- Provocando un conflicto de verdad
- Los marcadores de conflicto
- El estilo
diff3yzdiff3: ver también el ancestro - Orientarse durante el conflicto:
git status git diffdurante un conflicto: las tres versiones- Resolver y marcar como resuelto
- Atajos:
--oursy--theirspor fichero - Abortar:
--aborty--quit git mergetool: herramientas gráficas- Conflictos especiales: borrado frente a modificado
git rerere: no resolver dos veces lo mismo
- 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 |
| Sí | No | Gana ours |
| No | Sí | Gana theirs |
| Sí | Sí, igual | Gana esa versión común |
| Sí | 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.
- 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:
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();
}[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.
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();
}[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:
Y ahora, el de Bruno:
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.
- 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:
-
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.
-
Un fichero puede tener varios bloques de conflicto, cada uno con su juego de marcadores. Hay que resolverlos todos.
-
Los marcadores son texto normal. No hay nada mágico en ellos: puedes editarlos, borrarlos o dejarlos (si los dejas,
git committe avisará, pero puedes forzarlo y meter basura en el proyecto, así que revisa siempre). -
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.
- El estilo
diff3 y zdiff3: ver también el ancestro
diff3 y zdiff3: ver también el ancestroGit puede mostrar tres secciones en lugar de dos, añadiendo la del ancestro común. Se controla con la opción merge.conflictStyle:
Abortamos y repetimos la fusión para verlo:
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
ifdelante y no tocó elforEach.
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:
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 zdiff3Encaja con lo que ya configuraste en la lección 01-06 y no tiene ninguna contrapartida.
- Orientarse durante el conflicto:
git status
git statusDurante un conflicto, el repositorio está en un estado especial. git status es tu brújula y cambia por completo:
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:
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:
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:
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:
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.
git diff durante un conflicto: las tres versiones
git diff durante un conflicto: las tres versionesgit diff a secas, durante un conflicto, muestra un formato que no habías visto: el diff combinado.
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.jsTambié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 ramaEsto 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:
- 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.jsY a continuación, abrir la aplicación o pasar las pruebas.
Ahora se marca como resuelto:
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".
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 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.
* 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:
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.
- Atajos:
--ours y --theirs por fichero
--ours y --theirs por ficheroA 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.jsO con los comandos modernos que vimos en la lección 03-02:
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:
Dos avisos importantes:
-
Cogen el fichero ENTERO, no solo la zona conflictiva. Si
app.jstenía además cambios de la otra rama en otra parte del fichero que se habían fusionado bien,--ourslos tira también. Úsalo solo cuando de verdad quieras una versión completa. -
No los confundas con
-X ours/-X theirsde 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 commitY si prefieres empezar de cero un fichero que has estropeado editando:
Restaura el fichero con los marcadores de conflicto originales, como estaba justo después de la fusión fallida.
- Abortar:
--abort y --quit
--abort y --quitSi 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:
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:
--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 |
git mergetool: herramientas gráficas
git mergetool: herramientas gráficasPara conflictos grandes, editar marcadores a mano es incómodo. git mergetool abre una herramienta de tres o cuatro paneles: base, ours, theirs y resultado.
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 vimdiffY una opción que casi todo el mundo acaba activando:
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.
- 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:
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:
- El tipo de conflicto es
modify/delete, nocontent. - 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.
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:
En los dos casos, después:
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:
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.
Las dos ramas crearon el mismo fichero con contenido distinto. Aquí sí 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.
git rerere: no resolver dos veces lo mismo
git rerere: no resolver dos veces lo mismoUn ú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.
¿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:
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:
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í:
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-bCon el estilo por defecto:
function saludar(nombre) {
<<<<<<< HEAD
return "¡Hola " + nombre + "!";
=======
}
function saludar(nombre, apellido) {
return "Hola " + nombre + " " + apellido;
>>>>>>> rama-b
}Con diff3:
<<<<<<< HEAD
function saludar(nombre) {
return "¡Hola " + nombre + "!";
||||||| 8a1d5c3
function saludar(nombre) {
return "Hola " + nombre;
=======
function saludar(nombre, apellido) {
return "Hola " + nombre + " " + apellido;
>>>>>>> rama-bQué 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.jsgit 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-notasCONFLICT (modify/delete): notas.md deleted in HEAD and modified in actualizar-notas. Version actualizar-notas of notas.md left in tree.
Antes de decidir, vemos qué aportaba la rama:
Opción A: mantener el borrado.
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.mdLa 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 fusionada1,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.
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. Conmerge.conflictStyleendiff3ozdiff3aparece además el bloque|||||||con el contenido del ancestro, que es la información que de verdad permite resolver bien. git statuses la brújula: la secciónUnmerged 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 congit show :1:,:2:y:3:, yMERGE_HEADes lo que define que hay una fusión en curso.- Resolver es dejar el fichero como debe quedar y marcarlo con
git add(ogit rmsi 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>(ogit 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 --abortrestaura el estado previo por completo.--quitsale de la fusión conservando el desorden, y es para casos avanzados. git mergetoolabre una herramienta de varios paneles con base,ours,theirsy 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 rererememoriza 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
- ¿Qué es Git?
- Instalando Git
- Terminología Básica de Git
- El Modelo de Datos de Git
- Configurando Git
- Configuración Inicial
Módulo 2: Operaciones Básicas de Git
- Creando un Repositorio
- Clonando un Repositorio
- Flujo de Trabajo Básico de Git
- Preparando y Confirmando Cambios
- Inspeccionando Cambios con git diff
- Visualizando el Historial de Confirmaciones
Módulo 3: Ramas y Fusión
- Entendiendo las Ramas
- Creando y Cambiando Ramas
- Fusionando Ramas
- Estrategias de Fusión
- Resolviendo Conflictos de Fusión
- Gestión de Ramas
Módulo 4: Trabajando con Repositorios Remotos
- Entendiendo los Repositorios Remotos
- Agregando un Repositorio Remoto
- Autenticación con Repositorios Remotos
- Obteniendo y Extrayendo Cambios
- Enviando Cambios
- Rastreando Ramas
Módulo 5: Operaciones Avanzadas de Git
- Rebase
- Rebase Interactivo
- Cherry-Picking de Confirmaciones
- Guardando Cambios Temporales
- Etiquetando Confirmaciones
- Revirtiendo Confirmaciones
Módulo 6: Herramientas y Técnicas de Git
- Usando Git Hooks
- Git Bisect
- Git Blame
- Git Log y Alias
- Submódulos de Git
- Múltiples Copias de Trabajo con git worktree
Módulo 7: Estrategias de Colaboración y Flujo de Trabajo
- Forks y Pull Requests
- Revisiones de Código con Git
- Flujo de Trabajo Git Flow
- GitHub Flow
- Trunk Based Development
- Integración Continua con Git
Módulo 8: Mejores Prácticas y Consejos de Git
- Escribiendo Buenos Mensajes de Confirmación
- Manteniendo un Historial Limpio
- Ignorando Archivos con .gitignore
- Atributos de Fichero con .gitattributes
- Mejores Prácticas de Seguridad
- Consejos de Rendimiento
Módulo 9: Solución de Problemas y Depuración
- Problemas Comunes de Git
- Deshaciendo Cambios
- Resolviendo Divergencias con el Remoto
- Recuperando Confirmaciones Perdidas
- Tratando con Repositorios Corruptos
- Técnicas Avanzadas de Depuración
