* notes / git
Git rebase vs merge
— Una explicación honesta de qué hace cada comando a nivel de grafo de commits, cuándo conviene cada estrategia, la regla de oro que casi nadie respeta y la conversación de equipo que acabó decidiendo nuestra política.
Cada cierto tiempo, en algún canal de Slack de algún equipo, alguien escribe la frase mágica: «oye, ¿hacemos merge o rebase?». Lo que viene a continuación oscila entre la guerra santa y el consejo no solicitado, y rara vez se discute lo único que de verdad importa: qué hace cada operación al grafo de commits y qué consecuencias tiene para quien venga detrás a leerlo. Este artículo es mi intento de poner orden en esa discusión, sin dogmas, a partir del modelo interno de git y de la conversación de equipo que acabó modelando mi postura.
Contexto: git es un grafo, no una pila
Conviene volver al principio. Un commit en git es un objeto inmutable que contiene un árbol (el estado del repositorio en ese instante), uno o más punteros a commits padre, autor, fecha y mensaje. Las ramas no son más que referencias móviles que apuntan a un commit; el checkout a una rama mueve HEAD a esa referencia. Cuando hablamos de fusionar o rebasar, no estamos moviendo carpetas de archivos, sino añadiendo o reescribiendo aristas en un grafo dirigido y acíclico.
Esa precisión es importante porque ambas operaciones, merge y rebase, acaban dejando el árbol de trabajo en un estado equivalente: los mismos archivos con los mismos contenidos. Lo que difieren es la historia que cuentan.
Cómo funciona git merge
Cuando estoy en la rama feature y ejecuto git merge main, git busca el antepasado común entre ambas ramas y aplica una de dos estrategias.
- Si
mainno ha avanzado desde el punto de divergencia, git hace un fast-forward: simplemente mueve la referenciafeatureal commit al que apuntamain. No se crea ningún commit nuevo. El historial queda lineal. - Si ambas ramas han avanzado, git crea un commit de merge con dos padres: el último de
featurey el último demain. Ese commit es la cicatriz visible de la fusión; preserva la topología y permite reconstruir, a posteriori, qué se hizo en cada rama y cuándo se integraron.
La opción --no-ff fuerza el segundo caso incluso cuando el primero sería posible. Eso resulta útil para que cada rama temática deje constancia explícita de su inicio y su final.
Cómo funciona git rebase
git rebase main, ejecutado desde feature, hace algo radicalmente distinto. Toma cada commit de feature posterior al antepasado común, los guarda como parches, mueve feature al final de main, y los aplica uno a uno. El resultado es una secuencia lineal, sin merge commits, en la que parece que el trabajo se hubiese hecho directamente encima de main.
Lo importante: los commits resultantes son nuevos. Tienen distinto SHA, aunque introduzcan los mismos cambios; cambian la fecha del committer, no la del autor; y, en cuanto a la identidad del grafo, son objetos distintos. Esa es la razón por la que rebasar una rama compartida con otras personas crea caos: lo que tú reescribes deja huérfanas las copias que los demás tenían.
Las dos historias, lado a lado
El primer panel muestra el estado de partida: main con tres commits, feature con dos commits derivados del segundo de main. El segundo panel ilustra el merge: nace un nuevo commit M con dos padres, la rama feature queda visible como tal. El tercero ilustra el rebase: los dos commits de feature se reescriben (de ahí el indicador prima) y la historia queda lineal, como si nunca hubiese existido una bifurcación.
Para ver el grafo que produce un merge en vivo, basta este gitGraph de Mermaid:
gitGraph commit id: "C1" commit id: "C2" branch feature commit id: "F1" commit id: "F2" checkout main commit id: "C3" checkout feature merge main id: "M" tag: "merge"
Una sesión real, comando a comando
# punto de partida
git checkout feature
git log --oneline --graph --all
# * d3f2a1 (feature) F2
# * b9c8e7 F1
# | * 5a4b3c (main) C3
# |/
# * 7a8b9c C2
# * 1d2e3f C1
# opción A: merge
git checkout feature
git merge main
git log --oneline --graph --all
# * 7e6f5d (feature) Merge branch 'main' into feature
# |\
# | * 5a4b3c (main) C3
# * | d3f2a1 F2
# * | b9c8e7 F1
# |/
# * 7a8b9c C2
# * 1d2e3f C1
# opción B: rebase (en una copia paralela)
git checkout feature
git rebase main
git log --oneline --graph --all
# * f8e9d0 (feature) F2'
# * 4c5d6e F1'
# * 5a4b3c (main) C3
# * 7a8b9c C2
# * 1d2e3f C1Obsérvese que en la opción B los SHAs d3f2a1 y b9c8e7 han desaparecido, sustituidos por 4c5d6e y f8e9d0. Si alguien más estaba trabajando sobre feature, ahora tendría que rebasear su rama sobre la nueva versión, o usar --force-with-lease para empujar la suya, con todo el riesgo y la conversación previa que eso implica.
La regla de oro y sus matices
La regla clásica reza: no rebasees ramas compartidas. La razón es exactamente la del párrafo anterior: rebasar reescribe, y reescribir lo que otros han clonado es un acto que destruye trabajo ajeno si no se coordina.
Pero, como toda regla, tiene matices:
- Una rama feature personal, todavía no presentada en PR, se puede rebasear cuanto se quiera. Es tu cuaderno; ordénalo como te dé la gana antes de enseñárselo a nadie.
- En una PR ya abierta, se puede rebasear con honestidad si se avisa al revisor.
--force-with-lease(en lugar de--forcea secas) protege contra el caso en que alguien haya empujado encima de lo que vas a sobrescribir. - En
main,developo cualquier rama integradora, la respuesta corta es no. La larga es: nunca, salvo que estés seguro de que sólo tú tienes acceso de escritura y aceptes las consecuencias.
--rebase-merges y la opción de squash
Hay un caso más: rebasear preservando los merges. git rebase --rebase-merges main mueve la rama al final de main pero respeta los commits de fusión internos. Útil para ramas de larga vida con submerges intencionales que se desea mantener.
Y hay una tercera vía a la dicotomía: el squash merge, popularizado por GitHub. Toma todos los commits de una PR, los aplasta en uno solo, y lo añade a main con un único commit de fusión simbólico. La historia local de la rama se preserva sólo en la PR cerrada, no en el repositorio. Es la elección de equipos que valoran un main con tantos commits como PRs mezcladas, ni uno más.
La conversación de equipo que acabó decidiendo nuestra política
En el equipo donde más he trabajado, la discusión duró exactamente dos cafés. Quien defendía el merge sin fast-forward argumentaba que la topología del grafo es información valiosa: permite ver qué se desarrolló en paralelo, qué se integró y cuándo. Quien defendía el rebase alegaba que un main lineal es más fácil de bisecar y de leer en un git log.
Acabamos eligiendo una postura híbrida que sigue rigiendo nuestros repos:
- Dentro de una rama de PR, rebase libre mientras la PR esté abierta, con
--force-with-leasecuando haga falta. - La integración a
mainse hace por squash merge, dejando un único commit por PR con un mensaje cuidado, y enlace a la PR en el cuerpo. - Las ramas de release de larga vida se integran a
mainpor merge --no-ff, para preservar el hito.
Esta política no es la única razonable; es la nuestra, y funciona porque la decidimos juntos.
Hallazgos
- Ni rebase ni merge son superiores en abstracto. Lo son en relación con un contexto: la rama, los lectores futuros, el número de personas trabajando.
- Saber qué hace cada operación al grafo desactiva la mayoría de discusiones doctrinales. Cuando los dos bandos comparten modelo mental, el debate se centra en valores (legibilidad, trazabilidad, simplicidad) y no en supersticiones.
--force-with-leasedebería ser el únicoforceque uno escriba en su vida cotidiana. Pierde diez segundos y gana protección frente a destruir trabajo de otros.
Closure
Si su equipo aún discute rebase o merge como si fuese una cuestión de identidad, propóngales lo mismo que propuse yo: una tarde con un repositorio de juguete, los comandos delante, y git log --graph --all corriendo entre cada operación. Ver el grafo cambiar en directo desactiva más prejuicios que cualquier artículo. Incluido éste.