在写自动化脚本或终端操作 Git 时,大家习惯认为:只要 git add 没报错、退出码是 0,改动就一定进暂存区了。但今天在提交博客作者配置时,碰到了一个非常诡异的静默失败:文件改动清清楚楚,git diff 能正常输出改动内容,执行 git add 也正常返回 0,但紧接着跑 git status,文件依然显示未暂存,git diff --cached 什么都没有。
故障现场:改动真实存在,但就是暂存不进去 #
故障复现环境如下:
- 操作系统:macOS Darwin 24.6.0 (arm64, APFS 文件系统)
- Git 版本:git version 2.50.1 (Apple Git-155)
- 触发场景:提交《https://blog.baifan.site/evaluating-agent-skills-first-principles/》的配套配置修改,编辑了
data/authors/ByF.toml的首行注释。
工作区的状态非常直观:
|
|
改动就在那里。于是执行常规暂存:
|
|
退出码是 0。但紧接着检查状态:
|
|
换用其他常见的参数强推:
|
|
平时常用的常规命令全部失效。无论怎么加,暂存区都当它不存在,而且整个过程不报任何错误。
直接查底层:索引和对象库到底存了什么 #
既然常规的高阶命令看不出问题,只能用 Git 底层命令(Plumbing)直接查看内部的数据结构。
先看工作区文件的实际哈希,和索引里记录的哈希:
|
|
数据呈现出直接的矛盾:
- 工作区的最新内容哈希是
de32019...。 - 暂存区
.git/index里记录的依然是旧哈希881fa63...。 git add返回了 0,但既没有把新内容写入对象库,也没有更新索引里的条目。
再排查是否是文件系统锁残留,或者文件被设置了特殊忽略标记:
|
|
输出大写字母 H,说明文件处于正常的受追踪状态(不是小写 h 的 assume-unchanged,也不是 S 的 skip-worktree)。没有忽略标记,也没有锁文件卡住。
为什么会这样:Git 的 Stat 缓存偷懒了 #
问题出在 Git 的性能优化机制:Stat 缓存(Stat Cache)。
在有成千上万个文件的大仓库里,如果每次跑 git status 或 git add 都要逐字读取磁盘文件去算 SHA 哈希,磁盘 I/O 很快就会撑不住。因此,Git 在二进制索引文件 .git/index 里维护了一个 cache_entry 结构。
在 Git 源码(read-cache.c)中是这样定义的:
|
|
Git 在检查文件有没有被修改时,第一步调用的不是哈希计算,而是系统调用 lstat()。内部函数 ie_match_stat() 会优先比对以下元数据:
- 文件修改时间(
mtime的秒和纳秒) - 状态改变时间(
ctime) - 设备号(
dev)和 Inode 节点号(ino) - 文件体积(
size)
只有当这些元数据有变动时,Git 才会认为文件“可能被改动了”,进而打开文件读取实际内容。
为什么 git diff 看得到,而 git add 却跳过了?
#
git diff走的是差异比对路径。当它需要生成文本差异时,会直接去读工作区内容。git add走的是常规的索引刷新流程(builtin/add.c->refresh_cache())。- 在这次操作前,沙盒环境曾因为权限问题尝试写入
.git/index.lock并报错失败(Operation not permitted),随后切换环境重新执行,导致宿主文件系统的时间戳与索引内部记录产生短暂错位。 - 在时间戳精度截断或文件系统缓存延迟的特定窗口下(经典的 Racy Git 竞态),
ie_match_stat()发生了短路判断:Git 误以为磁盘文件的元数据和索引记录是一致的,从而推断出“这文件没有改动过”。
因为 Git 认为文件“根本不需要更新”,所以 git add 认为自己正常执行完毕,顺理成章地返回了退出码 0。
解决办法:用底层命令强制刷新与写入 #
既然常规命令被表层的 Stat 缓存卡住了,解决思路就是跳过它的启发式判断,直接用底层命令强制检查并写入。
排障与修复命令速查 #
| 排查步骤 | 推荐底层命令 | 预期正常行为 | 异常/故障状态表现 |
|---|---|---|---|
| 1. 校验磁盘真实内容 | git hash-object <file> |
打印当前修改后的新哈希 | 若与预期不符说明未落盘 |
| 2. 检查暂存区记录 | git ls-files --stage <file> |
应显示与磁盘一致的新哈希 | 旧哈希(证明 git add 未更新索引) |
| 3. 刺破 Stat 缓存 | git update-index --really-refresh |
保持静默退出(通过检查) | 打印 <file>: needs update(证实缓存脱节) |
| 4. 强制直写索引库 | git update-index --add <file> |
退出码 0 并更新索引哈希 | 索引哈希瞬间变更为磁盘新值 |
第一步:强制跳过元数据比对 #
使用底层管道命令 git update-index 加上 --really-refresh 参数:
|
|
终端立刻打印出 data/authors/ByF.toml: needs update。
--really-refresh 的作用,就是让 Git 忽略所有基于 lstat() 元数据的快速短路检查,强制读取磁盘文件的实际内容和索引哈希对比。这一步直接打破了 Stat 缓存的误判,让 Git 内部确认该文件确实变脏了。
第二步:底层命令直接入库 #
此时即便常规 add 仍然可能受缓存干扰,可以直接用底层命令把文件强行压入索引:
|
|
索引里的哈希立即变成了工作区的真实哈希 de32019...。
随后检查状态并提交,恢复正常:
|
|
阻塞解除,提交顺利推送。
排查后的几点经验 #
在编写自动化构建脚本、CI 流程,或者类似《https://blog.baifan.site/claude-code-skills-system/》中提到的终端自动化工具时,有几点值得注意:
- 退出码 0 不等于真正产生了预期改动:
git add的职责是“把改动加入暂存区”。当它的内部判断认为“没有改动”时,目标自然算作完成,返回 0 属于逻辑自洽,但对上层调用方来说就是隐蔽的静默失败。 - 自动化脚本不要只看命令返回值:对于必须确保暂存成功的流程,检查退出码是不够的。通过
git diff --cached --quiet确认暂存区非空,或者用git ls-files --stage直接核验哈希,才算真正闭环。 - 遇到诡异状态时多看底层命令:常规高阶命令(Porcelain)为了体验做了很多隐式缓存与优化。当遇到状态错乱、常规命令不讲道理时,直接用底层命令(Plumbing,如
update-index、hash-object、cat-file)去查对象库和索引,往往是排查问题最快的捷径。