↓ 跳过正文
git add 返回 0 却拒绝暂存:一次 Git 索引 Stat Cache 幽灵故障排查
  1. Posts/

git add 返回 0 却拒绝暂存:一次 Git 索引 Stat Cache 幽灵故障排查

在写自动化脚本或终端操作 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 的首行注释。

工作区的状态非常直观:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ git status
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   data/authors/ByF.toml

$ git diff data/authors/ByF.toml
diff --git a/data/authors/ByF.toml b/data/authors/ByF.toml
index 881fa63..de32019 100644
--- a/data/authors/ByF.toml
+++ b/data/authors/ByF.toml
@@ -1,4 +1,5 @@
-# 作者实体数据 —— 主题 single.html 的 SingleAuthor 按 front matter 原文大小写查 "ByF"。
+# 同 ByF.toml —— schema.html 按小写 "byf" 查询本文件,两文件内容必须一致。
+

改动就在那里。于是执行常规暂存:

1
2
3
$ git add data/authors/ByF.toml
$ echo $?
0

退出码是 0。但紧接着检查状态:

1
2
3
4
5
6
7
8
$ git status
Changes not staged for commit:
	modified:   data/authors/ByF.toml

no changes added to commit (use "git add" and/or "git commit -a")

$ git diff --cached
# 没有任何输出

换用其他常见的参数强推:

1
2
3
4
5
6
7
$ git add -u
$ git add -f data/authors/ByF.toml
$ git commit -a -m "chore: test"
On branch main
Changes not staged for commit:
	modified:   data/authors/ByF.toml
no changes added to commit

平时常用的常规命令全部失效。无论怎么加,暂存区都当它不存在,而且整个过程不报任何错误。

直接查底层:索引和对象库到底存了什么
#

既然常规的高阶命令看不出问题,只能用 Git 底层命令(Plumbing)直接查看内部的数据结构。

先看工作区文件的实际哈希,和索引里记录的哈希:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 计算工作区文件当前的 blob 哈希
$ git hash-object data/authors/ByF.toml
de320197c11de72a4b1a648d67cbba9a5ba60a39

# 查看暂存区(.git/index)当前记录的条目
$ git ls-files --stage data/authors/ByF.toml
100644 881fa632c8de2f5b24386e405dbf80f41583f435 0	data/authors/ByF.toml

# 查看当前分支最新提交(HEAD)中的 blob 哈希
$ git ls-tree HEAD data/authors/ByF.toml
100644 blob 881fa632c8de2f5b24386e405dbf80f41583f435	data/authors/ByF.toml

数据呈现出直接的矛盾:

  1. 工作区的最新内容哈希是 de32019...。
  2. 暂存区 .git/index 里记录的依然是旧哈希 881fa63...。
  3. git add 返回了 0,但既没有把新内容写入对象库,也没有更新索引里的条目。

再排查是否是文件系统锁残留,或者文件被设置了特殊忽略标记:

1
2
3
4
5
6
7
# 检查是否存在锁文件
$ ls -la .git/index.lock
ls: .git/index.lock: No such file or directory

# 检查文件在索引里的状态标记
$ git ls-files -v data/authors/ByF.toml
H data/authors/ByF.toml

输出大写字母 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)中是这样定义的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct cache_time {
    uint32_t sec;
    uint32_t nsec;
};

struct cache_entry {
    struct cache_time ce_ctime;
    struct cache_time ce_mtime;
    uint32_t ce_dev;
    uint32_t ce_ino;
    uint32_t ce_mode;
    uint32_t ce_uid;
    uint32_t ce_gid;
    uint64_t ce_size;
    struct object_id oid;
    /* ... 标志位与路径名 ... */
};

Git 在检查文件有没有被修改时,第一步调用的不是哈希计算,而是系统调用 lstat()。内部函数 ie_match_stat() 会优先比对以下元数据:

  1. 文件修改时间(mtime 的秒和纳秒)
  2. 状态改变时间(ctime)
  3. 设备号(dev)和 Inode 节点号(ino)
  4. 文件体积(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 参数:

1
2
$ git update-index --really-refresh
data/authors/ByF.toml: needs update

终端立刻打印出 data/authors/ByF.toml: needs update。

--really-refresh 的作用,就是让 Git 忽略所有基于 lstat() 元数据的快速短路检查,强制读取磁盘文件的实际内容和索引哈希对比。这一步直接打破了 Stat 缓存的误判,让 Git 内部确认该文件确实变脏了。

第二步:底层命令直接入库
#

此时即便常规 add 仍然可能受缓存干扰,可以直接用底层命令把文件强行压入索引:

1
2
3
4
5
6
# 强制读取文件内容计算哈希、写入对象库,并直接更新索引条目
$ git update-index --add data/authors/ByF.toml

# 验证索引中的哈希值
$ git ls-files --stage data/authors/ByF.toml
100644 de320197c11de72a4b1a648d67cbba9a5ba60a39 0	data/authors/ByF.toml

索引里的哈希立即变成了工作区的真实哈希 de32019...。

随后检查状态并提交,恢复正常:

1
2
3
4
5
6
7
8
$ git status
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   data/authors/ByF.toml

$ git commit -m "chore: update ByF.toml comment wording"
[main 5a70ed9] chore: update ByF.toml comment wording
 1 file changed, 2 insertions(+), 1 deletion(-)

阻塞解除,提交顺利推送。

排查后的几点经验
#

在编写自动化构建脚本、CI 流程,或者类似《https://blog.baifan.site/claude-code-skills-system/》中提到的终端自动化工具时,有几点值得注意:

  1. 退出码 0 不等于真正产生了预期改动:git add 的职责是“把改动加入暂存区”。当它的内部判断认为“没有改动”时,目标自然算作完成,返回 0 属于逻辑自洽,但对上层调用方来说就是隐蔽的静默失败。
  2. 自动化脚本不要只看命令返回值:对于必须确保暂存成功的流程,检查退出码是不够的。通过 git diff --cached --quiet 确认暂存区非空,或者用 git ls-files --stage 直接核验哈希,才算真正闭环。
  3. 遇到诡异状态时多看底层命令:常规高阶命令(Porcelain)为了体验做了很多隐式缓存与优化。当遇到状态错乱、常规命令不讲道理时,直接用底层命令(Plumbing,如 update-index、hash-object、cat-file)去查对象库和索引,往往是排查问题最快的捷径。
 Author
作者
ByF (BaiFan)
ByF,即 BaiFan(白帆)。系统后端工程师,专注 AI 底层原理与智能体架构。主站 baifan.site。