Deshacer cambios en Git: reset, revert, reflog y stash
Deshacer en Git con salida real: restore, reset --soft/--mixed/--hard, rescate con reflog y fsck, revertir un merge y cuándo un commit se pierde de verdad.
Serie: curso de Git 5/6 (avanzado). Para quienes ya usan ramas (branches), rebase y remotos (clases 1 a 4) y quieren corregir errores sin miedo. Vas a aprender qué comando para deshacer toca cada una de las tres áreas de Git, cómo recuperar commits "perdidos" y cuándo algo de verdad ya no tiene vuelta atrás.
Esta clase es para quien acaba de escribir git reset --hard y vio desaparecer su trabajo, para quien hizo push de un merge roto a main un viernes por la tarde, o para quien hizo stash pop y terminó en un conflicto. Si eres freelancer y el único repositorio del proyecto vive en tu computadora, o estás en tu primer empleo y no quieres preguntar en el chat del equipo cómo se arregla esto, aquí tienes las recetas. Git casi nunca borra de inmediato los datos que ya tienen commit, pero sí borra al instante los que no lo tienen, y esa diferencia decide qué puedes salvar. Cada escenario de abajo se reprodujo y se recuperó en un repositorio de pruebas, con los mensajes exactos que imprimió Git.
Entorno: Git 2.54.0 (Apple Git-157), Mac con Apple Silicon (M1 Pro), repositorios de prueba con una identidad de demostración y fechas de commit fijas (así los hashes coinciden si repites los pasos), gc.auto en 0 en los repositorios.
¿Qué estado intentas deshacer?
Toda pregunta sobre deshacer se vuelve sencilla en cuanto dices dónde vive el cambio ahora mismo:
- Working tree (directorio de trabajo): los archivos que editas.
- Index (staging area): lo que va a contener el próximo commit; se llena con
git add. - Commits: la historia, a la que apuntan los nombres de las ramas.
Más dos áreas laterales: los archivos no rastreados (untracked; Git no los conoce, así que la mayoría de los comandos para deshacer los ignora) y el stash. Nuestro repositorio de prueba tiene tres commits:
8636d9e Add shipping
6b8084b Add tax
7e5d3c2 Add cart
git restore: deshacer cambios que todavía no tienen commit
git restore (agregado en Git 2.23 junto con git switch) solo trabaja con archivos; nunca mueve una rama.
echo "oops" >> cart.py
git restore cart.py # working tree <- index: edit discarded
echo "oops2" >> cart.py
git add cart.py
git status --short # M cart.py (staged)
git restore --staged cart.py
git status --short # M cart.py (edit kept, just unstaged)
La posición de la columna en git status --short te dice qué área tiene el cambio: columna izquierda = index, columna derecha = working tree. git restore --staged pasa un cambio de la columna izquierda a la derecha; git restore a secas descarta la columna derecha. Este segundo es destructivo: una edición descartada nunca la guardó Git, así que ningún comando la trae de vuelta.
reset --soft, --mixed y --hard: un comando, tres radios de destrucción
git reset <commit> mueve la rama actual a <commit>. El modo decide cuánto del index y del working tree se sobrescribe para que coincida. Ejecutamos cada modo desde el mismo estado inicial: sobre 8636d9e, un archivo nuevo en staging notes.txt y una edición sin staging en cart.py. Luego git reset --<modo> HEAD~1:
## reset --soft HEAD~1
6b8084b Add tax
MM cart.py <- "Add shipping" is back in the index, our edit still in the file
A notes.txt <- still staged
## reset --mixed HEAD~1
Unstaged changes after reset:
M cart.py
6b8084b Add tax
M cart.py <- everything is now unstaged
?? notes.txt <- the new file became untracked, but it still exists
## reset --hard HEAD~1
HEAD is now at 6b8084b Add tax
6b8084b Add tax
<- status is empty, and `ls` shows only cart.py:
notes.txt was deleted, the cart.py edit is gone
En resumen: con --soft, "Add shipping" vuelve al index y la edición sigue en el archivo; con --mixed, todo queda fuera del staging y notes.txt pasa a no rastreado, pero existe; con --hard, notes.txt se borró y la edición de cart.py desapareció.
| Modo | Mueve la rama | Index | Working tree | Uso típico |
|---|---|---|---|---|
--soft |
sí | se conserva | se conserva | Rehacer el o los últimos commits como uno solo |
--mixed (por defecto) |
sí | se reinicia | se conserva | Quitar el commit y el staging, conservar las ediciones |
--hard |
sí | se reinicia | se sobrescribe | Tirar todo y dejarlo igual a un commit |
--soft es la forma más limpia de hacer squash sin rebase interactivo: git reset --soft main && git commit convierte todos los commits desde main en uno.
Recuperar commits "perdidos" con reflog
Ahora el accidente clásico. Los mismos tres commits, un notes.txt en staging y luego un reset de más:
$ git reset --hard HEAD~2
HEAD is now at 7e5d3c2 Add cart
$ git log --oneline
7e5d3c2 Add cart
Los dos commits no se borraron; simplemente ya nada apunta a ellos. El reflog es un diario local de por dónde ha pasado HEAD:
$ git reflog
7e5d3c2 HEAD@{0}: reset: moving to HEAD~2
8636d9e HEAD@{1}: commit: Add shipping
6b8084b HEAD@{2}: commit: Add tax
7e5d3c2 HEAD@{3}: commit (initial): Add cart
HEAD@{1} significa "donde estaba HEAD hace un movimiento". Git también deja ORIG_HEAD después de un reset, un rebase o un merge:
$ git rev-parse --short ORIG_HEAD
8636d9e
$ git reset --hard ORIG_HEAD
HEAD is now at 8636d9e Add shipping
Una rama borrada funciona igual. git branch -d se niega a borrar una rama sin merge, -D la fuerza, y el reflog todavía recuerda el commit:
$ git branch -d feature/coupon
error: the branch 'feature/coupon' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D feature/coupon'
$ git branch -D feature/coupon
Deleted branch feature/coupon (was 7a35098).
$ git reflog | head -3
8636d9e HEAD@{0}: checkout: moving from feature/coupon to main
7a35098 HEAD@{1}: commit: Add coupon
8636d9e HEAD@{2}: checkout: moving from main to feature/coupon
$ git branch feature/coupon HEAD@{1}
Dos detalles que la gente aprende por las malas. Primero, Deleted branch … (was 7a35098) ya imprime el ID: cópialo antes de limpiar la terminal. Segundo, el reflog propio de la rama se borra junto con ella; después de recrearla, git reflog show feature/coupon solo mostró branch: Created from HEAD@{1}. El que te salva es el reflog de HEAD.
Lo que reset --hard destruye de verdad
Volvamos al notes.txt que borró reset --hard. Nunca tuvo commit, pero estaba en staging, y git add escribe el contenido en la base de datos de objetos de inmediato. Así que sobrevive como un blob "colgante" (dangling), sin nombre de archivo:
$ git fsck --lost-found
dangling blob 2b14cb5b6471c8b527d696145e409ca8669f2718
$ cat .git/lost-found/other/2b14cb5b6471c8b527d696145e409ca8669f2718
uncommitted
Qué se puede recuperar y qué no después de git reset --hard, según lo observado:
| El cambio estaba… | Después de reset --hard |
Cómo recuperarlo |
|---|---|---|
| con commit | fuera de la rama | git reflog, ORIG_HEAD |
en staging (git add), sin commit |
archivo borrado | solo el contenido, con git fsck --lost-found (sin nombre de archivo) |
| editado pero nunca en staging | perdido | nada en Git; quizá el historial de tu editor |
| archivo no rastreado | intacto (en una segunda prueba, draft.txt sobrevivió) |
no hace falta |
revert: deshacer en una rama compartida
reset reescribe la historia, y en una rama que ya tiene push eso lleva a los problemas de force-push de la clase 4. git revert, en cambio, agrega un commit nuevo que aplica el cambio inverso:
$ git revert --no-edit HEAD
[main 8f354f5] Revert "Add shipping"
1 file changed, 1 deletion(-)
Revertir un commit más viejo puede generar conflictos, porque commits posteriores tocaron las mismas líneas. Revertir "Add tax" cuando "Add shipping" estaba en la línea siguiente:
CONFLICT (content): Merge conflict in cart.py
error: could not revert 6b8084b... Add tax
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git revert --continue".
Un commit de merge tiene dos padres, así que Git necesita saber qué lado es "la línea principal" (mainline):
$ git revert --no-edit HEAD
error: commit 3ac3872670169d6aa36cf213e83c05243df56c77 is a merge but no -m option was given.
fatal: revert failed
$ git revert --no-edit -m 1 HEAD
[main 9c31898] Revert "Merge branch 'feature/points'"
1 file changed, 1 deletion(-)
delete mode 100644 points.py
La trampa de revertir un merge. Después de revertir el merge, la rama del feature recibió un commit más y volvimos a hacer merge. Git reportó éxito, pero points.py, el archivo del merge original, no estaba:
$ git merge --no-edit feature/points
Merge made by the 'ort' strategy.
extra.py | 1 +
$ ls
cart.py extra.py <- points.py silently not there
Git considera que los commits viejos del feature ya están integrados (están en la historia); el revert solo ocultó su efecto. La solución es revertir el revert primero y después hacer merge:
* aafbbe4 Merge branch 'feature/points'
|\
| * b4d87b7 Add bonus
* | 0fd236d Reapply "Merge branch 'feature/points'"
* | 6a29dbc Revert "Merge branch 'feature/points'"
* | 226cdb3 Merge branch 'feature/points'
Ahora ls mostró cart.py extra.py points.py. Git 2.54 llamó Reapply "…" al revert de un revert.
stash: guardar trabajo a medias
$ git stash
Saved working directory and index state WIP on main: bcb47cf Add shop config
$ git status --short
?? new_feature.py <- untracked file was NOT stashed
El archivo no rastreado no entró al stash. Usa git stash -u -m "wip: discount" para incluir los archivos no rastreados y ponerle a la entrada un nombre que reconozcas en git stash list. Si main cambia la misma línea mientras el trabajo está guardado, pop entra en conflicto, y la línea importante es la última:
$ git stash pop
CONFLICT (content): Merge conflict in shop.conf
...
The stash entry is kept in case you need it again.
Un stash descartado es solo un commit inalcanzable, así que fsck lo encuentra:
$ git stash drop
Dropped refs/stash@{0} (9d2345afb5693fba01e9879a695ab3e6c7a6b3c6)
$ git fsck --unreachable | grep commit
unreachable commit c010236b209f9c188608ddd37dea64073476e8e6 <- index on main: …
unreachable commit 88f8023dad8a7d4bc420893d821a6f4fa7d79cc4 <- WIP on main: … (the earlier, popped stash)
unreachable commit 90fdaa1ff359276990a9500f667b559e5f3973e9 <- untracked files on main: …
unreachable commit 9d2345afb5693fba01e9879a695ab3e6c7a6b3c6 <- On main: wip: discount
$ git stash apply 9d2345a
Tabla de decisión: "quiero deshacer…"
| Quiero deshacer… | Comando | ¿Seguro en una rama con push? |
|---|---|---|
| una edición en un archivo, sin staging | git restore <file> |
sí (solo local), pero la edición se pierde |
un git add |
git restore --staged <file> |
sí |
| mi último commit, conservando los cambios para volver a hacer commit | git reset --soft HEAD~1 |
no, reescribe la historia |
| mi último commit y el staging | git reset HEAD~1 (mixed) |
no |
| todo desde un commit, archivos incluidos | git reset --hard <commit> |
no, y borra el trabajo sin commit |
| un commit que ya tiene push | git revert <commit> |
sí |
| un merge que ya tiene push | git revert -m 1 <merge> (y después revertir el revert) |
sí |
| un reset o rebase que acabo de hacer | git reset --hard ORIG_HEAD o HEAD@{1} |
no aplica (local) |
| una rama borrada | git branch <name> <id del reflog> |
sí |
| un stash descartado | git fsck --unreachable y luego git stash apply <id> |
sí |
Medido: reflog contra fsck, y el punto sin retorno
¿Buscar trabajo perdido es caro en un repositorio más grande? Generamos 5,000 commits con git fast-import (Python random.seed(42), archivos de 20 líneas, 15,000 objetos en un pack de 2.07 MiB), perdimos los últimos 3 commits con git reset --hard HEAD~3 y medimos los dos métodos de búsqueda. Mac con Apple Silicon (M1 Pro), 3 ejecuciones:
| Método | Ejecución 1 | Ejecución 2 | Ejecución 3 |
|---|---|---|---|
git reflog -n 5 |
0.031 s | 0.025 s | 0.025 s |
git fsck --lost-found |
0.164 s | 0.164 s | 0.189 s |
Los dos son instantáneos con este tamaño. Fíjate en lo que reportó fsck: un solo dangling commit a5eadcf…, no tres. Solo lista las puntas; los dos commits perdidos más viejos son alcanzables desde esa punta, así que git log a5eadcf muestra los tres.
El punto sin retorno, demostrado a propósito:
$ git reset --hard HEAD~1
$ git show -s --format='%h %s' HEAD@{1}
af39527 Add b <- still recoverable
$ git reflog expire --expire=now --all
$ git gc --prune=now
$ git show -s af39527
fatal: ambiguous argument 'af39527': unknown revision or path not in the working tree.
$ git rev-parse HEAD@{1}
fatal: log for HEAD is empty
Por defecto, las entradas del reflog se guardan 90 días (30 días para commits que ya no son alcanzables), y gc solo elimina objetos sueltos con más de dos semanas. En la vida normal tienes semanas. Nunca ejecutes reflog expire más gc --prune=now "para limpiar" mientras todavía puedas necesitar algo.
Errores comunes
git reset --hard para "limpiar" antes de un pull. Lo que pasa: las ediciones sin staging se destruyen sin preguntar. Solución: primero git stash -u, o ejecuta git status y léelo.
git checkout -- file cuando querías cambiar de rama. Lo que pasa: se descarta la edición de ese archivo. Solución: usa la separación de la 2.23+, git switch para ramas y git restore para archivos, así el comando destructivo tiene un nombre evidente.
Volver a hacer merge después de un merge revertido. Lo que pasa: mensaje de éxito, pero faltan los cambios viejos (como vimos arriba). Solución: git revert <el commit del revert> antes de volver a hacer merge.
Límites honestos y notas de versión
- Solo se comprobó en Git 2.54.0 (Apple Git-157).
git restoreygit switchexisten desde Git 2.23. El mensajeReapply "…"al revertir un revert es lo que imprimió la 2.54; las versiones anteriores escribenRevert "Revert …". - Los plazos de 90/30 días del reflog y las dos semanas de prune son los valores por defecto documentados de Git (
gc.reflogExpire,gc.reflogExpireUnreachable,gc.pruneExpire); no estaban configurados en nuestros repositorios, y no esperamos 90 días para verlos expirar. - Los servicios de hosting guardan sus propias copias y pueden permitir que un administrador restaure una rama sobrescrita con force-push; eso no se probó aquí.
- Los tiempos vienen de una sola máquina con archivos pequeños.
Ejercicios
- Ejecutaste
git reset --hard HEAD~3en tu rama local y luego hicistegit commituna vez. ¿Qué entrada del reflog tiene el commit que quieres recuperar y qué comando devuelve la rama a ese punto? - Alguien de tu equipo quiere deshacer un merge que ya está en el
maincompartido. ¿Qué comando debe usar y qué tiene que recordar la próxima vez que se haga merge de esa rama del feature?
Respuestas
HEAD@{2}(HEAD@{0}es el commit nuevo yHEAD@{1}el reset).git reset --hard HEAD@{2}, o más seguro,git branch rescue HEAD@{2}y revisar primero. Lee siempregit reflogen lugar de contar de memoria.git revert -m 1 <merge-id>. Antes de volver a hacer merge de la rama, debe ejecutargit revert <id de ese revert>; si no, los cambios del merge original se quedan fuera.
Anterior: Curso de Git 4: rebase, cherry-pick e historia · Siguiente: Curso de Git 6: internos, objetos y rendimiento · Antes: primer commit · Volver a la ruta de estudio para tu primer trabajo.
Preguntas frecuentes
¿Qué diferencia hay entre git reset --soft, --mixed y --hard?
Los tres mueven la rama actual a otro commit. --soft deja los cambios deshechos en el staging, --mixed (el modo por defecto) los deja en los archivos pero fuera del staging, y --hard sobrescribe el staging y los archivos rastreados. En nuestra prueba, --hard además borró un archivo nuevo que estaba en staging; solo se pudo recuperar su contenido, como un blob colgante (dangling) encontrado con git fsck --lost-found.
¿Cómo recupero un commit después de git reset --hard?
Ejecuta git reflog, busca la entrada anterior al reset (HEAD@{1} en nuestra prueba, u ORIG_HEAD justo después del reset) y ejecuta git reset --hard HEAD@{1} o crea una rama ahí. Funciona mientras exista la entrada del reflog; después de git reflog expire --expire=now --all y git gc --prune=now el commit desapareció y Git respondió 'unknown revision'.
¿Uso git revert o git reset en una rama compartida?
git revert. Agrega un commit nuevo que deshace uno anterior y nunca reescribe la historia, así que los push siguen siendo fast-forward. Revertir un merge necesita -m 1, y si después quieres volver a hacer merge de esa rama primero tienes que revertir el revert; si no, Git solo integra los commits nuevos y deja fuera los cambios viejos sin avisar, como mostró nuestra prueba.