Skip to content

Git Diff 自检

本课交付结果

你将交付 reviewGitDiff(root, allowedPaths):通过无 shell 的 git diff --numstat HEAD -- 获取稳定文件列表、增删行和二进制标记,按路径排序并生成未授权、锁文件、二进制三类风险。

岗位问题

Agent 认为自己只改了测试,不代表工作树事实如此。生成后必须从版本控制系统重新观察实际 diff;纯文本 diff 太长且难聚合,numstat 更适合结构化门禁。

前置检查

前置知识快照

先通过多文件事务:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 17

需要本机 Git。Lab 每次创建临时仓库,并配置固定本地身份,不访问网络也不修改真实仓库。

补丁事务描述“工具计划并尝试修改什么”,Git diff描述“相对基线工作树实际上变化了什么”。两份证据来源独立,才能发现check脚本、格式化器、生成器或工具缺陷产生的额外路径。只复述changes数组不算自检。

git diff --numstat HEAD -- 是面向机器的有限协议:文本文件给新增、删除行数和路径,二进制给 -- 与路径。最后的 -- 结束选项,避免路径或后续参数被解释成flag。Runtime固定cwd到可信仓库并关闭shell。

原理拆解

用固定 executable 和 argv 调 Git,关闭 shell;非零退出码成为明确错误。numstat 普通行为数字,二进制行为 -/-。授权项可以是精确文件或以 / 结尾的目录前缀;风险码保留路径供审批和 Trace 使用。

mermaid
flowchart LR
  A[可信 repo root] --> B[git diff --numstat HEAD --]
  B --> C{退出码}
  C -- 非零 --> D[结构化 Git 错误]
  C -- 零 --> E[逐行 tab 解析]
  E --> F[数字增删 / 二进制 null]
  F --> G[按 path 稳定排序]
  G --> H[授权路径检查]
  G --> I[锁文件风险]
  G --> J[二进制风险]
  H --> K[files + risks]
  I --> K
  J --> K

为什么不用人类可读patch?完整diff可能很大,包含颜色、上下文与任意文件内容,不适合第一层门禁。numstat先给出范围和规模,系统决定是否允许、是否超预算,再按需读取具体patch。风险评审应从元数据收窄到内容。

不要追加 --binary。该选项会在普通patch后附加可应用的二进制正文,stdout不再是纯numstat;解析器把正文按tab切割会产生NaN和伪路径。要求最小协议时,只传产生该协议所需的flag。

解析一行时前两列是added/deleted,剩余tab拼回路径,因为合法文件名可能含tab。added或deleted为短横线时标记binary,行数设null,不用Number("-")制造NaN。生产实现还需处理Git对重命名和特殊路径的转义配置。

稳定排序让同一工作树产生相同报告、哈希和审批顺序。Git遍历顺序不应传播到评测;路径比较规则要固定。风险数组也可按路径与类型排序,避免规则添加顺序改变报告hash。

授权路径分精确文件和显式目录前缀。src/a.ts 只允许该文件,src/ 允许其下路径;裸 src 不应作为前缀,否则 src-old 也通过。规范化相对路径必须拒绝绝对、上跳与奇异分隔符。

授权不等于无风险。pnpm-lock.yaml 即使在allowedPaths中仍触发LOCKFILE_CHANGED,因为依赖图、供应链与大量间接代码可能变化;二进制即使授权仍难以文本审查,也触发BINARY_CHANGED。一个文件可同时产生多个风险,不能首个命中就停止。

未授权路径是事实偏差:Agent或副作用修改了计划外文件。报告保留 UNAUTHORIZED_PATH:path,上层通常停止并回滚或请求扩大授权,不能只打印warning后继续。授权来自用户任务与补丁计划,不由模型在执行后自行扩展。

代码实验

失败实现

危险实现拼命令并解析彩色patch文本:

ts
const output = execSync(`git diff HEAD ${paths.join(" ")}`).toString();
return output.split("\n").filter((line) => line.startsWith("+++"));

正确实现固定argv并解析numstat:

ts
const result = spawnSync("git", ["diff", "--numstat", "HEAD", "--"], {
  cwd: root,
  encoding: "utf8",
  env: { PATH: process.env.PATH ?? "" },
  shell: false,
});
if (result.status !== 0) throw new Error(`git diff 失败:${result.stderr.trim()}`);

const files = result.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
  const [added, deleted, ...pathParts] = line.split("\t");
  const binary = added === "-" || deleted === "-";
  return {
    path: pathParts.join("\t"),
    additions: binary ? null : Number(added),
    deletions: binary ? null : Number(deleted),
    binary,
  };
});

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/18-git-diff-review/starter test
pnpm --dir bootcamps/coding-agent/labs/18-git-diff-review/solution test

Starter 应声明 实现 Diff 风险检测;Solution 应通过 5 项临时 Git 仓库测试。

二进制测试直接检查结构与风险:

ts
it("标记二进制修改", async () => {
  await writeFile(join(root, "image.bin"), Buffer.from([0, 9, 8]));
  const review = reviewGitDiff(root, ["image.bin"]);
  expect(review.files[0]).toMatchObject({ additions: null, deletions: null, binary: true });
  expect(review.risks).toContain("BINARY_CHANGED:image.bin");
});

关键实现讲解

不要同时传 --binary:它会附加二进制 patch 正文,污染 numstat-only 协议。解析后按 path 排序,保证同一 diff 的报告哈希稳定。锁文件变更即使已授权也仍是风险,因为依赖图可能大幅变化。

Lab用同步spawn便于短小测试,生产Agent应复用Week3 Process Executor,获得超时、取消、输出预算和干净环境。Git diff在巨大仓库也可能输出过多;numstat较小但仍要限制字节与文件数量,并在截断时让门禁失败而非审查不完整集合。

环境只传PATH仍可能使用仓库和用户Git配置。生产命令可设置受控HOME、禁用pager、颜色和外部diff,固定 core.quotepath 等解析相关选项。机器协议必须不受用户别名与展示配置影响。

运行与验证

真实运行输出

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 18
pnpm --filter @coding-agent/editor test

失败产物检查:观察 risks 中的 UNAUTHORIZED_PATH:a.txtLOCKFILE_CHANGED:pnpm-lock.yamlBINARY_CHANGED:image.bin;它们是门禁证据,不只是日志字符串。

真实Solution应通过五项临时仓库测试:

text
solution: 5 passed, 0 failed
覆盖: 排序与增删行 / 未授权路径 / 精确授权 / 锁文件风险 / 二进制风险

每个测试只操作mkdtemp中的新仓库,设置本地身份并提交基线;验证没有访问网络或真实项目。再检查命令参数中没有 --binary,shell为false,报告文件顺序稳定。

常见失败与排查

故障案例 1

二进制patch正文被解析成大量伪文件。

症状:files出现NaN行数和奇怪base85文本路径,报告体积暴涨。

根因:命令同时传 --numstat--binary,却假设stdout每行都是numstat。

定位:记录精确argv和stdout前几种行形态,检查是否存在GIT binary patch

修复:门禁命令只请求numstat;具体二进制内容不进入文本解析器,统一标记风险。

故障案例 2

目录授权意外放行兄弟路径。

症状:允许src后,src-old/secret.ts没有UNAUTHORIZED风险。

根因:对裸字符串使用startsWith,没有要求目录分隔边界。

定位:用src/src-old/src2/构造近似反例,观察匹配规则。

修复:目录授权必须显式以斜杠结尾,文件授权精确相等;路径先规范化。

故障案例 3

依赖图变化被普通授权掩盖。

症状:补丁允许修改锁文件后报告零风险,评审没看到大量依赖变动。

根因:风险规则写成互斥分支,授权成功就跳过锁文件与二进制检查。

定位:同一已授权锁文件修改应同时无UNAUTHORIZED但有LOCKFILE_CHANGED。

修复:授权与固有风险独立计算,一个文件可积累多个风险;上层分别处理。

课后作业

加入未跟踪文件发现、重命名解析、总增删行预算和敏感路径规则;输出机器可读 JSON 报告并计算规范哈希。

进阶要求是把暂存、未暂存和未跟踪三类事实合并而不重复,支持非HEAD基线与重命名,记录Git版本和命令证据。给报告加入总文件、总行数、敏感路径与授权来源,并在任何输出截断时默认门禁失败。

验收 Rubric

维度通过标准常见扣分
调用固定 argv、shell:false拼接 Git 命令
解析数字与 -/- 正确归一二进制变成 NaN
授权精确路径与显式目录前缀宽泛 startsWith
风险三类风险保留路径证据只返回布尔值

总项目增量

总项目包:@coding-agent/editor

总项目路径:packages/editor/src/diff-review.ts

总项目验证命令:pnpm --filter @coding-agent/editor test

Diff 自检给出事实;下一课把不同失败归一成可决定重试与否的分类。

延伸阅读

方案对比与工程取舍

git diff完整patch提供最丰富内容,却体积大且可能泄露秘密;--name-only只给路径,无法评估规模和二进制;--stat面向人类且格式受宽度影响;--numstat在路径、行数与协议稳定性之间平衡,适合第一层机器门禁。通过后再按授权路径读取有限patch。

Git不是所有工作区都有,未初始化目录仍可使用Patch Transaction自己的after摘要。产品应把Git review作为可用时的独立证据,而不是让编辑器依赖Git才能工作。没有Git时提高人工审查等级或使用文件快照diff,不能静默跳过门禁。

Lab相对HEAD只观察已跟踪工作树修改,不包含未跟踪新文件。生产必须补 git ls-files --others --exclude-standard,并把新文件 additions按未知或实际行数处理。否则补丁新建恶意脚本不会出现在报告。暂存区差异也要根据基线策略纳入。

相对HEAD会把用户原有未提交修改与Agent新修改一起显示。allowedPaths只能判断路径,无法区分所有权;Patch Transaction的before/after哈希可以标记本事务增量,或在隔离worktree中运行Agent。不要把用户已有脏文件误归因给当前补丁,更不能自动回滚。

最可靠方案是在任务开始记录基线树与工作区快照,在隔离worktree提交或比较。若必须在脏工作树运行,报告分成preexisting与introduced,最终风险仍覆盖实际全部变化。用户应知道哪些差异不是Agent产生,评审不能只显示“本次计划”。

重命名的numstat路径可能用花括号简写或特殊格式,简单tab解析得到展示路径而非单一真实路径。生产可加 -z 使用NUL分隔,关闭路径quote,并按Git文档解析rename旧新路径。机器协议优先使用无歧义分隔符。

文件名可以包含换行和tab,逐行文本协议存在边界。课程为了可读使用换行/tab,真实不可信仓库应使用 -z 并以Buffer解析。路径再规范化为仓库相对形式,拒绝上跳与绝对表示。安全解析不能假设“正常文件名”。

增删行预算是风险代理,不是质量结论。十行可能修改认证逻辑,一千行可能是机械格式化;门禁用阈值触发更深审查,而非自动判断好坏。风险报告同时保留文件类型、敏感路径与任务授权,交给人或更具体规则。

锁文件风险可进一步计算对应manifest是否也修改、依赖数量变化和完整性字段。只改锁文件可能是安装器版本漂移,也可能供应链攻击;manifest与lock一起改也仍需审查。不要因为工具自动生成就跳过。

二进制变化无法用文本diff评审,可记录大小、MIME、before/after哈希和专用预览。模型通常不应自动生成可执行二进制;图片等资产可进入人工视觉审查。BINARY_CHANGED是路由信号,不是必然拒绝所有资产。

敏感路径规则包括CI发布、权限策略、密钥配置、数据库迁移与依赖manifest。它们即使授权也提高风险级别。规则来源与版本进入报告,项目可增加规则但不能覆盖组织deny。路径匹配复用规范化glob并测试近似前缀。

非零Git退出可能因为不是仓库、HEAD不存在、权限或Git缺失,错误恢复不同。首个commit之前可选择空树hash作为基线;非仓库回退文件diff;其他执行故障停止。不要把所有stderr原样送模型,其中可能含绝对路径。

同步spawn会阻塞事件循环,大仓库服务端不适合。复用有界异步Executor可以取消和超时;结果截断意味着报告不完整,必须失败或提高限制后重试,不能把部分files当完整事实。完整性标志是一等字段。

环境固定还要避免Git hooks吗?git diff通常不运行commit hooks,但外部diff、textconv与filter可能执行程序。使用 --no-ext-diff、禁用textconv或受控配置,确保只读观察不会调用仓库配置的外部代码。命令Guard的allow基于这些执行假设。

报告hash基于规范JSON:固定schema版本、路径排序、风险排序、数字/null明确,不包含generatedAt等波动字段。相同diff得到相同hash,审批token可绑定;时间戳放在hash外。升级schema改变hash空间,避免新旧解释混用。

风险不应只有字符串,生产结构可用 {code,path,severity,evidence}。Lab字符串便于断言,结构对象便于UI本地化、过滤和策略。序列化时仍可生成稳定文本key,兼顾机器与人。

测试临时仓库必须配置本地user.name/email,确保commit不依赖开发者全局配置;env只给受信PATH与隔离HOME,避免全局attributes改变二进制检测。真实Git集成测试比mock更能验证协议,但要保持完全本地无害。

变异测试加入--binary、移除shell:false、将目录匹配改裸startsWith、授权后提前continue、删除排序。对应测试都应失败。再用特殊文件名和未跟踪文件扩展门禁,证明生产解析不只覆盖理想仓库。

最终审查流程先看报告范围与风险,再读取每个授权文本patch,运行测试,最后再次生成numstat确认没有后续命令产生新差异。Diff review不是一次性截图,而是提交前后的事实门禁。报告hash变化会使旧审批失效。

用普通文本变化走一遍解析。基线 a.txt 含A一行,修改为B与C两行,Git输出 2\t1\ta.txt。解析器得到additions二、deletions一、binary false;路径精确命中allowedPaths,无风险。报告只有一项,排序稳定。这是人类diff之外足以做范围门禁的最小事实。

若allowedPaths只有src/,同一 a.txt 不匹配精确文件或目录前缀,产生UNAUTHORIZED_PATH。系统不应自动把实际路径加入授权,因为那会让门禁永远通过;它应回到补丁计划,判断是工具副作用、授权遗漏还是用户已有修改。

锁文件用例把 pnpm-lock.yaml列入allowedPaths,授权检查通过,不产生UNAUTHORIZED,但固有规则仍追加LOCKFILE_CHANGED。两个维度可以同时表达“这次确实计划改它”和“它仍值得更高审查”。将风险做成互斥布尔会丢掉这种组合信息。

二进制用例改变三个字节,numstat输出短横线。解析器不猜行数,两个字段均为null并标记binary,追加BINARY_CHANGED。Number转换若发生在判断之前会得到NaN,而NaN在JSON序列化与比较中产生更多歧义;协议分支应先识别哨兵。

目录授权只接受以斜杠结尾,是API层减少歧义的设计。src/能匹配src/a.ts,不能匹配src-old/a.tssrc被视为精确文件名,而非目录。若调用方想授权目录,必须明确表达边界。小小的语法约束比复杂猜测更安全。

allowedPaths自身需要去重、规范化和数量上限。空字符串不得成为全局前缀,../应转换或拒绝,绝对路径与上跳禁止。授权清单由可信计划生成并绑定审批,不接受模型在review调用时随意扩大为根目录。

Git diff输出中的路径采用仓库相对语义,与文件工具工作区相对路径应对齐。若任务root是monorepo子目录,Git root与workspace root可能不同,需要显式转换并证明仍在授权范围。隐式使用当前目录会让同一相对路径指向不同身份。

HEAD并非总是正确基线。任务开始时仓库可能已有脏修改,或Agent在隔离分支创建了中间commit。Runtime应记录base commit或树hash,review相对该不可变基线;Lab用HEAD是因为每个临时仓库刚提交固定base。生产代码不能把这个教学假设硬编码成所有流程。

暂存与未暂存差异的Git命令不同。默认 git diff HEAD通常涵盖两者对已跟踪文件的综合变化,但未跟踪仍缺失。测试要创建staged、unstaged和untracked三类,确保最终合并没有漏项或重复。每类来源可作为evidence字段。

submodule变化在numstat中具有特殊形式,可能表现为一行或模式变化。它等同依赖指针更新,应作为高风险而非普通文本文件。生产风险规则识别gitlink mode、子模块路径和.gitmodules,要求人工审查目标commit与来源。

文件删除与新增的行数仍是数字,但风险含义不同。报告可以通过另一个 --name-status -z命令获取status,再与numstat按路径合并;或使用单一更丰富机器协议。多个命令之间工作树可能变化,最好在锁或隔离快照下执行,并记录同一基线。

行数统计受Git文本属性与换行影响。大型单行文件改一字符可能显示一增一删,却实际字节巨大;因此结合文件大小变化和二进制检测。numstat是快速信号,不是完整成本估计。达到敏感类型或大小阈值时读取更详细元数据。

报告中不要包含完整stderr。Git错误可能回显宿主绝对路径或配置;对外返回稳定错误码与相对仓库信息,内部受控Trace保留有限诊断。Failure Taxonomy将Git启动失败归tool或validation,恢复策略决定是否重试。

Git可执行程序也必须来自受信PATH或固定位置。恶意仓库无法直接替换系统Git,但若cwd优先PATH或环境被项目控制,可能启动同名脚本。复用Process Executor的最小环境,记录Git版本与解析路径,保证观察者本身可信。

同一报告需要与Patch Transaction关联。transaction提供计划路径和afterHash,review提供实际Git路径与行数;交叉检查实际集合是否为计划集合的子集或精确集合,hash是否吻合。仅凭allowedPaths范围可能允许目录中意外的第二个文件。

计划允许目录是方便表达,却弱于精确文件集合。任务初期可授权src/,补丁形成后应收紧为实际计划路径,再次review。权限逐步收敛能减少副作用空间:发现阶段宽只读,编辑阶段精确写,审查阶段核对事实。

风险严重度不应由模型决定。组织策略为UNAUTHORIZED设阻断、LOCKFILE设审批、BINARY设专用审查;项目可收紧,不能自行降级。UI展示所有风险,不因某项已确认就隐藏其他项。确认token绑定报告hash,diff变化后重新审批。

真实验收还要在有空格、中文和特殊字符的文件名上运行。文本行协议可能暴露问题,生产 -z解析器应保持原始字节到合法UTF-8路径映射。无法安全表示的路径直接阻断,不能用损坏字符串执行后续读取或授权。

最后做时间一致性测试:生成报告后修改另一个文件,旧报告hash不能继续用于提交;提交前重新review并与批准hash比较。事实门禁只有靠近副作用最终点才有效,过早截图会在后续测试或格式化阶段过期。

团队还应定期抽样比较numstat报告与完整patch,确认解析器没有漏掉重命名、属性、子模块或特殊路径。发现新形态后先加入脱敏夹具和失败测试,再扩展协议,不要在线上静默容忍。机器接口的稳定来自持续验证,而不是假设Git永远只输出三列普通文本。

本课完成的标准不是“能调用git diff”,而是能回答四个可审计问题:实际改了哪些路径、规模多大、是否超出授权、是否触发固有风险。答案必须来自受信命令、完整有界输出和确定解析。任何一项证据缺失,Runtime都应暂停提交而非猜测通过。

再把报告与用户看到的评审界面逐项核对,确认路径、行数、二进制和风险没有在展示层丢失或被降级。机器门禁负责阻断,界面负责让人理解;二者使用同一规范报告,才能避免“后台有风险、前台看不见”。

证据必须完整一致。

下一课衔接

Diff门禁可能返回未授权、锁文件或二进制风险,编辑与测试也会产生各种异常。下一课把Runtime全域失败归一为十类、可重试性和稳定指纹。结构化分类优先于文本,避免权限消息里的“timed out”被错误当超时,从而让恢复循环做出可靠停止决定。

  • Git plumbing/porcelain 输出稳定性。
  • Diff budget 与变更风险评分。
  • 规范化报告和可复现哈希。

从零实现 Mini Code Agent Runtime