Uso del stacked diff en Git

Uso del stacked diff en Git


Nuevo
gitproductividad

¿Qué es un stacked diff?

Un stacked diff es una técnica de desarrollo en Git que consiste en dividir una funcionalidad o cambio grande en una serie de cambios más pequeños y dependientes entre sí. Cada cambio se basa en el anterior, formando una pila (stack) de modificaciones que se pueden revisar y fusionar de manera secuencial.

El principal beneficio de utilizar esta técnica radica en la buena práctica de tener pull requests pequeños, autocontenidos y enfocados, lo que permite una revisión del código eficiente y de mayor calidad, además de permitirte avanzar con el desarrollo sin tener que esperar a que los cambios iniciales hayan sido aprobados.

En la siguiente animación se presenta un ejemplo sencillo de cómo se crean y se integran secuencialmente las ramas usando stacked diff:

Stacked diff simple

¿Cuándo conviene usar stacked diff?

Generalmente el stacked diff se utiliza en estas dos situaciones:

  • Terminas de desarrollar el feature 1, empiezas a revisar los requerimientos del feature 2 y te das cuenta que el feature 2 depende del feature 1, por lo que creas o haces un rebase del feature 2 con respecto al feature 1.

  • Terminas de desarrollar un feature grande, pero con el fin de tener pull request más fáciles de revisar y con menores riesgos, decides separarlos en dos features dependientes entre sí.

Esto es una práctica muy común en el manejo de las ramas de desarrollo y muchos desarrolladores lo hacen sin estar conscientes de que básicamente están haciendo un stacked diff; sin embargo, manejar este tipo de técnicas sin las herramientas adecuadas puede generar complejidad innecesaria a la hora de hacer rebase o mergear.

¿Cómo se arma un stacked diff?

Existen varias herramientas para poder gestionar los stacked diff, tales como: graphite y gh stack. Pero lo que todos los desarrolladores tenemos a la mano (y sin ningún costo adicional) es Git, por lo que una de las mejores formas de implementarlo es utilizando los comandos nativos de Git. Para simplificar un poco el ejemplo, asumiremos que creamos un feature grande y lo queremos dividir en 3 features:

# partimos desde la rama inicial, que es la rama objetivo donde irán los cambios
git checkout feature/stacked-diff

# desde stacked diff creamos la rama stacked-diff-1 y agregamos sus cambios
git checkout -b feature/stacked-diff-1
git add . && git commit -m "feat: stacked diff 1"

# desde stacked-diff-1, creamos la rama stacked-diff-2 y agregamos sus cambios
git checkout -b feature/stacked-diff-2
git add . && git commit -m "feat: stacked diff 2"

# desde stacked-diff-2, creamos la rama stacked-diff-3 y agregamos sus cambios
git checkout -b feature/stacked-diff-3
git add . && git commit -m "feat: stacked diff 3"

Luego de haber creado la secuencia de ramas dependientes, podemos situarnos en la rama de stacked-diff-3 y verificar las dependencias entre las ramas:

# recordando que estamos en stacked-diff-3
# hacemos un rebase interactivo hacia la rama objetivo (feature/stacked-diff)
git rebase feature/stacked-diff -i

# el output esperado del rebase interactivo:
pick abcxyz1 feat: stacked diff 1
update-ref refs/heads/feature/stacked-diff-1

pick abcxyz2 feat: stacked diff 2
update-ref refs/heads/feature/stacked-diff-2

pick abcxyz3 feat: stacked diff 3

El update-ref asegura que las ramas seguirán apuntando a los commits correctos después del rebase. Tal como se muestra el rebase interactivo, es como si Git tratara todos los commits como una única línea continua, teniendo acceso a los commits de las 3 ramas con un solo rebase.

Recomiendo configurar git para que siempre enlace las ramas dependientes:

git config --global rebase.updateRefs true

De lo contrario, tendrás que hacer esto cada vez que quieras hacer rebase:

git rebase branch --update-refs

Al olvidar --update-refs, Git no actualiza las ramas intermedias, lo que puede hacer que pierdas la relación entre ellas. Este es un caso típico de como se vería si no existiesen referencias, es un rebase directo hacia la rama objetivo sin poder actualizar las ramas intermedias:

# recordemos que estamos situados en feature/stacked-diff-3
git rebase feature/stacked-diff -i 

# visualización del rebase interactivo
pick abcxyz3 feat: stacked diff 3

Una vez armado el stacked diff en local, podemos proceder a crear cada uno de los pull request y esto se puede hacer en un solo comando:

git push origin feature/stacked-diff-1 feature/stacked-diff-2 feature/stacked-diff-3

To github.com:guzmanem/stacked-diff.git
* [new branch] feature/stacked-diff-1 -> feature/stacked-diff-1
* [new branch] feature/stacked-diff-2 -> feature/stacked-diff-2
* [new branch] feature/stacked-diff-3 -> feature/stacked-diff-3

En el remoto hay que tener el cuidado de apuntar correctamente las ramas, la rama feature/stacked-diff-2 tiene que apuntar al feature/stacked-diff-1 y el feature/stacked-diff-3 al feature/stacked-diff-2.

Stacked diff armado

Una vez armado los 3 pull requests, ya está todo listo para ser revisado.

¿Cómo se resuelven las solicitudes de cambios en pull requests con stacked diff?

La forma ideal de resolver solicitudes de cambios es utilizando rebase, pues mantiene una historia limpia de los commits y es necesaria para mantener el update-ref entre ramas. Supongamos que recibes comentarios en el commit abcxyz1 del feature/stacked-diff-1 y en el commit abcxyz2 del feature/stacked-diff-2, sin la estrategia del stacked diff (--update-refs) lo que se haría habitualmente es lo siguiente:

# rebase del stacked diff 1
git checkout feature/stacked-diff-1
git rebase feature/stacked-diff -i

# visualización del rebase del stack 1 con su respectivo fixup
pick abcxyz1 feat: stacked diff 1
f abcxyz4 fix: reparar primer commit

# rebase del stacked diff 2
git checkout feature/stacked-diff-2
git rebase feature/stacked-diff-1 -i

# visualización del rebase del stack 2 con su respectivo fixup
pick abcxyz2 feat: stacked diff 2
f abcxyz5 fix: reparar segundo commit

# rebase del stacked diff 3
git checkout feature/stacked-diff-3
git rebase feature/stacked-diff-2 -i

# visualización del rebase del stacked diff 3 sin ningún fixup.
# nótese que no se hace realmente nada, solo se hace
# para mantener la relación entre las ramas.
pick abcxyz3 feat: stacked diff 3

Es necesario hacer rebase de todas las ramas, incluso si algunos de sus commits no han cambiado, ya que el primer rebase altera los identificadores de los commits, lo que provoca que las ramas hijas pierdan su referencia al historial original. En general, hacer los rebases uno a uno es confuso, propenso a errores y te puedes ver fácilmente superado si hay mala resoluciones de conflictos o comandos git mal ejecutados. Haciendo uso de --update-refs adecuadamente esto se puede simplificar:

# rebase del stacked diff 3, pero con las referencias de las ramas intermedias
git checkout feature/stacked-diff-3
git rebase feature/stacked-diff -i

# el output esperado del rebase interactivo :
pick abcxyz1 feat: stacked diff 1
f abcxyz4 fix: reparar primer commit
update-ref refs/heads/feature/stacked-diff-1

pick abcxyz2 feat: stacked diff 2
f abcxyz5 fix: reparar segundo commit
update-ref refs/heads/feature/stacked-diff-2

pick abcxyz3 feat: stacked diff 3

Así con solo un rebase logras actualizar las ramas que tuvieron solicitudes de cambios sin mayor complejidad. Todas estas ramas, luego de ser actualizadas, se pueden cargar al origin ejecutando este comando:

# el uso del force es necesario porque recordemos que el rebase cambia todos los códigos
# de los commits implicados.
git push origin feature/stacked-diff-1 feature/stacked-diff-2 feature/stacked-diff-3 --force

Así logramos actualizar todos los pull request y resolvemos los comentarios en cada uno de ellos.

¿Qué consideraciones se debe de tener al utilizar stacked diff?

Si estamos en la situación donde es conveniente utilizar un stacked diff, debemos de tener en cuenta diversos factores para poder realizarlo de forma efectiva:

  • Limitar la cantidad de ramas dependientes, si bien update-refs te permite manejar n ramas, entre mayor cantidad de ramas estés manejando, mayor probabilidad de confusión y de cometer errores. A su vez, es fundamental mantener los pull requests pequeños y fáciles de revisar. Existe un tradeoff notorio entre el tamaño del pull request y la cantidad de ramas del stacked diff. Para definir esto, usualmente dependerá de la naturaleza del feature: no es lo mismo tener un stacked diff de 10 ramas corrigiendo estilos que un stacked diff de 3 ramas modificando procesos críticos de tu aplicación.

  • Las ramas deben de ser dependientes entre sí de manera funcional, es decir, el feature 2 no puede funcionar sin la existencia del feature 1. No tener esta condición puede volver injustificado el uso del stacked diff.

  • Las ramas deben de tener la menor cantidad de “dependencia conceptual” o “dependencia de revisión”, es decir, no debes de entender completamente el feature 1 para poder revisar el feature 2, aunque estos sí dependan de forma funcional. Esto es importante, pues si todos las ramas son 100% dependientes de forma conceptual, todos los revisores estarán obligados a revisar todos los pull requests para poder aprobarlos con confianza, lo cual puede incurrir en una disminución drástica en la rapidez de las revisiones.

  • A menos que estés en la situación en la que hiciste un feature grande y luego lo separaste, no tienes que esperar terminar el feature 2 para pedir la revisión del feature 1, si ya tienes el feature 1 listo ¡mándalo a revisión! Si la revisión del equipo es lo suficientemente ágil o el pull request no es tan complicado, es probable que al terminar el feature 2 ya el feature 1 esté listo para mergear, así evitas tener una gestión de ramas tan compleja.

  • Habitualmente, lo ideal es que se empiece a mergear desde la primera rama creada, esto se puede hacer de forma continua sin peligro de romper production si los pull request fueron planificados de esa forma o si tienes una estrategia de feature flags que te respaldan. Si tienes 3 stacks y mergeas desde el último, obtendrás un pull request enorme antes de mergear a la rama objetivo. Si bien todo el código estaría revisado a ese punto, hay riesgos e inseguridades asociadas a dejar todo en un solo pull request final (resolución de conflictos mal hechos, commits no testeados, entre otros), por lo que estarás tentado en hacer una revisión general antes de mergear.

  • Evitar lo máximo posible stacked diff compartido con otro desarrollador, como la estrategia está fuertemente arraigada en utilizar rebase, hacer rebase entre dos o más desarrolladores puede volver muy complejo la gestión de las ramas y se pueden cometer fácilmente diversos errores. Pensemos que luego de un rebase hay que hacer un push --force, por lo que fácilmente le puedes borrar el código al otro desarrollador si te descuidas. En diversos escenarios, es mejor esperar que el otro desarrollador haga merge primero.

Conclusión

Aunque el stacked diff es una estrategia poderosa para gestionar pull requests más pequeños y enfocados, no siempre resulta ser la opción más adecuada. En la mayoría de los casos, lo más recomendable es trabajar con ramas completamente independientes, lo que facilita el merge sin necesidad de esperar la aprobación de otras ramas. Muchas veces esto se puede lograr con una buena planificación previa del desarrollo, por ejemplo, a través de una planificación anticipada con herramientas como diagramas PERT. El stacked diff cobra sentido cuando se enfrentan cambios muy grandes o bloqueos por dependencias, mejorando sustancialmente la productividad al evitar tiempos muertos en la creación y revisión de pull requests.