Navegando entre commits en Git, revertir cambios y alternativas al merge con rebase y cherry-pick

Finalizamos esta serie de Git/GitHub mediante un apartado en el que aprenderás a moverte por los commits, es decir, a jugar con la temporalidad del programa, retroceder en el tiempo (deshacer commits), reescribir la historia, a corregir errores y a ver otras alternativas a git merge.

Te recomendamos que te pases por nuestro artículo de Dominando las ramas en Git antes de continuar, pues se va a utilizar el mismo repositorio.

Para este taller es necesario utilizar la terminal de macOS/Linux o Git Bash de Windows y opcionalmente tener Python con Anaconda instalado. Revisa el artículo de instalación y el de configuración si todavía no lo has hecho.


Git – Navegar entre commits

HEAD

Quizá nos interese echar un vistazo a una versiones anteriores. Aunque antes de empezar a navegar entre commits, tenemos que saber dónde estamos. ¿Dónde está apuntando Git? ¿A qué rama? ¿A qué commit? ¿Local o remoto?

Esto lo hacemos mediante HEAD, que representa el estado actual del repositorio y determina en qué punto de la historia se encuentra tu proyecto. Puedes mover HEAD cambiando de rama, realizando un checkout a un commit específico, o realizando operaciones como merge o rebase.

Cuando haces un commit, HEAD se mueve automáticamente para apuntar al nuevo commit que acabas de crear. Esto significa que HEAD siempre señala el commit más reciente de la rama actual, a no ser que lo movamos.

git checkout main
git log
Resultado de git log para ver donde apunta HEAD
Resultado de git log para ver donde apunta HEAD

Vemos que HEAD apunta a main, y en concreto a su último commit. Con checkout podremos ir moviendo esa referencia. Ya lo hicimos para movernos entre ramas, ahora lo haremos entre commits.

Estos tres comando serían equivalentes

git checkout HEAD^
git checkout HEAD~1
git checkout 65b5edc

El carácter ^ significa “vete un commit hacia atrás”. Podemos poner también ^^ para dos commits hacia atrás, y para más commits lo recomendable es usar ~X, siendo X el número de retrocesos. En cuanto a 65b5edc, no es más que una abreviatura del hash, que corresponde al commit 65b5edc6368216bd590aa11031ff07f6fded92c5. Git nos lo permite hacer para no tener que lidiar con todo el hash.

Cuando HEAD apunta directamente a un commit específico en lugar de apuntar a una rama, se denomina detached HEAD. Es importante tener en cuenta que trabajar en un estado de detached HEAD puede ser peligroso si no se tiene cuidado, ya que los nuevos commits no estarán respaldados por una rama, y pueden perderse fácilmente. Por lo tanto, se recomienda crear una nueva rama en el punto en el que te encuentras, o hacer checkout a otra existente para evitar la pérdida de trabajo.

Y lo más importante cuando estés probando HEAD, recuerda a lo que hemos venido. Investiga cómo son tus archivos mientras te vayas moviendo entre commits, ya que irán cambiando según la versión.

Ramas

Al igual que con los commits, podemos movernos libremente entre ramas, hashes de commits y ramas remotas:

git checkout fix_bug_amount
git checkout HEAD^
git checkout 65b5edc
git checkout remotes/origin/fix_bug_amount
git checkout main

Con estas operaciones hemos conseguido cambiar de rama, movernos entre commits previos de esa misma rama, e incluso ver cómo es la copia de la rama remota fix_bug_amount (origin/fix_bug_amount).

Si quieres profundizar más en el tema, te recomendamos la documentación oficial de Git.


Deshacer commits – git reset, git revert

¿Qué ocurre cuando tenemos versiones (commits) que no aportan nada y queremos eliminar? Tenemos dos opciones:

  1. Reset: con reset le decimos a Git a qué commit queremos volver, eliminando todo lo que haya posterior a ese commit. Esto es apropiado si estamos trabajando con una rama local, puesto que si eliminamos commits sobre los que trabajan compañeros, va a haber una inconsistencia en el árbol de versiones.
  2. Revert: este comando es adecuado si trabajamos con más gente, porque no elimina los commits que queremos deshacer, y conseguimos el mismo resultado que el reset. Lo que hace es crear un commit nuevo equivalente al commit donde queríamos retroceder, de tal manera que los commits “inservibles” no los elimina, y tenemos igualmente el programa en el punto en el que lo queríamos.
git reset –soft vs git reset –hard

  1. git reset –hard: Esta opción deshace los cambios realizados en el Working Directory y el Staging Area, y mueve el puntero HEAD y la rama actual al commit especificado. Todos los cambios no comprometidos se eliminarán de forma permanente. Ten en cuenta que esta opción es destructiva y se recomienda usarla con precaución, ya que puede resultar en la pérdida irreversible de cambios.
  2. git reset –soft: Esta opción mantiene los cambios en el Working Directory y el Staging Area, pero mueve el puntero HEAD y la rama actual al commit especificado. Los cambios se mantendrán como cambios no comprometidos, lo que significa que aún estarán disponibles para ser confirmados en un nuevo commit. Esta opción es útil cuando deseas deshacer un commit anterior pero mantener los cambios en el Staging Area para realizar ajustes o llevar a cabo un nuevo commit basado en esos cambios.
En resumen, git reset –hard deshace los cambios y mueve el puntero HEAD al commit especificado, mientras que git reset –soft mantiene los cambios y mueve el puntero HEAD al commit especificado. La elección entre estas opciones depende de si deseas deshacer por completo los cambios o mantenerlos para realizar nuevos commits. Recuerda que siempre es recomendable hacer una copia de seguridad de tus cambios antes de utilizar cualquiera de estas opciones, especialmente con –hard, para evitar la pérdida de datos importantes.

Por ejemplo, si queremos volver al commit A0, podemos hacerlo mediante git reset o mediante git revert:

Diferencias entre git reset y git revert
Diferencias entre git reset y git revert

Reset

Probemos a revertir un commit. Vamos a la rama main, importamos numpy en el script de Python:

import numpy as np

Y commiteamos.

git add .
git commit -m "added numpy import"

A continuación eliminamos dicho commit, que nos hemos arrepentido… Comprueba qué pasa en todo momento mediante git log.

git log
git reset --hard HEAD^
git log

Volvemos al commit donde apuntaba antes. El nuevo commit ha desaparecido. Fíjate que lo hacemos con ^, precisamente porque es el commit inmediatamente anterior, aunque podríamos hacerlo a un commit concreto usando su hash.

TIP: Si vas a realizar operaciones de este tipo y no estás muy seguro de lo que estás haciendo, prueba a crearte un repo y a simular el caso, antes de eliminar de forma permanente commits del repositorio.

Revert

Ahora prueba a usar el revert. Lo mismo, crea un commit nuevo y vuelve hacia atrás. Verás que en vez de eliminar ese commit, seguirá existiendo y habrá un commit nuevo indicativo del revert.

git log
git revert 96e9fe50b4a672f
git log

En resumen, usa git reset cuando necesites deshacer cambios y mover la rama a una confirmación anterior, pero ten en cuenta que puede afectar al historial compartido. Utiliza git revert cuando quieras deshacer cambios específicos sin alterar el historial y mantener un registro claro de los cambios revertidos en el proyecto.


Descartar cambios en local y volver al último commit

Es posible que nos pongamos a revisar una rama, y sin querer toquemos algo del código para debuguearlo, probar cosas, o simplemente que nos confundamos pensando que estamos en otra rama. Si queremos volver al punto de partida, al último commit, podemos hacerlo con reset. El git reset --hard volverá todos los archivos al estado del último commit. Dicho en idioma de Git, todo lo que esté guardado en el Working Directory o Staging Area lo eliminaremos.

git reset --hard

Opcionalmente podemos añadir git clean -fxd, que eliminará todos los nuevos archivos que no estén en el Staging Area, es decir, si habíamos creado archivos sin querer, también los eliminará. Ahora bien, lleva cuidado de no eliminar lo que no debes.


Formas alternativas de incorporar cambios entre ramas – git rebase, git cherry-pick

Rebase

El comando git rebase mueve o reaplica los commits de una rama sobre otra rama base. En lugar de crear un nuevo commit de fusión como el merge, el rebase modifica el historial de commits, creando una secuencia lineal y más limpia de commits. El rebase es útil para mantener un historial de commits más ordenado, y evitar la creación de ramificaciones innecesarias.

Veamos un ejemplo de cómo funciona el rebase. Resolveremos un bug nuevo en la rama new_bug y después incorporaremos los cambios a la rama main.

git branch new_bug
# Put some changes in sales.py
git add .
git commit -m "random changes in main"

git checkout new_bug
# Put some changes in another line of sales.py
git add .
git commit -m "random changes in new_bug"

git checkout main
git rebase new_bug
git log
Principales argumentos de git rebase

  1. branch: Especifica la rama de destino a la que deseas aplicar los cambios. Puedes proporcionar el nombre de la rama o su referencia.
  2. -i o –interactive: Permite realizar una reorganización interactiva, lo que te brinda la capacidad de editar, reordenar o eliminar confirmaciones durante el proceso de rebase.
  3. –onto : Especifica una nueva base para aplicar los cambios. Puedes usar esto para reubicar una secuencia de confirmaciones en una ubicación diferente dentro del historial.
  4. -p o –preserve-merges: Intenta preservar las fusiones en el historial de confirmaciones durante el rebase. Esto puede ser útil para mantener la estructura de fusiones de una rama durante el proceso.
  5. –abort: Permite abortar un rebase en curso y regresar a la situación anterior.

Vemos que se han incorporado los commits de new_bug en main, sin tener que realizar ningún commit de unión como lo habría hecho merge.

Donde apunta cada rama mediante el comando git log
Donde apunta cada rama mediante el comando git log

Mover commits individuales – git cherry-pick

Se utiliza para copiar commits específicos de una rama a otra. No es necesario mergear las ramas, y es bastante sencillo de utilizar. Es importante tener en cuenta que al copiar un commit con git cherry-pick, se crea un nuevo commit en la rama de destino con un identificador de commit diferente. Esto significa que no se realiza una fusión directa de ramas, y puede generar conflictos si los cambios aplicados entran en conflicto con los cambios existentes en la rama de destino.

El comando git cherry-pick es útil en situaciones donde deseas incorporar cambios específicos de una rama a otra sin afectar el resto del historial de cambios.

Veamos cómo funciona. Vamos a crear una rama con un script nuevo donde tendremos los datos de clientes. Y a continuación nos llevaremos ese commit a la rama main.

import pandas as pd

# Data load
clients = pd.DataFrame({'Name': ['Juan', 'Pepe', 'Ana'], 'Sales': [300, 150, 200]})
git checkout -b clients_feature

# añadimos el python script
git add .
git commit -m "new clients info added"

git log
# Get the new commit-hash

git checkout main
git cherry-pick commit-hash

Y ya tenemos el commit en la rama main, sin tener que realizar ningún merge ni rebase. Puedes añadir todos los hashes de commits que quieras simplemente separándolos por espacios.

¿Qué usamos? ¿merge, rebase o cherry-pick?

Comparativa entre git merge, git rebase y git cherry-pick
Comparativa entre git merge, git rebase y git cherry-pick

La diferencia principal radica en cómo se incorporan los cambios y cómo se modifica el historial de commits. El merge crea un nuevo commit de fusión, el rebase modifica el historial de commits existentes y el cherry-pick crea nuevos commits. La elección entre ellos depende de la estructura deseada del historial de commits y del propósito específico de la incorporación de cambios.

Se recomienda usar merge cuando el historial de commits no es relevante y la prioridad es incorporar los cambios completos de una rama en otra. En cuanto al rebase se debe usar cuando deseas incorporar los cambios de una rama en otra y deseas mantener un historial de commits más limpio, evitando ramificaciones innecesarias. Sin embargo, ten en cuenta que el rebase modifica el historial de commits existentes y puede causar conflictos si se utiliza en ramas compartidas o públicas. Se recomienda usar cherry-pick cuando solo deseas incorporar cambios selectivos, sin traer todo el historial de commits de una rama. Puedes utilizarlo para aplicar correcciones de errores críticos o características específicas en ramas diferentes.


Reescribir la historia

En este épico apartado vamos a ver cómo podemos modificar, reordenar y fusionar commits.

Van a ser muy habituales tareas de reescribir commits para que sean más explicativos, para describir mejor el problema, o incluso tener que hacer un rebase de una rama de cara a simplificar la línea temporal.

Modificar un commit – git commit –ammend

Una acción muy habitual es querer modificar el mensaje del último commit. Para ello utilizamos ammend:

git commit --amend
Modificación del ensaje del commit mediante git commit -amend
Modificación del ensaje del commit mediante git commit -amend

¡Ojo! Fíjate que ha cambiado el hash del commit, por lo que no es recomendable aplicar un ammend sobre un commit que ya está en el repo remoto y compartido con otros usuarios.

Otra operación muy común es añadir cambios que se nos había olvidado incluir en el último commit. Para ello también nos servimos de ammend. Con los cambios hechos en el Working Directory:

# importa matplotlib en sales.py
git add .
git commit --amend
git log
git show commit-hash

Recuerda que ammend te modifica en este caso también el hash del commit.

Hemos añadido correctamente esa línea al último commit:

Comprobación de cambios en el git commit -amend
Comprobación de cambios en el git commit -amend

Si queremos modificar los mensajes de otros commits que no sean los inmediátamente superiores, entonces ya hay que acudir al rebase interactivo.

Modificación múltiple de commits – git rebase -i

Esta acción la llevaremos a cabo mediante una “interfaz gráfica” de Git, es decir, se abrirá un archivo que tendremos que modificar, introduciendo las instrucciones del rebase, y cuando lo cerremos, Git aplicará los cambios.

Para este ejemplo vamos a crear un repo nuevo y a añadir unos commits fake. La idea es poner el commit A4 como el primero, después el A1, y juntar los commits A2 y A3 en uno nuevo mediante la operación squash. Con el rebase interactivo podremos modificar, reordenar y combinar los commits.

Reordenamiento y modificación de commits mediante git rebase -i
Reordenamiento y modificación de commits mediante git rebase -i

Partimos de la rama con esta pinta:

Estado de la rama antes de aplicar el rebase interactivo
Estado de la rama antes de aplicar el rebase interactivo
git rebase -i HEAD~4

Ejecutamos un rebase interactivo con los 4 últimos commits. Se abrirá un archivo de texto donde nos cuenta qué podemos hacer en el rebase.

¿Qué podemos hacer con el rebase interactivo?

Con el rebase interactivo en Git, puedes tener un mayor control y flexibilidad sobre cómo se aplican los commits y cómo se organiza el historial de commits. Al utilizar el rebase interactivo, puedes realizar varias acciones, como:

  1. Reordenar commits: Puedes cambiar el orden de los commits para que se apliquen en un orden diferente al original.
  2. Combinar commits (squash): Puedes fusionar varios commits en uno solo para tener un historial más conciso y legible.
  3. Editar commits: Puedes modificar el contenido de los commits, como cambiar mensajes de commit, editar archivos o realizar otras modificaciones.
  4. Eliminar commits: Puedes eliminar commits específicos del historial, lo que puede ser útil para corregir errores o eliminar cambios no deseados.
  5. Dividir commits: Puedes dividir un commit en varios commits más pequeños, lo que permite una granularidad más fina en el historial de cambios.
El rebase interactivo es una herramienta poderosa, pero debes tener cuidado al utilizarla, especialmente en repositorios compartidos o públicos, ya que puede modificar el historial de commits existentes y causar conflictos para otros colaboradores. Es recomendable utilizar el rebase interactivo principalmente en ramas locales y comunicarte con otros colaboradores para coordinar los cambios si es necesario.

Le indicamos las operaciones que nos interesan. Reordenamos A4 y A1, editamos A1 y juntamos A3 con A2. Guardamos y cerramos. En este punto todavía no se han producido todos los cambios.

Operaciones a aplicar en el rebase interactivo

git rebase -i reordena sin problema, pero se encuentra después con un edit. Se detiene el rebase y nos sugiere que corramos un git commit --amend para resolver el edit. Con este comando vuelve a lanzar un archivo para que modifiquemos el nombre del commit. Digamos que es la “interfaz gráfica” del edit.

git commit --amend
git amend para continuar con el rebase
git amend para continuar con el rebase

Ponemos el nombre que deseemos:

Cambio de nombre
Cambio de nombre

A continuación se encuentra con un squash, le decimos que queremos continuar con el squash (git rebase --continue), y por tanto aparecerá otra vez el archivo de texto para que escribamos el nuevo commit del squash. Cerramos, y así nos queda el nuevo árbol:

git rebase --continue
git rebase --continue para seguir con el rebase interactivo
git rebase –continue para seguir con el rebase interactivo

Fíjate que los ids de los commits han cambiado, por lo que ten mucho cuidado con estas operaciones si trabajas en repos remotos.

TIP: Si estamos en medio de un rebase y hemos cometido algún error, siempre podemos volver al punto previo al rebase mediante git rebase --abort.

Quitar un archivo que no debería ir en el último commit

Se trata de una situación de lo más habitual, puesto que podría pasarnos que con un git add . se nos cuele un archivo muy pesado en el commit, y que a la hora de subirlo a GitHub el archivo sea tan grande que GitHub lo rechace. Pero ya lo tenemos commiteado en el repo junto con otros cambios y tendremos que solventarlo.

También podría ocurrirnos que metamos en un commit archivos que usamos de debug o de testing, o incluso que tengamos cambios que deberían ir en otro commit.

Vamos a crear dos archivos txt (light_file.txt y heavy_file.txt). Uno lo querremos conservar en el commit y el otro quitarlo. Añadimos los cambios para un nuevo commit.

git add .
git commit -m "added light and heavy files"

Y posteriormente quitamos el heavy_file.txt del commit. Aun así, se mantendrá en el Working Directory.

# Eliminamos heavy_file.txt del commit, y modificamos su mensaje del commit
git reset --soft HEAD^ 
git restore --staged heavy_file.txt
git commit -c ORIG_HEAD 
Eliminación de archivo heavy_file.txt
Eliminación de archivo heavy_file.txt

También podríamos directamente eliminar el archivo, tanto del commit como del ordenador, es decir, del Working Directory:

git rm <path/to/unwanted_file>
git commit --amend

Tags en Git

Los tags son referencias estáticas a puntos específicos en la historia de los commits. Se utilizan para marcar versiones, hitos importantes o momentos significativos en el desarrollo de un proyecto. Son nombres descriptivos y permanentes asignados a un commit específico.

A diferencia de las ramas, los tags no se mueven automáticamente cuando se crean nuevos commits. Permanecen asociadas a uno específico y sirven como puntos de referencia fijos en la línea de tiempo del proyecto. Los tags son útiles para marcar versiones estables del código, lanzamientos importantes o hitos que se desean resaltar.

¿Qué tipos de tags hay en Git?

En Git existen dos tipos principales de tags: los tags ligeros (lightweight tags) y los tags anotados (annotated tags).

  1. Tags ligeros (Lightweight tags): Los tags ligeros son simplemente nombres asociados a un commit específico. Son creadas utilizando el comando git tag seguido del nombre del tag y el identificador del commit al que se desea asociar. Estos tags son simples referencias estáticas y no contienen metadatos adicionales como nombre de autor, fecha o mensaje.
  2. Tags anotadas (Annotated tags): Los tags anotados son más detallados y contienen información adicional, como un mensaje, un nombre de autor, una fecha y una firma opcional. Estos tags se crean utilizando el comando git tag -a seguido del nombre del tag y el identificador del commit al que se desea asociar.
Los tags anotados son útiles cuando se desea almacenar información adicional sobre un hito o versión específica, como la descripción de una versión de software, los cambios importantes realizados o los créditos de los colaboradores. Además, los tags anotados se almacenan como objetos Git independientes, lo que permite acceder fácilmente a sus metadatos en el futuro. Ambos tipos de tags son útiles en diferentes contextos y se pueden utilizar según las necesidades específicas del proyecto y la información que se desee asociar a los commits marcados.

Podemos crear un tag asociado al último commit de la siguiente manera:

git tag -a v1.1 -m "my version 1.1"
Tags en los commits de la rama
Tags en los commits de la rama

Es lo que se conoce como un annotated tag. El primer argumento indica el identificador del tag y el segundo sería su descripción.

También podemos asociar un tag a un commit concreto:

git tag -a v1.2 <commit-hash>

Si queremos buscar por tag:

# Para listar todos los tags
git tag

# Para buscar los tags que empiecen por "v1"
git tag -l "v1*"
Búsqueda por tag
Búsqueda por tag

Resumen

Hacer push/pull, commits, etc… va a ser el día a día del desarrollador, pero realmente no son comandos que nos vayan a resolver problemas. Revisar otros commits, volver hacia atrás, eliminar un archivo de un commit… son problemas que nos van a ir surgiendo y que tendremos que solventar de una forma limpia y sin deshacer cambios en el repositorio.

Con este artículo finalizamos la primera parte de los artículos de competencias básicas de Git y GitHub. Todo feedback siempre es bienvenido y si te gustaría que siguiésemos publicando temas más avanzados de Git como Git submodules, GitOps, GitHub Codespaces o GitHub actions, simplemente háznoslo saber en comentarios o por redes sociales 🙂

SIGUIENTE EPISODIOÉchale un vistazo al artículo de materiales de Git y GitHub para que sigas practicando y aprendiendo.

Resumen de comandos utilizados

ComandoDescipción
git checkout HEAD^Ir un commit hacia atrás
git checkout HEAD~2Ir dos commits hacia atrás
git checkout <commit-hash>Ir a un commit concreto
git reset –hard <commit-hash>Eliminar commits de forma permanente
git revert <commit-hash>“Eliminar commits” manteniéndolos en el histórico.
git rebase <branch>Merge creando una secuencia lineal ene l historial
git cherry-pick <commit-hash>Como hacer un copy-paste de uno o varios commits
git commit –amendModificar el último commit
git rebase -i HEAD~4Cambiar el historial de los ultimos commits
git tag -a v1.1 -m “my version 1.1”Añadir un tag al último commit
git tag -a v1.2 <commit-hash>Añadir un tag a un comit concreto
Resumen de comandos para navegar entre commits: revertir, cambios, alternativas al merge y tags

1 comentario en “Navegando entre commits en Git, revertir cambios y alternativas al merge con rebase y cherry-pick”

  1. Pingback: We Learn Data

Los comentarios están cerrados.

Scroll al inicio