主题
Unified Patch Editor
本课交付结果
你将交付 applyPatch(root, changes, options) 与 rollbackPatch(transaction):支持多文件修改、新建、删除和前置内容校验;任一错误或校验失败都会用内存快照恢复全部文件,不依赖 Git reset/checkout。
岗位问题
一个功能通常同时改实现、测试和配置。若第三个文件失败而前两个已落盘,仓库进入半完成状态;用 git reset 回滚又可能删除用户原有未提交修改。补丁系统需要只撤销自己触碰的字节。
前置检查
前置知识快照
先验证原子单文件编辑:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 16理解 null 在补丁协议中表示“文件不存在”,以及修改前内容为何是乐观并发控制条件。
before:null 表示调用者断言目标当前不存在,after:null 表示提交后应不存在。由此可统一表示创建、修改和删除:null到文本是创建,文本到文本是修改,文本到null是删除。null不是空字符串,空文件仍是存在状态。
本课的事务边界只覆盖 changes数组中的路径。用户在这些路径之外的未提交修改必须保持不动,因此禁止用 git reset --hard 或 checkout 回滚。快照记录每个目标进入事务前的精确 Buffer或不存在,失败时只恢复这些事实。
原理拆解
先一次性验证所有路径、重复项和 before 内容,再保存每个目标的原始 Buffer | null。随后依次落盘;外部 check 返回 false 或任一写入抛错时,按逆序恢复快照。回滚只使用文件 API,不读取 Git 状态。
mermaid
flowchart TD
A[root + changes] --> B[拒绝重复路径]
B --> C[解析所有安全工作区路径]
C --> D[读取 Buffer/null 快照]
D --> E{全部 before 匹配?}
E -- 否 --> F[零写入失败]
E -- 是 --> G[形成 transaction]
G --> H[依次创建/修改/删除]
H --> I{外部 check 通过?}
I -- 是 --> J[提交并返回快照]
I -- 否或写失败 --> K[逆序 rollback]
K --> L[字节级恢复后抛错]“先全部预检,再开始写”相当于数据库事务的 prepare阶段。若边验证边写,第三个文件 before冲突时前两个已变化,系统必须走复杂回滚;更糟的是尚未建立完整快照。一次性预检让所有可预见冲突都在零副作用阶段发现。
重复路径必须拒绝。相同文件在一个 changes数组出现两次时,第二条 before是基于事务前还是第一条之后?数组顺序会隐藏语义,回滚快照也会重复。要求调用方先合并同一路径,事务每个目标只有一个最终状态。
快照使用 Buffer,不用 UTF-8字符串。即使协议 after是文本,原文件可能含 BOM、不同换行或意外字节;失败恢复必须逐字节相等。不存在用 null明确表达,回滚时删除本事务新建文件,不能用空 Buffer替代。
路径验证复用真实工作区边界:拒绝绝对路径和 ..,比较 root与父目录 realpath,防止通过 symlink父目录写到外部。新文件不存在,无法 realpath目标,所以必须验证真实父目录。路径检查在所有写之前完成。
before内容是乐观锁。Agent基于读取到的 A生成 A到AA补丁,如果用户已改成AX,事务拒绝覆盖。直接比较文本适合Lab,生产协议可比较 beforeHash并按需要携带文本片段;无论形式,冲突必须让模型重新读取,而不是自动套用。
外部 check是提交后验证,例如运行类型检查或业务不变量。它返回 false或抛错时,事务恢复全部文件。check本身可能有外部副作用,本事务只能回滚文件 changes,不能撤销网络或数据库动作,因此应只运行受限、只读验证命令。
逆序回滚符合栈式副作用原则。若创建嵌套目录、删除文件和修改文件依次发生,反向恢复更接近撤销真实操作顺序。Lab的简单文件互相独立,但生产实现应坚持逆序,并聚合回滚错误,不让第一个恢复失败阻止其余目标尝试。
代码实验
失败实现
危险实现一边检查一边写,后续冲突会留下半补丁:
ts
for (const change of changes) {
const current = await readFile(change.path, "utf8");
if (current !== change.before) throw new Error("冲突");
await writeFile(change.path, change.after ?? "");
}正确结构先准备完整快照,再进入 try/rollback:
ts
const prepared = [] as PreparedChange[];
for (const change of changes) {
const absolute = await workspacePath(realRoot, change.path);
const original = await readFile(absolute).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if ((original?.toString("utf8") ?? null) !== change.before) {
throw new Error(`补丁前置内容不匹配:${change.path}`);
}
prepared.push({ change, absolute, original });
}
const transaction = snapshot(prepared);
try {
for (const item of prepared) await applyOne(item);
if (options.check && !(await options.check())) throw new Error("补丁校验失败");
return transaction;
} catch (error) {
await rollbackPatch(transaction);
throw error;
}bash
pnpm --dir bootcamps/coding-agent/labs/17-unified-patch-editor/starter test
pnpm --dir bootcamps/coding-agent/labs/17-unified-patch-editor/solution testStarter 应声明 实现补丁快照事务;Solution 应通过 5 项测试。
失败校验测试要证明两个目标都精确恢复:
ts
it("check 失败恢复全部字节", async () => {
const beforeA = await readFile(join(root, "a.txt"));
const beforeB = await readFile(join(root, "b.txt"));
await expect(applyPatch(root, [
{ path: "a.txt", before: "A", after: "changed" },
{ path: "b.txt", before: "B", after: null },
], { check: async () => false })).rejects.toThrow("校验失败");
expect(await readFile(join(root, "a.txt"))).toEqual(beforeA);
expect(await readFile(join(root, "b.txt"))).toEqual(beforeB);
});关键实现讲解
路径在写入前统一约束到真实工作区,禁止绝对路径、.. 和 symlink 父目录逃逸。快照保存 Buffer 而不是重新编码的字符串,确保 BOM、换行和任意字节可以原样恢复。before 不匹配时整个事务尚未产生副作用。
Lab落盘使用直接 writeFile,课程作业要求复用第16课原子单文件策略。事务原子性与单文件原子性是两层:快照保证跨文件失败能恢复,临时文件+rename保证每个单独写入不会留下半文件。两者结合仍不是操作系统级真正多文件原子提交,观察者可能在中间看到部分新状态。
返回 transaction后允许显式 rollback,适合用户预览后撤销。事务对象包含真实 root和快照,必须视为敏感且不可由模型篡改;生产实现冻结对象、加入transaction id和afterHash,回滚前确认当前文件仍是本事务写入版本,避免覆盖用户后续修改。
运行与验证
真实运行输出
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 17
pnpm --filter @coding-agent/editor test失败产物检查:校验失败测试保存 a.txt、b.txt 的原始 Buffer;回滚后用 toEqual 比较字节。另一个测试证明新增文件消失、修改文件恢复,过程中没有调用 reset 或 checkout。
真实运行应得到 Solution五项全绿:
text
solution: 5 passed, 0 failed
覆盖: 双文件修改 / 创建与删除 / 路径越界 / check失败字节恢复 / 无Git显式回滚额外搜索实现与测试,确认没有 git reset、git checkout 或仓库级清理调用。失败后检查新增文件消失、删除文件恢复、原Buffer一致,并确保工作区其他未列路径保持原样。
常见失败与排查
故障案例 1
第三个文件冲突,前两个已经改变。
症状:applyPatch报 before不匹配,但仓库留下部分实现与测试修改。
根因:边遍历边写,没有在提交前完成全部预检与快照。
定位:让最后一条 before故意错误,失败后逐个比较所有先前文件。
修复:prepare阶段只读取和验证,全部成功后才能进入写循环;快照覆盖每个目标。
故障案例 2
回滚恢复了代码,却删除用户未提交工作。
症状:补丁校验失败后,事务外的本地修改也消失。
根因:使用 git reset --hard 或 checkout作为便捷回滚,作用域是整个仓库。
定位:事务前创建一个未列入changes的脏文件,失败后检查其字节;审计子进程调用。
修复:只按事务快照恢复列出的路径,新增恢复为不存在,原有恢复原Buffer。
故障案例 3
回滚后的文件看似相同但哈希变化。
症状:文本比较通过,Git仍显示BOM、换行或二进制差异。
根因:快照解码成字符串再写,原始字节在编码往返中变化。
定位:使用带BOM、CRLF或非UTF-8字节夹具,回滚前后比较Buffer与SHA-256。
修复:快照始终保存并复制Buffer,恢复直接写原字节;文本只用于before协议校验。
课后作业
为每次写入改用第 16 课原子文件策略,并注入第三步写入故障;证明前两步也恢复。增加 symlink、嵌套新目录和并发外部修改测试。
进阶要求是为 transaction记录每个路径的 before/after哈希和提交状态,回滚前用afterHash做CAS,发现用户后续修改时停止并生成冲突报告。再把临时目录创建与清理纳入快照,保证失败后没有空目录残留。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 预检 | 所有路径与 before 先验证 | 写到一半才发现冲突 |
| 快照 | Buffer/null 精确表达原态 | 重新编码丢字节 |
| 回滚 | 只撤销本事务触及文件 | 使用 reset/checkout |
| 安全 | 路径不能逃出工作区 | 仅做字符串前缀判断 |
总项目增量
总项目包:@coding-agent/editor
总项目路径:packages/editor/src/patch-transaction.ts
总项目验证命令:pnpm --filter @coding-agent/editor test
补丁事务保证编辑完整性;下一课从 Git Diff 独立检查实际影响范围与风险。
延伸阅读
方案对比与工程取舍
依赖 Git commit/worktree回滚实现成熟,却要求仓库存在、状态可控,也可能碰到用户脏工作树;纯文件快照适用于任何目录且作用域精确,代价是内存与自建事务逻辑;复制整个工作区隔离最强,但大型仓库成本高。课程选择逐目标Buffer快照,适合作为编辑器内核。
内存快照简单快速,但大文件与多文件会占用大量RAM。生产系统在预检时限制文件数量与总字节,超过后使用临时快照目录或独立worktree。无论存储介质,快照内容要有权限保护、生命周期清理和哈希校验,不能变成秘密泄漏缓存。
快照落盘时也需要原子写和加密策略。临时目录位于受控位置、mode最小,transaction完成或过期后删除。崩溃恢复可扫描事务日志,判断哪些路径已提交,再逆序恢复;没有持久化journal,进程崩溃后内存快照消失,无法兑现跨崩溃回滚。
两阶段提交类比有边界。prepare验证了当前文件,但commit期间外部进程仍可修改;单机文件系统没有为多文件提供隔离锁。可以锁住编辑器内部并使用before/after哈希复检,或在隔离worktree完成整个补丁,再一次性向用户展示。不要把“会回滚”宣传成数据库级ACID。
外部check应运行在补丁已写入的同一可信workspace,并经过Week3 Sandbox Runtime。若check可以修改changes之外的文件,回滚无法恢复;因此尽量使用只读检查、临时环境,并在前后Git diff中发现额外路径。验证命令本身也是能力,不因属于事务就自动安全。
check超时、取消和非零退出都进入失败路径,但用户取消是否自动回滚需要产品定义。通常未确认补丁应回滚,用户显式选择保留草稿则可返回transaction等待决定。状态机区分 applied_unverified、committed、rolled_back和rollback_failed,避免一个布尔成功隐藏中间态。
rollback也可能失败,例如权限变化、磁盘满或路径被目录替换。不能在catch中吞掉并只抛原check错误;应聚合主失败与每个恢复失败,标记工作区需要人工处理,保留受控快照。尽最大努力恢复其余路径,最后给出精确未恢复清单。
回滚前的CAS防止覆盖用户后续修改。事务写入AA后用户改成AX,此时显式rollback若直接写A,会抹掉用户工作。比较当前hash是否等于transaction afterHash,不等则报告冲突并保留快照;用户决定三方合并或手工恢复。
目录创建需要纳入事务。为 src/new/a.ts mkdir会产生多个新目录,回滚删除文件后空目录可能残留。记录本事务创建的目录,逆序尝试删除且只删除仍为空的目录;已有目录绝不删除。目录同样不能穿过symlink。
删除文件要保存元数据。Buffer恢复内容,但mode、mtime、所有者、ACL与扩展属性可能不同。课程以字节级为验收,生产源码工具至少保存mode;高保真环境根据平台扩展快照。明确保证范围,避免用户误以为所有元数据都恢复。
创建文件的before必须是null,若目标已存在即冲突;删除的after为null,before必须匹配当前文本。允许“无论存在都覆盖”会破坏并发安全。需要强制操作时采用显式高风险模式和审批,不要让null语义含糊。
路径大小写与规范化影响重复检测。在大小写不敏感文件系统,A.ts 与 a.ts 可能是同一目标,仅用字符串Set不会发现。安全路径解析后应根据真实文件身份或平台规范键去重;macOS与Windows需要实际集成测试。
symlink父目录检查针对新文件尤为重要。link/new.ts目标不存在,但父目录realpath可能在工作区外;只对candidate做relative字符串比较会漏过。课程实现解析dirname,证明真实父目录仍在root。最终目标若已是symlink,也需要明确读写策略。
补丁数组顺序可以决定写入性能,却不应决定最终语义。重复路径拒绝后,各文件最终状态独立;报告和快照按规范路径排序更便于哈希与回放,实际回滚按提交顺序逆序。区分规范展示顺序和副作用顺序。
transaction对象应冻结深层Buffer副本,不能让UI修改snapshot。Buffer本身可变,单纯冻结对象并不能冻结字节;对外可以只暴露hash与元数据,真实Buffer保存在私有存储。Lab返回快照用于学习,生产API应缩小能力。
事务id与规范报告支持幂等。重复提交相同id应返回已知结果或拒绝,不能再次应用;回滚同一transaction两次也要有明确语义。状态持久化后,服务重启仍能防止重放。
测试除了五条Lab路径,还要注入第二、第三次写失败、check throw、rollback某项失败、外部并发修改、大小写重复、symlink父目录和目录残留。每个失败都核对changes内外Buffer。事务正确性主要存在于异常路径,快乐路径只占很小一部分。
审计记录补丁请求hash、路径清单、每个before/after hash、prepare结果、逐项提交、check结果与rollback状态。内容不进入长期日志,用户可从Git diff看实际变化。事件序列让事故中能判断在哪一步失败以及哪些字节仍需恢复。
变异测试可把预检移进写循环、快照改成字符串、回滚改用Git、允许重复路径、正序恢复或吞掉rollback错误。门禁应逐个变红。能杀死这些典型实现,才说明测试保护的是事务性质,不只是输出样例。
用双文件修改走一次成功路径。工作区中 a.txt 为A、b.txt 为B,changes声明A到AA、B到BB。prepare解析两条安全路径,读取两个Buffer,before都匹配,形成快照;commit依次写AA与BB,check缺省视为通过,返回transaction。此时显式rollback逆序写回B与A,最终哈希与事务前完全相同。
创建与删除展示null语义。new.txt 的 before为null,prepare确认ENOENT;b.txt 的before为B、after为null。commit创建新文件并删除旧文件;rollback看到新文件snapshot为null就删除,看到旧文件snapshot为Buffer就重建。空字符串不会参与存在性判断,避免误把空文件当不存在。
前置冲突路径应做到零写入。若a匹配而b已被用户改成BX,prepare读完a也不能立即写;检测b不匹配后直接失败。测试除比较a、b,还可以监控writeFile调用次数为零。结果正确与过程零副作用都要有证据。
check失败路径发生在所有change已落盘之后。check看到AA/BB并返回false,catch调用rollback,随后重新抛“补丁校验失败”。调用方最终看到原工作区,但Trace应保留“应用过、校验失败、已恢复”的事实,与prepare冲突的零写入失败区分。
显式rollback与自动rollback应复用同一函数,避免两套恢复逻辑漂移。函数对snapshots做副本逆序,不原地reverse事务数组;否则第一次回滚会改变顺序,第二次调试或Trace展示不一致。不可变数据原则也适用于失败工具。
回滚删除本事务创建文件时使用force可以容忍已经不存在,但若路径现在是用户创建的不同内容,CAS版本必须拒绝。Lab为教学简化,没有并发修改;文章必须把这个剩余风险说清,防止生产直接复制。
工作区root先realpath再写入transaction,保证后续rollback使用同一规范身份。若保留调用方别名路径,macOS /var 与 /private/var 等差异可能让边界比较或审计不一致。对外仍展示原相对路径,内部统一真实根。
父目录不存在时,参考 workspacePath会因realpath(dirname(candidate))失败,意味着嵌套新目录暂不支持,尽管commit含mkdir recursive。进阶实现应找到最近存在祖先并验证其realpath,再逐层创建,同时拒绝中途symlink。文章不能把“mkdir存在”误写成已安全支持任意嵌套目录。
删除目录不属于本课协议,after null只删除文件。递归目录删除风险更高、快照成本无界,应使用专门工具和审批。若 rm 对目录force失败或行为变化,事务应报告而不是自动递归。窄能力使快照范围可计算。
大批补丁需要总量门禁。prepare在读取前先限制change数量与声明文本大小,读取后限制snapshot总字节;超限返回budget错误且零写入。否则恶意或错误模型可让事务把整个仓库加载到内存。上限进入合同与Trace。
check命令执行期间用户仍可能修改文件。隔离worktree能减少竞态;没有隔离时,check结束后重新核对每个afterHash,确认验证的是当前版本,再标记提交。若变化则进入冲突,不应盲目rollback覆盖外部修改。
多事务并发需按规范路径锁定,且一次获取所有锁使用稳定排序防死锁。先锁a再b与先锁b再a可能互相等待;按路径排序获取,释放逆序。小型CLI用单全局编辑队列更简单,吞吐较低但容易证明。
失败报告应分别列 primary error和rollback errors。用户最需要知道工作区是否已恢复,而不仅是check为何失败。若恢复完整可安全重试,若部分失败必须停止Agent并展示路径与快照位置。Failure Taxonomy可把后者标为不可自动重试的validation或tool严重错误。
端到端验收还要保留一个事务外脏文件。补丁失败后,changes内路径恢复,事务外文件Buffer与mtime不变,Git status仍显示用户原修改。这个测试直接证明“不会用仓库级回滚”,是相较git reset方案最重要的用户价值。
事务完成后应生成规范摘要:创建、修改、删除数量,总字节变化,路径清单,before/after哈希,check与rollback状态。摘要按路径稳定排序并计算报告哈希,既能供审批,也能让恢复循环判断两次策略是否真的产生新补丁。完整Buffer仍留在私有快照存储,不进入模型上下文。
发布前进行崩溃演练:在每个提交点与回滚点注入进程终止,重启恢复器读取journal,最终工作区只能处于完整before或完整after,不能长期半状态。Lab的内存事务无法覆盖跨崩溃保证,工程版必须通过持久化事件或隔离worktree补上。明确哪些性质已验证、哪些依赖后续架构,是可靠课程与过度承诺的区别。
最后让另一位开发者只看transaction报告和Git diff重建发生过什么:哪些路径原来存在、计划变成什么、校验为何失败、恢复是否完整。若证据不足以回答,继续完善事件字段,而不是增加原始内容日志。可恢复系统首先要让事实可解释。
可解释、精确且不伤用户工作,才是事务真正的完成标准。
下一课衔接
补丁事务知道自己计划改什么,但提交后仍需从独立事实源确认工作树实际变化。下一课调用 git diff --numstat HEAD --,结构化解析路径与增删行,检查未授权路径、锁文件和二进制风险。事务意图与Git观察互相校验,防止工具或check产生额外副作用。
- 数据库事务的 prepare/commit/rollback。
- 乐观并发控制与前置哈希。
- 为什么 Agent 不能默认拥有仓库级回滚权。