你用 git clone --filter=blob:none --sparse 克隆了一个大型 monorepo,只检出 packages/billing 这一个子树。执行 git pull 之后,该子树里有些文件是空的,有些还停留在三个月前的内容,而 git status 偏偏报告工作区是干净的——尽管编辑器里明明显示着一个旧版的 package.json。根本原因几乎都是延迟加载(lazy fetch):blobless clone 只下载 commit 和 tree,不下载文件内容,blob 要等到被访问时才按需拉取。一旦某次 checkout、某个 CI 步骤或某段不稳定的网络没有触发(或没有完成)这次拉取,你就会得到空文件或旧文件,而且不报任何错。
最快的修复(多数情况有效): 用一次批量请求把 sparse 路径下所有缺失的 blob 补全,然后重新检出工作区。
git fetch origin
git backfill --sparse # Git 2.49+(2025 年 3 月);批量拉取 sparse 路径下所有缺失 blob
git checkout HEAD -- . # 用补全后的对象库重新落地工作区文件
git backfill(Git 2.49.0 引入)是补全缺失 blob 的现代、官方方式,它会成批拉取,而不是每个文件发一个慢请求。如果你的 Git 版本低于 2.49,请改用下面 Step 2 的回退方案。本文余下部分帮你判断对象为什么会缺失,以便选对修复方案并防止复发。
你属于哪一类?
| 症状 | 最可能的原因 | 跳转 |
|---|---|---|
文件空 / 旧;git fsck 报告缺失 blob | pull 之后 blob 从未被延迟拉取 | Step 2 |
| 整个移动过来 / 新增的目录都不见了 | sparse cone 没覆盖新路径 | Step 3 |
cat-file -e 只在 CI 里失败,本地正常 | 构建环境里设了 GIT_NO_LAZY_FETCH=1 | Step 4 |
git fetch 报连接失败 | promisor remote 的 URL 过期了 | Step 5 |
| 大型 force-push 后立刻随机失败 | 服务端 filter 缓存落后于 HEAD | 原因 3(等几分钟) |
常见原因
按命中率从高到低排列。
1. pull 之后 blob 从未被延迟拉取
--filter=blob:none 的 clone 会把 blob 下载推迟到有人读取文件时。一次普通的 git pull 会带回新的 commit 和 tree 对象,但不会下载新的 blob——除非某次 checkout(或某个读取文件内容的命令)触发了延迟拉取。如果始终没触发,文件就停留在上次拉取到的内容,或者是空的。
怎么判断: git ls-files --error-unmatch packages/billing/package.json 正常返回(Git 知道这个文件),但 blob 其实不在本地。用下面这条命令在不触发拉取的前提下确认:
git --no-lazy-fetch cat-file -e HEAD:packages/billing/package.json && echo present || echo MISSING
--no-lazy-fetch(等价于 GIT_NO_LAZY_FETCH=1)会让 cat-file -e 报告对象是否已在本地,而不是悄悄去拉取它。
2. sparse-checkout 的 cone 没包含更新过的目录
最初检出时设的是 packages/billing,但一次重构把部分代码挪到了 packages/shared/billing-utils。如果 cone 规则仍然只覆盖 packages/billing,那些移动过去的文件永远不会被检出,看起来就像”丢了”。
怎么判断: git sparse-checkout list 只显示 packages/billing。再执行 git log --oneline -- packages/shared/billing-utils——如果有近期 commit 出现,说明该路径是活跃的,只是不在你的 cone 范围内。
3. 服务端 partial-filter 缓存过期
在 GitHub Enterprise 或 GitLab 托管的大型 monorepo 上,服务端的 filter 缓存可能短暂落后于真实的 HEAD。在一次大型 force-push 后的一两分钟内做的 clone 或 fetch,可能会看到一组不一致的已过滤对象。
怎么判断: force-push 后立即执行 git fsck --connectivity-only 会报告缺失对象,但过几分钟、缓存追上之后,同一个 fetch 就成功了。git fsck 是 promisor-aware 的,不会把预期之内可延迟拉取的对象误报为缺失——它在这里报出来的都是真正不可达的对象。
4. GIT_NO_LAZY_FETCH=1 阻断了按需的 blob 下载
有些 CI 环境会设 GIT_NO_LAZY_FETCH=1(或 git -c ... --no-lazy-fetch)来避免构建过程中出现意料之外的网络请求。但在 partial clone 里,这个变量会阻断本应在首次读取时发生的延迟解析,导致文件为空或缺失。
怎么判断: 在构建环境里 echo "$GIT_NO_LAZY_FETCH" 返回 1,并且故障只在 CI 里复现、开发机上正常。
5. git gc 与 git fetch 并发导致对象库碎裂
在复用 partial-clone 工作区的共享 CI agent 上,一个 git gc --prune 可能与 git fetch 同时跑,在刚下载的 blob 被 index 引用之前就把它删掉了。
怎么判断: git fsck 报告缺失或悬空(dangling)的 blob,且 CI 日志显示有 gc 进程与 fetch 并行运行。
6. promisor remote 的 URL 变了,但 clone 配置没更新
promisor remote(延迟拉取 blob 的来源)迁到了新主机名,但 clone 的 remote.origin.url 仍指向旧地址,于是延迟拉取就会失败。
怎么判断: git config remote.origin.url 返回旧主机名,且 git fetch 报连接错误。
最短修复路径
Step 1:诊断缺失和过期的对象
git fsck --connectivity-only 2>&1 | head -20
git rev-list --objects --all --missing=print | grep -c '^?' # 缺失对象的数量
git status
如果 git fsck(或上面 rev-list 的计数)显示有缺失对象,转到 Step 2。如果 git status 是干净的但文件看着不对,说明工作区里是未反映到 index 的旧内容——转到 Step 3。
Step 2:批量补全 sparse 路径下缺失的 blob
在 Git 2.49.0 及以上版本,这是一次成批拉取,而不是每个文件一个请求:
git fetch origin
git backfill --sparse # 只下载与你 sparse-checkout cone 匹配的 blob
git checkout HEAD -- .
git backfill 目前标注为实验性(experimental),但它是官方推荐的路径;默认批大小为 50000 个对象(--min-batch-size)。在更老的 Git(2.49 以下)上,改用一次定向 checkout 来触发延迟拉取:
git fetch --filter=blob:none origin
git checkout HEAD -- packages/billing # 读取这些文件即可触发 blob 的延迟解析
Step 3:更新 sparse-checkout 的 cone 以包含新路径
git sparse-checkout set --cone \
packages/billing \
packages/shared/billing-utils \
packages/shared/types
git sparse-checkout reapply
git checkout HEAD -- packages/shared/billing-utils
git sparse-checkout reapply 会按更新后的 cone 重新执行检出,把新规则现在涵盖的文件落地到工作区。
Step 4:如果是 GIT_NO_LAZY_FETCH 在拦截,就取消它
unset GIT_NO_LAZY_FETCH
git checkout HEAD -- packages/billing/package.json
在 CI 里,把这个变量从环境中移除,或者在构建前加一个显式的 blob 预取步骤,让构建过程中不必再去拉取任何 blob:
git fetch origin
git backfill --sparse # 先把所有 sparse blob 预取好,再开始构建
Step 5:更新 promisor remote 的 URL
git remote set-url origin https://new-git-host.example.com/org/monorepo.git
git fetch origin
git backfill --sparse
Step 6:确认已修复
git fsck --connectivity-only # 不应有缺失对象行
git rev-list --objects --all --missing=print | grep -c '^?' # backfill 后 sparse 路径应为 0
git --no-lazy-fetch cat-file -e HEAD:packages/billing/package.json && echo present
git diff HEAD -- packages/billing # 不应有莫名其妙的 diff
git status # 干净
当 cat-file -e 在带 --no-lazy-fetch 的情况下仍报 present,说明 blob 是真的落到了磁盘上,而不只是”可以延迟拉取”。这才是工作区已经完整的真正信号。
预防建议
- 用
--filter=blob:none --sparseclone 之后,立刻执行git backfill --sparse,在开始干活前就把目标路径完全落地。 - 在 CI 里,对检出的那个 commit 倾向于采用全量 blob 策略。用
actions/checkout@v5时可以设置filter: blob:none加上sparse-checkout列表(cone 模式默认开启),再加一个git backfill --sparse步骤,这样构建过程中就不会卡在临时的延迟拉取上。 - 对于不需要在旧内容上做 blame/log 的构建 agent,
--filter=tree:0往往比blob:none更安全:它仍会下载检出 commit 可达的所有 blob,因此根本没有按文件的延迟拉取会失败。 - 把 cone 路径固化到一个纳入版本库的脚本里(
scripts/setup-sparse.sh),让所有开发者和 CI job 用同一份 cone 定义。 - 除非同时做预取,否则永远不要在使用 partial clone 的环境里设
GIT_NO_LAZY_FETCH=1——延迟拉取正是 partial clone 得以正常工作的机制。 - 给 partial-clone 命令加上
--recurse-submodules --also-filter-submodules,避免嵌套子仓库自己陷入不完整状态。 - 向 monorepo 主分支做大型 force-push 之后,给服务端 filter 缓存留一两分钟,再在 CI 里触发新的 clone。
常见问答 (FAQ)
Q:怎么统计 partial clone 里还有多少 blob 缺失?
A:git rev-list --objects --all --missing=print | grep -c '^?'。每个带 ? 前缀的对象 ID 都是缺失的、会在被访问时延迟拉取。执行 git backfill --sparse 之后,sparse cone 内的这个数字应该降到 0。
Q:git backfill 是什么?需要较新的 Git 吗?
A:git backfill(Git 2.49.0,2025 年 3 月)会成批下载缺失的 blob,而不是每个文件一个慢请求,同时还能获得更好的 delta 压缩。加上 --sparse 时,它只拉取与你 sparse-checkout cone 匹配的 blob。在低于 2.49 的 Git 上,回退用 git checkout HEAD -- <path> 来触发延迟拉取。用 git --version 查看你的版本。
Q:文件明明是旧内容,为什么 git status 还显示”干净”?
A:因为 index 与 HEAD 一致,Git 的元数据层是内部自洽的——但那个 commit 记录的 blob 从未被下载(或下载的是更早的状态)。git status 比较的是 index 与 HEAD,而不是磁盘上的字节与远端。先用 git log --oneline -3 确认 HEAD 在你预期的位置,再用 git backfill --sparse 拉取真正的内容。
Q:能不重新 clone 就把 partial clone 转成全量 clone 吗?
A:可以。去掉 filter 再重新拉取:git config --unset remote.origin.partialclonefilter,然后 git fetch --refetch origin 把 filter 之前跳过的所有对象都拉下来。超大仓库这一步会很慢,所以如果你只需要 sparse 路径的 blob,优先用 git backfill。
Q:我们的 monorepo 有 5 万多个文件,sparse + blobless 真的更快吗?
A:在开发机上是的——sparse checkout 让 git status 和 git diff 保持很快,因为它们只扫描 cone 范围。对 CI 而言,--filter=blob:none 缩短了初始 clone 时间,但每个 job 仍要为 blob 下载付出代价,所以在构建前先做一次 git backfill --sparse(或改用 --filter=tree:0),通常比构建中途被上千次延迟拉取拖死要好。请针对你自己的负载实测对比。
相关阅读
- 历史里的大文件让 push 失败
- Git LFS pointer 文件没被真实文件替换
- Submodule 怎么都拉不到最新 commit
- Clone 之后 git hooks 不执行
- Worktree 在分支删除后变成幽灵
- 找回 Git 历史里的旧版本文件
标签: #git #version-control #排查