那个 issue 我翻来覆去看了两遍。第一遍当产品讨论看,觉得又是一场“你不喜欢可以关掉”的拉扯;第二遍发现它跟产品关系不大——它讲的是一个默认值被放在了哪一层,以及放错层的默认值有多难收回来。
先把话说死:这一篇不读 Claude Code 的源码。那份东西不给人读,我也不会假装读过、再编一段函数出来给你看,那叫朗读源码,不叫导读。这一篇读的是这条 https://claude.ai/code/session_... 真正落地的那一层——git。不管上面那层是哪个工具、哪个模型、哪家厂商,最后要做的动作完全一样:把一段文本塞进一个 commit object 的 message 字段里。而 git 这一层的源码是公开的、稳定的、十年没大动过的,值得读到底。
anthropics/claude-code 的 issue #66504,2026 年 6 月 9 日由 joka-7 开,标了 enhancement 和 user-experience,优先级 Medium、分类 Other,现在关着。诉求很短:Claude Code 生成的提交信息和 PR 描述,默认会在底部挂一条形状像 https://claude.ai/code/session_... 的会话链接;没有开关提示、没有警告,引导流程里也没提过一句,用户通常是在 git log 已经被污染之后才发现。他给了三个方案:改成选择加入、保留默认但把设置做得更容易发现(比如首次提交时弹一次、带一个“别再加了”)、或者干脆删掉这条链接、只靠已有的 Co-Authored-By: Claude 尾注做署名。
这个仓库大概 145k star、23.1k fork。这个数字的意义不在于项目多火,而在于那条默认值的射程——射程乘以不可逆,就是这次讨论的全部重量。
至于这个 issue 本身,它连一个指派都没有,没有 milestone、没有关联分支、没有关联 PR,页面上连 reaction 都读不出来。也就是说,你既不知道它是被修掉的还是被关掉的,也拿不到“到底有多少人同样被这行字恶心到”这个数字。一个默认值改了没有,最后成了一个靠感觉的问题。
先不评价。看代码。
一条提交消息的一生,分四层
把这件事从“产品行为”翻译成“数据流动”:
[第 1 层] 内容生成层
工具(Claude Code)把「人类描述 + 会话 URL + Co-Authored-By」拼成一个字符串
└─ 在这一层,那条 URL 和「修了个空指针」是同等地位的纯文本
[第 2 层] 提交对象构造层
git 把这个字符串写进 .git/COMMIT_EDITMSG
└─ 跑 pre-commit / prepare-commit-msg / 编辑器 / commit-msg
└─ 读回文件内容,造 commit object,写进 object store
└─ 在这一层,git 只认识「一段字节」,它不知道什么是 URL
[第 3 层] 传输与托管层
push、服务端、PR 描述、网页渲染
└─ 在这一层,已经不可逆了:重写历史是所有人的事
[第 4 层] 人的眼睛
blame、log、review 的队友、来提 issue 的陌生人
看清楚这张图,整件事的症结就浮出来了:注入点在第 1 层,清理点在第 2 层,而第 2 层对第 1 层的产物一无所知。
第 2 层的 git 只是忠实地把你递给它的字节编进对象里。它不会替你判断“这条 URL 是不是该在这儿”,因为从它的视角看,那和别的正文没有任何区别。所有指望“在 git 那一层把它洗干净”的方案,本质上都是在让一个不认识 URL 的东西去处理 URL——它能做,但你得先教会它,而教会它的那个东西(钩子),是这篇要钻的重点。
钻进 git commit:钩子链条逐段啃
要讲清楚方案二(commit-msg 钩子剥链接)为什么靠不住,得先知道这道钩子长在链条的哪一节上。git 的钩子文档是公开的,但文档只告诉你“什么时候跑”,不告诉你“为什么它能跑成现在这样”。调用点集中在 builtin/commit.c,真正 fork 出去跑钩子那层在 run-command.c——函数名这些年改过几轮,从早期的 run_hook_le 一路演到现在 run_commit_hook / run_hooks_opt 那一族,你以本地树为准,我下面给的是教学性精简的轮廓,非完整源码,行号和参数细节请对着你自己那份 git 的树看:
/* run-command.c 一侧的骨架,教学性精简 */
int run_commit_hook(int editor, struct commit *commit,
const char *name, ...)
{
struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
struct strvec args = STRVEC_INIT;
struct strbuf hookname = STRBUF_INIT;
strbuf_addf(&hookname, "commit-msg"); /* 要跑哪一道 */
strvec_push(&args, hook_path(hookname.buf)); /* .git/hooks/commit-msg */
/* 关键:把消息所在的文件路径当作实参传进去 */
strvec_push(&args, git_path_commit_editmsg());
/* 钩子不存在就直接返回 0,不算错误 */
if (!is_hook_enabled(&opt))
return 0;
return run_hooks_opt(&opt, &args); /* fork + exec,等它退出 */
}
逐节看。
先看 hook_path()。钩子住在 .git/hooks/ 下面,而 .git/ 这个目录不随 clone 传播——git clone 复制的是对象库和引用,不是你的本地钩子目录。这一行是整段的钉子,慢一点看:整个方案二的死穴就钉在这儿——你写再好、再完善、再层层校验的钩子,克隆你仓库的人一个字节都拿不到。
再看 git_path_commit_editmsg()。这个参数是整个机制设计的支点:钩子拿到的不是消息字符串,是消息所在文件的路径。为什么?因为 git 需要让你(或者编辑器)能在提交落定之前反复改这个东西。消息先被写进 .git/COMMIT_EDITMSG,各路钩子在这个文件上依次跑,最后 git 读回文件内容造对象。钩子改文件,等于改最终消息。这是它“能干活”的原因,也是它“只能干成这样”的原因——它是个文本文件的读写约定,不是一棵语法树。
这里有一处常见的误读,值得单拎出来:不少人写钩子时满世界找“怎么把消息字符串传给钩子”,翻半天文档找不到,然后怀疑自己 hook 路径写错了。钩子根本不接字符串,它接的是文件路径,你所有的增删改都落在那个文件上。把这一点读穿,后面“钩子为什么只能正则匹配、不能结构化校验”就顺理成章了——它拿到的是一个文件,不是一个消息对象。
下面这段是同一件事的另一面,.git/COMMIT_EDITMSG 在链条上被改动的样子,我把它画成一个快照序列:
① 工具生成的初始内容(写入 COMMIT_EDITMSG)
--------------------------------------------------
fix: guard against null deref in cache lookup
Session: https://claude.ai/code/session_01Xy...Z9
Co-Authored-By: Claude <[email protected]>
② prepare-commit-msg 之后(可以在这里增删,拿到 file/source/sha1 三个参数)
--------------------------------------------------
(内容同上;多数人在这道钩子里加模板抬头,不是剥署名)
③ commit-msg 之后(拿到 file 一个参数,非零退出即中止提交)
--------------------------------------------------
fix: guard against null deref in cache lookup
Co-Authored-By: Claude <[email protected]>
④ git 读回文件内容 → 造 commit object
--------------------------------------------------
(第 ④ 步之后的任何改动,都是重写历史)
这道链上四道钩子的分工,值得钉一下:pre-commit 没有任何参数,不知道你要写什么,只适合跑 linter;prepare-commit-msg 拿得到消息文件、消息来源(message / template / merge / squash / commit)以及后接的 sha1,它跑在编辑器之前,适合补模板;commit-msg 拿得到消息文件,跑在编辑器之后、提交落定之前,非零退出就整个中止提交,因此它是唯一适合做“消息里不许有 X”这个校验的位置;post-commit 什么参数都没有,提交已经落地了,它只能通知。
所以事实清单里说的“commit-msg 钩子能剥掉链接”,在链条位置上是对的。剥链接这件事,就该落在这道钩子上。位置没错。
问题不在位置。
数据流:那条 URL 从哪一行进到对象里
我们把一条消息从字符串追到不可变的 commit object,看看它经过哪些手:
工具侧(闭源,只能推断):
会话状态 ──► 模板渲染 ──► "fix: ...\n\nSession: https://...\nCo-Authored-By: ..."
│
▼
git 侧(开源,可追):
git commit ◄── argv / -F / -m
│
├─► fmt_merge_msg / 消息预处理
├─► write 到 .git/COMMIT_EDITMSG ← 到这里,URL 已经是一串字节了
│
├─► run_commit_hook("pre-commit") (无参)
├─► run_commit_hook("prepare-commit-msg", file, source, sha1)
├─► launch_editor() (-m 时跳过)
├─► run_commit_hook("commit-msg", file) ← 唯一的校验窗口
│ └─ 非零退出 → 中止,COMMIT_EDITMSG 留着给你看
│
├─► read .git/COMMIT_EDITMSG 回来 ← 钩子改的就是这一步的输入
├─► commit_tree(...) → write_object_file()
│ └─► 对象入库,SHA-1 由「内容 + 父提交 + 作者 + 时间」算出
│
└─► run_commit_hook("post-commit") (无参,已落地)
追这条链,有三个点值得停下来。
第一,commit_tree 造出来的是一个内容寻址的对象。它的 SHA-1 是内容算出来的,所以“提交之后再改消息”不存在局部修改这回事——改了消息就等于造了一个全新对象,旧对象还在库里,引用得靠 rebase 或者 filter-repo 挪。想清楚这一步,你就明白为什么事实清单里那句“用户通常是在 git 历史已被污染后才发现”是整件事最沉的一句:污染这个词在这里不是修辞,是字面意义——你已经 push 出去的那几个 commit,它们的哈希由那行 URL 参与决定,删掉它,哈希全变,所有下游的 fork、评论引用、CI 记录全断。
第二,那个 URL 从进 COMMIT_EDITMSG 那一刻起,就已经和“修了个空指针”是同一类东西了。它在 git 眼里没有身份——不是 trailer,不是注释,不是元数据,就是消息正文里的一行。这一点后面要单独拎出来讲,因为它是方案三和方案一的分界线。
第三,commit-msg 是唯一能在这个链条上“拦”的位置,而它拦的是文件,不是结构。所以它拦得住什么、拦不住什么,取决于你怎么写它。
为什么本地钩子不是一个防线,是一个建议
现在回到方案二。事实清单里写着:commit-msg 钩子能剥掉链接,但在远程或云环境里并不总能可靠触发。这句话是对的,但说得太客气了。我把它的真实形状画出来:
你写了一个 .git/hooks/commit-msg 去剥 URL:
#!/bin/sh
sed -i '' '/^Session: https:\/\/claude\.ai\/code\/session_/d' "$1"
它在你机器上有效的条件:
① 这个文件存在 ← .git/ 不随 clone 传播,队友克隆下来没有
② 它可执行 ← 权限位不在版本控制里
③ 你没加 --no-verify ← 这个参数一句话跳过 pre-commit 和 commit-msg
④ 提交发生在你这台机器上 ← 云开发环境 / 容器 / CI 里跑 git commit,
用的是那个环境里的 .git/,不是你的
⑤ core.hooksPath 没被谁改过 ← 全局配置一改,钩子目录整个换地方
五条全满足,它才生效。这不是“不总能可靠触发”,这是默认不触发,偶然触发。而它要防的那个东西——别人仓库里出现的那行 URL——从第一天起就不在你的机器上,所以就算这五条全中,你防的也只是你自己,而问题从来不是“我加了一行”,是“我 push 出去的每个 commit 都带着这行,而看到它的是我队友、是来提 PR 的陌生人”。
换你写,你会把防线架在这儿吗?
还有一条更硬的:就算你想让全队都剥,你也发不出去这个钩子。.git/hooks/ 不在版本控制里,core.hooksPath 是每个克隆各自的配置。你能做的只有写一份 README 让人手动装。这不是工程手段,这是口头约定。
事实清单提到云环境里钩子不可靠——这句话底下藏的其实是另一个判断:当一条规则必须对“产物”成立时,执行点必须在产物的路径上,而不是在产出者的机器上。 commit object 是产物,产物的路径是“字符串 → COMMIT_EDITMSG → 对象库”。这个路径上唯一由工具自己控制的点,是第 1 层生成字符串的那一步。清理层在第 2 层,而第 2 层是分布式的、逐克隆差异化的、可以被一个命令行参数绕过的。
方案二不是错的,它是没有权限的。一个在你自己机器上、默认不生效、可以一句话绕过的文本替换,管不住一个在别人仓库里、不可逆的产物。
这一段我承认讲得有点碎,列了五条。但这五条坑过太多人,值。
那条 URL 本来有地方可站:trailer 和正文的区别
再说方案三——删掉链接,只靠 Co-Authored-By: Claude 尾注。
我一开始把这个当成一个审美偏好读的:有人嫌 URL 丑,有人觉得保留可回溯挺好,各退一步吧。后来发现不是。这是一次从结构化字段退回散文的对比,而且方向是反的。
git 对消息末尾那种 Key: value 形式的行有一套正式机制,叫 trailer,配套的命令是 git interpret-trailers。它可以解析、可以按规则追加、可以按分隔符归档、可以在 --format=%(trailers) 里被单独取出来:
# 追加一个可被 git 认识的 trailer
$ git commit --trailer "Co-authored-by: Claude <[email protected]>" -m "fix: ..."
# 只看 trailer,不看正文
$ git log --format='%(trailers:key=Co-authored-by,valueonly)' -1
# 剥掉某一类 trailer(结构化操作,不是文本替换)
$ git interpret-trailers --if-exists=replace --trailer "Co-authored-by:" < msg
对照一下两种写法在 git 里的身份:
写法 A(工具当年的选择):
fix: guard against null deref in cache lookup
Session: https://claude.ai/code/session_01Xy...Z9
Co-Authored-By: Claude <[email protected]>
└─ 第二行是正文散文。git 不知道它是什么。
想剥它,只能写正则去猜「哪一行像 URL」。
`git log --format=%b` 里它和真正的描述混在一起。
查引用、查 blame、做统计,它都是噪声。
写法 B(方案三主张):
fix: guard against null deref in cache lookup
Co-Authored-By: Claude <[email protected]>
└─ 这一行是 trailer。git 认识它。
可以按 key 过滤、替换、单独取出、单独统计。
它是字段,不是句子里的一截。
同样的信息,一个进了结构化字段,一个被塞进散文。这不是好不好看的问题——一个已经有结构可用、却选择用散文的默认值,逼着所有想处理它的人去写正则,而正则去匹配“哪一行像会话链接”这件事,天然是脆的:格式一改、前缀一换、用户自己在上面加了一行,钩子就失效了。
所以方案三不是“退一步删功能”,它是把署名从散文撤回结构里。可回溯的诉求可以靠 trailer 承载——Co-Authored-By 本身就是干这个的,而且它还是 git 里被广泛识别的约定,被各种工具解析。工具当年多加一条裸 URL,等于在已经有一个字段的地方,又手写了一行散文。这是我在这件事上最强的一个判断:那条 URL 的问题不是它出现在提交里,是它出现在了一个不需要它的位置上。
那你会问:把会话 ID 塞进一个自定义 trailer 行不行,比如 Claude-Session: 01Xy...?行。这样它至少是个字段,想剥的人 git interpret-trailers 一句话,想要的人 %(trailers:key=Claude-Session) 一句话。默认开不开是另一回事,但至少它不再污染 %b。
默认值、逃生舱门和射程
和 issue 作者唱个反调。
事实清单里写着:理想交互是引导流程中一次性询问用户要不要加链接。这是他三个方案里的第一个,也是最被同情的一个。我认为它是三个里面最差的一个。
理由很简单:你问的那个时刻,是用户信息最少的时刻。他还没提交过、没看过 git log 长什么样、没被队友在 PR 里问过“这个链接是什么”。你在他刚装完工具、连 hello world 都没跑通的时候弹一个框问他“要不要在提交信息里附带回到 Claude 会话的链接”——他凭什么知道?他只能猜。而猜出来的答案会被当成他的意志,一直生效到他哪天被恶心到去翻设置。这跟“默认开着,同时弹个提示”是同一件事换了张脸。
真正的杠杆在默认值,不在那个框。默认值决定了 99% 的人的行为,那个框只服务 1% 在开头就有明确偏好的人。把注意力花在优化一个只会被 1% 看到、而且是在信息最少的时刻看到的弹窗上,是在给错误的位置打补丁。
那 attribution.commit: "" 呢?事实清单里它被当作“其实能关,只是没人知道”。这句话本身就是它的判决书:一个只有文档知道、schema 知道、用户不知道的开关,在功能上等于不存在。 逃生舱门必须在逃生的那一刻可见。人们在被 git log 恶心到的那个瞬间,不会去读 JSON schema,他会去 google“怎么把这行去掉”,然后大概率找到一个已经过期的 Stack Overflow 答案。
顺便,把这种“设置里能关”当作辩护,是典型的跳步党姿势——把“存在一个开关”当成“问题已经解决”。它跳过的问题是:这个开关在谁的脑子里?在写 schema 那个人的脑子里,不在用户的脑子里。写 schema 的人和被污染 git log 的人,不是同一个人。
然后是射程。145k star、23.1k fork。这不是项目的荣誉勋章,这是默认值的复读次数。每多一个 fork,那个默认值就多一次被继承的机会,而继承它的那些人,是没有参与过这条默认值的讨论的。默认值的成本结构本来就是不对称的:收益归做决定的那个当下作者,成本落在所有没做决定的下游——队友、reviewer、来提 patch 的陌生人,以及五年后打开 git blame 的你自己。而由于 commit 是内容寻址的,这个成本还是不可逆的。
一处默认值写错,射程乘以不可逆。这才是这个 issue 值得写一篇的原因。
设计意图与代价,还有我不太确定的地方
把会话链接写进提交,出发点大概率是好的:可回溯。你想回头看“这行代码是在哪个会话里、跟模型来回几轮之后定下来的”。对一个把“agent 帮你写代码”当主业的工具来说,这是个真实的产品需求,不是拍脑袋加的。
代价在哪里?在于它没想清楚这条消息的读者是谁。commit message 的读者不是当下那个作者,是未来读 git log 和 git blame 的人。对那个人来说,这条 URL 打不开(会话是私有的、要鉴权)、看不懂(他不知道你的会话里发生了什么)、删不掉(不可逆)。把“给当下作者的回看入口”塞进“给未来读者的记录”里,是拿错了两份读者的名单。
这就是我说的“放错层”。不是放错文件、放错函数,是放错在数据的生命周期里——一个生命周期只有几天的会话 ID,被写进了一个生命周期按年计、还可能被永久归档的对象里。
有一处我得承认我不确定:.claude/settings.json 这个文件在各种布局下到底是项目共享的还是本地私有的,我没查过它的全部语义,就不硬说了。但不管是哪种,把“别在我的提交里加东西”这种高度个人化的偏好,放进一个可能跟着仓库走的配置文件里,这个方向本身就不太对。这种设置应该跟着人走,不跟着仓库走——否则 A 想关、B 想开,两个人开始为一行 URL 在 settings 里打架。这一点如果有读者比我清楚,欢迎来打我脸,我省得继续猜。
我可能是太较真了。但源码不会替自己吹,得有人替它说实话:那行 URL 从设计上就没站稳,它在第 1 层被当作正文生成,在第 2 层被当作字节处理,在第 3 层变成不可逆的历史,全程没有任何一层真正读得懂它。
读完这一篇,建议你接着打开这几样东西
一是 git help hooks,重点读 prepare-commit-msg 和 commit-msg 两节,看清楚它们各自拿到什么参数、跑在编辑器之前还是之后、非零退出会怎样。这是今天这整张地图的出处,而且它是你机器上就有的文档,不是二手转述。
二是打开你那份 git 的源码树,builtin/commit.c 里搜 run_commit_hook 的调用点,数一数到底跑了几道、按什么顺序;再跳去 run-command.c 看它怎么把 .git/hooks/commit-msg 拼出来、怎么 fork、怎么等退出码。这一步做完,你对“钩子是在哪个坐标上拦消息”会有一个不再需要回忆的直觉。
三是拿 git interpret-trailers 玩十分钟:给你的某条提交 --trailer 加一行自定义字段,再用 git log --format='%(trailers)' 把它单独取出来,然后试着把同一段信息写进正文,对比一下取的时候你得多写多少正则。做完这个对比,你对“结构化字段和散文差在哪”就不用我解释了。
四是如果真被那行字烦到了,别去写 sed 钩子——那是把一个没有权限的方案再实现一遍。正确的位置永远在生成那一层,去找你那个工具自己的配置项;找不到,就去给它开 issue,把今天这张地图贴上去,告诉它问题不在文案,在放错了层。
地图给你了,剩下的路自己顺着源码走到底。
