/rollback informó de un éxito y se comió en silencio el trabajo sin commitear de tu repo anidado


Parecía el deshacer más seguro de tu vida. Un refactor se torció, así que revisaste /rollback list, elegiste un checkpoint de hace dos horas y escribiste el número. El terminal dijo que todo había ido bien. Después haces cd a un subdirectorio para seguir y el trabajo sin commitear del repositorio que hay ahí simplemente ha desaparecido: no está en la papelera ni en un stash.

Ese es el issue #127073. Los checkpoint sí toman una instantánea de tu directorio de trabajo, pero cuando ese directorio contiene otro repositorio git, el repositorio interno se registra como un gitlink —una referencia a un commit a secas— y sus archivos nunca entran en la instantánea. Aun así, el rollback informaba de éxito, de modo que perdiste el trabajo y encima te llevaste un visto bueno falso. La corrección (PR #127839, que rescata #127077 de @dskwe) aterrizó en main el 2026-09-29.

Qué guardan los checkpoint y qué no guardaron nunca

La mecánica es lo que decide cuándo te toca a ti. Antes de cada llamada a write_file, patch o terminal, Hermes hace commit de una instantánea del directorio de trabajo en un repositorio bare compartido:

~/.hermes/checkpoints/
  ├── store/                 # one shared bare repo
  │   ├── refs/hermes/<hash> # per-project branch tip
  │   ├── indexes/<hash>     # per-project git index
  │   └── projects/<hash>.json

Usar el propio modelo de objetos de git mantiene las instantáneas baratas y comparables con diff, y ahí está exactamente el hueco: git no guarda el contenido de un subrepositorio dentro de su padre. Todo lo que contiene el árbol padre es un gitlink que apunta a un commit. Los cambios que no hayas commiteado en el repo interno no existen para el repositorio externo.

Eso da la peor combinación posible:

  • los archivos que el agente cambió en el árbol externo se restauran desde el checkpoint ✅
  • la misma operación reinicia el repositorio interno al commit que nombra su gitlink, descartando lo no commiteado ❌
  • y el comando devuelve éxito ✅ ← esta es la parte realmente peligrosa

Después del arreglo: negarse de entrada en vez de disculparse después

La nueva regla no se anda con rodeos: decide antes de tocar nada y, si el rollback fuera a alcanzar un gitlink, no cambies absolutamente nada.

El comportamiento, punto por punto:

  • Un rollback completo (/rollback <N>) se rechaza, porque restaurarlo todo cubre por fuerza el gitlink.
  • También se rechaza cualquier selección que toque un gitlink: el gitlink en sí, un archivo por debajo de él o un directorio padre que lo contenga. El rechazo ocurre antes de que se modifique cualquier archivo, índice, ref o instantánea de seguridad previa al rollback, y el error nombra los repositorios que no se capturaron.
  • Las selecciones que evitan gitlinks sí restauran, incluidos los pathspec de Git como *.txt, :(glob)… o :(exclude)tool. La implementación resuelve primero la selección contra un índice desechable: un comodín que alcanzaría un gitlink se rechaza, y uno que no lo alcanzaría se comporta exactamente como antes. Esto importa: el intento anterior de este arreglo rechazaba sin más cualquier pathspec, lo que eliminaba la recuperación con *.txt que sigue funcionando.
  • La protección usa la ruta registrada en el checkpoint, no el sistema de archivos de hoy. Antes, un notes.txt capturado que después pasó a ser un symlink hacia un repo anidado no se podía restaurar; ahora sí. A la inversa, un gitlink registrado que desde entonces se ha sustituido por un symlink sin relación con él se sigue rechazando. La validación de confinamiento de rutas (_validate_file_path) no cambia.

Más que un rechazo: ahora los checkpoint lo dicen en voz alta

Negarse no arregla lo que ya ha pasado, así que el parche añade también divulgación en el momento de la captura. Cualquier checkpoint que haya guardado un gitlink recibe una anotación añadida a su propio mensaje:

before write_file: app.py [nested git repos not captured: tool]

Con más de tres rutas obtienes las tres primeras más +N, por ejemplo [nested git repos not captured: a, b, c (+2)]. Como los listados de /rollback ya muestran la descripción de cada checkpoint, ves el aviso mientras eliges uno, no después de un rollback fallido.

El mismo parche corrigió un bug que ocultaba la anotación: la superficie rollback.list del TUI y de Desktop leía una clave message mientras el manager la llama reason, así que esos listados no mostraban nunca las descripciones de los checkpoint. Ya está mapeado, lo que hace visibles tanto las descripciones antiguas como la nueva etiqueta.

Un detalle multiplataforma: los componentes de ruta ahora se dividen con el propio Path de la plataforma, así que packages/nested y packages\nested se rechazan correctamente en Windows —el hallazgo sobre normalización de rutas que se planteó al revisar el PR anterior.

Qué hacer con todo esto

Una vez claros los entresijos, el consejo práctico es corto, y cada punto te ahorra horas:

  1. Haz commit dentro del subrepo antes de dejar que un agente toque el externo. Un gitlink apunta a un commit; solo queda referenciado el contenido commiteado. Si todavía no quieres ese trabajo en la línea principal, git stash o un commit desechable son mejores que confiar en que el checkpoint lo cubra.
  2. Lee la lista antes de hacer rollback. /rollback list muestra ahora [nested git repos not captured: …]. Ver esa anotación significa que este checkpoint solo puede restaurar los archivos externos: el repositorio interno lo gestionas tú.
  3. Usa pathspec para restauraciones quirúrgicas. /rollback 1 src/broken_file.py es mucho más seguro que un rollback de todo un directorio, y los comodines vuelven a estar soportados siempre que no toquen un gitlink.
  4. No cuentes con esto en los backends de contenedor. Con docker, singularity, modal, daytona o un plugin de contenedor, las rutas pertenecen al sandbox y no al host: Hermes no toma checkpoint para ellas, y /rollback lista los checkpoint del host que existan pero rechaza diff y restore, tanto en el CLI como en los chats de mensajería. /diff session da la misma razón. Los backends local y SSH no se ven afectados.

Cuatro barreras de seguridad que ya existían merecen recordarse cuando una montaña rusa se convierte en «¿y por qué no puedo restaurar nada?»: los directorios con más de 50 000 archivos se omiten, un solo archivo que supere max_file_size_mb (10 MB por defecto) se queda fuera de la instantánea, el store elimina el commit más antiguo de cada proyecto por turnos —round-robin— en cuanto supera max_total_size_mb (500 MB por defecto), y los checkpoint se desactivan de forma transparente cuando git no está en el PATH.

Para ver cuánto espacio ocupa el historial, o para borrarlo:

hermes checkpoints status     # total size, project count, per-project breakdown
hermes checkpoints prune      # drop orphan/stale checkpoints and GC the store
hermes checkpoints clear      # delete the whole base (all /rollback history)

Una condición previa: los checkpoints son opt-in. Desde la v2 están desactivados por defecto: actívalos para una sesión con hermes chat --checkpoints, o globalmente con checkpoints.enabled: true en ~/.hermes/config.yaml. Una vez activos, cada proyecto guarda hasta max_snapshots: 20 checkpoints por defecto, y el mantenimiento automático corre como máximo una vez cada 24 horas, descartando los proyectos sin tocar durante 7 días. Si nunca los activaste, /rollback estará simplemente vacío: eso no es un bug.

Mantén firme el modelo mental: un checkpoint es un deshacer de lo que el agente acaba de hacer, no una copia de seguridad de cada archivo que existe en tu árbol. Nos topamos con la misma clase de sorpresa en el artículo sobre la corrupción silenciosa de archivos: confiar en que una herramienta revierta por ti solo funciona cuando sabes exactamente qué guardó.

La versión corta

  • Un repositorio git anidado aparece en un checkpoint como un gitlink; sus cambios sin commitear nunca estuvieron en la instantánea.

  • Antes del arreglo, /rollback sobrescribía el repo interno y encima informaba de éxito: la peor pareja posible.

  • Ahora se rechaza un rollback completo, y cualquier selección que toque un gitlink (él mismo, un archivo por debajo o su directorio padre), antes de que cambie nada; el error nombra los repositorios no capturados.

  • Las selecciones que evitan gitlinks siguen restaurando, pathspec de Git incluidos, y el manejo de symlinks y de rutas en Windows sigue la ruta registrada.

  • Los checkpoint que se perdieron un repo anidado se etiquetan [nested git repos not captured: …] en el momento de la captura, visible en /rollback list, en el TUI y en Desktop.

  • En la práctica: haz commit o stash dentro del subrepo primero, prefiere /rollback <N> <path> y recuerda que los backends de contenedor no tienen checkpoint del host.

  • Los checkpoints son opt-in desde la v2 (hermes chat --checkpoints o checkpoints.enabled: true): una lista /rollback vacía suele significar que nunca los activaste.

Referencia completa de flags en hermes checkpoints; para el trabajo de integridad vecino, mira esta tanda de correcciones de compactación.