/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 就“拒绝执行”,而不是“事后道歉”
新实现的核心原则很简单:在动手之前就判断,如果这次回滚会碰到 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 路径归一化问题。
你自己该怎么做
机制清楚之后,实践上的建议其实不多,但每条都能省下几个小时:
- 子仓库里先提交,再让 agent 动外层目录。 gitlink 指向的是一个提交;只有提交过的内容才会被引用到。正在编辑的活儿如果不想进主线,
git stash或临时提交都比“指望检查点兜住”可靠。 - 回滚前先读列表。
/rollback list现在会显示[nested git repos not captured: …],看到这个标注就意味着:这次回滚只能碰外面的文件,子仓库请自己处理。 - 用 pathspec 做精确恢复。 想只撤销某个文件时写
/rollback 1 src/broken_file.py,比整目录回滚安全得多——而且现在带通配符的选区也能用,只要它不摸到 gitlink。 - 在容器后端里别指望它。 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;上下文压缩相关的完整性修复可以看 这轮压缩修复。