快照与撤销
基于影子 git 仓库的文件快照:存放位置与各方法的实现,何时打快照,以及 revert、unrevert 与延迟清理如何配合完成撤销。
撤销是编码 agent 的安全网:模型把文件改坏了,用户要能一键退回。OpenCode 的做法是给每个项目配一个影子 git 仓库,在每个模型步前后各记一次文件树,撤销时照着记录把文件改回去。
怎么用
- TUI 的
/undo:撤掉最近一条 user 消息及其后的全部内容(会话忙时先中止),并把那条消息的原文放回输入框,改一改就能重发。 /redo:边界向后挪一轮;已是最后一轮时整体取消撤销。- 在消息菜单里选 Revert,说明文字是“undo messages and file changes”。
- 服务端接口为
POST /session/:id/revert(可带partID精确到某个 Part)与/unrevert。 - 配置
snapshot: false关闭快照,此后撤销不再回滚文件;非 git 项目本来就不打快照。
影子仓库
packages/opencode/src/snapshot/index.ts:68:
const state = {
directory: ctx.directory,
worktree: ctx.worktree,
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
vcs: ctx.project.vcs,
}
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]影子仓库放在数据目录下(Linux 上即 ~/.local/share/opencode/snapshot/项目ID/worktree哈希)。所有命令都以 --git-dir 指向影子仓库、--work-tree 指向用户的工作区:索引和对象都是自己的,不会动用户的 .git,只在判断忽略规则等场合读取它。
几处工程细节:
- 首次初始化:关掉
core.autocrlf、core.fsmonitor,打开长路径与符号链接支持,再为大仓库开启feature.manyFiles、index v4、多线程与 untracked cache。 - 借用用户仓库的对象:
seed把用户仓库的对象库写进影子仓库的objects/info/alternates,并复制用户仓库的 index。注释举的例子是 chromium 这种巨型仓库,否则第一次git add --all重新计算哈希可能要花几分钟。 - 暂存范围:每次只暂存有变化的已跟踪文件与未跟踪文件;按用户仓库的规则剔除被忽略的文件;超过 2 MB 的未跟踪文件写进排除列表。
- 并发与回收:每个影子仓库一把信号量,操作串行执行;启动 1 分钟后开始,每小时执行一次
git gc --prune=7.days。
六个方法
| 方法 | git 操作 | 用途 |
|---|---|---|
track() | add 后 write-tree | 记录当前文件树,返回树哈希 |
patch(hash) | diff --cached --name-only | 列出相对某快照改动过的文件 |
diff(hash) | diff --cached | 相对某快照的完整文本差异 |
diffFull(from, to) | diff --numstat 与 cat-file --batch | 两个快照之间的逐文件结构化差异 |
restore(hash) | read-tree 与 checkout-index -a -f | 把快照里的文件全部写回工作区 |
revert(patches) | 逐文件 checkout | 只把指定文件恢复到指定快照 |
track(snapshot/index.ts:318,删减后):
const track = Effect.fnUntraced(function* () {
return yield* locked(
Effect.gen(function* () {
if (!(yield* enabled())) return
// ...
yield* add()
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
const hash = result.text.trim()
yield* Effect.logInfo("tracking", { hash, cwd: state.directory, git: state.gitdir })
return hash
}),
)
})快照只是 write-tree 得到的树对象哈希,没有提交历史,旧对象交给定期 gc 回收。revert 则有两处讲究:同一个文件出现在多个 patch 里时,只按最早的那个 patch 恢复,也就是回到被撤销范围内第一次改动它之前的样子;若该文件在快照里根本不存在(说明它是之后才新建的),就直接删除。路径互不嵌套、来自同一快照的文件每 100 个一批执行 checkout,失败再退回逐个处理。
何时打快照
快照点都在流处理器里:create 时先 track 一次;step-start Part 记下这个哈希;step-finish 时再 track 一次写进 step-finish Part,并用 patch 求出本步改动的文件,有改动就写一个 patch Part(hash 是本步起点快照,files 是改动过的文件);步中途被中断时,cleanup 同样会补写 patch Part。
所以每个 patch Part 的含义是:“这些文件在本步被改过,改动前的样子在这棵树里。”此外,SessionSummary.computeDiff 取一轮对话首个 step-start 与末个 step-finish 的快照,用 diffFull 算出本轮的逐文件差异,写进 user 消息的 summary.diffs。
revert:先暂存,后清理
packages/opencode/src/session/revert.ts:38 的 revert:
assertNotBusy:会话正在运行时拒绝撤销。- 定位撤销点:目标 Part 之前若已有 text 或 tool Part,就精确到这个 Part;否则退到所在轮次的 user 消息,整轮撤销。
- 收集撤销点之后的全部 patch Part。
- 处理文件(
revert.ts:70):
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)第一次撤销时先 track 记下当前状态,作为日后反悔的依据;已处于撤销状态又撤一次时,先 restore 回那个原始状态,再应用新的 patch 列表——连续多次 /undo 始终从同一基线出发。rev.diff 是撤销造成的文本差异,TUI 从中解析出受影响的文件列表。
- 计算这段范围的差异并广播
session.diff,把边界写进会话头的revert字段。
注意,这一步一条消息都不删。界面把边界之后的消息显示为“已撤销”,并提示可用 /redo 恢复。真正的删除发生在 cleanup:下一次 prompt、shell 或手动压缩到来时,它按边界删掉消息(精确到 Part 时只删目标消息里该 Part 及之后的 Part),再清除 revert 字段。
unrevert(revert.ts:91)则反过来:restore 回撤销前记下的状态,清除边界。消息本来就没删,一切原样回来。
“暂存加延迟清理”让 /redo 与 /unrevert 都几乎零成本,真正不可逆的时刻被推迟到用户发出下一条消息。
V2 的 packages/core/src/snapshot.ts 换成了按 Location 划分、以内容寻址 ID 表示快照的接口(capture、files、diff、preview、restore),投影器里也把撤销拆成了暂存、提交、清除三种事件,见V2 会话内核。
下一篇:工具契约与注册表——进入第 2 部分,看工具如何定义、注册与执行。