martes, 19 de septiembre de 2017

Merge vs Rebase

Cuando se trabaja en equipo todo el mundo acepta con naturalidad la necesidad de un coding standard o similar para buscar la mayor legibilidad, consistencia y mantenibilidad del código. Igualmente, y en relación al control de código fuente, debería haber un consenso sobre que flujo de trabajo y tipo de estrategia de merge seguir.

Trabajando con git, tarde o temprano hay que elegir, merge o rebase, o para ser más exactos, estrategia de merge de tipo fast-forward o tipo recursive.

Cualquier opción es válida y ambas tienen ventajas y desventajas.

En mi caso, intentaré explicar que es merge y rebase.

TL;DR

Merge con estrategia recursive hace explícita la integración de ramas y además mantiene contiguos todos los commits de una característica, por el contrario, dificulta la legibilidad del repositorio y podemos acabar fácilmente con un guitar hero http://devhumor.com/media/i-fucked-up-git-so-bad-it-turned-into-guitar-hero

Por otro lado, rebase mantiene una historia lineal del proyecto, pero está sujeto a más situaciones comprometidas, es más propenso a meter la pata. La regla de oro es no hacer rebase sobre commits públicos, o al menos no hacerlo sobre commits sobre los que cualquier miembro del equipo haya basado su trabajo. Además, con rebase perdemos la trazabilidad de cuando se integró una rama.

En mi caso apuesto por merge, forzando incluso con --no-ff para evitar una estrategia fast-forward y así ser totalmente explícito de cuando se integró una rama. Sin embargo, para descargar cambios del repositorio remoto me parece adecuado usar git pull --rebase para evitar ciertos commits extra de merge que no aportan valor al repositorio.

Por último, el rebase de tipo clean-up es gratis en local y parece una buena práctica ¿Por qué no usarlo?

Para los ejemplos, asumo que no tienes guardado nada de valor en el directorio C:\Temp y deberían poderse seguir de principio a final si no te cambias de directorio. Además, y aunque normalmente los ejemplos se ejecutan por comandos, también mencionaré de vez en cuando a SourceTree porque es el cliente de git que uso habitualmente.

Para hacer nuestro primer merge:

mkdir C:\Temp
cd C:\Temp
mkdir example
cd example
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"
git checkout -b develop
echo F3 > F3.txt
git add .
git commit -m "C3"
git checkout master
echo F4 > F4.txt
git add .
git commit -m "C4"

Aquí la historia se ha bifurcado porque se han hecho commits en distintas ramas desde un mismo commit base.

clip_image001

Para integrar los cambios de develop en master tenemos 2 opciones: merge o rebase.

Los mantras oficiales (grábatelos a fuego) son:

  • merge “me voy a y cojo de”
  • rebase “estoy en y rebaso a”

Por ahora veremos merge y más adelante rebase

git checkout master
git merge develop                
clip_image002

Ahora el commit de merge tiene 2 padres. El primero es el commit de la rama donde se estaba (master) y el segundo es el commit de la rama que se integró (C3 de develop). El orden es importante porque después (y si fuera necesario) podríamos ver sólo los commits de una rama (excluyendo los que vinieron por integración de otras ramas) con git log --oneline --first-parent. Otra opción muy socorrida es git log --oneline --no-merge que muestra el log sin ningún commit de merge.

Para probar otro tipo de merge, tenemos que volver a la situación anterior, como si no hubiéramos hecho el merge. Para ello bastaría con retroceder la rama actual un commit con git reset --hard master^, el problema es que en Windows hay que poner dos acentos circunflejos porque hay que escapar ciertos caracteres https://stackoverflow.com/questions/9600549/using-a-caret-when-using-git-for-windows-in-powershell, así que en vez de poner git reset --hard master^ será mejor poner git reset --hard master~ que para el caso es lo mismo.

git reset --hard master~
git checkout develop
git merge master
clip_image004

Como vemos, el cambio más reseñable es que el mensaje por defecto del commit de merge es distinto.

  • Merge branch ‘<rama-mergeada>’ si se está integrando en master
  • Merge branch ‘<rama-mergeada>’ into <rama> si se está integrando en cualquier otra rama

Si estamos trabajando solos, esos serían los dos tipos de commits de merge que deberíamos encontrarnos normalmente.

Podría haber un tercero, si un merge que podría haber sido resuelto con la estrategia “fast-forward” (esto es, simplemente avanzar el puntero sin crear ningún commit de merge) lo forzamos para que sí haya un commit de merge con --no-ff

cd c:\Temp
rm -rf *
mkdir example
cd example
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"
git checkout -b develop
echo F3 > F3.txt
git add .
git commit -m "C3"
git checkout master                     
clip_image005
git merge develop

clip_image006

clip_image007

Si ahora hacemos esto otro

git reset --hard master~
git merge develop --no-ff                    

clip_image008

clip_image009

Está claro, si la estrategia de merge es recursive será cuando veamos un commit de merge, si es fast-forward no.

--no-ff desde Source Tree equivale a marcar la casilla “Create a new commit even if fast-forward is possible”

clip_image010

Sin embargo, si trabajando en equipo, pueden aparecer otros commits de merge cuando nos traigamos los cambios del remoto con git pull

Para trabajar con un remoto y no tener que crearlo en github, bitbucket o similar, podemos usar un repositorio bare y así todo queda en casa.

cd C:\Temp
rm -rf *
mkdir central
cd central
git init --bare
cd ..
mkdir example1
cd example1
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"
git remote add origin C:\Temp\central
git push -u origin master
cd ..
git clone C:\Temp\central example2                    

En este momento ya tenemos un repositorio remoto y 2 repositorios locales, ¡todo listo!

cd example1
echo F3 > F3.txt
git add .
git commit -m "C3"
git push                    
clip_image011
cd ..
cd example2
echo F4 > F4.txt
git add .
git commit -m "C4"                      
clip_image012

Si hacemos un git push desde example2 fallará porque no estamos al día, hay cambios en el remoto que no nos hemos bajado.

git fetch                     
clip_image013

Para ver git pull me parece interesante comentar la ventana de SourceTree

clip_image015

Si no está marcado “Commit merged changes immediately”, sería un git pull --no-commit, luego tendríamos los cambios en el working copy pero no se hará el commit.

clip_image016

“Create a new commit even if fast-forward is possible” indicará si agregar o no el modificador --no-ff

“Rebase instead of merge (WARNING: make sure you haven’t pushed your changes)” ejecutará git pull --rebase

En caso de no estar marcada ninguna opción, simplemente será un git pull

clip_image017

La diferencia más notable en el commit de merge es que ahora el mensaje predeterminado es “Merge branch ‘<rama>’ of ‘dirección_remoto’

git pull es lo mismo que hacer git fetch + git merge origin/<current-branch>, la única diferencia sería el mensaje predeterminado del commit de merge, que ahora sería “Merge remote-tracking branch 'origin/<rama>'”

clip_image018

Y si hubiéramos lanzado git pull --rebase, nuestro commit C4 se aplica en lo alto de la rama evitando un commit extra de merge

clip_image019

Y llegados a este punto ya tenemos claro de dónde vienen los distintos commits de merge, cuando se pueden producir y como reconocerlos atendiendo a sus mensajes predeterminados.

Antes de meternos con rebase es importante entender el concepto de reescribir la historia y porqué tiene mucho peligro.

Seguramente haya muchos más comandos que reescriban la historia, pero los más habituales son git commit --amend y git rebase

cd C:\Temp
rm -rf *
mkdir example
cd example
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"                    
clip_image020

Ahora nos damos cuenta de que en el commit C2 el texto del fichero F2.txt no es correcto o no nos gusta el mensaje del commit o nos hemos dejado algún fichero en el index o sin guardar https://marketplace.visualstudio.com/items?itemName=PaulCBetts.SaveAllTheTime y queremos que sea parte del commit y, sea como sea, no queremos hacer un nuevo commit sino reemplazar el último. Esto se consigue con --amend que combina el staging area/index con el contenido del último commit y reemplaza el último commit. Con --no-edit simplemente decimos que nos vale el mensaje del último commit.

echo F2_upated > F2.txt
git add . 
git commit --amend --no-edit                    

El identificador del commit ha cambiado, hemos reescrito la historia.

clip_image021

En SourceTree también podemos hacer --amend

clip_image022

Como decía, el tema está en que hemos reescrito la historia, antes el commit era a59987c y ahora es 3d41599 (de nuevo, será otro en tu equipo), es decir, son 2 commits completamente diferentes a ojos del repositorio, luego si ya eran públicos (estaban subidos al remoto), cualquier otro commit que los estuviera referenciado ya no los va a encontrar y habrá lío garantizado…

Vamos a reproducir un lío gordo y así lo vemos.

cd C:\Temp
rm -rf *
mkdir central
cd central
git init --bare
cd ..
mkdir example1
cd example1
echo F1 > F1.txt
git init
git add .
git commit -m "C1"
echo F2 > F2.txt
git add .
git commit -m "C2"
git remote add origin C:\Temp\central
git push -u origin master
cd ..
git clone C:\Temp\central example2
cd example2
echo F3 > F3.txt
git add .
git commit -m "C3"                     

Aquí estamos a nivel, todo bien.

En example1

clip_image023

En example2, C3 tiene como padre el commit C2

clip_image024

Pero ahora el señor de example1 va a reescribir la historia haciendo un commit --amend y entonces C2 ya no será el mismo commit sino algún otro con un identificador distinto

cd ..
cd example1
echo F2_1 > F2_1.txt
git add .
git commit --amend -m "C2_updated”                    

Y en example1 tenemos lo siguiente:

clip_image025

¡Uy, que feo!, aparece C2_updated (a quien apunta master) pero sigue estando C2 (a quien apunta origin/master). De hecho, C2 tiene el mismo identificador, sigue estando ahí porque está en el remoto (es público).

Lógicamente tampoco el señor de example1 puede hacer un git push, no está al día, origin/master ya no está apuntado a master.

¿Qué opciones tiene example1? Pues hacer merge de origin/master en master

git merge origin/master master

Que podría o no darle un conflicto en función de que como haya sido su --amend, en nuestro ejemplo no dará conflicto, hemos añadido un fichero.

clip_image026

Estoy seguro de que el señor de example1 no quería este commit de merge… pero bueno, igualmente hace un push

                    git push
            

¿Y cómo queda el señor de example2?

cd ..
cd example2
git fetch                

Pues directamente le han hecho el lío, lo que él pensaba era un valor seguro, un commit público que estaba en el remoto grabado a piedra, pues ya no lo es tanto, sigue estando ahí pero ya no es lo mismo… se ha roto la regla de oro, no reescribir la historia en commits públicos

clip_image027
git pull
clip_image028

Y ahora ya puede hacer

git push

Gracias, señor de example1, acabas de afear la historia del repositorio y además has agregado una complejidad que me acordaré de ti por siempre… por lo menos no has hecho un git push –force, en fin…

La idea es que reescribir commits que son públicos es una pésima idea y fuente de problemas, no se hace y punto, es otro mantra.

Y por fin llegamos al rebase, que mola mucho… pero reescribe la historia, así que cuidado.

Rebasar es “mover” commits a un nuevo commit base.

Para integrar cambios, se puede hacer con merge o con rebase + merge, que siempre será fast-forward… y es aquí donde está el debate ¿merge o rebase? Según a quién preguntes te dirá una cosa u otra

Se hace rebase por alguno de estos motivos:

  • Mantener una historia “lineal” del proyecto para mejorar la legibilidad
    • Aunque se pierda trazabilidad en la integración de ramas porque no hay commits de merge
  • Hacer un clean-up de mi historia local con un rebase interactivo

Hacer un clean-up de mi historia local con un rebase interactivo.

Hacer un clean-up no parece que suscite debate (siempre y cuando se haga en local, en remoto no se hace, recuerda que reescribe la historia, el mantra…). Lo hacemos porque en local podríamos haber hecho n commits (porque nos apeteció, es tu repo, ahí no manda nadie) pero cuando queremos compartir los cambios con el resto (subir al remoto) queremos pasarle un poco la mopa para que quede todo bonito y apañado.

Por ejemplo, estoy trabajando en mi local con aparente desgana:

cd C:\Temp
rm -rf *
mkdir example
cd example
git init
echo example > example.txt
git add .
git commit -m "Initial commit"
echo F2 > F2.txt
git add .
git commit -m "C2"
echo F3 > F3.txt
git add .
git commit -m "C3"
echo F4 > F4.txt
git add .
git commit -m "C4"
echo F_fake > F_fake.txt
git add .
git commit -m "Fake"
echo F5 > F5.txt
git add .
git commit -m "C5"
echo F1 > F1.txt
git add .
git commit -m "C1"                    
clip_image029

Sinceramente, no voy a subir eso al remoto para que lo vean mis compañeros, los commits están desordenados, tengo un commit fake y además no me he preocupado por los comentarios, ¡ni yo sé que he incluido en cada commit1

Se podría hacer el rebase interactivo por comandos, pero con franqueza, una herramienta como SourceTree nos facilitará la vida.

clip_image030

Y pasamos de

clip_image031

A esto otro (haciendo squash, ordenando, eliminado y cambiando mensajes de commits)

clip_image032

clip_image033

Esto es otra cosa, ¡ya puedo subir mis cambios!

Si quieres hacerlo interactivo por consola, puedes hacerlo con git rebase [-i] <commit_base>, pero se va a abrir casi seguro VIM y ahí te apañes tú… Si por ejemplo “Initial commit” tuviera el identificador c7f0016

git rebase -i c7f0016
            
clip_image035

El otro sabor de rebase (el que entra en debate con merge) es el “no interactivo”, el que se rige por el mantra “estoy en y rebaso a”. Lo veremos con un ejemplo típico de feature branch.

cd C:\Temp
rm -rf *
mkdir example
cd example
git init
echo example > example.txt
git add .
git commit -m "Initial commit"
echo A1 > A1.txt
git add .
git commit -m "A1"
echo A2 > A2.txt
git add .
git commit -m "A2"
git checkout -b features/B
echo B1 > B1.txt
git add .
git commit -m "B1"
echo B2 > B2.txt
git add .
git commit -m "B2"
echo B3 > B3.txt
git add .
git commit -m "B3"
git checkout master
echo A3 > A3.txt
git add .
git commit -m "A3"
echo A4 > A4.txt
git add .
git commit -m "A4"
git checkout features/B                                  
clip_image036
git rebase master 

clip_image037

clip_image038

El rebase ha cogido todos los commits de features/B y los ha puesto a continuación del último commit de master, todo lineal, muy bonito… pero ha reescrito la historia de los commits de features/B

Si estás trabajando sólo tú en esa rama en este momento (y no hay ningún compañero pendiente de subir cambios al repositorio en esa rama, y tampoco nadie creó una rama a partir de un commit de esa rama), no hay problema, si no lío…

Desde Source Tree, hubieramos hecho

clip_image039

clip_image040

Por cierto, si el rebase da conflictos, los resolvemos y después git rebase --continue o git rebase --abort

Y si queremos deshacer el rebase podemos usar git reset --hard ORIG_HEAD https://stackoverflow.com/a/692763

Y después de este tipo de rebase tenemos que hacer un merge, que será siempre fast-forward

git checkout master
git merge features/B
clip_image041

Y nada más, espero que te sirva este post porque, lo que es seguro, es que yo sí volveré a él cada vez que tenga que hablar sobre este tema, seguramente para refrescar el porqué de las cosas o bien para actualizarlo porque he descubierto que algo no funcionaba como pensaba.

Un saludo!