/rollback reported success — and silently ate the uncommitted work in your nested repo

It looked like your safest undo ever. A refactor went sideways, so you checked /rollback list, picked a checkpoint from two hours ago, and typed the number. The terminal said success. Then you cd into a subdirectory to carry on and the uncommitted work in the repository sitting there is simply gone — not in the trash, not in a stash.
That is issue #127073. Checkpoints do snapshot your working directory, but when that directory contains another git repository, the inner repository is recorded as a gitlink — a bare commit reference — and its files never enter the snapshot. Rollback still reported success, so you lost the work and got a false all-clear. The fix (PR #127839, salvaging #127077 by @dskwe) landed on main on 2026-09-29.
What checkpoints store, and what they never stored
The mechanics decide when you get bitten. Before a write_file, patch, or terminal call, Hermes commits a snapshot of the working directory into a shared bare repository:
~/.hermes/checkpoints/
├── store/ # one shared bare repo
│ ├── refs/hermes/<hash> # per-project branch tip
│ ├── indexes/<hash> # per-project git index
│ └── projects/<hash>.json
Using git’s own object model keeps snapshots cheap and diffable — and that is exactly where the gap comes from: git does not store a sub-repository’s contents inside its parent. All the parent tree holds is a gitlink pointing at one commit. Edits you have not committed in the inner repo do not exist as far as the outer repository is concerned.
That yields the worst possible combination:
- files the agent changed in the outer tree are restored from the checkpoint ✅
- the same operation resets the inner repository to the commit its gitlink names, discarding uncommitted content ❌
- and the command returns success ✅ ← this is the actually dangerous part
After the fix: refuse up front instead of apologising afterwards
The new rule is blunt: decide before touching anything, and if the rollback would reach a gitlink, change nothing at all.
The behaviour, point by point:
- A full rollback (
/rollback <N>) is refused, because restoring everything necessarily covers the gitlink. - Any selection that touches a gitlink is refused too — the gitlink itself, a file below it, or a parent directory containing it. The refusal happens before any file, index, ref, or pre-rollback safety snapshot is modified, and the error names the repositories that were not captured.
- Selections that avoid gitlinks still restore, including Git pathspecs such as
*.txt,:(glob)…,:(exclude)tool. The implementation resolves the selection against a throwaway index first: a wildcard that would reach a gitlink is refused, one that would not behaves exactly as before. This matters — the earlier attempt at this fix rejected every pathspec outright, which removed the*.txtrecovery that still works. - The guard uses the path recorded in the checkpoint, not today’s filesystem. Previously, a captured
notes.txtthat later became a symlink into a nested repo could not be restored; now it can. Conversely, a recorded gitlink that has since been replaced by an unrelated symlink is still refused. Path-confinement validation (_validate_file_path) is unchanged.
More than a refusal: checkpoints now say so out loud
Refusing cannot help with what has already happened, so the patch also adds capture-time disclosure. Any checkpoint that stored a gitlink gets an annotation appended to its own message:
before write_file: app.py [nested git repos not captured: tool]
More than three paths and you get the first three plus +N, for example [nested git repos not captured: a, b, c (+2)]. Since /rollback listings already render a checkpoint’s description, you see the warning while choosing a checkpoint — not after a failed rollback.
The same patch fixed a bug that hid the annotation: the TUI and Desktop rollback.list surface read a message key while the manager calls it reason, so those listings never showed checkpoint descriptions at all. Now mapped, which makes both the old descriptions and the new label visible.
One cross-platform detail: path components are now split with the platform’s own Path, so packages/nested and packages\nested both refuse correctly on Windows — the path-normalisation finding raised in review of the earlier PR.
What to do with this
Once the mechanics are clear, the practical advice is short, and each item saves hours:
- Commit inside the sub-repo before letting an agent touch the outer one. A gitlink points at a commit; only committed content is referenced. If you do not want work in the main line yet,
git stashor a throwaway commit beats hoping the checkpoint covers it. - Read the list before rolling back.
/rollback listnow shows[nested git repos not captured: …]. Seeing that annotation means this checkpoint can only restore outer files — handle the inner repository yourself. - Use pathspecs for surgical restores.
/rollback 1 src/broken_file.pyis far safer than a directory-wide rollback, and wildcards are supported again as long as they do not touch a gitlink. - Do not count on it in container backends. With
docker,singularity,modal,daytona, or a container plugin, paths belong to the sandbox rather than the host: Hermes takes no checkpoints for them, and/rollbacklists existing host checkpoints while refusing diff and restore, in the CLI and in messaging chats alike./diff sessiongives the same reason. Local and SSH backends are unaffected.
Four existing guardrails are worth remembering when a roller-coaster turns into “why can I not restore anything”: directories with more than 50,000 files are skipped, a single file above max_file_size_mb (default 10 MB) stays out of the snapshot, the store drops the oldest commit per project round-robin once it exceeds max_total_size_mb (default 500 MB), and checkpoints are transparently disabled when git is not on PATH.
To see how much space history occupies, or to clear it:
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)
One precondition first: checkpoints are opt-in. Since v2 they are off by default — enable them for a single session with hermes chat --checkpoints, or globally with checkpoints.enabled: true in ~/.hermes/config.yaml. Once on, each project keeps up to max_snapshots: 20 checkpoints by default, and auto-maintenance runs at most once every 24 hours, dropping projects untouched for 7 days. If you never turned them on, /rollback will simply be empty — that is not a bug.
Keep the mental model straight: a checkpoint is an undo for what the agent just did, not a backup of every file that exists in your tree. We ran into the same class of surprise in the silent file corruption post: trusting a tool to revert for you only works when you know exactly what it stored.
The short version
-
A nested git repository appears in a checkpoint as a gitlink; its uncommitted changes were never in the snapshot.
-
Before the fix,
/rollbackoverwrote the inner repo and reported success — the worst pairing. -
Now a full rollback, and any selection touching a gitlink (itself, a file below it, or its parent), is refused before anything changes, and the error names the uncaptured repositories.
-
Selections that avoid gitlinks still restore, Git pathspecs included, and symlink/Windows path handling follows the recorded path.
-
Checkpoints that missed a nested repo are labelled
[nested git repos not captured: …]at capture time, visible in/rollback list, the TUI, and Desktop. -
Practically: commit or stash inside the sub-repo first, prefer
/rollback <N> <path>, and remember container backends have no host checkpoints. -
Checkpoints are opt-in since v2 (
hermes chat --checkpointsorcheckpoints.enabled: true); an empty/rollbacklist usually just means the store was never enabled.
Full flag reference on hermes checkpoints; for the neighbouring integrity work, see this round of compaction fixes.