La lección anterior dejó el catálogo montado y resolvió los casos de un minuto. Ahora toca el comando que aparecía en media docena de filas de aquella tabla y que llevamos cinco módulos prometiendo: git reset.

Es probablemente el comando peor entendido de Git. Tiene tres modos que hacen cosas distintas, dos formas de invocarlo que se comportan de manera completamente diferente, y una fama de peligroso que es merecida solo en uno de sus usos. La confusión no es casual: reset hace dos trabajos que en otros sistemas serían dos comandos distintos.

Esta lección lo desmonta pieza a pieza y después lo pone en contexto. Porque reset no es la respuesta a "quiero deshacer algo": es una de las respuestas, y elegir la equivocada es lo que convierte un cambio no deseado en un problema para todo el equipo. Al final tendrás una tabla de decisión que responde a la única pregunta que importa: ¿dónde está lo que quiero deshacer?

Y una advertencia que vale para toda la lección: aquí aparece el único comando de Git que destruye de verdad, sin red de seguridad posible. No es reset. Es git clean.

Contenido

  1. Las tres zonas, otra vez
  2. git reset mueve la rama: la idea central
  3. Los tres modos: --soft, --mixed, --hard
  4. Casos ordenados por lo que quieres deshacer
  5. git reset <commit> -- <ruta>: la otra forma
  6. git restore: el comando moderno para el trabajo diario
  7. git clean: el que sí destruye
  8. ORIG_HEAD: la red de seguridad inmediata
  9. La tabla de decisión

  1. Las tres zonas, otra vez

Todo reset se entiende en cuanto se recuperan las tres zonas de la lección 01-03. Merece la pena tenerlas delante:

flowchart LR
    WD["Directorio de trabajo<br/>(los ficheros que editas)"]
    IDX["Índice / staging<br/>(.git/index)"]
    HEAD["HEAD → rama → commit<br/>(la base de datos)"]

    WD -->|"git add"| IDX
    IDX -->|"git commit"| HEAD
    HEAD -->|"git reset --hard"| WD
    HEAD -->|"git reset --mixed"| IDX

Tres fotos del proyecto que pueden coincidir o no:

Zona Qué contiene Dónde vive
HEAD El último commit de la rama actual .git/refs/heads/<rama> (41 bytes)
Índice Lo que entraría en el próximo commit .git/index (binario)
Directorio de trabajo Los ficheros reales en disco Tu carpeta

Cuando git status dice "Changes to be committed", está comparando índice con HEAD. Cuando dice "Changes not staged for commit", compara directorio de trabajo con índice. Con eso claro, reset deja de ser un misterio: cada modo decide hasta cuál de esas tres zonas llega el cambio.

  1. git reset mueve la rama: la idea central

Antes de los modos, la operación básica. Esto es lo que hace git reset <commit> siempre, en los tres modos:

Mueve la rama actual para que apunte a <commit>. Y como HEAD apunta a la rama, HEAD se mueve con ella.

No borra commits. No modifica commits. Reescribe un fichero de 41 bytes (lección 03-01).

flowchart LR
    subgraph A["Antes"]
        direction LR
        a1["e91d4a8"] --> a2["4f8a2e6"] --> a3["b52c9d1"] --> a4["7d3a8f4"]
        ra(["GT-241"]) -.-> a4
        ha(["HEAD"]) -.-> ra
    end
    subgraph B["git reset HEAD~2"]
        direction LR
        b1["e91d4a8"] --> b2["4f8a2e6"] --> b3["b52c9d1<br/>(inalcanzable)"] --> b4["7d3a8f4<br/>(inalcanzable)"]
        rb(["GT-241"]) -.-> b2
        hb(["HEAD"]) -.-> rb
    end
    A ==> B

b52c9d1 y 7d3a8f4 siguen existiendo íntegros en .git/objects. Lo que ha cambiado es que GT-241 ya no los alcanza, así que git log no los muestra. Recuperarlos es trivial mientras el reflog los recuerde (lección 09-04).

De aquí salen las dos consecuencias que hay que interiorizar:

1. reset es seguro sobre trabajo local. Aunque te equivoques, los commits siguen ahí.

2. reset es peligroso sobre trabajo publicado. Porque mover la rama hacia atrás y volver a avanzar produce una historia distinta de la que otros tienen. El push será rechazado (non-fast-forward), y forzarlo destruiría el trabajo de los demás. Es la regla de oro de la lección 05-01, y su tratamiento completo es la lección 09-03.

Un tercer matiz que casi nadie conoce y que evita muchos sustos:

git reset            # sin commit: equivale a git reset --mixed HEAD

Sin argumento de commit, reset usa HEAD, es decir, no mueve nada. Solo actúa sobre el índice. Por eso git reset a secas es completamente inofensivo: vacía el área de preparación y deja tus ficheros intactos.

  1. Los tres modos: --soft, --mixed, --hard

La diferencia entre los tres es hasta dónde propaga el cambio.

Modo Mueve HEAD/rama Reescribe el índice Reescribe el directorio de trabajo ¿Destruye trabajo?
--soft No No No
--mixed (por defecto) No No
--hard : lo no confirmado
--merge Parcialmente (conserva cambios locales no conflictivos) Rara vez
--keep Parcialmente (aborta si hay conflicto) No

Visualmente, sobre las tres zonas:

flowchart TD
    subgraph S["--soft"]
        s1["HEAD ⟵ se mueve"]
        s2["Índice: intacto"]
        s3["Trabajo: intacto"]
    end
    subgraph M["--mixed (por defecto)"]
        m1["HEAD ⟵ se mueve"]
        m2["Índice ⟵ se reescribe"]
        m3["Trabajo: intacto"]
    end
    subgraph H["--hard"]
        h1["HEAD ⟵ se mueve"]
        h2["Índice ⟵ se reescribe"]
        h3["Trabajo ⟵ SE SOBRESCRIBE"]
    end

--soft: deshacer el commit, conservarlo todo preparado

git reset --soft HEAD~1
git status --short
M  app.js
A  estilos.css

El commit ha desaparecido de la rama, y todo su contenido está preparado, listo para volver a confirmarse. Los M y A con la letra en la primera columna significan "en el índice".

Es el modo de "quiero rehacer este commit". De hecho, es exactamente lo que hace git commit --amend por dentro.

--mixed: deshacer el commit y despreparar

git reset HEAD~1          # --mixed es el defecto
git status --short
 M app.js
?? estilos.css

Ahora la letra está en la segunda columna: los cambios están en el directorio de trabajo, sin preparar. Y estilos.css, que era nuevo, ha vuelto a ser un fichero sin seguimiento.

Es el modo de "quiero rehacer este commit y volver a elegir qué entra". Es el que usaste en la lección 05-02 para dividir un commit en dos.

--hard: deshacer el commit y el contenido

git reset --hard HEAD~1
git status
On branch GT-241
nothing to commit, working tree clean

Todo limpio, como si aquel commit nunca hubiera existido. Y aquí está el peligro, que conviene enunciar con precisión:

--hard no destruye los commits (siguen en la base de datos). Destruye los cambios sin confirmar que hubiera en ese momento en el índice y en el directorio de trabajo.

Eso es lo que no tiene vuelta atrás, porque nunca estuvo en .git/objects (lección 09-01, tabla del apartado 1).

Una excepción importante y tranquilizadora: --hard no toca los ficheros sin seguimiento. Si tenías un notas.txt que nunca añadiste, sigue ahí. Lo que se lleva por delante son las modificaciones de ficheros seguidos.

La comparación en un solo experimento

# Punto de partida: un commit, y además un cambio sin confirmar
git log --oneline -2
git status --short
7d3a8f4 (HEAD -> GT-241) GT-241 filtro de pendientes
b52c9d1 GT-241 base del filtro
 M app.js
Comando git log -1 app.js (mi cambio sin confirmar) Contenido del commit deshecho
git reset --soft HEAD~1 b52c9d1 Conservado En el índice
git reset --mixed HEAD~1 b52c9d1 Conservado (mezclado) En el directorio de trabajo
git reset --hard HEAD~1 b52c9d1 PERDIDO Descartado

La fila del --hard es la que hay que memorizar: se lleva dos cosas a la vez, el commit y tu trabajo en curso. Y solo una de las dos se puede recuperar.

--merge y --keep: los modos que casi nadie usa

Existen y en dos situaciones son exactamente lo que quieres:

# Mueve la rama pero intenta CONSERVAR mis cambios locales
git reset --keep <commit>

--keep es un --hard educado: mueve la rama y actualiza los ficheros que difieren, pero aborta si eso pisaría un cambio local. Es la opción sensata cuando quieres retroceder y no estás seguro de tener todo confirmado:

error: Entry 'app.js' not uptodate. Cannot merge.

Ese error, aquí, es una buena noticia: te ha salvado.

--merge es parecido pero intenta fusionar los cambios locales en el destino. Se usa sobre todo para abortar una fusión a mano.

Regla práctica: cuando tengas la tentación de escribir --hard y no estés seguro, escribe --keep. Si funciona, no había nada que perder; si falla, acabas de evitar el problema.

  1. Casos ordenados por lo que quieres deshacer

Este es el recetario. Está ordenado de menor a mayor alcance.

4.1. Descartar cambios de un fichero en el directorio de trabajo

Ana ha estado probando cosas en estilos.css y quiere volver a como estaba.

git restore estilos.css              # forma moderna (Git 2.23+)
git checkout -- estilos.css          # forma antigua, equivalente

Esto sí destruye, y sin red: las modificaciones no confirmadas no están en ninguna parte. Git ni siquiera pregunta.

# Antes de destruir, mira qué vas a perder
git diff estilos.css

# Todo el directorio de trabajo
git restore .

4.2. Sacar algo del índice sin perder el cambio

Bruno ha hecho git add . y ha metido sin querer notas-personales.md.

git restore --staged notas-personales.md     # forma moderna
git reset notas-personales.md                # forma antigua, equivalente

No has perdido nada: el fichero sigue modificado en el directorio de trabajo, solo deja de estar preparado.

git status --short
M  app.js
?? notas-personales.md

Fíjate en la asimetría, que es la fuente de confusión número uno:

Quita del índice Destruye el cambio
git restore --staged <f> No
git restore <f> No
git restore --staged --worktree <f>

git restore sin opciones actúa sobre el directorio de trabajo. Con --staged, sobre el índice. Es lo contrario de lo que mucha gente asume, y es la razón de que git restore merezca su propio apartado (el 6).

4.3. Deshacer el último commit conservando los cambios

El caso más frecuente de todos. Carla ha confirmado y se ha dado cuenta de que el commit mezcla dos cosas.

git reset --soft HEAD~1

No has perdido nada. El commit ya no está en la rama, pero todo su contenido está preparado. Ahora puede rehacerlo:

git status --short
M  app.js
M  estilos.css

Si además quiere volver a elegir qué entra en cada commit, --mixed y git add -p (lección 02-04):

git reset HEAD~1
git add -p app.js            # elegir trozo a trozo
git commit -m "GT-241 calcular el contador sobre la lista completa"
git add .
git commit -m "GT-241 estilos del indicador de pendientes"

Ese es el flujo completo de "he mezclado dos cosas en un commit", y es el que se usa a diario.

4.4. Solo quiero cambiar el mensaje

git commit --amend

No hace falta reset para esto. --amend sustituye el último commit (lección 02-04), y con -m ni siquiera abre el editor:

git commit --amend -m "GT-241 calcular el contador sobre la lista completa"

Recuerda que el hash cambia: si estaba publicado, esto reescribe historial.

4.5. Tirar los últimos N commits

git reset --hard HEAD~3

Tres commits fuera de la rama. Antes de ejecutarlo, dos costumbres que cuestan cinco segundos:

# 1. Anota dónde estás
git log --oneline -1
git rev-parse HEAD > /tmp/donde-estaba.txt

# 2. O mejor: pon un marcador. Es gratis y no se olvida
git branch respaldo-GT-241

Con git branch respaldo-GT-241, el --hard deja de ser irreversible en ningún sentido: los commits siguen alcanzables desde el respaldo. Es el mismo patrón que en la lección 09-01, apartado 5.2, y el que se generaliza en la 09-04.

Y si ya lo hiciste sin respaldo: tampoco pasa nada, mientras no fueran cambios sin confirmar.

git reflog -5                       # busca el hash anterior al reset
git reset --hard b52c9d1            # o mejor: git branch rescate b52c9d1

4.6. Volver la rama exactamente a como está en el remoto

git fetch origin
git reset --hard origin/main

Es el "descarta todo lo mío y déjame como el servidor". Útil cuando tu copia local se ha enredado sin remedio y sabes que no hay nada tuyo que salvar.

Comprueba antes qué vas a tirar:

git log --oneline origin/main..HEAD      # commits míos que no están en el remoto
git status --short                       # cambios sin confirmar

Si la primera lista no está vacía, no ejecutes el --hard sin crear antes una rama de respaldo.

4.7. Deshacer algo que ya está publicado

Aquí reset no es la respuesta. Reescribir una rama publicada obliga a forzar el push y destruye el trabajo de quien tenga la versión anterior.

La respuesta es git revert (lección 05-06): crea un commit nuevo con el cambio inverso, no reescribe nada, no hace falta forzar, y queda constancia.

git revert 4f8a2e6
git push

La única excepción: una rama publicada que es exclusivamente tuya (tu rama de trabajo GT-241, que nadie más usa). Ahí sí puedes reescribir y forzar con --force-with-lease, y eso es la lección 09-03.

  1. git reset <commit> -- <ruta>: la otra forma

Aquí está la fuente principal de confusión con reset, y merece un apartado propio.

Cuando añades una ruta, git reset hace algo completamente distinto:

git reset <commit> -- <ruta> NO mueve la rama. Copia el estado de <ruta> en <commit> al índice, y deja el directorio de trabajo intacto.

Forma ¿Mueve la rama? ¿Toca el índice? ¿Toca el directorio de trabajo?
git reset <commit> Según el modo Según el modo
git reset <commit> -- <ruta> No, nunca Sí, solo esa ruta No, nunca

Por qué no puede mover la rama: mover HEAD a otro commit es una operación sobre el repositorio entero. No existe "mover la rama solo para un fichero". Así que cuando das una ruta, Git entiende que quieres la otra operación: rellenar el índice desde un commit.

Consecuencia directa: git reset <commit> -- <ruta> no acepta --hard.

git reset --hard HEAD~1 -- app.js
fatal: Cannot do hard reset with paths.

Precisamente porque el modo --hard implica tocar el directorio de trabajo, y esta forma nunca lo hace.

Para qué sirve realmente

Es la forma de preparar el contenido antiguo de un fichero, típicamente para deshacer solo una parte de un commit:

# El commit 4f8a2e6 tocó tres ficheros; quiero deshacer solo lo de estilos.css
git reset 4f8a2e6~1 -- estilos.css
git status --short
M  estilos.css

El índice tiene ahora la versión anterior de estilos.css, preparada. Un git commit crea un commit que deshace solo esa parte.

Y si además quieres que el fichero en disco cambie, la forma moderna y mucho más clara es git restore:

git restore --source=4f8a2e6~1 --staged --worktree estilos.css

El caso "ese fichero no debería estar en el repositorio"

Retomando la lección 08-03:

git rm --cached configuracion-local.json

git rm --cached quita el fichero del índice (deja de tener seguimiento) pero lo conserva en disco. Es la operación que hay que hacer cuando algo entró en el repositorio y luego lo añadiste al .gitignore. Es una prima hermana de git reset -- <ruta>, pero para dejar de seguir el fichero en lugar de restaurar su contenido.

  1. git restore: el comando moderno para el trabajo diario

Git 2.23 partió el viejo git checkout en dos comandos con nombres honestos: git switch para las ramas (lección 03-02) y git restore para los ficheros. Es la herramienta que deberías usar a diario, dejando reset para lo que de verdad implica mover la rama.

# Descartar cambios del directorio de trabajo
git restore app.js
git restore .

# Quitar del índice (conservando el cambio en disco)
git restore --staged app.js

# Ambas cosas: dejar el fichero como en HEAD, sin rastro del cambio
git restore --staged --worktree app.js

# Traer un fichero desde otro commit o rama
git restore --source=HEAD~3 app.js
git restore --source=origin/main estilos.css

# Interactivo: elegir trozo a trozo qué descartar
git restore -p app.js

La tabla de equivalencias con lo antiguo, porque vas a ver las dos formas en documentación y en respuestas de foro:

Objetivo Moderno Antiguo
Descartar cambios en disco git restore <f> git checkout -- <f>
Quitar del índice git restore --staged <f> git reset HEAD <f>
Traer un fichero de otro commit git restore --source=<c> <f> git checkout <c> -- <f>
Cambiar de rama git switch <rama> git checkout <rama>
Crear y cambiar git switch -c <rama> git checkout -b <rama>

git restore -p merece una mención aparte. Es el hermano de git add -p: te enseña cada trozo del diff y te pregunta si quieres descartarlo. Es la forma quirúrgica de deshacer parte de un cambio sin perder el resto, y evita el "voy a descartar todo y rehacerlo".

git restore -p app.js
@@ -12,6 +12,9 @@ function renderizarTareas() {
   lista.innerHTML = '';
+  console.log('DEBUG tareas', tareas);
   tareas.forEach(tarea => {
Discard this hunk from worktree [y,n,q,a,d,e,?]?

Perfecto para quitar los console.log de depuración conservando el trabajo real.

  1. git clean: el que sí destruye

Todo lo anterior tiene red de seguridad en algún grado. Este comando no tiene ninguna, y por eso merece el tono más serio de la lección.

git clean borra ficheros sin seguimiento. Y un fichero sin seguimiento nunca ha pasado por .git/objects: no hay commit, no hay blob, no hay reflog, no hay fsck que valga. Cuando git clean lo borra, está borrado, igual que con rm.

La regla absoluta: -n primero, siempre

git clean -n
Would remove volcado.sql
Would remove capturas/
Would remove notas-personales.md

-n (o --dry-run) no borra nada: solo lista. Léelo entero. Si en esa lista aparece algo que te importa, no ejecutes el comando real.

Esta costumbre no es opcional. Es la diferencia entre limpiar el proyecto y perder el fichero de configuración local que llevas seis meses manteniendo a mano.

Las opciones

Opción Qué hace Peligro
-n / --dry-run Solo lista lo que borraría Ninguno
-f / --force Borra de verdad (obligatoria: sin ella no hace nada) Alto
-d Incluye directorios sin seguimiento Alto
-x Incluye también lo ignorado por .gitignore Muy alto
-X Borra solo lo ignorado, conservando lo demás Medio
-i / --interactive Pregunta fichero a fichero Bajo
-e <patrón> Excluye lo que coincida

Los usos habituales:

# Lo que casi siempre quieres: ver, y luego borrar ficheros y directorios nuevos
git clean -nd
git clean -fd

# Dejar el proyecto como recién clonado (¡también borra node_modules, .env, builds!)
git clean -ndx
git clean -fdx

# Borrar solo lo ignorado: limpiar artefactos de compilación conservando lo demás
git clean -fdX

# El modo seguro cuando dudas
git clean -id

-x es la que hay que mirar dos veces. Borra lo que .gitignore protege, y ahí suele estar precisamente lo que no está en el repositorio pero sí te importa: .env con la configuración local, credenciales de desarrollo, bases de datos de prueba.

El modo interactivo

git clean -id
Would remove the following items:
  capturas/  notas-personales.md  volcado.sql

*** Commands ***
    1: clean                2: filter by pattern    3: select by numbers
    4: ask each             5: quit                 6: help
What now> 4

La opción 4 (ask each) pregunta uno por uno. Cuando hay muchos ficheros y solo algunos sobran, es la forma correcta.

El "reset total" y su coste

La combinación que circula por internet como "dejarlo todo limpio":

git reset --hard HEAD && git clean -fdx

Eso deja el directorio exactamente como un clon recién hecho de ese commit. Y se lleva por delante, sin preguntar y sin recuperación posible:

  • Todos los cambios sin confirmar de ficheros seguidos.
  • Todos los ficheros nuevos que no habías añadido.
  • Todo lo ignorado: .env, node_modules/, configuraciones locales, bases de datos de prueba.

Es un comando legítimo —en CI, en un contenedor, en un repositorio de usar y tirar— y una catástrofe en la máquina de alguien que llevaba dos horas trabajando. Nunca lo pegues sin haber ejecutado antes git clean -ndx y leído la lista.

La única prevención que funciona

# Antes de cualquier limpieza dudosa
git stash push -u -m "por si acaso"

git stash -u (lección 05-04) guarda también los ficheros sin seguimiento, y los guarda como objetos de Git. Con eso, lo que ibas a borrar pasa a estar en la base de datos y, por tanto, deja de ser irrecuperable. Cuesta un segundo.

  1. ORIG_HEAD: la red de seguridad inmediata

Antes de una operación que mueve HEAD de forma sustancial —reset, merge, rebase, pull—, Git guarda la posición anterior en una referencia especial:

cat .git/ORIG_HEAD
git rev-parse ORIG_HEAD

Así que el "deshacer" inmediato de un reset es:

git reset --hard ORIG_HEAD

O, siguiendo el patrón seguro:

git branch rescate ORIG_HEAD

Dos advertencias sobre ORIG_HEAD:

  • Solo guarda una posición, la de la última operación. Si haces dos reset seguidos, la primera se pierde. Para eso está el reflog, que guarda todo el historial de movimientos (lección 09-04).
  • No recupera cambios sin confirmar. ORIG_HEAD es un hash de commit: devuelve la rama a su sitio, no tu trabajo en curso.

Es la red de seguridad de los treinta segundos siguientes. Para todo lo demás, la 09-04.

  1. La tabla de decisión

La pregunta correcta no es "¿qué comando uso para deshacer?", sino "¿dónde está lo que quiero deshacer?". Con esa respuesta, el comando es inmediato.

flowchart TD
    Q1{"¿Está confirmado?"}
    Q1 -->|No| Q2{"¿Está preparado<br/>(git add)?"}
    Q1 -->|Sí| Q3{"¿Está publicado<br/>en el remoto?"}

    Q2 -->|No| R1["git restore &lt;f&gt;<br/>(o git clean si no tiene seguimiento)"]
    Q2 -->|Sí| R2["git restore --staged &lt;f&gt;"]

    Q3 -->|No| Q4{"¿Solo el último<br/>commit?"}
    Q3 -->|Sí| R5["git revert (05-06)<br/>o --force-with-lease si la rama es tuya (09-03)"]

    Q4 -->|Sí| R3["git commit --amend<br/>o git reset --soft HEAD~1"]
    Q4 -->|No| R4["git reset HEAD~N<br/>o git rebase -i (05-02)"]

Y en forma de tabla, que es como se consulta en el momento:

Quiero deshacer... Estado Comando ¿Recuperable?
Cambios en un fichero editado Sin confirmar git restore <f> No
Parte de los cambios de un fichero Sin confirmar git restore -p <f> No
Un git add Preparado git restore --staged <f> Sí (no se pierde nada)
Un fichero nuevo que sobra Sin seguimiento git clean -n y luego -f No
El último commit, conservando el trabajo Sin publicar git reset --soft HEAD~1 Sí (reflog)
El mensaje del último commit Sin publicar git commit --amend Sí (reflog)
Un fichero olvidado en el último commit Sin publicar git add <f> && git commit --amend --no-edit Sí (reflog)
Los últimos N commits y su contenido Sin publicar git reset --hard HEAD~N Sí los commits; no lo sin confirmar
Un commit del medio de la rama Sin publicar git rebase -i con drop (05-02) Sí (reflog)
Solo un fichero de un commit Sin publicar git reset <c>~1 -- <f> y confirmar
Un commit ya publicado Publicado git revert <sha> (05-06) Sí (es aditivo)
Una fusión ya publicada Publicado git revert -m 1 <sha> (05-06)
Un commit en mi rama personal publicada Publicado, rama propia reset + push --force-with-lease (09-03) Sí, avisando
Todo: volver al estado del remoto Cualquiera git fetch && git reset --hard origin/<rama> Los commits sí; lo sin confirmar no
Todo, incluidos ficheros nuevos Cualquiera git reset --hard && git clean -fdx No lo no confirmado

La fila que gobierna todas las demás es la de "¿está publicado?". Si la respuesta es sí y la rama es compartida, reset sale de la lista y la única herramienta correcta es revert.

Errores Comunes y Consejos

Error 1: creer que reset --hard borra commits. No los borra: mueve la rama. Los commits siguen en .git/objects y el reflog los encuentra (09-04). Lo que sí destruye son los cambios sin confirmar.

Error 2: usar --hard cuando bastaba --soft. Si solo querías rehacer el commit, --soft conserva todo preparado. --hard se lleva además tu trabajo en curso.

Error 3: confundir git restore <f> con git restore --staged <f>. El primero destruye el cambio en disco; el segundo solo lo saca del índice y no pierde nada. Son casi opuestos.

Error 4: esperar que git reset <commit> -- <ruta> mueva la rama. No lo hace nunca, y por eso --hard con rutas da error. Es otra operación con el mismo nombre.

Error 5: ejecutar git clean -fdx copiado de internet. Borra .env, configuraciones locales y todo lo ignorado, sin recuperación posible. -n primero, siempre.

Error 6: hacer reset en una rama compartida y forzar el push. Destruye el trabajo de quien tuviera los commits. En lo publicado, revert (05-06).

Error 7: usar git reset --hard origin/main sin comprobar qué hay tuyo por delante. git log --oneline origin/main..HEAD cuesta un segundo y te dice exactamente qué vas a tirar.

Error 8: olvidar que --hard respeta los ficheros sin seguimiento. No es un error grave, pero explica el "he hecho reset --hard y esto sigue aquí": no tenía seguimiento.

Consejo 1: cuando dudes entre --hard y no hacer nada, usa --keep. Hace lo mismo, pero aborta en lugar de pisar cambios locales.

Consejo 2: git branch respaldo-<algo> antes de cualquier reset --hard. Cuesta dos segundos y convierte la operación en trivialmente reversible.

Consejo 3: usa git restore y git switch en lugar de git checkout. Los nombres dicen lo que hacen y evitan errores por confusión.

Consejo 4: git restore -p para quitar los console.log. Quirúrgico, y conserva el resto del trabajo.

Consejo 5: git stash push -u antes de cualquier limpieza. Convierte lo irrecuperable en recuperable por un segundo de esfuerzo.

Consejo 6: recuerda ORIG_HEAD. El deshacer inmediato de un reset, merge, rebase o pull que acabas de lamentar.

Ejercicios

Ejercicio 1: los tres modos, en el mismo punto de partida

  1. Crea un repositorio con app.js y tres commits. En el tercero, añade también estilos.css.
  2. Modifica app.js sin confirmar y crea un fichero nuevo notas.txt sin añadirlo.
  3. Ejecuta git reset --soft HEAD~1 y anota la salida de git status --short y git log --oneline -1.
  4. Deshaz con git reset --hard ORIG_HEAD... y observa qué ha pasado con tu modificación de app.js. Explícalo.
  5. Repite el experimento completo con --mixed y con --hard, y rellena una tabla con tres columnas: log -1, cambio sin confirmar en app.js, y notas.txt.
  6. Explica por qué notas.txt sobrevive incluso a --hard.

Ejercicio 2: reset con ruta frente a reset sin ruta

  1. Sobre un repositorio con cuatro commits, donde el tercero modificó app.js, estilos.css e index.html, intenta git reset --hard HEAD~1 -- app.js y explica el error.
  2. Usa git reset HEAD~2 -- estilos.css y comprueba con git status, git diff y git diff --cached en qué zona ha quedado el cambio.
  3. Confirma. Comprueba que has deshecho solo la parte de estilos.css del tercer commit y que el resto sigue en el proyecto.
  4. Consigue el mismo resultado con git restore --source=... --staged --worktree y compara el estado del directorio de trabajo en ambos casos.

Ejercicio 3: el límite real de la recuperación

  1. En un repositorio de pruebas, crea tres situaciones a la vez: un commit sin publicar, un cambio preparado con git add, y un cambio solo en disco.
  2. Ejecuta git reset --hard HEAD~1.
  3. Recupera el commit usando el reflog.
  4. Intenta recuperar el cambio preparado con git fsck --lost-found y git cat-file -p.
  5. Intenta recuperar el cambio que estaba solo en disco. Documenta por qué no es posible.
  6. Repite todo el ejercicio ejecutando antes git stash push -u y explica qué cambia.

Soluciones

Solución 1:

mkdir /tmp/p9-02 && cd /tmp/p9-02 && git init -q -b main
git config user.name "Ana Ferrer"; git config user.email "[email protected]"

echo "// gestor-tareas" > app.js && git add . && git commit -q -m "chore: inicio"
echo "// listado" >> app.js && git commit -q -am "feat: listado"
echo "// filtro" >> app.js && echo "body{margin:0}" > estilos.css
git add . && git commit -q -m "feat: filtro y estilos"

# 2. Estado sucio antes del reset
echo "// TRABAJO SIN CONFIRMAR" >> app.js
echo "notas privadas" > notas.txt
git status --short
 M app.js
?? notas.txt
# 3. --soft
git reset --soft HEAD~1
git log --oneline -1
git status --short
b52c9d1 feat: listado
M  app.js
A  estilos.css
?? notas.txt

El commit ha salido de la rama y su contenido está preparado (M y A en la primera columna). Y aquí hay un detalle fino: app.js aparece una sola vez porque el índice contiene tanto lo del commit deshecho como tu modificación sin confirmar aún fuera... en realidad la modificación de app.js sigue en el directorio de trabajo, y el índice tiene la versión del commit deshecho. Compruébalo:

git diff --cached --stat     # lo del commit deshecho, preparado
git diff --stat              # tu modificación sin confirmar, aún pendiente
 app.js      | 1 +
 estilos.css | 1 +
 app.js | 1 +

Nada se ha perdido: las dos capas están intactas y separadas.

# 4. Deshacer con ORIG_HEAD
git reset --hard ORIG_HEAD
git log --oneline -1
git status --short
grep -c "TRABAJO SIN CONFIRMAR" app.js
7d3a8f4 feat: filtro y estilos
?? notas.txt
0

La rama ha vuelto a su sitio, pero la modificación de app.js ha desaparecido: --hard la ha sobrescrito. ORIG_HEAD devuelve la posición de la rama, no tu trabajo en curso. Es exactamente la advertencia del apartado 8.

# 5. La tabla completa (rehaciendo el estado sucio cada vez)
Comando git log -1 Modificación de app.js notas.txt
git reset --soft HEAD~1 b52c9d1 Conservada (en disco) Intacto
git reset --mixed HEAD~1 b52c9d1 Conservada (mezclada con lo del commit) Intacto
git reset --hard HEAD~1 b52c9d1 PERDIDA Intacto
# 6. Por qué notas.txt sobrevive
git ls-files | grep notas
(sin salida)

notas.txt no está en el índice: Git no lo sigue. reset opera sobre HEAD, el índice y los ficheros que Git conoce; un fichero sin seguimiento le es invisible. Para borrarlo hace falta git clean (apartado 7).

Solución 2:

mkdir /tmp/p9-02-b && cd /tmp/p9-02-b && git init -q -b main
echo "v1" > app.js; echo "v1" > estilos.css; echo "v1" > index.html
git add . && git commit -q -m "c1"
echo "v2" > app.js && git commit -q -am "c2"
echo "v3" > app.js; echo "v3" > estilos.css; echo "v3" > index.html
git add . && git commit -q -m "c3: toca los tres ficheros"
echo "v4" > app.js && git commit -q -am "c4"
# 1. El error
git reset --hard HEAD~1 -- app.js
fatal: Cannot do hard reset with paths.

Con una ruta, reset nunca toca el directorio de trabajo ni mueve la rama; --hard implica ambas cosas, así que es contradictorio. Git lo rechaza en lugar de hacer algo a medias.

# 2. La forma correcta
git reset HEAD~2 -- estilos.css      # HEAD~2 = c2, es decir, antes de c3
git status --short
M  estilos.css
git diff --cached estilos.css        # índice vs HEAD: sí hay cambio
git diff estilos.css                 # trabajo vs índice: también, en sentido inverso
cat estilos.css
-v3
+v1
v3

Clave: el fichero en disco sigue siendo v3. Solo el índice tiene v1. Por eso git diff (trabajo vs índice) muestra el cambio inverso. Y la rama no se ha movido:

git log --oneline -1
9c4e7b2 c4
# 3. Confirmar solo esa parte
git commit -q -m "revert parcial: devolver estilos.css al estado previo a c3"
cat app.js estilos.css index.html
v4
v1
v3

app.js conserva v4, index.html conserva v3 (lo que aportó c3), y solo estilos.css ha vuelto atrás. Se ha deshecho una tercera parte de un commit.

Aunque atención: el fichero en disco seguía siendo v3 al confirmar. El commit dice v1 y el disco dice v3, así que ahora hay una modificación pendiente. Esa asimetría es justo lo que evita restore:

# 4. La forma moderna, que deja disco e índice coherentes
git restore --source=HEAD~3 --staged --worktree estilos.css
git status --short
cat estilos.css
M  estilos.css
v1

Índice y directorio de trabajo coinciden. Esa es la diferencia práctica: reset -- <ruta> solo prepara; restore --staged --worktree prepara y escribe en disco. Para el uso diario, restore es más predecible.

Solución 3:

mkdir /tmp/p9-02-c && cd /tmp/p9-02-c && git init -q -b main
echo "base" > app.js && git add . && git commit -q -m "c1"

# 1. Las tres situaciones
echo "COMMIT SIN PUBLICAR" >> app.js && git commit -q -am "c2: trabajo confirmado"
echo "CAMBIO PREPARADO" >> estilos.css && git add estilos.css
echo "CAMBIO SOLO EN DISCO" >> app.js
git status --short
A  estilos.css
 M app.js
# 2. El desastre
git reset --hard HEAD~1
git status --short           # (sin salida)
cat app.js
ls
base
app.js

Todo fuera: el commit, el fichero preparado y la modificación en disco.

# 3. El commit vuelve
git reflog -3
3f8a1d6 HEAD@{0}: reset: moving to HEAD~1
8f4c2a9 HEAD@{1}: commit: c2: trabajo confirmado
3f8a1d6 HEAD@{2}: commit (initial): c1
git branch rescate 8f4c2a9
git show rescate --stat
 app.js | 1 +

Recuperado íntegro.

# 4. El cambio preparado también
git fsck --lost-found
dangling blob 6f2b9d4a8c1e5f3b7d9a2c6e4f8b1d3a5c7e9f2b
git cat-file -p 6f2b9d4
CAMBIO PREPARADO
git cat-file -p 6f2b9d4 > estilos.css     # recuperado

El git add lo salvó. Al preparar el fichero, Git creó el blob y lo escribió en .git/objects. El reset --hard quitó la referencia desde el índice, pero el objeto se quedó.

# 5. El cambio que estaba solo en disco
git fsck --lost-found | wc -l
grep -c "SOLO EN DISCO" $(git rev-list --objects --all | awk '{print $1}' > /dev/null; echo app.js)
1
0

Solo hay un objeto colgante, el del add. La línea CAMBIO SOLO EN DISCO no existe en ninguna parte de .git. Nunca se calculó su hash, nunca se comprimió, nunca se escribió. Git no puede recuperar algo de lo que jamás tuvo noticia. Las únicas vías reales serían el historial local de tu editor (VS Code, IntelliJ y Vim con undofile lo guardan) o una copia de seguridad del sistema de ficheros.

# 6. Con stash previo, todo cambia
cd /tmp && rm -rf p9-02-d && mkdir p9-02-d && cd p9-02-d && git init -q -b main
echo "base" > app.js && git add . && git commit -q -m "c1"
echo "COMMIT SIN PUBLICAR" >> app.js && git commit -q -am "c2"
echo "CAMBIO PREPARADO" >> estilos.css && git add estilos.css
echo "CAMBIO SOLO EN DISCO" >> app.js
echo "fichero nuevo sin añadir" > borrador.txt

git stash push -u -m "por si acaso"
git reset --hard HEAD~1
git stash list
stash@{0}: On main: por si acaso
git stash pop
git status --short
cat app.js | tail -1
ls
A  estilos.css
 M app.js
?? borrador.txt
CAMBIO SOLO EN DISCO
app.js  borrador.txt  estilos.css

Todo intacto, incluido el fichero sin seguimiento gracias a -u. El stash convirtió las tres capas en commits ocultos dentro de la base de datos, y por tanto en algo recuperable incluso si se hubiera borrado el stash (lección 09-04).

Un segundo de git stash push -u compra la diferencia entre "he perdido dos horas" y "no ha pasado nada".

Conclusión

reset deja de dar miedo en cuanto se entiende que hace dos cosas con el mismo nombre.

  • git reset <commit> mueve la rama. No borra commits: reescribe un fichero de 41 bytes. Los commits que dejan de ser alcanzables siguen íntegros en la base de datos.
  • Los tres modos se distinguen por hasta dónde llegan: --soft solo mueve HEAD; --mixed (por defecto) además reescribe el índice; --hard además sobrescribe el directorio de trabajo. Solo --hard destruye algo, y lo que destruye son los cambios sin confirmar, no los commits. Los ficheros sin seguimiento no los toca.
  • --keep es el --hard prudente: aborta en lugar de pisar tu trabajo local.
  • git reset <commit> -- <ruta> es otra operación: nunca mueve la rama, nunca toca el disco, solo rellena el índice. Por eso --hard con rutas es un error.
  • Para el trabajo diario, git restore: --staged para despreparar (no pierde nada), sin opciones para descartar en disco (destruye), --source para traer de otro commit y -p para hacerlo trozo a trozo.
  • git clean es el único comando de este módulo sin red de seguridad, porque lo que nunca se confirmó no está en .git/objects. -n antes que -f, siempre; -x merece leerse dos veces; git stash push -u lo convierte todo en recuperable.
  • ORIG_HEAD deshace el último reset, merge, rebase o pull. Solo guarda una posición.
  • Y la decisión, en una pregunta: ¿está publicado? Si no, reset, --amend o rebase -i. Si sí y la rama es compartida, revert (lección 05-06), sin excepciones.

Queda pendiente el caso que la tabla de decisión despacha con un "ver 09-03": qué hacer cuando lo que se ha enredado no está dentro de tu repositorio, sino entre tu repositorio y el servidor. El push rechazado, el mensaje de ramas divergentes, el compañero que reescribió el historial publicado y te dejó con trabajo colgando de una base que ya no existe.

Todo eso, y el cierre de la promesa que dejamos abierta en la lección 04-05, en la lección 09-03: Resolviendo Divergencias con el Remoto.

Dominando Git: De Principiante a Avanzado

Módulo 1: Introducción a Git

Módulo 2: Operaciones Básicas de Git

Módulo 3: Ramas y Fusión

Módulo 4: Trabajando con Repositorios Remotos

Módulo 5: Operaciones Avanzadas de Git

Módulo 6: Herramientas y Técnicas de Git

Módulo 7: Estrategias de Colaboración y Flujo de Trabajo

Módulo 8: Mejores Prácticas y Consejos de Git

Módulo 9: Solución de Problemas y Depuración

Módulo 10: Git en el Mundo Real

© Copyright 2026. Todos los derechos reservados