Back to Blog

OpenCode Git 快照:比 Undo 更可靠的回滚系统

2026-04-27T10:40:00+08:00
OpenCode Git Snapshot 回滚 版本控制

OpenCode Git 快照:比 Undo 更可靠的回滚系统

用 AI 编程工具最怕什么?不是 AI 写不出代码,是 AI 写坏了代码你还回不去。

我第一次用 AI coding agent 的时候就踩过这个坑:让 agent 帮我重构一个模块,它一口气改了十几个文件,改完发现方向完全错了。我用 IDE 的 undo 想回退,结果只恢复了最后一个文件,前面的改动已经混在一起分不清了。最后只能 git stash 碰运气,运气不好就得手动对照改回去。

OpenCode 从设计之初就想到了这个问题。它的解决方案很优雅——在后台跑一个独立的 Git 仓库,在你每次让 agent 做事之前自动拍一张快照。这篇我们就来拆解这套快照系统是怎么工作的。

本文提纲

  1. 隐藏的 Git 仓库:快照存在哪里
  2. 自动快照:Agent 每次行动前的安全网
  3. 文件恢复:回到任意一个快照点
  4. 单文件回滚:精确恢复指定文件
  5. Diff 查看:看清 Agent 到底改了什么
  6. 大文件跳过与 .gitignore 联动
  7. GC 策略:每小时清理,7 天过期
  8. 快照如何让 Undo 变得可靠

隐藏的 Git 仓库:快照存在哪里

OpenCode 不会在你的项目 .git 里动任何东西。它在系统数据目录下维护了一个完全独立的 Git 仓库,专门用来存快照。

路径长这样:

~/.local/share/opencode/snapshot///

这里有两个关键设计:

第一,用 project-id 隔离不同项目。每个项目的快照互不干扰,你同时开三个项目,就有三个独立的快照仓库。

第二,用 worktree-hash 区分同一个项目的不同工作目录。如果你用了 Git worktree(比如一个目录跑开发分支,另一个跑特性分支),每个 worktree 都有自己的快照链。

这个隐藏仓库的初始化逻辑也很有意思。OpenCode 不会提前创建它,而是在第一次调用 track() 的时候才懒初始化:

if (!existed) {
  yield* git(["init"], {
    env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
  })
  yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
  yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
  yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
  yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
}

注意几个配置:core.longpaths=true 是为了在 Windows 上支持长路径;core.symlinks=true 保留符号链接;core.fsmonitor=false 关掉文件系统监控,因为快照仓库不需要实时感知变更。

自动快照:Agent 每次行动前的安全网

快照的核心是 track() 函数。每次 agent 要对项目做改动之前,OpenCode 都会调用一次 track(),把当前文件状态拍下来。

track() 做了三件事:

1. 同步 .gitignore 规则

它先把项目根目录的 .gitignore.git/info/exclude 规则复制到快照仓库的 info/exclude 文件里。这样快照仓库就知道哪些文件该忽略、哪些该追踪。

2. 暂存所有变更文件

通过两个 Git 命令找出所有需要暂存的文件:

git diff-files --name-only -z -- .    # 已追踪但有改动的文件
git ls-files --others --exclude-standard -z -- .  # 新增但未追踪的文件

然后把它们 git add 进暂存区。

3. 写入 Tree 对象

最后调用 git write-tree,生成一个 tree hash。这个 hash 就是快照的标识符——它指向一个完整的目录树快照。

注意这里用的是 write-tree 而不是 commit-tree。OpenCode 的快照不需要 commit message、不需要 author 信息、不需要 commit 历史。它只需要知道"在某个时间点,项目的目录结构长什么样"。Tree 对象正好满足这个需求,而且比 commit 对象更轻量。

整个过程有信号量(Semaphore)保护,不会出现并发问题。如果你同时跑多个 session,每个 session 的快照操作都会排队等锁。

文件恢复:回到任意一个快照点

如果你用 OpenCode 的 /undo 命令,它调用的就是 restore() 函数。

恢复的逻辑很直接:

git read-tree         # 把 snapshot 的 tree 读进 index
git checkout-index -a -f              # 从 index 恢复所有工作区文件

read-tree 把快照的目录树加载到 Git 的 index(暂存区)里,checkout-index 再把 index 里的内容强制覆盖到工作区。两步走,回到过去。

这个过程不会影响你的项目 .git。因为 read-treecheckout-index 都是在那个隐藏的快照仓库上操作,只是 --work-tree 指向了你的项目目录。你的 Git 历史、分支、remote 完全不受影响。

/undo 命令可以连续执行多次。每次 undo 都会回退到更早的一个快照点。如果你想反悔,/redo 会重新应用之前 undo 掉的变更。

单文件回滚:精确恢复指定文件

restore() 是整体恢复,但有时候 agent 只改坏了一个文件,你不想把其他正常的改动也回退掉。这时候用 revert() 就对了。

revert() 接受一个 Patch 列表,每个 Patch 包含一个快照 hash 和一组文件路径。它的工作方式是逐文件恢复:

git checkout  -- 

这个命令的意思是"从指定快照中恢复这个文件到当前工作区"。

如果文件在那个快照中不存在(比如它是后来新建的),OpenCode 会直接删掉这个文件。逻辑很清晰:快照里没有的文件,就是不该存在的文件。

有意思的是,revert() 还做了批量优化。当多个文件属于同一个快照、且路径之间不会互相干扰时(不会出现 a/ba/b/c 同时存在的情况),它会一次 git checkout 多个文件,减少 Git 进程的启动开销。每批最多处理 100 个文件。如果批量操作失败,它会自动降级为逐文件恢复——这是一种很务实的容错设计。

Diff 查看:看清 Agent 到底改了什么

快照系统提供了两层 diff 能力:diff()diffFull()

diff(hash) 返回标准 unified diff 格式的文本。它本质上是:

git diff --cached --no-ext-diff  -- .

这个对比的是"当前暂存区"和"指定快照"之间的差异。简单直接,适合在终端里快速查看。

diffFull(from, to) 更强大,它对比两个任意快照之间的差异,返回结构化数据:

{
  file: "src/index.ts",
  patch: "- old line\n+ new line\n...",  // 完整的 diff patch
  additions: 5,
  deletions: 3,
  status: "modified"  // "added" | "deleted" | "modified"
}

底层实现有个很聪明的优化。对于大量文件的 diff,它不会逐文件调用 git show 去取内容,而是用 git cat-file --batch 做批量读取——一次请求把所有文件内容都拿回来,然后在内存里做 diff 计算。这比逐文件调用快了一个数量级。

如果批量读取失败了(比如某些 Git 版本不支持),它会自动降级到逐文件的 git show。又是一种渐进式降级的思路。

diff 输出还会自动过滤掉被 .gitignore 规则忽略的文件变更。你不会在 diff 里看到 node_modules 之类的干扰信息。

大文件跳过与 .gitignore 联动

快照不是什么都存。它有两道过滤网。

大文件跳过

源码里硬编码了一个 2MB 的上限:

const limit = 2 * 1024 * 1024  // 2MB

track() 发现某个未追踪的新文件超过 2MB 时,它不会报错,而是直接跳过。跳过的方式是把文件路径加到快照仓库的 info/exclude 里:

const block = new Set(untracked.filter((item) => large.has(item)))
yield* sync(Array.from(block))

这样后续的 track() 调用也不会再尝试追踪这些大文件。对用户来说完全透明——你可能永远不知道有个二进制大文件被悄悄跳过了。

但注意,这个限制只对未追踪的新文件生效。如果一个文件之前已经被快照追踪了,后来变大超过 2MB,diff-files 仍然会发现它的变更并继续追踪。这是一个合理的取舍:新文件跳过是为了避免快照仓库膨胀,已追踪文件继续追踪是为了保证恢复的一致性。

.gitignore 联动

快照系统会调用 git check-ignore --no-index --stdin 来检查每个候选文件是否被项目的 .gitignore 规则命中。被忽略的文件不会进入快照,已经在快照里的也会被移除:

const ignored = yield* ignore(all)
if (ignored.size > 0) {
  yield* drop(Array.from(ignored))
}

这里用 --no-index 参数有个讲究:即使某个路径已经被快照仓库追踪了,check-ignore 仍然会用模式匹配来检查它。这确保了如果你后来把某个文件加进了 .gitignore,它会被正确地从快照中移除,而不是继续占用空间。

GC 策略:每小时清理,7 天过期

快照不能无限增长。OpenCode 的清理策略是这样的:

const prune = "7.days"

// 每小时执行一次,启动后延迟 1 分钟开始
yield* cleanup().pipe(
  Effect.repeat(Schedule.spaced(Duration.hours(1))),
  Effect.delay(Duration.minutes(1)),
  Effect.forkScoped,
)

清理操作就是一条命令:

git gc --prune=7.days

git gc 会做两件事:一是打包松散的 Git 对象(把零散的文件合并成 packfile),二是删除不可达的对象。--prune=7.days 意味着超过 7 天没有被任何引用指向的对象会被清除。

这意味着你的快照最多保留 7 天。7 天之前的快照数据会被清理掉。对于日常开发来说,7 天的回溯窗口已经足够了。如果你需要更长久的版本历史,那是项目 Git 仓库的事,不是快照系统的职责。

这个 GC 任务是用 forkScoped 启动的——它绑定到 OpenCode 进程的生命周期。进程退出,GC 任务自动停止。下次启动又会重新注册。所以即使你杀了进程,也不会有残留的后台任务。

GC 操作本身也受信号量保护,不会和正在进行的快照操作冲突。如果 GC 失败了(比如文件权限问题),错误会被静默吞掉,不会影响正常使用。快照系统宁可多占一点空间,也不能因为清理失败而阻断你的工作流。

快照如何让 Undo 变得可靠

理解了上面的机制,我们就能看明白 OpenCode 的 /undo 为什么比普通的 undo 可靠。

传统的 undo 有几个硬伤:它只能撤销文本编辑操作,不能撤销文件创建和删除;它依赖于编辑器的操作栈,一旦操作栈被清空就回不去了;它只作用于当前文件,没法做跨文件的原子性回退。

OpenCode 的快照系统解决了所有这些问题:

原子性——每次 agent 行动前拍一次快照,快照包含所有被追踪文件的完整状态。undo 的时候恢复的是整个项目目录树,不存在"改了一半"的情况。

跨文件一致性——agent 经常一次改多个文件。快照在 write-tree 的时候是原子的,要么所有变更都拍进去,要么都不拍。恢复的时候也是一次 checkout-index 全部恢复。

不受编辑器状态影响——快照存在独立的 Git 对象里,和你的编辑器 undo 栈完全无关。你可以关闭编辑器、重启电脑、甚至删除 .git 重新 init,快照还在。

可选择性——你既可以整体恢复(restore),也可以精确到单文件(revert)。这比一刀切的 undo 灵活得多。

可审查——在恢复之前你可以先 diff 看看 agent 到底改了什么,确认需要回退再操作。不是盲目的 undo。

这套系统的本质思路是:不要信任 AI agent 的输出,但要保留完整的状态历史,让人类随时可以审查和回退。这是 AI 编程工具应该有的安全姿态。

用了一段时间之后,我现在的习惯是:让 agent 做完改动,先 /undo 看看 diff,确认没问题再 redo 回来。多花几秒钟,但心里踏实多了。


作者: itech001 来源: 公众号:AI人工智能时代 主页: https://www.theaiera.cn(每日分享最前沿的AI新闻和技术)

本文首发于 AI人工智能时代,转载请注明出处。

Enjoyed this article? Share it with others!