Desarrollo colaborativo con Git Rebase

Ignacio Gonzalez y Matheus Marabesi

Ver biografía y publicaciones del autor

Git es una herramienta base con la cual los desarrolladores interactúan de manera cotidiana por diferentes motivos, siendo el versionado de código fuente su aplicación más común. Desde una perspectiva simplificada, el versionado de código nos hace recordar la manera en que solíamos compartirlo en el pasado mediante FTP, ZIP, TAR y otros formatos. No obstante, este enfoque no es el más apropiado cuando hablamos del desarrollo de aplicaciones modernas en equipo, ya que carece de las funcionalidades necesarias para tal propósito.

En esta publicación, exploramos detalladamente Git, centrándonos específicamente en git merge/git rebase, una de las funcionalidades más populares y, al mismo tiempo, complejas que posibilita una colaboración efectiva en equipos de desarrollo.

Contexto

Antes de sumergirnos en la magia del rebase, examinemos cómo se lleva a cabo la colaboración con ramas, o mejor dicho, "contextos".

Consideremos el caso de un equipo que sigue el flujo de trabajo git-flow y desea mergear la rama de una nueva funcionalidad (A1) en la rama develop. En este escenario, Nacho y Pepe componen al equipo.

Pepe y Nacho crean una rama desde develop con 4 commits y luego Nacho comienza a trabajar en una nueva funcionalidad: A1.

Este flujo puede interpretarse desde diversos contextos, como se ilustra en la imagen anteriormente expuesta.  Es decir, desde el punto de vista de la rama develop, A1 se encuentra a un commit por delante. En cambio, desde la perspectiva de la rama A1, develop está a 1 commit detrás.

Los contextos son otra forma de ver las ramas, las cuales nos ofrecen una comprensión más clara de los historiales de commits que gestiona Git.

Cuando te encuentras en el contexto A1 (desde git, haciendo un git check), los cambios no afectan de ninguna manera al contexto develop. Es como si ambos estuvieran en mundos paralelos, pero compartiendo su historial y partiendo del mismo punto base.

No es sino hasta que los fusionas (mediante git merge) que el contexto develop se entera de que existe en el contexto A1, y viceversa. De hecho, podrías eliminar todo en develop y el contexto A1 permanecerá intacto. Se podría decir que A1 es una especie de duplicado de develop con sus propios commits adicionales.

Sin embargo, Git es inteligente y no crea duplicados (esto está relacionado con los grafos); en cambio, los contextos (o ramas) son simplemente referencias a commits, como se ilustra en la imagen a continuación:

Al mergear A1 en develop, unimos las dos líneas temporales, es decir, toda la funcionalidad creada en A1 pasa a formar parte de develop, y desde allí se pueden crear nuevas ramas para funciones o correcciones de bugs. De hecho, es común que Git genere un commit con lo que se ha fusionado, pero aquí ya estaríamos entrando en la materia de tipos de merges y, de momento, este no es el objetivo del blog.

Pero, ¿qué ocurre si nos apetece combinar un commit realizado en A1 antes de hacer el merge? ¿O eliminar un commit innecesario? ¿O ajustar los commits para que tengan un historial lineal?

En el siguiente paso, abordaremos la conocida técnica de rebase.

Rebase

Ahora, considerando lo dicho, imaginemos que Nacho sigue implementando cosas en A1, mientras que Pepe sigue desarrollando y añadiendo elementos en develop. El estado general llega a esta etapa:

Cómo queríamos mantener la historia de manera lineal, evitando un commit de merge (que básicamente uniría c10 y c7 para integrar A1 en develop), decidimos hacer un rebase.

Los pasos que seguimos fueron:

  • git checkout A1
  • git rebase develop
  • Resolvemos conflictos (si los hay).

Lo que hace el comando 'git rebase develop' es traer commits de la rama develop a A1 de la siguiente manera:

Aquí concluyen todas las explicaciones, pero si hasta ahora no has logrado comprender lo que está sucediendo...así es como funciona: git rebase intenta organizar el historial de manera lineal antes de fusionarlo con develop, y para lograrlo, lo que hace es traer todos los commits de develop debajo de los cambios de A1.

Hacer un rebase localmente antes de compartir la rama no supone ningún problema, pero al tener la rama compartida en el servidor (lo que llamamos remoto), la historia cambia. Volvamos al ejemplo anterior con las dos ramas, como se muestra en la imagen a continuación:

Nacho pusheó los cambios (c7) y Pepe ha estado trabajando en pushear c10. Ahora nos enfocaremos más en A1:

Sucesivamente, Nacho ejecuta el rebase, resolviendo conflictos a nivel local (si los hay), y llega al siguiente resultado:

En esta situación, nos enfrentamos a un problema, ya que hay una discrepancia entre la versión remota y la local para Pepe. Él todavía tiene la versión anterior al rebase realizado por Nacho:

Git es lo suficientemente inteligente para reconocer esto, por lo cual, cuando realizas un rebase, necesitas hacer un push force hacia el remoto. Es importante tener en cuenta que las ramas tienen el mismo nombre, pero ya no comparten el mismo historial, y deseas que tu versión local coincida con la remota.

Haciendo un push force lograrás que las versiones locales y remota sean iguales, como se ilustra en la imagen a continuación:

Nacho, por su parte, ha completado todo lo que tenía que hacer, pero ahora nos enfrentamos al otro lado de la moneda: el entorno local de Pepe que aún no se ha actualizado.

Si Pepe realiza un pull en la rama, experimentará un inconveniente considerable, ya que el remoto indicará que hay discrepancias con su entorno local. Además, si hizo algún cambio durante este periodo, es probable que pierda esa información, ya que la versión 'verdadera' es la que refleja el control remoto.

¿Force o Force with lease?

La idea detrás del force radica en que si tengo esto en el remoto:

Y por alguna razón, consideramos que c2 no es correcto, así que lo eliminamos, introducimos C3 y C4, y eso es lo que queremos como versión correcta:

En el momento en que intentemos llevar esto al remoto, no se nos permitirá debido a la divergencia entre las ramas.

Si lo desea y está seguro, puede git push --force esto, y no importa lo que haya en develop, su rama lo anulará.

Ahora bien, ¿qué sucede si alguien más, mientras yo arreglaba mi local, pusheo un cambio en el remoto? Supongamos que nuestro entorno local se asemeja a la imagen anterior, pero en el remoto se ve como la siguiente:

Entonces, al ejecutar git push -f, el remoto perderá el commit c5. Para evitar este escenario, puedes utilizar git push-force-with-lease y, en este caso, el push será rechazado para evitar perder esa información.

Rebase y conflictos derivados

Cuando haces un rebase, la forma de afrontar conflictos es ir commit por commit para resolverlos, lo cual puede resultar un poco tedioso.

Cada vez que se resuelve un conflicto, usamos git rebase --continue para confirmar que estamos satisfechos con el resultado y pasar al siguiente commit. A veces, las cosas pueden salir mal y complicarse, para esos casos siempre existe la opción de cancelar el rebase usando git rebase --abort.

Rebase interactivo: diversión para todos

Aunque esto queda fuera del focus de esta publicación, quería mencionar brevemente la opción interactiva de git rebase, que se activa con el comando git rebase -i. Aquí, Git te ofrece flexibilidad para modificar y operar con múltiples commits al mismo tiempo, permitiendo acciones como squash, cambiar mensajes, entre otras. ¡Es una herramienta verdaderamente útil!

Conclusiones

En este blog post, hemos explorado, a través de ejemplos, cómo rebase y merge son estrategias utilizadas para integrar código en entornos de trabajo colaborativos. Además, los ejemplos que hemos usado se han centrado en situaciones cotidianas, proporcionando una comprensión práctica y aplicable a la realidad de los desarrolladores que trabajan en equipo.

New call-to-action