← 返回首页

Git 2.55 大仓库瘦身实测:blob:none、sparse 与 LFS 到底省了多少

Git大仓库partial clone

仓库里有 48 MiB,但你真正要打开的也许只是 300 个文件里的 10 个。Git 给了你六个开关来少拿一些东西:--depth、--filter=blob:none、--filter=tree:0、--sparse、它们的组合,以及 git lfs migrate。麻烦在于它们减的根本不是同一个东西——有的只减工作区,有的只减历史文件内容,有的两者都减,代价是以后每次 git log -p 都去远端补货。

我实际测试了这六种组合在同一样本仓库上的字节级结果。数据全部来自本机 Windows 实跑(Git 2.55.0.windows.3 与 git-lfs 3.7.1),每一节都给了命令和真实输出,你可以照抄复现。

先给决策树:你该动哪个开关

拿不准就选 --filter=blob:none --sparse:它是唯一同时压住 .git 和工作区的组合,代价是必须连着网用。

样本仓库:48 MiB 是怎么堆出来的

为了让差异能被量出来,我造了一个结构刻意偏大的仓库:300 个 500 字节的文本文件分在 30 个目录里,外加一个 8 MiB 的二进制资产,连续改 6 版。

git init -q -b main .
for i in $(seq 0 299); do
  d=src/mod$((i % 30)); mkdir -p "$d"
  head -c 500 /dev/zero | tr '\0' 'y' > "$d/f$i.txt"
done
mkdir -p assets
for v in 0 1 2 3 4 5; do
  head -c 8388608 /dev/urandom > assets/model.bin
  git add -A && git commit -q -m "v$v"
done
git gc --aggressive -q --prune=now

跑完 .git 目录是 50,410,860 字节,git count-objects -vH 报 size-pack: 48.02 MiB、in-pack: 56。这里用的是随机数填充,Git 压不动它;换成可压缩的文本资产,绝对值会小很多,但六种方式之间的比例关系不变。

六种方式的字节级对照

同一个源仓库、六种克隆方式,全部走 file:/// 协议(原因见踩坑录第一、二条):

方式.git 体积相对全量工作区文件
git clone(全量)50,408,484 B100%301
git clone --depth 18,450,792 B16.8%301
--filter=blob:none --no-checkout35,077 B0.070%0
--filter=blob:none(默认检出)8,453,742 B16.8%301
--filter=tree:0 --no-checkout30,365 B0.060%0
--filter=blob:none --depth 18,452,100 B16.8%301
git clone --sparse50,409,793 B100.0%0
--filter=blob:none --sparse + set src/mod163,795 B0.127%10

第一行和最后一行差 790 倍。但先看第四行:只要你不加 --no-checkout,克隆结束后 Git 会立刻检出 HEAD,而检出 HEAD 需要 HEAD 树里的每一个 blob——于是 blob:none 刚省下的东西,在 2.9 秒的检出里又被原样拉了回来。

三个反直觉结论

结论一:--sparse 一点都不省 .git。 实测 git clone --sparse 的 .git 是 50,409,793 B,比全量克隆的 50,408,484 B 还多 1,309 B(多出来的是稀疏检出配置文件)。它省的是工作区:检出后工作区文件数是 0,执行 git sparse-checkout set src/mod1 src/mod2 之后变成 20 个。官方 git-sparse-checkout 文档(2026-10-22 抓取,页面标注 2.55.0)的原话是 change the working tree from having all tracked files present to only having a subset of those files,从头到尾讲的是工作树,与对象库无关;同一页还有一句全大写提醒:THIS COMMAND IS EXPERIMENTAL。

结论二:blob:none 是延迟,不是免除。 我实际测试过这个代价:一个 .git 只有 35,090 B 的 blobless 仓库,仅仅执行一条 git cat-file -s <历史 blob 的 oid>,.git 就涨到 8,427,456 B,多了 8,392,366 B——一次访问让它变成原来的 240 倍。取回来的 blob 会永久留在本地,删掉远端也不会消失。所以 blobless 仓库在 CI 里跑一次 git log -p --all,省下的就作废了。

结论三:--filter=blob:none --depth 1 基本是白写的。 实测 8,452,100 B,比单独 --depth 1 的 8,450,792 B 还大 1,308 B。原因很直白:--depth 1 之后只剩一个提交,那个提交的文件内容又必须被检出,blob 过滤在这个形状的历史上没有任何可过滤的对象。两个开关不是叠加关系。

git-lfs 迁移:仓库并没有变小

我跑了完整流程:先全量克隆(.git = 50,408,497 B,size-pack: 48.02 MiB),再 git lfs migrate import --include="*.bin" --everything,然后 git reflog expire --expire=now --all 加 git gc --prune=now -q。

结果是 .git 从 50,408,497 B 涨到 100,746,552 B,接近翻倍。git count-objects -vH 显示 in-pack 从 56 变成 81,而 size-pack 仍然是 48.02 MiB——那六个 8 MiB 的旧 blob 一个都没被清掉。

根因在 ref 上:迁移只改写了本地分支。实测 refs/heads/main 指向 552b69a14f2f18e2...(新历史),而 refs/remotes/origin/main 仍指向 c6f0c8982585a61e...(旧历史)。旧提交还被远端跟踪 ref 引用着,旧 blob 就仍然是可达对象,git gc 再怎么 --prune=now 也清不掉。

删掉两个远端跟踪 ref、重新过期 reflog 再 gc 之后,size-pack 从 48.02 MiB 掉到 7.65 KiB,in-pack 从 81 回到 57,.git 总量回到 50,396,692 B。

然后是最该说的那句:彻底跑完之后 .git 是 50,396,692 B,迁移之前是 50,408,497 B,一共只省了 11,805 字节,0.023%。

因为 .git/lfs 里躺着 50,331,648 B——6 个历史版本 × 8,388,608 B,一个不少,只是从 pack 搬到了 LFS 的对象库。LFS 解决的是以后 clone 时不必下载这些大文件,以及 pack 不再被巨型 blob 撑住,不是仓库变小。如果你的目标是让现有仓库真的瘦下来,该用的是把历史里的文件彻底删除的工具,而不是 LFS。

⚠️ 本节数据基于不可压缩的随机二进制。真实场景里的图片、音频、模型权重同样接近不可压缩,方向一致;如果你的大文件是可压缩的文本类资产,pack 侧原本就能压掉一部分,LFS 前后的差距会更明显。

踩坑录:8 个真实报错与触发条件

以下 8 条全部在本机复现,退出码与输出照抄。

报错一:用本地路径克隆时 --depth 被静默忽略

$ git clone -q --depth 1 "D:/tmp/src" "D:/tmp/shallow"
warning: --depth is ignored in local clones; use file:// instead.

退出码 0,克隆「成功」,.git 是 50,396,153 B(和全量的 50,396,147 B 一样),git rev-list --count HEAD 仍然返回 6。Git 对本地路径默认走硬链接与复制优化,不走 upload-pack,服务端侧的截断根本没发生。想真的拿到浅克隆,必须写成 file:///D:/tmp/src。

报错二:--filter 有完全一样的坑

$ git clone --filter=blob:none "D:/tmp/src" "D:/tmp/blobless"
warning: --filter is ignored in local clones; use file:// instead.

同样退出码 0,产物是全量。

报错三:服务端不支持 filter 时只警告并降级(最危险的一条)

$ git clone --filter=blob:none "file:///D:/tmp/src-nofilter" "D:/tmp/nb"
warning: filtering not recognized by server, ignoring

退出码 0,克隆完成,.git = 50,408,679 B,跟全量没有区别。更麻烦的是:

$ git config --get remote.origin.partialclonefilter
blob:none

这个仓库在配置上声明自己是 partial clone,实际上一个 blob 都没少。 后续任何依赖 promisor 的判断都会基于一个错误的自我认知。服务端需要显式打开开关,Git v2.55.0 官方 Documentation/config/uploadpack.adoc 的原文是 If this option is set, upload-pack will support partial clone and partial fetch object filtering:

git config uploadpack.allowFilter true

自托管的裸仓库做 partial clone 之前先确认这一项。判断有没有生效不要看 warning 是否出现,直接量 .git 的体积。

报错四:git lfs migrate 拒绝在工作区脏的时候执行

$ git lfs track "*.bin"
$ git lfs migrate import --include="*.bin" --everything
override changes in your working copy?  All uncommitted changes will be lost! [y/N]
working copy must not be dirty

退出码 2。这里有个自锁的坑:一旦 git lfs track 把 *.bin filter=lfs diff=lfs merge=lfs -text 写进 .gitattributes,clean 过滤器立刻生效,git status 就把那个大文件判成已修改——工作区变脏,migrate 拒绝执行。我把 .gitattributes 提交之后它仍然判定为脏。正确顺序是先 migrate 再 track,迁移前不要动 .gitattributes。

报错五:迁移后远端跟踪 ref 仍指向旧历史,gc 清不掉旧 blob

判据是比对两个 ref:

$ git rev-parse refs/heads/main
552b69a14f2f18e262538edb933f72b25a481b49
$ git rev-parse refs/remotes/origin/main
c6f0c8982585a61e63bc100b1dd65179d3d759fe

两个值不同就说明旧历史还挂着。清掉它们再 gc:

git update-ref -d refs/remotes/origin/main
git update-ref -d refs/remotes/origin/HEAD
git reflog expire --expire=now --all
git gc --prune=now

报错六:blobless 仓库断了 promisor 远端之后

三条命令三种报错,退出码都是 128:

$ git cat-file -s b023e6741d61ac1e47b030f5c389a45358a8c9ce
fatal: git cat-file: could not get object info

$ git show b023e6741d61ac1e47b030f5c389a45358a8c9ce
fatal: bad object b023e6741d61ac1e47b030f5c389a45358a8c9ce

$ git log -p -1
fatal: unable to read c5b4eef6e79bff515a54f17b3f372a9370f4ea6a

git log -p 这条最容易被误判:提交信息与 commit 行会正常打印,然后才在读对象时炸掉,看起来像 Git 坏了。实际上它只是需要一个它以为随时能取到的对象。离线之前先 git fetch --refetch,或者干脆别用 blobless。

报错七:sparse-checkout 的 cone 模式不接受文件

$ git sparse-checkout set assets/model.bin
fatal: 'assets/model.bin' is not a directory; to treat it as a directory anyway, rerun with --skip-checks

退出码 128。cone 模式只吃目录,这是设计如此。官方 git-sparse-checkout 文档原文:By default, the input list is considered a list of directories。

报错八:sparse-checkout 接受不存在的目录且不报错

$ git sparse-checkout set src/nope
(退出码 0,无任何输出)

$ git sparse-checkout list
src/mod5
src/nope

目录不存在也照样写进配置,退出码 0、零输出。 你以为把 src/nope 加进了工作区,实际什么都没发生。写完一定要用 git sparse-checkout list 配合 git ls-files 回读确认。

本文的边界

下一步

如果你喜欢「文件里到底存了什么、每个旋钮值多少字节」这个思路,可以看 Parquet 与 Arrow 的列式存储实测,它和本文是同一套打法:造一份已知结构的数据,然后把每个默认值单独关掉量一遍。

依赖与版本约束是另一个方向的坑——PEP 440 版本约束复盘 里那条「pip 的报错文案本身是错的」,和本文的「Git 的 warning 是静默降级」属于同一类问题:工具的自我描述不等于事实。

DuckDB 1.5.6 实测 走的是同一条本机可实测路径,手上没有 GPU 和服务器的时候可以照着它选题。

👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务

👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速

📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页