精准撤销 AI 改码,同时保留未提交工作
文章解释 Git 回退与恢复代理执行前目录状态的区别,后者需要保留用户未提交及被忽略的文件,并移除代理新建文件。作者介绍内容寻址检查点方案,给出约 90 行标准库 Python 实现,并与四种 Git 操作进行测试和计时比较。
AI 深度解读
AI 改坏代码后执行 git checkout -- .,可能把你尚未提交的修改一起抹掉,却留下 AI 新建的文件。这篇文章要解决的,是一个更精确的问题:怎样回到「这一轮 AI 开始之前」,保住已有工作,同时撤销代理造成的变化。
作者用约 90 行 Python 标准库代码实现了文件检查点,并与四种 Git 做法比较。结论很实用:Git 的提交历史和 AI 的操作历史需要分别管理。但检查点能否可靠,取决于它能不能拦住每一次文件写入。
为什么 Git 撤销不了你想撤销的那一轮
测试项目包含 index.html、212 行的 styles.css、app.js,以及被 .gitignore 忽略的 .env。最后一次提交之后,用户修改了页面标题,还新建了未跟踪的 notes.md。
接着,代理把 CSS 改成 41 行,修改 HTML 引入新建的 theme.css,重写 JS,并把 .env 的 API_URL 指向 localhost。
正确的撤销结果应该保留用户的标题和笔记,恢复原有代码及环境变量,再删除 theme.css。作者在独立副本上运行各个方案,逐字节比较文件,结果如下:
checkpoint restore matches the state before the turn: True differs: []
git checkout -- . matches the state before the turn: False differs: ['.env', 'index.html', 'theme.css']
git stash -u matches the state before the turn: False differs: ['.env', 'index.html', 'notes.md']
git checkout + git clean -fd matches the state before the turn: False differs: ['.env', 'index.html', 'notes.md']
git stash create before the turn matches the state before the turn: False differs: ['.env', 'theme.css']
重点看最后一行:即使提前创建 Git 快照,作者的这套操作仍未完整恢复目标状态。
原因是撤销边界不同。git checkout -- . 恢复已跟踪文件时,会连用户尚未提交的标题修改一起撤掉;git stash -u 会收走未跟踪的笔记,却不包含忽略文件;git clean -fd 能删除代理新文件,也会删除用户的未跟踪文件。忽略文件需要额外考虑 -a,而作者用于提前快照的 git add -A 还会改变真实暂存区。
你要恢复的是某轮操作之前的工作目录,Git 默认处理的却是提交、暂存区和文件跟踪状态。
这组结果说明常用命令没有自动满足需求,并不能证明 Git 无法通过更复杂的组合实现类似效果。
检查点只记住代理动手前的内容
osFoundry 在 Rust 模块 checkpoint_store.rs 中采用了内容寻址存储(content-addressed storage):代理写文件之前,把旧内容保存到 <project>/.osfoundry/checkpoints/blobs/<sha256>,再由 /rewind 恢复。
这里的 SHA-256 相当于内容指纹。字节相同,就共用一个 blob;每轮检查点只记录路径及旧内容的指纹。
实现有三个关键约束。
同一路径在一轮里只捕获第一次。代理可能连续修改一个文件,撤销时需要的是它动手之前的版本,中间版本不能覆盖最初记录。
不存在的文件记为 absent。这样撤销新建文件时,才能明确执行删除。超过 MAX_BLOB = 8 * 1024 * 1024 的文件则记为 skipped,并返回原因,避免把不完整恢复报告成成功。
跨轮恢复也容易出错。撤销第 1 轮时,第 2、3 轮必须一起撤销;同一个文件在多轮都被改过,要选择受影响范围内最早的旧内容。
下面这段代码解决了跨轮恢复和新建文件删除的问题:
def restore(self, turn):
target = next(c["at"] for c in self.index if c["turn"] == turn)
affected = sorted((c for c in self.index if c["at"] >= target),
key=lambda c: c["at"]) # oldest first
done, report = set(), {"restored": [], "deleted": [], "failed": []}
for cp in affected:
for f in cp["files"]:
path, pre = f["path"], f["pre"]
if path in done:
continue
done.add(path)
if not self._contained(path): # re-check, the index is just a file
report["failed"].append((path, "outside the workspace"))
elif pre["kind"] == "skipped":
report["failed"].append((path, pre["reason"]))
elif pre["kind"] == "absent":
Path(path).unlink(missing_ok=True)
report["deleted"].append(path)
else:
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_bytes((self.blobs / pre["sha256"]).read_bytes())
report["restored"].append(path)
self.index = [c for c in self.index if c["at"] < target]
self._save()
return report
要看的是 oldest first 和 done 的配合:按时间从早到晚遍历,每个路径只处理一次。作者说,Rust 版本最初也写反过,是测试 rewinding_a_turn_also_undoes_later_turns 抓出了问题。
恢复前还要重新校验路径,因为 index.json 可以被修改,不能直接相信其中的目标位置。示例会解析已有祖先目录的符号链接,检查路径是否仍在工作区内。
少量修改够快,全库改写要换存储方式
作者使用真实 React、TypeScript 项目的 src 副本测试:4,686 个文件,共 121.4 MB,没有超过 8 MB 的文件。随机选择文件、保存旧内容、追加一行,再恢复并逐字节比较,所有恢复都匹配。
测试环境是 Apple M1、8 GB 内存、macOS 15、APFS SSD、Python 3.9.6 和 git 2.39.5。
作为对照,每轮运行 git add -A 再执行 git stash create,无修改时耗时 61 至 68 ms,修改四个文件时为 50 至 67 ms。全库重写后,下一次快照达到 1,241 ms,因为 Git 需要重新计算时间戳变化文件的哈希。
检查点只处理被写入的文件,少量编辑时通常只需几毫秒。不过,示例每次捕获都重写整个 index.json:全库操作时索引达到 1.3 MB,前 100 个文件平均每次捕获 1.29 ms,最后 100 个升至 9.54 ms。
作者改为每次追加一行 JSON 后,同样 4,686 个文件总耗时 2.5 s,前后每次捕获分别为 0.41 ms 和 0.61 ms。批量格式化、全库重命名这类操作,更适合追加日志。
空间也有账要算。4,686 个文件包含 4,677 种不同内容,源码文件之间几乎没多少重复可省。一个 2 MB 文件连续十轮各追加一行,会留下 10 个 blob,占 20.0 MB;若后续各轮始终写入相同字节,则只需原版与修改版两个 blob,占 4.0 MB。
内容寻址只消除完全相同的内容,不会自动保存版本差量。121.4 MB 的原始 blob 用 zlib level 6 压缩后是 33.7 MB;同一目录提交一次后的 Git 对象库为 41.0 MB。作者据此建议长期保存时加压缩,但这不能直接当成两套存储方案的全面性能排名。
我的判断
我认同按代理轮次建立检查点。它把「撤销哪些操作」定义清楚,也能保住轮次开始前尚未提交的工作。临时提交、stash 或独立 worktree 都有用,但需要额外安排才能实现相同的撤销体验。
真正的前提是写入入口统一。示例只看得到经过 write() 的操作,代理执行 shell 中的 rm、sed -i 就能绕过捕获。作者称 osFoundry 把代理文件写入集中到一个函数,但文章没有详细展示 shell 命令如何纳入这条路径。自建工具不能直接照搬这个保证。
检查点保存过旧内容,不代表恢复时可以无条件覆盖当前内容。
用户在代理结束后手动修改文件,示例恢复仍会覆盖这些修改。作者提出记录代理写入后的哈希,恢复前检查;若内容发生变化,先另存当前版本。这是改进建议,不能当作示例已经实现的能力。
存储也需要回收。示例撤销时删除索引记录,并未删除对应 blob,仍需 prune 清理无引用内容,再限制保留轮数。
因此,这份实现适合解释机制和作为原型。性能结论来自单一机器、单一目录;路径检查、失败恢复、并发写入等工程边界,也没有得到完整验证。
能拿来做什么
- 在日常 AI 编程中,先确认工具的撤销边界:能否保留已有未提交修改、恢复
.env,以及删除本轮新建文件。这三项比按钮是否叫「撤销」更有用。 - 自建 Node 编程代理时,可以把检查点放进统一文件写入入口,保留首次捕获、
absent和skipped三种语义,并明确 shell 修改是否受保护。 - 让代理执行全库格式化或批量重命名前,应检查存储是否每写一个文件就重写完整索引。作者的实测支持在大量写入时改用追加日志。
- 为恢复功能增加当前内容校验和恢复前备份,让用户在代理之后补写的代码也有找回路径,同时设置保留轮数与 blob 回收策略。