Agent 改坏了代码怎么退回去:opencode 的快照与回滚机制,和你自己 git commit 的边界
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
先把名字说清楚:这里讲的 opencode,是终端里那个同名的开源编码 Agent 项目(仓库地址见上方标注),不是泛指「开源的代码」,也不是任何一个名字里带 open 的模型。下面所有结论都来自它自己的源码文件。
它的快照系统从头到尾没有往你的仓库里写过一个对象:它另开了一个 git 目录,--git-dir 指向你 XDG 数据目录下的一份私有存储,--work-tree 才指向你的项目,于是它能记录工作区的每一次变化,却不碰你的 HEAD、不碰你的暂存区、不产生任何一条你会在 git log 里看到的记录。 这一句是理解它和你自己 git commit 边界的全部起点。你手动提交是在写项目历史,是要给同事看的;它做快照是在攒一份「Agent 每跑一步之前,磁盘长什么样」的流水账,只服务于「退回去」这一个动作,用完就该被 gc 掉。两者放在同一个工作区里同时运行,互不知情,也互不覆盖——前提是你知道各自的边界在哪。
opencode 是一个跑在终端里的开源编码 Agent(MIT 许可证,仓库地址见上),代码按 monorepo 组织,packages/ 下有 32 个包。本文只拆其中和「改坏了怎么办」直接相关的四个文件。
一、它解决的是哪个问题:Agent 的中间态不配进你的历史
一次 Agent 会话里,模型可能连着调十几次编辑工具,中间夹着跑测试、装依赖、删文件。这些中间态里绝大多数是没有价值的:写错的函数签名、删掉又加回来的 import、一次跑偏的批量替换。如果用 git commit 去记录它们,你的历史立刻变成噪声;如果不记录,出问题的时候你手里就只剩「当前磁盘状态」这一个版本,回不去。
opencode 的取法是:把「可回退」这件事和「版本历史」这件事彻底拆开。快照捕获的时机全部挂在会话处理器上——在流式响应开始之前先抓一次(packages/opencode/src/session/processor.ts 里注释写得很直白:AI SDK 可能在发出 start-step 事件之前就已经在内部执行工具了,所以放到事件回调里抓会太晚),每个 step 开始时若还没有快照再补一次;step 结束时再抓一张,写进 step-finish 片段,同时拿这一步开始时那个哈希去算一次改动清单,写成一个 type: "patch" 的消息片段。
这个顺序值得多看一眼:patch 片段里带的哈希不是「这一步做完之后」的树,而是「这一步动手之前」的树,配上这一步碰过的文件清单。回滚要的正是「动手之前」这个版本——把这些文件按那个树哈希 checkout 回来,这一步就等于没发生过。所以会话记录里天然攒着一串「退回去所需的坐标」,一步一条,hash 加 files 两样就够。清单为空的 step 不会产生片段,纯聊天不会在快照层留下任何东西。
二、快照是怎么攒出来的:一个影子 git 目录
核心文件是 packages/opencode/src/snapshot/index.ts。它对外暴露的接口很短:init、cleanup、track、patch、restore、revert、diff、diffFull。
存储位置由这一行决定:
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
Global.Path.data 在 packages/core/src/global.ts 里是 XDG 数据目录下的 opencode。所以每个项目、每个工作区各有一份独立的快照仓库,落在你的数据目录里,而不是项目里。所有 git 调用都统一走这个前缀:
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
track() 做四件事。第一次运行时它 git init 出这个影子仓库,并写死一串配置:core.autocrlf=false、core.longpaths=true、core.symlinks=true、core.fsmonitor=false,外加为大仓库调优的 feature.manyFiles=true、index.version=4、index.threads=true、core.untrackedCache=true。关掉 autocrlf,快照存的字节就和磁盘上的字节一致,回滚不会顺手把换行符改掉;fsmonitor 也一并关掉——这个项目在别处跑 git 时同样带着 core.fsmonitor=false,packages/opencode/src/git/index.ts 的默认参数里就有,worktree 那边查状态还专门临时 -c 关一次。
第二件事叫 seed,同样只在第一次跑。它读你真实仓库的 --git-common-dir,把其中的 objects 目录(以及你自己配的 alternates)写进影子仓库的 objects/info/alternates,再把你的 index 文件直接复制过去。源码注释解释了动机:在 chromium 这种量级的检出上,git add --all 重新计算哈希要花上几分钟,共享对象库和索引之后这一步基本被消掉。代价是这份快照存储和你的仓库对象库产生了物理依赖。
第三件事是 add。它先用 diff-files --name-only -z 列出已跟踪文件的改动,用 ls-files --others --exclude-standard -z 列出未跟踪文件,两者取并集,再用你真实仓库的规则做一次 check-ignore --no-index --stdin -z 过滤。被 .gitignore 命中的文件不但不会加入,还会用 rm --cached 从快照索引里踢出去。剩下的文件里,未跟踪且体积超过 2 * 1024 * 1024 的会被写进影子仓库的 info/exclude 挡掉,其余的才作为一组路径喂给 add --all --sparse --pathspec-from-file=-,从标准输入按 NUL 分隔一次性传进去入索引。路径都被包成了顶层字面量 pathspec,避免文件名里带的特殊字符被 git 当成通配。
第四件事是收尾:write-tree,拿到一个树对象的哈希返回出去。注意这里没有 commit,快照的标识就是一个裸的 tree 哈希——这也是为什么它在任何 git log、任何分支、任何 reflog 里都不会出现。
整个服务对同一个 gitdir 持有一个只有 1 个许可的信号量,track、patch、restore、revert、diff、diffFull 全部串行执行。另外它 fork 了一个后台循环,延迟 1 分钟启动、之后每小时跑一次 gc --prune=7.days,把七天前的对象清掉。快照存储是会自己变小的,但它不保证你半个月前那次会话还能退回去。
三、回滚有两条路:按文件挑回来,和把整棵树写回去
packages/opencode/src/session/revert.ts 是会话层的入口,它只有三个方法:revert、unrevert、cleanup。
revert 收到的输入是 sessionID + messageID + 可选的 partID,也就是「退到对话里的哪一条消息、哪一个片段」。它先 assertNotBusy——会话正在跑就直接拒绝,不允许边生成边回滚。然后遍历全部消息,定位到回退点:如果没给 partID,回退点会落到该消息之前最近的那条用户消息上;定位之后,把回退点之后所有的 patch 片段收集起来,得到一串 { hash, files }。
接着是关键的三行:
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
第一行:在动手之前,先把「现在」也拍一张快照存进会话状态。这张快照就是 redo 的凭据。第二行:如果之前已经处于回退状态,先把当时那张「现在」还原回来,保证多次回退是从同一个基线上算的。第三行才是真正的回滚。
snapshot 模块里的 revert(patches) 走的是逐文件路径。它把所有文件去重后排成操作序列,对相邻的、哈希相同的、路径之间不构成前缀包含关系(源码里那个 clash 判断)的操作最多合并 100 个一批,先 ls-tree --name-only 查这些路径在目标树里在不在:在的用 checkout <hash> -- <files> 一次性写回;不在的说明是这一段对话新建出来的文件,直接从磁盘删掉。批量失败就退化成单文件逐个处理,单文件 checkout 失败时还会再查一次 ls-tree,确认文件确实存在于快照里就保留原样、只记一条日志,不做破坏性动作。
unrevert 走的是另一条路——restore(snapshot),实现是 read-tree <snapshot> 加 checkout-index -a -f,把整棵树按索引铺回工作区。这条路更粗暴,也更快,但它只负责「把索引里的文件写出来」,索引里没有的文件它不会去删。所以 opencode 把删除新增文件这件事交给了逐文件那条路径去做,两条路各管一半。
最后是 cleanup。回退之后你并没有立刻失去后悔的机会,会话里那些消息还在,只是被标了 revert 状态。真正抹掉是在你下一次发消息或者跑 shell 的时候:packages/opencode/src/session/prompt.ts 在 prompt 和 shellImpl 的开头都会检查 session.revert,有就调 revert.cleanup(session),把回退点之后的消息和片段从存储里真删掉,然后清除 revert 标记。换句话说,回退是暂时的,接着说下一句话才是确认。
面向用户的入口在文档里是 /undo 和 /redo 两个命令,键位默认是 ctrl+x u 和 ctrl+x r(packages/web/src/content/docs/tui.mdx);服务端对应两个走 SessionRevert.Service 的 HTTP 处理器。文档也明确写了前提:这套东西内部用 git 实现,你的项目必须是一个 git 仓库。
四、四个模块各管什么
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 快照存储 | 影子 git 目录的初始化、入索引、write-tree、按文件回滚、整树还原、算 diff、定时 gc | packages/opencode/src/snapshot/index.ts | 大仓库首次 track 变慢、数据目录占用变大时 |
| 会话回退 | 定位回退点、收集 patch 片段、存 redo 凭据、下一轮对话时清理历史 | packages/opencode/src/session/revert.ts | 用 /undo、/redo,或发现回退后历史被截断时 |
| patch 片段生成 | 每个 step 前后调 track(),把 hash + files 写成消息片段 | packages/opencode/src/session/processor.ts | 想搞清楚「这一步到底动了哪些文件」时 |
| 真实仓库读写 | 对你自己的仓库跑 status/diff/numstat/show/merge-base,以及 apply 补丁 | packages/opencode/src/git/index.ts | 看 Agent 相对某个 ref 改了什么时 |
| 工作区隔离 | 在数据目录下建 git worktree、跑启动脚本、reset 与移除 | packages/opencode/src/worktree/index.ts | 想让 Agent 在独立目录里干活时 |
| 快照开关 | 配置项 snapshot,置为 false 即整体停用 | packages/web/src/content/docs/config.mdx | 超大仓库、多子模块导致索引慢或磁盘涨得厉害时 |
这里有个值得单独说的事实:packages/opencode/src/git/index.ts 那个 Git 服务,接口列表从 run、branch、prefix、defaultBranch、hasHead、mergeBase、show、status、diff、stats、patch、patchAll、patchUntracked、statUntracked 一直到 applyPatch——没有 commit,没有 add,没有 stash,没有 push。它对你的仓库基本是只读的,唯一的写口是 git apply -。提交这件事,opencode 没打算替你做。它甚至连 --no-optional-locks 都加在了默认参数里,摆明了不想在你手动操作 git 的时候跟你抢锁。
五、边界与代价:它明确不管的事
这套设计换来了「不污染历史」,同时也放弃了不少东西,用之前得心里有数。
被 .gitignore 忽略的文件,它一个都不存。 过滤逻辑用的是你真实仓库的 ignore 规则,命中的文件不但不入快照,已经在快照索引里的还会被 rm --cached 移除。这意味着 Agent 如果改了你的 .env、改了 ignore 掉的本地配置、改了构建产物目录里的东西,/undo 一律救不回来。这不是 bug,是它为了不把密钥和缓存塞进快照存储所做的取舍,但你必须知道这条线在哪。
未跟踪的大文件不进快照。 超过 2 MB 的未跟踪文件会被写进影子仓库的 exclude 名单,回滚时同样不受管。
不是 git 仓库就没有快照。 enabled() 的判断是项目 vcs 必须为 git,且配置里的 snapshot 不为 false。在一个随手 mkdir 出来的目录里让 Agent 干活,没有任何回退能力,这时候你唯一的兜底是自己的备份。
范围限定在当前目录子树。 列文件和算 diff 的命令末尾都跟着 -- .,且 cwd 用的是当前 directory。你在子目录里启动会话,快照覆盖的就是这个子树。
它不保证长期可回溯。 每小时一次的 gc --prune=7.days 会清掉旧对象,快照存储是短期的撤销缓冲区,不是归档。
回退会截断对话。 前面说过,下一次发消息时 cleanup 会把回退点之后的消息和片段真删掉。你如果只是想「看看退回去长什么样,再决定」,那就在决定之前不要发新消息。
磁盘和首次索引的代价是真实的。 官方配置文档直接写明:大仓库或子模块多的项目上,快照系统会导致索引慢、占用明显上升,可以用 snapshot: false 关掉,代价是改动无法从界面回滚。
还有一件它完全不管的事:安全。这类工具会在你的机器上跑 shell 命令、直接改磁盘上的源文件、把代码内容发给模型服务商。快照能救的只是「文件被改坏了」,救不了「密钥被读走了」「私有代码被发出去了」「一条 rm -rf 打到了工作区之外」。快照只覆盖工作区内、未被 ignore 的文件;工作区之外的删除、已经发生的网络外发,都在它的射程外。会话处理器里确实接了权限服务来拦工具调用,但权限放松到什么程度是你自己配的——放太松,Agent 的破坏面就远大于快照的覆盖面。
站内另外三篇和这个话题相邻但分工不同,可以配合着看:改动边界怎么约定 讲的是事前怎么把 Agent 的可写范围划出来,工作区隔离 讲的是让它在别的目录里干活的通用做法,复现与回放 讲的是事后怎么把一次运行重新跑一遍;本篇只管一件事——opencode 这个具体项目,在事中和事后是怎么把文件退回去的。
六、上手与避坑清单
第一次在大仓库里跑,先预期一次慢启动。 为什么会踩:track() 第一次要 init 影子仓库并把整个工作区的改动过一遍索引,虽然有 seed 复用你的对象库和索引,仍然可能明显卡一下。怎么避:第一次别在会话正忙的时候去观察,先在小仓库上熟悉流程;确实扛不住就按文档把 snapshot 关掉,同时接受失去界面回滚这个后果。
别指望它能救 .env。 为什么会踩:ignore 过滤是静默生效的,patch 片段和 diff 输出里都会把 ignore 命中的条目滤掉,你从界面上看不到「有个文件没被保护」。怎么避:凡是让 Agent 碰配置文件、凭据文件、本地脚本的任务,动手前自己复制一份;更稳的做法是压根不让它有读写这些路径的权限。
回滚之后不要急着发下一句话。 为什么会踩:cleanup 挂在 prompt 和 shellImpl 的入口,你一发消息,回退点之后的历史就被真删了,/redo 也就没得可 redo。怎么避:退回去之后先看 diff、先跑测试,确认这是你要的状态,再继续对话。
会话跑着的时候回滚会被拒绝。 为什么会踩:revert 开头就是 assertNotBusy。人在着急的时候本能反应是「赶紧撤」,但那一刻会话往往正在生成。怎么避:先停掉当前生成,再回滚。
你自己在同一个工作区里手动改文件,快照会一起吃进去。 为什么会踩:快照记录的是工作区状态,不区分是谁改的。你在 Agent 跑的同时手动编辑,这些改动会进入下一次 track(),回滚时可能被一并退掉。怎么避:Agent 跑的时候不要在同一个工作区里手改;真要并行,用 worktree 隔离。
worktree 的 reset 是真的会删东西。 为什么会踩:packages/opencode/src/worktree/index.ts 里的 reset 会 fetch 默认分支、reset --hard 到那个 ref、再 clean -ffdx,还会对子模块递归执行 reset 和 clean,最后用 status --porcelain=v1 校验必须干净、不干净就报错。这个组合下未提交、未跟踪的东西一律没了。怎么避:把 reset 当成「把这个隔离目录恢复出厂」来用,只对确认可丢弃的 worktree 执行。好消息是它对主工作区做了硬拦截——目录等于主工作区时直接返回失败,提示不能 reset 主工作区。
worktree 是有命名规则的,别手动去动它。 为什么会踩:创建时分支名是 opencode/<name> 形式,目录落在数据目录的 worktree/<项目 id>/ 下,移除时会 worktree remove --force 再 branch -D。你手动改名或手动删目录,会让这套记账对不上。怎么避:创建和移除都走它自己的接口。
快照存储和你的仓库对象库是绑在一起的。 为什么会踩:seed 写了 alternates 指向你仓库的 objects 目录。怎么避:不要指望把项目删了之后还能从快照目录里捞回什么;快照是撤销缓冲,不是备份。
收尾:三条自检,和接下来该读哪个文件
用之前先回答三个问题:你的项目是 git 仓库吗(不是就没有回滚);这次任务会碰到 ignore 掉的文件吗(会就自己先备份);你打算让 Agent 在主工作区还是隔离目录里干活(并行就用 worktree)。这三条过了,/undo 才是一张可靠的安全网,而不是一个心理安慰。
想继续往下挖,路线是清楚的:先把 packages/opencode/src/snapshot/index.ts 从 track 读到 revert,理解「tree 哈希 + 文件清单」这个最小回滚单位;再回到 packages/opencode/src/session/processor.ts 看快照是在流式响应的哪些位置被触发的;最后读 packages/opencode/src/session/revert.ts,把 revert / unrevert / cleanup 这三步的状态机在纸上画一遍。这三个文件加起来不到一千行,读完你对「它到底能救什么、不能救什么」就不会再有猜的成分。
想把这套思路推广到别的工具上,可以接着看 检查点与常任机制 和 改坏的代码怎么回滚。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 opencode 怎么住进编辑器:扩展只是启动器,Agent 还在终端 和 opencode 会话数据存在哪:本地存储层与同步层是怎么拆开的。