快照与撤销

基于影子 git 仓库的文件快照:存放位置与各方法的实现,何时打快照,以及 revert、unrevert 与延迟清理如何配合完成撤销。

作者 David更新于 第 12 篇(共 38 篇)

撤销是编码 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:

  1. assertNotBusy:会话正在运行时拒绝撤销。
  2. 定位撤销点:目标 Part 之前若已有 text 或 tool Part,就精确到这个 Part;否则退到所在轮次的 user 消息,整轮撤销。
  3. 收集撤销点之后的全部 patch Part。
  4. 处理文件(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 从中解析出受影响的文件列表。

  1. 计算这段范围的差异并广播 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 部分,看工具如何定义、注册与执行。

本页目录