Git
Chapters ▾ 2nd Edition

7.7 Herramientas de Git - Reiniciar Desmitificado

Reiniciar Desmitificado

Antes de pasar a herramientas más especializadas, hablemos de reset y checkout. Estos comandos son dos de las partes más confusas de Git cuando los encuentras por primera vez. Hacen tantas cosas, que parece imposible comprenderlas realmente y emplearlas adecuadamente. Para esto, recomendamos una metáfora simple.

Los Tres Árboles

Una manera más fácil de pensar sobre reset y checkout es a través del marco mental de Git como administrador de contenido de tres árboles diferentes. Por “árbol”, aquí realmente queremos decir “colección de archivos”, no específicamente la estructura de datos. (Hay algunos casos donde el índice no funciona exactamente como un árbol, pero para nuestros propósitos es más fácil pensarlo de esta manera por ahora).

Git como sistema maneja y manipula tres árboles en su operación normal:

Árbol Rol

HEAD

Última instantánea del commit, próximo padre

Índice

Siguiente instantánea del commit propuesta

Directorio de Trabajo

Caja de Arena

El HEAD

HEAD es el puntero a la referencia de bifurcación actual, que es, a su vez, un puntero al último commit realizado en esa rama. Eso significa que HEAD será el padre del próximo commit que se cree. En general, es más simple pensar en HEAD como la instantánea de tu último commit.

De hecho, es bastante fácil ver cómo es el aspecto de esa instantánea. Aquí hay un ejemplo de cómo obtener la lista del directorio real y las sumas de comprobación SHA-1 para cada archivo en la instantánea de HEAD:

$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon  1301511835 -0700
committer Scott Chacon  1301511835 -0700

commit inicial

$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152...   README
100644 blob 8f94139338f9404f2...   Rakefile
040000 tree 99f1a6d12cb4b6f19...   lib

Los comandos cat-file y ls-tree son comandos de “fontanería” que se usan para cosas de nivel inferior y que no se usan realmente en el trabajo diario, pero nos ayudan a ver qué está sucediendo aquí.

El Índice

El índice es tu siguiente commit propuesto. También nos hemos estado refiriendo a este concepto como el “Área de Preparación” de Git ya que esto es lo que Git ve cuando ejecutas git commit.

Git rellena este índice con una lista de todos los contenidos del archivo que fueron revisados por última vez en tu directorio de trabajo y cómo se veían cuando fueron revisados originalmente. A continuación, reemplaza algunos de esos archivos con nuevas versiones de ellos, y git commit los convierte en el árbol para un nuevo commit.

$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0	README
100644 8f94139338f9404f26296befa88755fc2598c289 0	Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0	lib/simplegit.rb

Nuevamente, aquí estamos usando ls-files, que es más un comando entre bastidores que te muestra a qué se parece actualmente el índice.

El índice no es técnicamente una estructura de árbol – en realidad se implementa como un manifiesto aplanado – pero para nuestros propósitos, está lo suficientemente cerca.

El Directorio de Trabajo

Finalmente, tienes tu directorio de trabajo. Los otros dos árboles almacenan su contenido de manera eficiente pero inconveniente, dentro de la carpeta .git. El Directorio de trabajo los descomprime en archivos reales, lo que hace que sea mucho más fácil para ti editarlos. Piensa en el Directorio de Trabajo como una caja de arena, donde puedes probar los cambios antes de enviarlos a tu área de ensayo (índice) y luego al historial.

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

El Flujo de Trabajo

El objetivo principal de Git es registrar instantáneas de tu proyecto en estados sucesivamente mejores, mediante la manipulación de estos tres árboles.

reset workflow

Visualicemos este proceso: digamos que ingresa en un nuevo directorio con un solo archivo. Llamaremos a esto v1 del archivo, y lo indicaremos en azul. Ahora ejecutamos git init, que creará un repositorio Git con una referencia HEAD que apunta a una rama no nacida (master aún no existe).

reset ex1

En este punto, solo el árbol del Directorio de Trabajo tiene cualquier contenido.

Ahora queremos hacer “commit” a este archivo, por lo que usamos git add para tomar contenido en el directorio de trabajo y copiarlo en el índice.

reset ex2

Luego ejecutamos git commit, que toma los contenidos del índice y los guarda como una instantánea permanente, crea un objeto de “commit” que apunta a esa instantánea y actualiza master para apuntar a ese “commit”.

reset ex3

Si ejecutamos git status, no veremos ningún cambio, porque los tres árboles son iguales.

Ahora queremos hacer un cambio en ese archivo y hacerle un nuevo “commit”. Pasaremos por el mismo proceso; primero, cambiamos el archivo en nuestro directorio de trabajo. Llamemos a esto v2 del archivo, y lo indicamos en rojo.

reset ex4

Si ejecutamos git status ahora, veremos el archivo en rojo como “Changes not staged for commit” porque esa entrada difiere entre el índice y el directorio de trabajo. A continuación, ejecutamos git add para ubicarlo en nuestro índice.

reset ex5

En este punto si ejecutamos git status veremos el archivo en verde debajo de “Changes to be committed” porque el Índice y el HEAD difieren – es decir, nuestro siguiente “commit” propuesto ahora es diferente de nuestro último “commit”. Finalmente, ejecutamos git commit para finalizar el “commit”.

reset ex6

Ahora git status no nos dará salida, porque los tres árboles son iguales nuevamente.

El cambio de ramas o la clonación pasa por un proceso similar. Cuando verifica una rama, eso cambia HEAD para que apunte a la nueva “ref” de la rama, rellena su Índice con la instantánea de esa confirmación, luego copia los contenidos del Índice en tu Directorio de Trabajo.

El Papel del Reinicio

El comando reset tiene más sentido cuando se ve en este contexto.

A los fines de estos ejemplos, digamos que hemos modificado file.txt de nuevo y le hemos hecho “commit” por tercera vez. Entonces ahora nuestra historia se ve así:

reset start

Hablemos ahora sobre lo que reset hace exactamente cuando es llamado. Manipula directamente estos tres árboles de una manera simple y predecible. Hace hasta tres operaciones básicas.

Paso 1: mover HEAD

Lo primero que reset hará es mover a lo que HEAD apunta. Esto no es lo mismo que cambiar HEAD en sí mismo (que es lo que hace checkout), reset mueve la rama a la que HEAD apunta. Esto significa que si HEAD está configurado en la rama master (es decir, estás actualmente en la rama master), ejecutar git reset 9e5e64a comenzará haciendo que master apunte a 9e5e64a.

reset soft

No importa qué forma de reset invoques con un ``commit, esto es lo primero que siempre intentará hacer. Con reset --soft, simplemente se detendrá allí.

Ahora tómate un segundo para mirar ese diagrama y darte cuenta de lo que sucedió: esencialmente deshizo el último comando git commit. Cuando ejecutas git commit, Git crea una nueva confirmación y mueve la rama a la que apunta HEAD. Cuando haces reset de vuelta a HEAD~ (el padre de HEAD), está volviendo a colocar la rama donde estaba, sin cambiar el Índice o el Directorio de Trabajo. Ahora puedes actualizar el Índice y ejecutar git commit nuevamente para lograr lo que git commit --amend hubiera hecho (ver Cambiando la última confirmación).

Paso 2: Actualizando el índice (--mixed)

Ten en cuenta que si ejecutas git status ahora, verás en verde la diferencia entre el Índice y lo que el nuevo HEAD es.

Lo siguiente que reset hará es actualizar el Índice con los contenidos de cualquier instantánea que HEAD señale ahora.

reset mixed

Si especificas la opción --mixed, reset se detendrá en este punto. Este también es el comportamiento por defecto, por lo que si no especificas ninguna opción (sólo git reset HEAD~, en este caso), aquí es donde el comando se detendrá.

Ahora tómate otro segundo para mirar ese diagrama y darte cuenta de lo que sucedió: deshizo tu último commit y también hizo unstaged de todo. Retrocedió a antes de ejecutar todos los comandos git add y git commit.

Paso 3: Actualizar el Directorio de Trabajo (--hard)

Lo tercero que reset hará es hacer que el Directorio de Trabajo se parezca al Índice. Si usas la opción --hard, continuará en esta etapa.

reset hard

Entonces, pensemos en lo que acaba de pasar. Deshizo tu último commit, los comandos git add y git commit, y todo el trabajo que realizaste en tu Directorio de Trabajo.

Es importante tener en cuenta que este indicador (--hard) es la única manera de hacer que el comando reset sea peligroso, y uno de los pocos casos en que Git realmente destruirá los datos. Cualquier otra invocación de reset puede deshacerse fácilmente, pero la opción --hard no puede, ya que sobrescribe forzosamente los archivos en el Directorio de Trabajo. En este caso particular, todavía tenemos la versión v3 de nuestro archivo en un “commit” en nuestro DB de Git, y podríamos recuperarla mirando nuestro reflog, pero si no le hubiéramos hecho “commit”, Git hubiese sobrescrito el archivo y sería irrecuperable.

Resumen

El comando reset sobrescribe estos tres árboles en un orden específico, deteniéndose cuando se le dice:

  1. Mueva los puntos HEAD de la rama a (deténgase aquí si --soft)

  2. Haga que el Índice se vea como HEAD (deténgase aquí a menos que --hard)

  3. Haga que el Directorio de Trabajo se vea como el Índice

Reiniciar Con una Ruta

Eso cubre el comportamiento de reset en su forma básica, pero también puedes proporcionarle una ruta para actuar. Si especificas una ruta, reset omitirá el paso 1 y limitará el resto de sus acciones a un archivo o conjunto específico de archivos. Esto realmente tiene sentido – HEAD es solo un puntero, y no se puede apuntar a sólo una parte de un “commit” y otra parte de otro. Pero el Índice y el Directorio de Trabajo pueden actualizarse parcialmente, por lo que el reinicio continúa con los pasos 2 y 3.

Entonces, supongamos que ejecutamos git reset file.txt. Este formulario (ya que no especificó un commit SHA-1 o una rama, y no especificó --soft o --hard) es una abreviatura de git reset --mixed HEAD file.txt, la cual hará:

  1. Mueva los puntos HEAD de la rama a (omitido)

  2. Haga que el Índice se vea como HEAD (deténgase aquí)

Por lo tanto, básicamente solo copia archivo.txt de HEAD al Índice.

reset path1

Esto tiene el efecto práctico de hacer unstaging al archivo. Si miramos el diagrama para ese comando y pensamos en lo que hace git add, son exactamente opuestos.

reset path2

Esta es la razón por la cual el resultado del comando git status sugiere que ejecute esto para descentralizar un archivo. (Consulte Deshacer un Archivo Preparado para más sobre esto).

Igualmente podríamos no permitir que Git suponga que queríamos “extraer los datos de HEAD” especificando un “commit” específico para extraer esa versión del archivo. Simplemente ejecutaríamos algo como git reset eb43bf file.txt.

reset path3

Esto efectivamente hace lo mismo que si hubiéramos revertido el contenido del archivo a v1 en el Directorio de Trabajo, ejecutado git add en él, y luego lo revertimos a v3 nuevamente (sin tener que ir a través de todos esos pasos) Si ejecutamos git commit ahora, registrará un cambio que revierte ese archivo de vuelta a v1, aunque nunca más lo volvimos a tener en nuestro Directorio de Trabajo.

También es interesante observar que, como git add, el comando reset aceptará una opción --patch para hacer unstage del contenido en una base hunk-by-hunk. Por lo tanto, puede hacer unstage o revertir el contenido de forma selectiva.

Aplastando

Veamos cómo hacer algo interesante con este poder recién descubierto – aplastando “commits”.

Supongamos que tienes una serie de confirmaciones con mensajes como “oops.”, “WIP” y “se olvidó de este archivo”. Puedes usar reset para aplastarlos rápida y fácilmente en una sola confirmación que lo hace ver realmente inteligente. (Aplastando muestra otra forma de hacerlo, pero en este ejemplo es más simple usar reset.)

Supongamos que tiene un proyecto en el que el primer “commit” tiene un archivo, el segundo “commit” agregó un nuevo archivo y cambió el primero, y el tercer “commit” cambió el primer archivo otra vez. El segundo “commit” fue un trabajo en progreso y quieres aplastarlo.

reset squash r1

Puedes ejecutar git reset --soft HEAD~2 para mover la rama HEAD a un “commit” anterior (el primer “commit” que deseas mantener):

reset squash r2

Y luego simplemente ejecuta git commit nuevamente:

reset squash r3

Ahora puedes ver que el historial alcanzable, la historia que empujarías, ahora parece que tuvo un “commit” con archivo-a.txt v1, luego un segundo que ambos modificaron archivo-a.txt a v3 y agregaron archivo-b.txt. El “commit” con la versión v2 del archivo ya no está en el historial.

Echale Un vistazo

Finalmente, puedes preguntarte cuál es la diferencia entre checkout y reset. Al igual que reset, checkout manipula los tres árboles, y es un poco diferente dependiendo de si se le da al comando una ruta de archivo o no.

Sin Rutas

Ejecutar git checkout [branch] es bastante similar a ejecutar git reset --hard [branch] porque actualiza los tres árboles para que se vea como [branch], pero hay dos diferencias importantes.

Primero, a diferencia de reset --hard, checkout está en el directorio-de-trabajo seguro; Verificará para asegurarse de que no está volando los archivos que tienen cambios en ellos. En realidad, es un poco más inteligente que eso – intenta hacer una fusión trivial en el Directorio de Trabajo, por lo que todos los archivos que no hayan cambiado serán actualizados. reset --hard, por otro lado, simplemente reemplazará todo en general sin verificar.

La segunda diferencia importante es cómo actualiza HEAD. Donde reset moverá la rama a la que HEAD apunta, checkout moverá HEAD para señalar otra rama.

Por ejemplo, digamos que tenemos las ramas master y develop que apuntan a diferentes commits, y actualmente estamos en develop (así que HEAD la señala). Si ejecutamos git reset master, develop ahora apuntará al mismo “commit” que master. Si en cambio ejecutamos git checkout master, develop no se mueve, HEAD sí lo hace. HEAD ahora apuntará a master.

Entonces, en ambos casos estamos moviendo HEAD para apuntar al “commit” A, pero el cómo lo hacemos es muy diferente. reset moverá los puntos HEAD de la rama A, checkout mueve el mismo HEAD.

reset checkout

Con Rutas

La otra forma de ejecutar checkout es con una ruta de archivo, que como reset, no mueva HEAD. Es como git reset [branch] file en que actualiza el índice con ese archivo en ese “commit”, pero también sobrescribe el archivo en el Directorio de Trabajo. Sería exactamente como git reset --hard [branch] file (si reset permitiera ejecutar eso) - no está directorio-de-trabajo seguro, y no mueve a HEAD.

Además, al igual que git reset y git add, checkout aceptará una opción --patch para permitir revertir selectivamente el contenido del archivo sobre una base hunk-by-hunk.

Resumen

Esperamos que ahora entiendas y te sientas más cómodo con el comando reset, pero probablemente todavía estés un poco confundido acerca de cómo exactamente difiere de checkout y posiblemente no puedas recordar todas las reglas de las diferentes invocaciones.

Aquí hay una hoja de trucos para cuáles comandos afectan a cuáles árboles. La columna “HEAD” dice “REF” si ese comando mueve la referencia (rama) a la que HEAD apunta, y “HEAD” si se mueve al propio HEAD. Presta especial atención a la columna WD Safe: si dice NO , tómate un segundo para pensar antes de ejecutar ese comando.

HEAD Index Workdir WD Safe?

Nivel de Commit

reset --soft [commit]

REF

NO

NO

SI

reset [commit]

REF

SI

NO

SI

reset --hard [commit]

REF

SI

SI

NO

checkout [commit]

HEAD

SI

SI

SI

Nivel de Archivo

reset (commit) [file]

NO

SI

NO

SI

checkout (commit) [file]

NO

SI

SI

NO