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;DRMerge 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.
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
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
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
git merge develop
Si ahora hacemos esto otro
git reset --hard master~ git merge develop --no-ff
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”
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
cd .. cd example2 echo F4 > F4.txt git add . git commit -m "C4"
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
Para ver git pull me parece interesante comentar la ventana de SourceTree
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.
“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
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>'”
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
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"
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.
En SourceTree también podemos hacer --amend
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
En example2, C3 tiene como padre el commit C2
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:
¡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.
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
git pull
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"
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.
Y pasamos de
A esto otro (haciendo squash, ordenando, eliminado y cambiando mensajes de commits)
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
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
git rebase master
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
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
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!
No hay comentarios:
Publicar un comentario