AI 建议删掉读取源码行的函数里的一处检查,理由是“调用方已经保证行号有效”。函数很短,看起来确实有点多余。你打开 Git 历史,发现这一行最后修改于一个叫“压缩比较表达式空格”的提交。

接下来该看这个提交的 diff,再往前找:这处检查挡住什么输入,什么时候加入,当时还改了什么,现在的调用方是否仍然需要它。下面用一段可以在本地重建的历史,把这几个问题逐一查清。

从让人疑惑的一行开始

假设一个源码阅读器的行号从 1 开始,当前实现是:

def read_line(lines, line_number):
    """Read a source line using a one-based line number."""
    if line_number<1:
        raise ValueError("line number must be positive")
    if line_number > len(lines):
        raise IndexError("line number is past the end")
    return lines[line_number - 1]

对 ["alpha", "omega"] 这两行,行号 1 应该读到 alpha,行号 2 应该读到 omega。Python 的索引 -1 却会读取最后一个元素。删掉下界检查后,行号 0 减去 1,变成索引 -1,于是返回 omega;行号 -1 则变成索引 -2,返回 alpha。

只读当前代码就能解释这个结果。查历史要回答的是另一件事:拒绝这些输入的规则是哪次加入的,那次还留下了什么线索?

在本地重建四个提交

把 git-history-lab.py 下载到一个空的工作目录。需要 Git 2.29+ 和 Python 3.9+,不需要安装第三方包。在自己的终端运行:

python3 git-history-lab.py create history-lab --record history-check
cd history-lab
git log --reverse --format='%h %s' HEAD

Windows 用户可按本机配置把 python3 换成 Python 3 启动命令。整个过程只用 Git 和 Python,不依赖 macOS API。本次记录来自 macOS 上的 Git 2.50.1、Python 3.9.6,没有另行实测 Windows 和 Linux。

脚本会新建本地仓库、运行验证,把输出存到 history-check。目标目录已经存在时,它会停止;提交使用专门的教学作者,不修改用户全局 Git 配置。例子中的姓名、日期、提交说明和阅读器场景均为教学构造。

ce4c0cf Add one-based source line lookup
e87088e Reject nonpositive source line numbers
7d41ebc Rename line reader module
27a0dc5 Compact comparison spacing

每个提交还有便于输入的标签:

标签 改动
baseline 新增 line_reader.py,测试第一行、最后一行和超出末尾的行号
fix-positive-lines 增加下界检查,以及 0、-1 和空列表的测试
rename-module 文件改名为 source_lines.py,更新测试的导入路径
format-spacing 把 line_number < 1 改为 line_number<1

最后这处紧凑空格是刻意安排的,方便认出最后一次改动。

先沿着 blame 打开 diff

查看第 3 行:

git blame -L 3,3 HEAD -- source_lines.py
git show --format=fuller format-spacing -- source_lines.py

blame 把这一行归到格式提交 27a0dc57,补丁里是:

-    if line_number < 1:
+    if line_number<1:

再忽略空白差异,追一次:

git blame -w -L 3,4 HEAD -- source_lines.py

这次会看到 e87088e0 和旧路径 line_reader.py。Git blame 会自动跟随整文件重命名,-w 则在归属计算中忽略空白。这个例子里让普通 blame 停在较新提交上的是空格改动,重命名本身没有遮住原始归属。具体规则见官方 git-blame 文档。

针对这里的改动,验证脚本还比较了前后的 Python 语法树,并运行测试,两项结果都相同。-w 用来查历史归属,不能代替行为验证:Python 缩进、字符串内部的空格都可能影响程序。

已经读过某次格式提交的补丁后,也可以明确跳过它:

git blame --ignore-rev format-spacing -L 3,3 HEAD -- source_lines.py

在这个仓库里,它同样能追到原修复。不要只凭提交标题里写着“格式化”就忽略整次修改,先看 diff。blame 提供的是行的历史归属,开发者的考虑和缺陷责任还需要别的材料。

用稳定的文本找出原始改动

错误消息没有随着文件名和空格改变。拿它搜索:

git log --format='%h %s' -p -S 'line number must be positive' HEAD -- '*.py'

结果会列出原修复,以及 line_reader.py 中的补丁。*.py 用引号包住,让 Git 处理路径匹配,也把旧文件名包括进来。这个仓库只有一次提交增减了这条消息。

-S 根据字符串出现次数的变化筛选提交;改过附近的行,并不一定会命中。规则见 Git 官方的 pickaxe 说明。

换成格式修改涉及的表达式,再搜一次:

git log --format='%h %s' -p -S 'line_number < 1' HEAD -- '*.py'

这次会同时看到格式提交和原修复:前者删掉了带空格的精确写法,后者加入了它。多一个结果并没有出错,顺着补丁看,再决定是否换个稳定片段。没有结果也要查原因:文本所在的行可以变,指定字符串的出现次数却保持不变。

已经知道文件时,这个例子也可以这样追:

git log --follow --format='%h %s' -p -S 'line number must be positive' HEAD -- source_lines.py

--follow 用于单个文件的重命名历史。跨文件寻找同一段逻辑时,要检查旧路径,必要时扩大路径范围,别把过窄的搜索当成完整历史。

用 log -L 看一段代码怎样变化

知道相关行号后,可以直接查看这两行沿历史变化的补丁:

git log --format='%h %s' -L 3,4:source_lines.py HEAD

在教学仓库中,它会列出空格改动和最初加入检查的补丁。它能穿过这次纯文件重命名,但没有改动该代码块的重命名不会单独列出一个补丁。想看完整的四步顺序,可以回到前面的提交列表。

要看整个函数,运行:

git log --format='%h %s' -L :read_line:source_lines.py HEAD

这次还能追到 baseline 的原始索引表达式。例子的 .gitattributes 声明了 *.py diff=python,让 Git 使用 Python 函数头规则。函数识别受语言和 diff 配置影响,详见 自定义 diff 块标题的官方说明。识别不出来时,这个文件也可以用明确行号:

git log --format='%h %s' -L 1,7:source_lines.py HEAD

log -L 的文件路径写在范围参数里,不要再追加 -- 文件路径。指定范围必须存在于起点版本,而且只从一个版本开始追踪。这些限制见官方 git-log 文档。

真实仓库还可能有合并、代码复制、函数重写,以及浅克隆缺少早期提交的问题。行历史帮助你定位要读的补丁,仓库从未记录过的理由仍需另找来源。

把解释放到两个版本里验证

找到修复后,把实现和测试一起打开:

git show --format=fuller fix-positive-lines -- line_reader.py test_lines.py

补丁增加了下界检查,也增加了对 0、-1 抛出 ValueError 的断言。验证脚本把已提交的源码和测试提取到临时目录,先用修复时的同一份测试跑旧实现,再运行各提交自带的测试,不切换你的工作区版本。

本次实际运行结果 能说明什么
旧实现对 0 返回 omega,对 -1 返回 alpha 这两个输入触发了 Python 负索引
修复时的测试跑旧实现,两处断言失败 新增断言能抓住旧行为
修复、重命名、空格改动三个版本,各通过五项测试 这些用例在修复后的版本表现一致
重命名前后源码字节相同,空格改动前后语法树相同 后两次修改保留了该函数的实现

旧版本自带的三项测试也全通过。测试通过有时只是因为没有覆盖你关心的输入。完整检查项目见 预期结果;本地 history-check/transcript.txt 保存实际命令与输出,包括预期出现的两处失败。

示例的提交说明写着“行号 0 读到了最后一行”,测试复现了这个函数行为。要在自己的项目里删掉检查,还得看当前调用方怎样拒绝零和负行号。这个例子假定输入为整数,其他类型需要另做校验。

让 AI 按找到的材料解释

拿到提交编号、补丁和测试输出后,再让 AI 把它们串起来:

解释这处下界检查为什么存在。依据给出的修复及父提交,说明输入 0 和 -1 的结果,指出相应的回归断言。把 diff 和已运行测试能说明的行为,与提交说明声称的动机分开。建议删除前,继续检查当前调用方;如果没有这些调用方或历史报告,说明哪项结论还无法确定。

回答应该把行为变化、记录的动机、当前调用方的未知情况分别讲清。只凭提交说明就断言发生过事故,要让它缩小结论。建议删除检查,则要让它找出所有受支持入口如何排除失败输入。继续查调用方时,可以参考从一个值开始读陌生代码;想把解释写成回归测试,可以接着看审查 AI 写的补丁。