/rollback 说回滚成功,但嵌套仓库里的未提交改动已经没了


那是你最有把握的一次“后悔药”:重构跑偏了,你看了一眼 /rollback list,挑了一个两小时前的检查点,敲下回滚编号,终端回了你一句成功。然后你切进子目录准备继续改——里面某个仓库的未提交工作不见了,而且回收站里没有,git stash 里也没有。

这正是 issue #127073 描述的场景:checkpoint 会对你的工作目录拍快照,但当目录里嵌着另一个 git 仓库时,那个仓库是以 gitlink(一条提交引用)的形式被记录的,它的文件根本没进快照。而回滚实现仍然报告成功——于是你既失去了子仓库的改动,又得到了一个“一切正常”的假信号。补丁在 2026-09-29 合并进 main(PR #127839,收编了 @dskwe 的 #127077)。

checkpoint 做了什么,又漏了什么

先看机制,这决定了你什么时候会踩到。Hermes 在 write_file / patch / terminal 调用之前,把工作目录快照提交到一个共享的裸 git 仓库里:

~/.hermes/checkpoints/
  ├── store/                 # 共享裸仓库
  │   ├── refs/hermes/<hash> # 每个项目一条分支指针
  │   ├── indexes/<hash>     # 每个项目的 git index
  │   └── projects/<hash>.json

“快照”用的是 git 自己的对象模型,好处是省空间、能 diff;代价也来自这里:git 不会把子仓库的内容存进父仓库。它只在树里放一个 gitlink——一条指向某个提交的引用。子仓库里你尚未提交的编辑,从父仓库的视角看压根不存在。

于是就有了那个最糟的组合:

  • 回滚前,父目录里被 agent 改过的文件会被恢复成检查点里的版本 ✅
  • 同一个操作会把子仓库整体重置到那条 gitlink 引用的提交,未提交内容被覆盖 ❌
  • 而命令返回的是成功 ✅ ← 这才是真正危险的部分

新实现的核心原则很简单:在动手之前就判断,如果这次回滚会碰到 gitlink,就什么都不改。

具体行为,值得逐条对照:

  • 整个目录回滚(/rollback <N>)会被拒绝——因为全量回滚必然覆盖 gitlink。
  • 只要选区触及 gitlink,也会被拒绝:gitlink 本身、gitlink 下面的文件、以及包含它的父目录,三种都算。拒绝发生在任何文件、index、ref 或“回滚前安全快照”被修改之前,错误信息里会点名是哪几个仓库没被捕获。
  • 不碰 gitlink 的选区照常工作,包括 Git 的 pathspec:*.txt、:(glob)…、:(exclude)tool 这类写法都能用。实现方式是把选区先在一个临时 index 上让 git 解析一遍——如果这个通配符会摸到 gitlink,就拒绝;摸不到,就和以前一样执行。这一条很重要:最初那版补丁把所有 pathspec 一律拒掉,反而把 *.txt 这种“本来能救命”的恢复手段弄没了。
  • 按检查点里记录的路径来判断,而不是看今天的文件系统。 以前如果一个被捕获的 notes.txt 后来变成了指向嵌套仓库的符号链接,恢复会被误拒;现在这种情况又能恢复了。反过来,记录的 gitlink 如果今天变成了一条无关的符号链接,依然会被拒绝。路径逃逸校验(_validate_file_path)保持不变。

不只是拒绝:检查点自己会“写在脸上”

拒绝救不了已经发生的事情,所以补丁还加了捕获时的披露:只要一个检查点里存了 gitlink,它自己的 message 就会被追加一段标注:

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

路径超过三个时显示前三个再加 +N,例如 [nested git repos not captured: a, b, c (+2)]。因为 /rollback 的列表本来就会展示检查点描述,你在一开始挑检查点的时候就能看到这条警告,而不是回滚失败之后才发现。

顺带修掉了一个让这条标注“看不见”的 bug:TUI 与 Desktop 的 rollback.list 读的是 message 字段,而管理器里叫 reason,于是那些列表一直没有显示检查点描述。现在做了字段映射,新标注在这些界面里也能正常出现。

最后是一个跨平台细节:路径分量现在用平台自己的 Path 去拆分,因此 packages/nested 和 packages\nested 在 Windows 上都会正确拒绝——这正是上一版补丁评审时提出的 Windows 路径归一化问题。

你自己该怎么做

机制清楚之后,实践上的建议其实不多,但每条都能省下几个小时:

  1. 子仓库里先提交,再让 agent 动外层目录。 gitlink 指向的是一个提交;只有提交过的内容才会被引用到。正在编辑的活儿如果不想进主线,git stash 或临时提交都比“指望检查点兜住”可靠。
  2. 回滚前先读列表。 /rollback list 现在会显示 [nested git repos not captured: …],看到这个标注就意味着:这次回滚只能碰外面的文件,子仓库请自己处理。
  3. 用 pathspec 做精确恢复。 想只撤销某个文件时写 /rollback 1 src/broken_file.py,比整目录回滚安全得多——而且现在带通配符的选区也能用,只要它不摸到 gitlink。
  4. 在容器后端里别指望它。 Docker / Modal / Daytona 这类后端下,路径属于沙箱而不是宿主机,Hermes 不会为这些路径建检查点,/rollback 会列出已有的宿主机检查点,但拒绝 diff 与恢复,并在 CLI 与消息网关里给出同样理由;/diff session 同理。SSH 与本地后端不受影响。

还有几个既有的护栏值得记住:目录超过 50000 个文件会被跳过,单个文件超过 max_file_size_mb(默认 10 MB)不进快照,存储总量超过 max_total_size_mb(默认 500 MB)时按项目轮转淘汰最旧的一条,git 不在 PATH 上时检查点整体静默关闭。这些边界决定了“为什么我明明有检查点却恢复不了”,排查时先确认它们。

想查看存储占用或清理历史:

hermes checkpoints status     # 总大小、项目数与每项目明细
hermes checkpoints prune      # 删除孤儿/过期检查点并 GC
hermes checkpoints clear      # 清空整个检查点库(所有 /rollback 历史)

先说一个前提:检查点自 v2 起默认是关的。 单次会话用 hermes chat --checkpoints 打开,或者全局在 ~/.hermes/config.yaml 里写 checkpoints.enabled: true。开启后每个项目默认最多保留 max_snapshots: 20 个快照,自动维护最多每 24 小时跑一次,会把 7 天没碰过的项目清掉。如果你从来没开过,/rollback 里自然是空的——那不是 bug。

最后提醒一句心态问题:检查点是后悔药,不是备份。它的价值在于“回滚 agent 刚才做的编辑”,而不是“替你保管任何工作树里存在的文件”。同样的教训我们在 文件被静默改坏那篇 里也碰到过一次:让工具替你安全地回退,前提是你清楚它到底有没有把那份内容真正存下来。

总结

  • 嵌套 git 仓库在检查点里只是一个 gitlink,未提交的改动从来没有进过快照。

  • 修复前,/rollback 会一边覆盖子仓库内容,一边报告成功——这是最危险的组合。

  • 现在:全量回滚、以及任何触及 gitlink(本体 / 其下文件 / 父目录)的选区,都会在改动任何东西之前被拒绝,错误信息点名未捕获的仓库。

  • 不触及 gitlink 的选区照常恢复,Git pathspec(含 *.txt)依然可用;符号链接与 Windows 路径都按记录路径正确处理。

  • 检查点在捕获时就会带上 [nested git repos not captured: …] 标注,/rollback list、TUI 与 Desktop 都能看到。

  • 实操建议:子仓库先提交或 stash;用 /rollback <N> <path> 做精确恢复;容器后端下没有宿主机检查点可用。

  • 检查点自 v2 起默认关闭(hermes chat --checkpoints 或 checkpoints.enabled: true);/rollback 列表为空通常只是因为你从没打开过它。

命令的完整参数见 hermes checkpoints;上下文压缩相关的完整性修复可以看 这轮压缩修复。