# 快照与撤销

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

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.org/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/opencode/snapshot-revert
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

```ts
        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`，删减后）：

```ts
        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`，失败再退回逐个处理。

## 何时打快照

快照点都在[流处理器](https://daiw.org/manual/opencode/processor)里：`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`）：

```ts
      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 从中解析出受影响的文件列表。

5. 计算这段范围的差异并广播 `session.diff`，把边界写进会话头的 `revert` 字段。

注意，这一步**一条消息都不删**。界面把边界之后的消息显示为“已撤销”，并提示可用 `/redo` 恢复。真正的删除发生在 `cleanup`：下一次 `prompt`、`shell` 或手动压缩到来时，它按边界删掉消息（精确到 Part 时只删目标消息里该 Part 及之后的 Part），再清除 `revert` 字段。

`unrevert`（`revert.ts:91`）则反过来：`restore` 回撤销前记下的状态，清除边界。消息本来就没删，一切原样回来。

```mermaid
sequenceDiagram
  participant U as 用户
  participant R as SessionRevert
  participant S as 影子仓库
  U->>R: revert 到某条消息
  R->>S: track 记下当前状态
  R->>S: 按 patch 逐文件恢复
  R->>R: 写入 revert 边界，消息保留
  alt 反悔
    U->>R: unrevert
    R->>S: restore 到记下的状态
  else 继续对话
    U->>R: 发送新消息
    R->>R: cleanup 删除边界后的消息
  end
```

“暂存加延迟清理”让 `/redo` 与 `/unrevert` 都几乎零成本，真正不可逆的时刻被推迟到用户发出下一条消息。

V2 的 `packages/core/src/snapshot.ts` 换成了按 Location 划分、以内容寻址 ID 表示快照的接口（`capture`、`files`、`diff`、`preview`、`restore`），投影器里也把撤销拆成了暂存、提交、清除三种事件，见[V2 会话内核](https://daiw.org/manual/opencode/session-v2)。

下一篇：[工具契约与注册表](https://daiw.org/manual/opencode/tool-contract)——进入第 2 部分，看工具如何定义、注册与执行。
