git blame 会为文件的每一行标注最近一次修改它的提交,以及该提交的作者和日期。
它给出的提交往往并不是你想找的那一个。你在一段并非自己编写的代码中查看某行奇怪的语句,结果 blame 甩给你一个十八个月前、涉及 4,000 个文件的 “apply prettier” 提交。
这种死胡同正是裸用 git blame 的常见结局,而如何突破它才是真正的技能所在。本文将介绍如何解读默认输出、如何用 -L、-w、-M 和 -C 缩小范围并消除噪音、如何沿父提交向前回溯直到找到真正有意义的改动,以及两个新增功能:--diff-algorithm(Git 2.53 及以上)和 git last-modified(Git 2.52 及以上)。
要点速览
- git blame 为某一行显示的提交只是最后一次触碰它的提交,这往往是一次重新格式化、重命名或代码移动,而不是赋予该行实际含义的那次改动。
- 在所报告提交的父提交上重新执行 blame(
git blame <hash>^ -- file)并反复进行,是找到源头改动的可靠方法;--ignore-rev和--ignore-revs-file可自动跳过已知的噪音提交。 -w忽略空白字符,-M追踪文件内部移动的行(默认阈值为 20 个字母数字字符),-C追踪从其他文件复制而来的行(默认为 40),最多可叠加三个-C标志来扩大搜索范围。- Git 2.53 为 git blame 增加了
--diff-algorithm,可接受patience、minimal、histogram或myers,其中myers为默认值。 - Git 2.52 引入了实验性的
git last-modified,可在一次遍历中报告目录下每个路径最后被哪个提交修改。
如何解读 git blame 的默认输出?
git blame 默认输出的每一行按顺序包含四个字段:缩写的提交哈希、作者姓名、作者日期和行号,其后是该行的内容。man 手册的默认格式章节列出了这些字段;Git 默认将哈希缩短为七位十六进制数字,并额外留出一列用于标记边界提交的插入符号(blame 能追溯到的最早提交)。除非 --date 或 blame.date 另有指定,日期以 ISO 格式打印。
git blame src/router.js
a1b2c3d4 (Jane Doe 2024-03-08 14:22:31 +0100 42) return cache.get(key) ?? fetchRoute(key);
从左到右解读:a1b2c3d4 是提交,Jane Doe 和时间戳是该提交的作者标识,42 是当前文件中的行号,右括号之后的一切就是该行本身。
关于这个哈希,重要的是理解它不是什么。它不是引入这段逻辑的提交,而是最近一次 diff 触碰到该行的提交;在一个有格式化工具、linter 和各种重构的代码库中,这往往只是一次机械性的改动。请把首次 blame 的结果当作线索,而不是定论。
如何用 -L 将 git blame 限定在某个行范围?
git blame -L 40,60 -- src/router.js 将标注限定在第 40 至 60 行,而 git blame -L :handleRoute -- src/router.js 则将其限定在名称匹配该正则表达式的函数体内。这两种形式都记录在 -L 选项下,该选项可以多次给出。
git blame -L 40,60 -- src/router.js
git blame -L :handleRoute -- src/router.js
:funcname 形式并不解析你的编程语言。它识别函数名的方式与 git diff 判断 hunk 头部该打印什么内容的方式相同,你可以通过 gitattributes 中的 diff 属性针对不同文件类型进行调整。范围的两个端点也都接受 /regex/ 模式,终点还接受 +N 偏移量,所以 -L '/^function handleRoute/,+15' 同样有效。
如何让 git blame 忽略空白字符和移动的代码?
传入 -w 会让 git blame 在比较版本时忽略空白字符,这样仅涉及缩进的重新格式化就不会再”认领”它所触碰的那些行。-M 可识别在同一文件内部位置发生变动的行,而 -C 则将搜索范围扩大到同一提交所修改的其他文件中转移过来的行;man 手册给出它们的默认匹配阈值分别为 20 和 40 个字母数字字符。
| 症状 | 标志 |
|---|---|
| 行被归因于重新缩进或清理行尾空格的提交 | -w |
| 行被归因于在文件内部重排代码的提交 | -M |
| 行是从另一个文件复制或移动而来 | -C(可叠加) |
| 行被归因于某个已知的批量提交 | --ignore-rev <hash> |
git blame -w -- src/router.js
git blame -M -- src/router.js
git blame -C -C -C -- src/router.js
每多加一个 -C,git blame 搜索复制来源的文件范围就更广:
-C搜索同一提交修改的其他文件。-C -C还会搜索首次添加本文件的那个提交所触碰的文件。-C -C -C再次扩大范围,涵盖任意提交中的文件。
如果多个 -C 标志带有数值阈值,以最后一个为准。整个文件的重命名则完全不需要任何标志:blame 会自行跨越重命名继续追踪这些行,而且 Git 目前没有提供关闭该行为的方式。
如何找到批量重新格式化之前的那个提交?
要越过一次机械性提交,就在该提交的父提交上重新执行 blame,即 git blame <hash>^ -- src/router.js,并不断重复,直到显示的提交是真正改变了该行行为的那一个。^ 后缀是标准的 gitrevisions 语法,表示第一个父提交,因此 blame 会从噪音提交落地之前的文件状态开始。
- 执行
git blame -L 40,60 -- src/router.js,记下目标行上的哈希。 - 用
git show --stat <hash>查看该提交。如果它是重新格式化、重命名或代码移动,则继续。 - 执行
git blame -n <hash>^ -L 40,60 -- src/router.js。-n标志会打印每一行在原始提交中的行号,这一点很重要,因为行号会在不同版本间漂移,下一轮你可能需要重新调整-L的目标范围。 - 从第 2 步开始重复,直到显示的提交确实改变了该行的功能。
git blame -n a1b2c3d4^ -L 40,60 -- src/router.js
当仓库中存在已知的噪音提交时,就不必手动逐级回溯了。--ignore-rev <hash> 会让 git blame 将行归因到指定提交之外的提交,而 --ignore-revs-file 则对整个哈希列表文件做同样的事,文件中每行写一个完整哈希。设置 blame.markIgnoredLines 可用 ? 标记被重新归因的行,设置 blame.markUnblamableLines 可用 * 标记无法重新归因的行。
git blame --ignore-rev a1b2c3d4 -- src/router.js
git blame --ignore-revs-file .git-blame-ignore-revs -- src/router.js
git config blame.markIgnoredLines true
关于将该列表以 .git-blame-ignore-revs 的形式提交并把 blame.ignoreRevsFile 指向它的做法,可参阅 5 Git Dotfiles Every Developer Should Know。
尝试不同的 diff 算法(Git 2.53 及以上)
Git 2.53 为 git blame 增加了 --diff-algorithm,可接受 patience、minimal、histogram 或 myers(default 是 myers 的别名),默认值为 myers。这一新增功能出现在 Git 2.53 发布说明中,man 手册的 —diff-algorithm 选项下列出了可接受的取值。
blame 通过对两个版本做 diff 来判定哪些父提交的行对应哪些子提交的行,而不同算法对行的配对方式不同。当一个提交把改动的行和未改动的行交错在一起时(重新格式化经常如此),某种算法可能把某一行归给这次重新格式化,而另一种算法则会归给最初编写它的提交。
git blame -L 40,60 -- src/router.js
git blame -L 40,60 --diff-algorithm=patience -- src/router.js
文档中并未说明哪种算法比另一种更”正确”。如果默认的归因看起来不合理,用 patience 或 histogram 重跑同一条命令只多花一次调用,却能给你一个可供比对的第二意见。
用 git last-modified 查询整个目录
Git 2.52 引入了 git last-modified,它通过单次历史遍历报告目录中每个路径最后被哪个提交修改,而不必对每个文件各执行一次 git log -1;该命令被标记为实验性,其行为可能发生变化。git-last-modified man 手册在 NAME 行中声明了实验性状态,并给出输出格式为 <oid> TAB <path>,每个路径一行,包含完整对象 ID,不含作者、日期或主题。
git last-modified -r -- src/
如果不加 -r(或非零的 --max-depth),你只会得到与 pathspec 本身匹配的条目,不会向下递归进入其子目录。重命名和模式变更都算作修改。它所替代的逐文件循环会为每个文件把相同的提交重新遍历一遍;而 last-modified 只遍历一次。它回答的是”这个模块最近有什么变化”,这与”这一行为什么存在”是不同的问题,在开始对单个文件做 blame 之前,值得先用它看一看。
Blame 是一个问题,而非定论
git blame 的输出指出的是最后一个触碰某行的人,而这个名字几乎从来不是你需要的答案。用 -L 聚焦范围、用 -w 和 -M/-C 剥离机械性噪音,然后沿父提交逐级回溯(或维护一份 ignore 文件),直到所显示的提交带有能解释该行的提交信息。拿到那个提交后,git show <hash> 会给你 diff 和背后的推理,这正是整个过程的意义所在:理解这段代码为何存在,从而在修改它时不会重蹈当初导致它出现的那场事故。
常见问题
git blame 只显示仍然存在的行,那我该如何查出是谁删除了某一行?
正如 man 手册所指出的,git blame 对被删除或被覆盖的行不提供任何信息。请改用 pickaxe:git log -S'some text' -- src/router.js 会列出每一个添加或删除了该字符串的提交,加上 -p 还能看到删除本身。此外,git blame --reverse a1b2c3d..HEAD -- src/router.js 会从该提交开始沿历史向前推进,指出每一行最后仍然存在的最新版本。
为什么 git blame 在某些行上显示 00000000 和 'Not Committed Yet'?
这些行带有未提交的改动。在不指定版本参数时,git blame 标注的是文件的工作区副本,因此任何与 HEAD 不同的行都会得到全零哈希,并以 'Not Committed Yet' 代替作者姓名。提交或 stash 这些改动,或者执行 git blame HEAD -- src/router.js 来标注已提交的版本并完全忽略本地编辑。
GitHub 的 blame 视图是否支持 .git-blame-ignore-revs 文件?
是的。GitHub 会自动将仓库根目录下名为 .git-blame-ignore-revs 的文件应用于其 blame 视图,使用与命令行相同的 --ignore-revs-file 机制,并在生效时显示 'Ignoring revisions' 横幅。无法重新归因到更早提交的行仍会显示被忽略的提交。该文件不会配置本地 git;每位开发者仍需执行 git config blame.ignoreRevsFile .git-blame-ignore-revs。
git blame 和 git log -L 有什么区别?
git blame 为每一行报告一个提交:在文件的某个特定版本中最近触碰该行的提交。而 git log -L 40,60:src/router.js 会跨历史追踪第 40 至 60 行,打印出每一个修改过它们的提交,并附上该范围的 diff,按时间由新到旧排列。用 blame 快速锁定嫌疑对象,用 log -L 观察这些行的演变过程。两者都接受 :funcname 形式,例如 git log -L :handleRoute:src/router.js。