AI 助手说已经给图书列表加上了标题搜索。你运行 git diff,看到一个新 import 和几行修改,却没看到搜索函数的实现。提案还会漏掉目录里确实存在的书。要判断这份补丁,第一步是弄清 Git 到底比较了哪两个状态。

下载 Git diff 练习包,里面有一个六本书的小程序,以及为教程编写的补丁和测试。需要 Node.js 22 或更新版本和 Git,无需安装依赖或联网。

先选比较对象,再看红绿行

Git 中的工作区、暂存区和提交快照是不同的状态。几条看起来很相似的命令,回答的问题并不一样:

命令 实际比较
git diff 暂存区 → 工作区:已跟踪文件中尚未暂存的修改
git diff --staged HEAD → 暂存区:准备进入下次提交的改动
git diff HEAD HEAD → 工作区:已跟踪文件相对 HEAD 的净变化,包含已暂存和未暂存的编辑
git diff base HEAD base 和 HEAD 指向的两个提交快照
git diff base...HEAD 两者的共同祖先 → HEAD:head 分支自分叉以来的变化

前三种比较都不会包含普通的未跟踪文件。同一个文件也可能同时有已暂存和未暂存的修改。各比较形式见 Git diff 官方文档。

Diff 只比较状态,不判断作者。如果 AI 开始前就有人工修改,混在一起的补丁无法自动指出每一行是谁写的。需要区分来源时,要保留任务开始前的状态或提交 ID。审查分支时同样要选对 base;换了基准,就换了要回答的问题。

找出普通 diff 没显示的文件

解压后进入 git-diff-reading-lab,创建练习仓库:

node scripts/create-workspace.mjs review-workspace
cd review-workspace
git status --short --untracked-files=all

脚本只接受一个尚不存在的新目录。它先提交原来的列表代码,打上 base 标签,再留下未提交的提案,不修改任何已有仓库。你会看到:

 M src/list.mjs
?? src/search.mjs
?? tests/search.test.mjs

在这里这种没有合并冲突的状态中,第一列描述暂存区,第二列描述工作区。 M 前面的空格有意义:list.mjs 已修改,但还没暂存。?? 表示未跟踪文件。详细规则见 Git status 短格式。

接着运行:

git diff --name-status
git diff --stat
git diff -- src/list.mjs

输出里只有 src/list.mjs。还要在编辑器中打开 src/search.mjs 和 tests/search.test.mjs。在 macOS 或 Linux 终端,也可以用 git diff --no-index -- /dev/null src/search.mjs 把新源码显示成新增内容;退出码 1 在这里表示两个文件有差异。练习包里的 patches/review.patch 包含三个文件的完整提案,而当前工作区的普通 diff 不包含两个未跟踪文件。

此时 git diff --staged 为空,因为没有暂存内容;git diff base...HEAD 也为空,因为提案还没提交,base 和 HEAD 仍是同一个提交。它们都没有检查这份工作区提案。

逐行读一个完整 hunk

完整补丁中,list.mjs 的部分如下:

diff --git a/src/list.mjs b/src/list.mjs
index f9ed85e..b551297 100644
--- a/src/list.mjs
+++ b/src/list.mjs
@@ -1,6 +1,8 @@
 import { pageBooks } from "./page.mjs";
+import { searchBooks } from "./search.mjs";
 // Keep the caller's array unchanged.
-export function listBooks(books, { page = 1, pageSize = 2 } = {}) {
+export function listBooks(books, { page = 1, pageSize = 2, query = "" } = {}) {
   const sorted = [...books].sort((a, b) => a.title.localeCompare(b.title, "en"));
-  return pageBooks(sorted, { page, pageSize });
+  const currentPage = pageBooks(sorted, { page, pageSize });
+  return searchBooks(currentPage, query);
 }

先看文件头,再读函数:

  • a/ 和 b/ 分别标记旧侧、新侧,不是分支名。
  • index f9ed85e..b551297 是新旧文件内容对象的缩写 ID,不是两个提交 ID。100644 表示文件类型及权限未变,仍是不可执行的普通文件。
  • --- 指向旧文件,+++ 指向新文件。新建文件的旧侧是 /dev/null,完整补丁的下一个文件就展示了这种形式。
  • @@ -1,6 +1,8 @@ 表示旧侧从第 1 行开始,共覆盖 6 行;新侧从第 1 行开始,共覆盖 8 行。这不是“删了 6 行、加了 8 行”。
  • Hunk 内以空格开头的是保留的上下文,- 是删除行,+ 是新增行。本例有 4 行上下文、2 行删除、4 行新增,所以旧侧合计 6 行,新侧合计 8 行。保留的注释也按一行上下文计数。

文件头的完整规则见 Git 补丁格式文档。行数可以直接对照下载包里的六行 base 文件和八行 proposed 文件核算。这里讲的是普通双侧 hunk;合并提交的 combined diff 使用另一种格式。

沿新增调用看它收到什么输入

这次需求很具体:在整个目录中按标题搜索,忽略大小写,再对匹配结果分页;保留原来的标题顺序,不改变传入的数据。

新 helper 会过滤它收到的列表:

export function searchBooks(books, query) {
  const needle = query.toLowerCase();
  return books.filter((book) => book.title.toLowerCase().includes(needle));
}

函数本身能正常搜索,调用方仍可能传错范围。打开没有改动的 src/page.mjs,可以看到它会从排好序的书中截取一页。提案中的 currentPage 到达 searchBooks 时,已经只剩这一页。

固定数据每页显示两本,原始第一页是 Amber Atlas 和 Moon Garden,Star Notes 排在后面。只在第一页搜索 sTaR,得到的是 [];需求要求的搜索结果第一页应当包含 b5。patches/fix.patch 的修复顺序是先搜索 sorted,再把匹配项交给 pageBooks。

本次先验证这一处行为。如果真实补丁影响很多调用方,可以继续做代码变更影响分析;变更文件清单本身不会列出所有消费者。

看清通过的测试调用了谁

从 review-workspace 回到练习包根目录,分别执行:

cd ..
node scripts/run-tests.mjs proposed author
node scripts/run-tests.mjs proposed requirement
node scripts/run-tests.mjs fixed requirement
node scripts/example.mjs

提案附带的 author 测试能通过:它把完整的两本书数组交给 searchBooks,验证混合大小写查询。测试没有调用 listBooks,自然也没有走到“先分页、后搜索”的路径。这个例子没有删除或削弱旧断言。

另外三项测试从公共列表入口检查新需求,三个版本使用同一份断言。两项原有回归检查继续核对排序、分页以及输入没有被修改:

版本 原有回归检查 新的列表搜索要求 新要求测试退出码
base:还没有搜索功能 2 项通过 0 项通过,3 项失败 1
proposed:分页后搜索 2 项通过 1 项通过,2 项失败 1
fixed:搜索后分页 2 项通过 3 项通过 0

运行 node scripts/run-tests.mjs base requirement,或替换版本名、测试组名,可以复现表中的其他结果。Base 的失败表示新功能尚未实现,不是旧列表发生回归。Proposed 的两项失败分别是无法凑齐应有的一页匹配项,以及漏掉原始第一页之外的匹配项。失败测试真实返回退出码 1,不要用 && 串起这些命令,否则后面的命令不会执行。

scripts/example.mjs 为每个版本输出一行 JSON。在各行的 results 中,sTaR 对应的值依次是 base 的 ["b1","b2"]、proposed 的 [] 和 fixed 的 ["b5"]。这些检查验证的是本例的搜索顺序,并不能证明一套生产搜索功能已全面正确。如果补丁还改动了测试断言,再看如何审查 AI 生成的测试。

亲自看一次“diff 为空,但补丁还在”

检查完三个文件后,只在这个练习仓库中把它们暂存:

cd review-workspace
git add -- src/list.mjs src/search.mjs tests/search.test.mjs
git diff
git diff --staged --stat
git diff --staged

普通 diff 现在为空,因为工作文件与暂存区一致。已暂存 diff 则包含全部三个文件,包括新 helper 和新测试。这个操作只改变练习仓库的暂存区,没有提交提案;审查真实项目时,也不要为了让文件出现在 diff 中就顺手暂存无关改动。

把同一份提案变成两个已提交版本

要把这个例子放进 Yenpo 的 Mac Git 差异查看器阅读,先另建一个两端都已提交的练习仓库。回到解压后的 git-diff-reading-lab 根目录;如果仍在 review-workspace 中,先执行 cd ..,再运行:

node scripts/create-workspace.mjs committed-workspace --committed
cd committed-workspace
git status --short --untracked-files=all
git rev-parse base
git rev-parse proposal
git diff base proposal --name-status
git diff base proposal

新模式先提交原始列表文件并标记为 base,再复制、暂存三个提案改动文件,创建第二提交并标记为 proposal。脚本会打印两个实际提交 ID。HEAD 指向 proposal,其父提交是 base;先前的 review-workspace 保持原样。

状态命令应当没有输出。两个快照都已提交,普通 git diff 和 git diff --staged 也为空,但明确的 base → proposal 对比仍包含三个文件:修改的 src/list.mjs、新增的 src/search.mjs 和 tests/search.test.mjs。还要打开没有改动的 src/page.mjs:列表函数仍然把分页后的子集传给新搜索函数。

两个提交中的源码分别与包内 versions/base、versions/proposed 一致。前文的 sTaR 观察结果仍是提案前 ["b1","b2"]、提案后 [],需求期望则为 ["b5"]。提交提案不会修复搜索顺序;包内另有 fixed 快照及其通过的需求检查,这个模式不会创建 fixed 提交。

准备脚本只接受一个新的子目录,已有文件、文件夹或符号链接都会被拒绝。它使用明确的虚构教学身份和固定时间,隔离继承的 Git 身份与配置,设置只写入新练习仓库,不改变用户全局身份或其他仓库。

在 Yenpo 中打开 committed-workspace,按Git 对比指南用打印的两个提交 ID 核对版本:旧侧是 base,新侧是 proposal。这里比较的是已提交快照,不包括未暂存或已暂存但未提交的编辑,也不会识别某轮 AI 对话改了哪些代码。