代码归属查询 (Who Changed)
回答“这段代码是谁写的、属于哪个工单、以及何时编写的”这些问题,无需离开终端。所有需要的信息都已经在本地 Git 历史中。代码托管平台唯一额外提供的是 PR 的描述,而输出结果会给出该 PR 的链接。
它取代了手动循环操作:打开文件,找到功能,打开 Blame 视图,复制哈希,粘贴到代码托管平台,点击进入 PR,查看工单和作者。
用户得到的信息
作者姓名、提交哈希、工单号、分支名、日期、PR 号,以及可点击的提交和 PR 链接。输出结果会先给出一个针对该代码范围的主要结论,然后是一个包含所有曾修改过该范围的提交列表。
运行脚本
bash ~/.claude/skills/who-changed/scripts/who-changed.sh <file> [start end] [raw]
如果该技能是按项目安装的,则路径为 .claude/skills/who-changed/scripts/who-changed.sh。
<file>单独:全文件模式。显示主要贡献者以及最近 12 个提交。<file> <start> <end>(或单行号):行范围模式。按提交显示每行的所有权。- 追加
raw:使用不带-w -C -M参数的 blame,适用于用户需要字节级真实信息的场景。
环境变量覆盖:WHO_CHANGED_BASE 用于设置 PR 查找时回溯的基础引用(默认值:origin/HEAD,然后是 origin/main、origin/master、main、master);WHO_CHANGED_TICKET_REGEX 用于更改工单匹配模式(默认值:[A-Z][A-Z0-9]+-[0-9]+)。
脚本输出的是制表符分隔的报告,并且从不写入仓库或网络。请为用户重新格式化输出内容,不要直接粘贴原始的 TSV。
步骤 1:将目标解析为行范围
脚本接收数字参数,因此函数名必须首先转换为行范围。Git 的 -L :functionName: 快捷方式依赖于语言特定的函数模式,对于许多语言(特别是 TypeScript 类成员)会失败并返回 no match。请自己解析行范围:
- 搜索声明,例如
^\s*(public|private|protected)?\s*(async\s+)?<name>\s*[(<],以及箭头函数属性\s*[:=]\s*(function|\(\)。对于 HTML 或 CSS,搜索字面选择器、键或块。 - 从该行开始读取,并从第一个
{开始进行花括号匹配,以找到结束行。 - 如果该名称匹配到多个位置,列出所有匹配项并询问选择哪一个。 永远不要静默地选择第一个——常见名称通常存在于多个文件中。
行号来自工作目录树,因此与用户在编辑器中看到的内容一致。
如果给定了明确的范围(285-300、285、L285-L300),则直接使用。如果只给定了文件路径,则使用全文件模式。
步骤 2:读取输出
## meta 块包含 base(PR 查找回溯的基础引用)、base_tip_date、web 和 host(链接基础,从 git remote 派生)、blame 模式,以及文件是否有未提交的更改。
## commits 中的行按拥有的行数排序。结论是第一行——拥有该范围最多行的提交。当范围确实被多个提交共享时,请提一下第二名的提交;一个由四个人在三年内共同构建的函数不应该被报告为属于某一个人。
脚本已经为 Bitbucket、GitHub 和 GitLab 远程仓库构建了 commit_url 和 pr_url。当它们为 - 时,表示没有可识别的远程仓库——请如实说明并省略链接,而不是凭空捏造一个托管平台。
按如下格式呈现(以下名字均为虚拟):
### calculateTotal - src/cart/cart.service.ts:42-61
**Alice Moreno** · `a1b2c3d` · 2023-02-08 · 拥有 20 行中的 13 行
工单 SHOP-88 · 分支 `SHOP-88-cart-totals` · PR #41
| 行数 | 作者 | 提交 | 日期 | 工单 | 分支 | PR |
|---|---|---|---|---|---|---|
| 13 | Alice Moreno | a1b2c3d | 2023-02-08 | SHOP-88 | SHOP-88-cart-totals | #41 |
| 6 | Ben Carter | 9f8e7d6 | 2026-03-11 | SHOP-310 | SHOP-310-rounding-fix | 未合并 |
| 1 | Chen Wei | 4c5d6e7 | 2024-06-20 | — | release-2024-06 | #112 |
然后是链接,接着只要置信度标志被触发,就写一条说明文字。
置信度标签及其含义说明
| 标签 | 含义 | 告知用户 |
|---|---|---|
ok | 工单分支通过 PR 合并 | 无需说明 |
local-only | 提交尚未到达 base | “尚未合并到基础分支” —— 没有 PR |
no-pr-merge | 在主线上,但没有 PR 合并携带它 | 直接提交到主线,或合并信息违反了约定 |
squash | 从 (#123) 后缀读取了 PR 号 | 压缩合并,因此该 PR 内的逐行历史已扁平化 |
low-no-ticket | 提交信息或分支中均无工单 | 说明没有记录任何工单。 不要猜测一个 |
low-integration-branch | 通过一个名称中没有工单的分支合并 | 该 PR 是集成分支合并的地方,而非代码编写的地方。置信度低 |
uncommitted | Not Committed Yet | 本地未提交的编辑;从结论中排除 |
较旧的代码通常通过长期存在的集成分支(develop、release-*、ui-dev)到达主线。对于这些情况,诚实的回答是:“通过 PR #112 合并到主线,但该 PR 是一个集成分支——它不是此代码编写时所在的 PR。”
为什么 -w -C -M 是默认选项
-w 忽略空白,-M 跟踪文件内移动的行,-C 跟踪从另一个文件复制来的行。这不仅仅是形式上的。以一个在 2023 年编写、仅在 2026 年重新缩进的代码块为例(数字仅为示例):
- 智能模式(
-w -C -M):9 行中的 9 行归功于原作者,2023 年 —— 正确 - 原始模式:9 行中的 8 行归功于重新缩进的人,2026 年 —— 将旧代码归功于他们
因此,命名的作者是最后一个更改行为的人,而不是最后一个触碰字节的人。当需要时请说明这一点,并作为后续步骤提供 raw 选项。
诚实规则
- 永远不要捏造工单 ID。 提交信息或分支中没有工单:报告
—并说明没有记录任何工单。 - 永远不要捏造作者。 如果 blame 结果不明确,说明哪些提交是候选。
- 只要触发了低置信度标志,务必区分“通过……合并”和“编于……”。
- 如果
base_tip_date看起来过时,说明基础引用最近未获取,可能漏掉了最近的合并。建议执行git fetch origin—— 不要未经用户同意就运行。
值得说明的限制(在相关时)
- “为什么”不在 Git 中。 PR 描述、审阅者和工单标题需要代码托管平台或跟踪器的 API 和凭据。PR 链接是交接点。
- 压缩合并会扁平化历史。 如果 PR 被压缩合并,一个提交就拥有整个功能的所有权,内部的逐行归属信息将丢失。
--ancestry-path选择将提交带到主线的第一个合并。 对于嵌套的功能分支,这可能是中间合并,而不是工单本身的 PR。- 全文件贡献者不跟踪重命名。
git shortlog没有--follow选项;提交列表会跟踪重命名,但贡献者统计不会。 -C偶尔会带来意外,将复制粘贴的代码归功于它来源的文件。- PR 检测识别三种合并消息风格: Bitbucket(
Merged in <branch> (pull request #N))、GitHub(Merge pull request #N from ...)和压缩合并(... (#N))。其他风格会落入no-pr-merge情况。
脚本不可用时的备选方法
git blame -w -C -M -L <start>,<end> --date=short -- <file>
git log -1 --date=short --pretty=format:"%H%n%an%n%ad%n%s%n%b" <sha>
git merge-base --is-ancestor <sha> origin/main # 如果不是祖先 -> 未合并
git log --merges --ancestry-path --reverse --pretty=format:"%h|%s" <sha>..origin/main | grep -m1 "pull request"
git branch --contains <sha> # 未合并情况
解析合并信息以提取分支和 PR 号。两个陷阱:git shortlog 需要显式的 HEAD 参数,否则会静默地读取标准输入并返回空;分支名可能带有前缀(feature/SHOP-88-...),因此不要将工单正则固定在字符串的开头。