Git LFS 系统学习文档
把二进制大文件从 git 历史里搬出去的标准方案。本文按「一步一步能跟着敲」组织, 所有带「实测」标记的命令输出,都是 2026-08-06 在本机真实跑出来的,不是凭记忆写的。 环境:Darwin 25.5.0 (arm64) · git 2.50.1 (Apple Git-155) · git-lfs 3.7.1 实验目录:
/tmp/lfs-lab/(用完可直接rm -rf,全程未触碰任何真实仓库)
⚠️ 本文的诚实边界:有若干条依赖联网/服务端的内容我没验证,完整清单见文末「验证状态」表 (不在这里列举,避免列漏——第一版就漏列了两项)。正文里每条未验证的内容都在原地标了 ⚠️。
📌 同目录相关文档:
260806-1-如何用Claude以10倍速度学习任何东西_整理版.md——本文第 13 章的自测题形式(一次只问一道)来自那份笔记里的「方法③ AI 考官」。
目录
- 它解决什么问题(心智模型)· 什么时候不该用
- 环境确认:你现在处在哪一步
- 第一步:跑通最小闭环
- 指针文件到底长什么样
.gitattributes:规则写在哪(以及另外两处配置)- 【核心事故】track 只管未来,不管过去
- 修历史:
git lfs migrate三步法 · 反向退出 LFS - 克隆端发生了什么:smudge / skip / pull
- 空间与配额治理(配额部分对你不适用)
- Unity 实战:你的仓库体检(实读 + 3 个缺口 + 大小写跨平台陷阱)
- 反模式清单
- 命令速查表
- 自测题(不看答案能答出几道)
- 学习路径:30 分钟 / 2 小时
1. 它解决什么问题(心智模型)
问题的根源
git 存的是每个版本的完整快照。文本文件可以做行级增量压缩,但二进制文件(psd/fbx/png/wav/mp4)改一个字节,git 就得再存一份全量。
一张 50MB 的 psd,改了 20 版
→ git 仓库里躺着 20 × 50MB = 1GB
→ 而且每个 clone 的人都要下载这 1GB
→ 哪怕他只需要最新那一版
LFS 的做法
git 仓库(轻) LFS 服务端(重)
┌──────────────┐ ┌──────────────┐
art.psd ──────► │ 指针文件 │ ───────► │ 真实二进制 │
(你看到的) │ 132 字节 │ │ 5 MB × N 版本 │
└──────────────┘ └──────────────┘
▲ │
│ checkout 时 smudge 过滤器把真文件换回来
└──────────────────────────┘
一句话:git 里只留一个 132 字节的指针,真文件放到独立的 LFS 服务端;你 checkout 哪个版本,就只下载哪个版本。
最容易误解的一点
LFS 不是压缩,不是加速下载。
它不减少你「当前需要的那份文件」的体积,它减少的是历史包袱——你不再需要下载这个文件的全部 20 个历史版本,只下当前这一版。
这个心智模型是后面所有操作的地基。第 6 章那个事故,本质就是没理解这一点。
什么时候不该用 LFS
同样重要的一面,很多教程不讲:
| 情况 | 为什么不该 |
|---|---|
| 小文件(几十 KB 的图标、配置) | 每个 LFS 文件都多一次网络往返和一条元数据,收益为负 |
纯文本(代码、json、yaml、.meta) |
git 对文本的增量压缩非常好,进 LFS 反而失去 diff/merge 能力 |
| 改动极少的大文件 | 只提交过一两次的话,历史包袱本来就不存在 |
| 需要逐行 diff / 合并的文件 | 进了 LFS 就只能整文件替换 |
判据不是「文件大不大」,是「它会不会被反复修改」。 一个 500MB 但只提交一次的安装包, 危害小于一个 5MB 但改了 200 版的贴图。
2. 环境确认:你现在处在哪一步
第 1 步:装没装
git lfs version
实测输出:
git-lfs/3.7.1 (GitHub; darwin arm64; go 1.25.3)
没装的话:brew install git-lfs。
第 2 步:全局钩子有没有装上
git config --global --get-regexp 'filter.lfs'
实测输出(说明已经 git lfs install 过了):
filter.lfs.required true
filter.lfs.clean git-lfs clean -- %f
filter.lfs.smudge git-lfs smudge -- %f
filter.lfs.process git-lfs filter-process
这四行就是 LFS 的全部魔法所在:
| 配置 | 作用 | 什么时候跑 |
|---|---|---|
clean |
真文件 → 指针 | 你 git add 时 |
smudge |
指针 → 真文件 | 你 checkout/clone 时 |
required true |
过滤器失败就中止,不静默放行 | 始终 |
如果这四行不存在,跑一次 git lfs install(每台机器一次,全局生效)。
3. 第一步:跑通最小闭环
完整跑一遍,5 分钟。建议你现在就照着敲一遍。
mkdir -p /tmp/lfs-lab/demo1 && cd /tmp/lfs-lab/demo1
git init -b main
git config user.name "LFS Lab"
git config user.email "lab@example.com"
① 仓库级初始化
git lfs install
实测输出:
Updated Git hooks.
Git LFS initialized.
② 声明哪些文件走 LFS
git lfs track "*.bin"
实测输出:
Tracking "*.bin"
⚠️ 引号不能省。写成
git lfs track *.bin的话,shell 会先把它展开成当前目录下的具体文件名, 于是.gitattributes里被写进一堆死文件名,新文件不生效。
③ 看看它写了什么
cat .gitattributes
实测输出:
*.bin filter=lfs diff=lfs merge=lfs -text
④ 造个 5MB 文件提交
dd if=/dev/urandom of=big.bin bs=1m count=5
git add .gitattributes big.bin # ← .gitattributes 必须一起提交!
git commit -m "add big.bin via lfs"
⑤ 验证:git 里实际存的是什么
git cat-file -p HEAD:big.bin
实测输出——这就是指针文件:
version https://git-lfs.github.com/spec/v1
oid sha256:f738a438f8a71a513cf5278e1809c1eb31565b5e5108e139177e084268459643
size 5242880
⑥ 诊断命令(出问题时的第一反应)
git lfs ls-files
实测输出:
f738a438f8 * big.bin
🎯 过关标准:
git cat-file -p HEAD:文件名看到的是上面那三行文本,而不是二进制乱码。 看到乱码 = 这个文件没有走 LFS。
4. 指针文件到底长什么样
实测:132 字节,3 行。
version https://git-lfs.github.com/spec/v1 ← 规范版本,固定
oid sha256:f738a438f8a71a... ← 真文件的 sha256,LFS 靠它去服务端取
size 5242880 ← 真文件字节数
为什么要知道这个:因为你迟早会遇到这一幕——
同事:「你发我的图打不开,用记事本打开是三行英文。」
那三行就是它。原因见第 8 章。
5. .gitattributes:规则写在哪
LFS 没有隐藏的数据库,「哪些文件走 LFS」这条规则全部写在 .gitattributes 这个纯文本文件里。
📌 但 LFS 的配置不止这一处,别记成「只有一个文件」:
.gitattributes—— 哪些文件走 LFS(本章内容,会随仓库分发给所有人).lfsconfig—— 传到哪个服务端(可选文件,同样会分发).git/config里的lfs.*—— 本机凭据/endpoint 覆盖(不分发,只影响你自己)排查「为什么我这儿正常他那儿不行」时,三处都要看。 (实测:你的 Unity 仓库没有
.lfsconfig,endpoint 记在本机.git/config里——见第 10 章)
*.psd filter=lfs diff=lfs merge=lfs -text
| 字段 | 含义 |
|---|---|
filter=lfs |
走 clean/smudge 过滤器(核心) |
diff=lfs |
git diff 时不要吐一屏二进制乱码 |
merge=lfs |
合并时不要尝试按行合并 |
-text |
明确声明是二进制,禁止换行符转换(Windows 队友的救命符) |
三条必须知道的规则
- 它必须被提交并推送。它只是个普通文件,你不提交,别人就没有这套规则。
- 它可以放在子目录里,只对该目录生效——大项目里常见
Assets/.gitattributes。 - 改它不会动已有文件。这是第 6 章的全部内容。
6. 【核心事故】track 只管未来,不管过去
我认为这是 LFS 最高频的翻车点(凭经验判断,没有数据支撑)。下面是实测复现。
事故复现
mkdir -p /tmp/lfs-lab/demo2 && cd /tmp/lfs-lab/demo2
git init -b main && git config user.name L && git config user.email l@e.com
# 还不知道有 LFS,直接提交了 3 个版本的 5MB 素材
for i in 1 2 3; do
dd if=/dev/urandom of=art.psd bs=1m count=5
git add art.psd && git commit -m "art v$i"
done
git count-objects -vH | grep size:
实测输出:
size: 15.04 MiB ← 3 个版本 × 5MB,全在仓库里
# 事后才想起来 track
git lfs track "*.psd" && git add .gitattributes && git commit -m "track psd"
dd if=/dev/urandom of=art.psd bs=1m count=5
git add art.psd && git commit -m "art v4 (now via lfs)"
git lfs ls-files # v4 确实走 LFS 了
git count-objects -vH | grep size:
实测输出:
8c374de024 * art.psd ← v4 走 LFS 了,看着像成功了
size: 15.06 MiB ← 但体积一点没降!反而还涨了 0.02 MiB
结论
git lfs track只影响之后的提交。前 3 个版本仍以真文件形式躺在 git 历史里, 仓库该多大还多大,每个 clone 的人还是要下这 15MB。
一个中间态:--renormalize
如果你只想把当前版本转成 LFS(历史不管),可以:
git add --renormalize .
git commit -m "renormalize"
实测验证(demo3,输出原样粘贴):
1) 提交后才 track: ls-files -> 0 个 (当前版本仍未走 LFS)
2) renormalize 后: ls-files -> 3915d5fe7d * a.psd (当前版本转过去了)
3) 但历史里第一版: /dev/stdin: data (file 判定为二进制,即真文件)
能解决“以后别再滚雪球”,不能解决“仓库已经很大”。 后者只有第 7 章一条路。
7. 修历史:git lfs migrate 三步法
🚨 这一步会重写 git 历史(所有 commit 的 SHA 全变)。 在共享分支上做 = 所有人必须重新 clone。先在克隆出来的副本上练。
第 1 步:只扫描,不改任何东西
git lfs migrate info --everything
实测输出:
Sorting commits: ..., done.
Examining commits: 100% (5/5), done.
*.psd 16 MB 3/3 files 100%
*.gitattributes 42 B 1/1 file 100%
LFS Objects 5.2 MB 1/1 file 100%
读法:*.psd 有 3 个未 LFS 化的版本、共 16 MB,这就是要迁的东西。
⚠️
--everything不能省。实测不加它的输出:*.gitattributes 42 B 1/1 file 100% LFS Objects 21 MB 4/4 files 100%
*.psd那一行直接消失了——因为它只看当前分支的当前状态。 不加--everything就下结论,会以为「没什么可迁的」。
第 2 步:真正迁移
git lfs migrate import --include="*.psd" --everything
实测输出:
Sorting commits: ..., done.
Rewriting commits: 100% (5/5), done.
Updating refs: ..., done.
Checkout: ..., done.
验证历史里的旧版本也变成指针了:
git cat-file -p HEAD~3:art.psd
实测输出:
version https://git-lfs.github.com/spec/v1
oid sha256:9c821c6bb7d38881bf24c573d81dee84789539375905e2e22dee2376bf4fcd38
size 5242880
第 3 步:清掉不可达的旧对象(这步最容易漏)
git count-objects -vH | grep -E "size:|size-pack" # 迁移后、gc 前
实测输出:
size: 15.10 MiB ← 迁移完了,体积居然还没降!
旧对象还被 reflog 挂着,得显式清:
git reflog expire --expire-unreachable=now --all
git gc --prune=now
git count-objects -vH | grep -E "size:|size-pack"
实测输出:
size: 0 bytes
size-pack: 2.94 KiB ← 15.10 MiB → 2.94 KiB
三步法总结
| 步骤 | 命令 | 漏了会怎样 |
|---|---|---|
| ① 扫描 | git lfs migrate info --everything |
不知道要迁什么,或误判「没东西可迁」 |
| ② 迁移 | git lfs migrate import --include="..." --everything |
— |
| ③ 清理 | git reflog expire ... && git gc --prune=now |
体积不降,以为迁移失败了 |
关于远端:本地瘦了不等于远端瘦了。force push 之后远端旧对象要等它自己 GC, 想立刻回收通常得找管理员。这一条我没有实际验证过(需要真实远端仓库),按官方文档的说法记在这里。
反向操作:把文件从 LFS 拿回普通 git
万一走错了路(比如把不该进 LFS 的小文件放进去了),有 export:
git lfs migrate export --include="*.dat" --everything
实测输出:
5475ec8987ad7536a2874da83faa5750c89706e30758c447ff5be9c518a91e93 (2.1 MB), done.
Deleting objects: 100% (1/1), done.
验证:git lfs ls-files 变空,git cat-file 拿到的是真二进制。
⚠️ 一个会让人困惑的细节(实测发现):
export不会删掉原来那行规则, 而是追加一行否定规则:*.dat filter=lfs diff=lfs merge=lfs -text ← 原来那行还在 *.dat !text !filter !merge !diff ← 新追加,用 ! 取消上面的属性看到
filter=lfs还在别慌,看最后一行——.gitattributes是后面的规则覆盖前面的。
8. 克隆端发生了什么:smudge / skip / pull
A) 正常 clone —— 真文件自动到位
git clone demo2 clone-normal
ls -lh clone-normal/art.psd
实测:5.0M,是真文件。
B) 跳过 smudge —— 只拿指针
GIT_LFS_SKIP_SMUDGE=1 git clone demo2 clone-skip
cat clone-skip/art.psd
实测输出:
文件大小: 132B
version https://git-lfs.github.com/spec/v1
oid sha256:8c374de02424d504539dab8b9de6a80d38c7100e026f69d860daa735b24bfb79
size 5242880
用途:CI 只需要代码不需要素材时、或者你只想快速看一眼仓库结构——能省掉全部 LFS 流量。
C) 事后补齐
git lfs pull
实测:132B → 5.0M。
「同事说图片打不开」的两种成因
| 现象 | 成因 | 修复 |
|---|---|---|
| 打开是三行文本 | ① 同事没装 git-lfs,smudge 过滤器根本没跑 | 装 git-lfs → git lfs install → git lfs pull |
| 打开是三行文本 | ② 装了,但 clone 时带了 GIT_LFS_SKIP_SMUDGE / 或拉取失败 |
git lfs pull |
| 图是真文件但不该是 | ③ .gitattributes 没提交,同事那边根本没有 LFS 规则,他提交的是真二进制 |
提交 .gitattributes,然后按第 7 章 migrate |
第 ③ 种最阴险:看起来一切正常,仓库在悄悄变胖。
9. 空间与配额治理
本地缓存
LFS 会在 .git/lfs/ 下留一份对象缓存:
du -sh .git/lfs
git lfs prune
实测输出:
缓存: 5.0M .git/lfs
1 local object, 1 retained, done.
prune 删的是「本地缓存里已经不需要的旧版本对象」,不动远端,可以放心跑。
📌 注意读这次的输出:
1 local object, **1 retained**—— 说明这次一个都没删 (那唯一的对象是当前版本,还需要)。真正能看出效果要在一个改过很多版的仓库上跑。 别把「命令跑通了」当成「清出空间了」。
远端配额 ⚠️ 未验证,且对你的仓库不适用
🚨 先说结论:这一节你可以跳过。 实测你的 Unity 仓库 LFS endpoint 是 内网自建服务端(
.git/config里的lfs.http://<内网服务端IP>:10000/<项目名>/...), 不是 GitHub,不受下面这些配额限制。这节留着是为了通用完整性。
以下数字来自我的既有知识,本次没有联网核实,也没有 GitHub LFS 仓库可测。 用到时请以 GitHub 官方计费页面为准。
- GitHub 免费额度大约是 1 GB 存储 + 1 GB/月带宽
- 带宽按「下载」计:CI 每跑一次全量 clone 就消耗一次,比存储更容易先烧光
- 超额后 LFS 会被限制,可能表现为推送被拒
对 Unity 项目的实际含义:一个正经的 Unity 仓库,美术资源轻松几十 GB, 免费额度基本等于不够用。方案通常是自建服务端(Gitea / GitLab / S3 后端)或购买配额。 选型这块我没有实测数据,只能提示你在开始迁移前先把这件事想清楚。
10. Unity 实战:你的仓库体检
🚨 写这一章前我先去读了你的真实仓库(
/Users/leo/_Data/_Work/Tap/<项目名>_dev,只读,未做任何修改), 结论是:你早就在用 LFS 了,而且配得比通用模板讲究。 所以这章不教你怎么配,只给体检结果和具体缺口。⚠️ 检查方法上我踩了两次同样的坑,写在这里以免你复用错方法: 我先用
grep -iE "png|fbx" .gitattributes查,无输出,差点得出「他没配 png/fbx」的错误结论—— 实际是字符类写法*.[pP][nN][gG]字面上没有连续的 “png”,grep 当然搜不到。 后来查*.bnk时又栽了一次。查这种.gitattributes必须先把字符类解码再比对:grep "filter=lfs" .gitattributes | awk '{print $1}' | sed 's/\[\(.\)[^]]*\]/\1/g'
实测体检结果(2026-08-06)
| 项目 | 实测值 | 判断 |
|---|---|---|
| 是否 git 仓库 | 是 | — |
.gitattributes |
135 行 | 成熟配置,非默认模板 |
| LFS 跟踪文件数 | 19,232 个 | 大规模在用 |
.git 总占用 |
34 GB | — |
.git/lfs 缓存 |
21 GB | 占了 .git 的六成 |
filter=lfs 规则数 |
48 条 | — |
.meta 处理 |
*.meta text eol=lf |
✅ 正确,没进 LFS |
| Unity YAML 合并 | .unity/.prefab/.asset 均挂 merge=unityyamlmerge |
✅ 已配 |
.gitignore |
Temp/ Library/ Logs/ obj/ Build/ 齐全 |
✅ 正确 |
.lfsconfig |
不存在(endpoint 记在本机 .git/config) |
✅ 正常 |
| LFS endpoint | 内网自建 <内网服务端IP>:10000 |
ℹ️ 非 GitHub,第 9 章配额不适用 |
core.ignorecase |
true(macOS APFS) |
⚠️ 见下文大小写陷阱 |
| 大小写覆盖写法 | 48 条里 30 条用字符类,12 条纯小写 | ⚠️ 缺口 1 |
被 # 注释的规则 |
7 条(44–50 行) | ⚠️ 缺口 2,需你确认 |
文件锁 lockable |
0 条规则 | ⚠️ 缺口 3 |
你的配置比我的模板好在哪
我原本准备给的模板是 *.psd filter=lfs ...。你的仓库(48 条 filter=lfs 规则里的前 30 条)用的是字符类写法:
*.7[zZ] filter=lfs diff=lfs merge=lfs -text
*.[aA][vV][iI] filter=lfs diff=lfs merge=lfs -text
*.[pP][nN][gG] filter=lfs diff=lfs merge=lfs -text
这个写法是对的,但我第一版给的理由是错的。 下面是实测。
⚠️ 更正:不是「大小写敏感」,是「行为随平台而变」
我第一版写的是「.gitattributes 的模式匹配大小写敏感,所以 Logo.PNG 会漏过 *.png」。
在你的 Mac 上实测,这句话是错的——*.png 照样匹配了 UPPER.PNG。
判决性实验(/tmp/lfs-audit,同一份 .gitattributes,只改一个配置):
| 环境 | core.ignorecase |
*.png 匹配 UPPER.PNG? |
结果 |
|---|---|---|---|
| 你的 Mac(APFS 大小写不敏感) | true(git 自动设的) |
✅ 匹配 | 正常走 LFS |
| Linux / 大小写敏感文件系统 | false |
❌ 不匹配 | 静默变成真二进制进仓库 |
实测:core.ignorecase=false 时 git lfs ls-files 输出为空,git cat-file 拿到的是 data(真二进制)。
所以真实风险比我第一版写的更隐蔽:
不是「到处都会漏」,而是「你的 Mac 上不会漏,Linux CI 或 Linux/WSL 开发机上会漏」。 一个在你机器上验证通过的
.gitattributes,换台机器就失效—— 不一致比一致地错更难查,因为你本地永远复现不出来。
结论不变,理由换了:字符类写法 *.[pP][nN][gG] 把大小写全枚举,不依赖平台行为,所以是对的。
怎么自查(以及一个陷阱)
git check-attr filter -- Assets/test.WEM
| 输出 | 含义 |
|---|---|
filter: lfs |
走 LFS |
filter: unspecified |
不走(会以真二进制进仓库) |
实测:文件不存在也能查,纯粹按规则推算。你的仓库里 test.WEM 查出来是 filter: lfs。
🚨 陷阱:
check-attr反映的是「你这台机器」的core.ignorecase。 实测同一条规则,Mac 上查UPPER.PNG得到filter: lfs,core.ignorecase=false的仓库里查同名文件得到filter: unspecified。 所以在你的 Mac 上自查,永远查不出 Linux 那边的问题。 真要验证跨平台,只能在 Linux 机器/CI 上跑一次,或者干脆改成字符类写法一劳永逸。
.meta 的处理也比我说的更精确
我说的是「不进 LFS」,你的配置是:
*.meta text eol=lf
显式声明为文本 + 强制 LF 换行,比单纯「不写规则」更稳,能防住 Windows 队友的 CRLF 污染。
三个具体缺口(不是「唯一」一个)
📌 第一版这里写的是「唯一的缺口:没有文件锁」——不成立。 那是我只检查了 4 个维度就下的结论。补查之后有 3 项,下面按「是否需要你确认」排序。 而且这仍然不是穷尽检查(13 GB 的非 LFS 对象我没查,见本章末)。
缺口 1:48 条规则里有 12 条没做大小写覆盖(理论隐患,当前未发生)
同一份 .gitattributes 里两种写法混用,说明是分两批追加的,后一批没沿用前一批的规范:
| 行号 | 规则 |
|---|---|
| 31–39 | *.a *.bundle *.bytes *.keystore *.mdb *.mine *.pdb *.so *.srcaar |
| 52–54 | *.unitypackage *.bnk *.wem |
✅ 实测结论:当前没有实际危害。 我把这 12 个扩展名逐个查了大小写变体 (
git ls-files | grep -iE "\.wem$" | grep -vE "\.wem$"之类),一个都没有。🚨 更正我第一版的判断:那一版我写「52–54 行风险最高,
.WEM/.Bytes最容易出现」—— 这是没查就下的判断,实测不成立。 真实情况是这 12 类文件目前全是小写扩展名, 所以哪怕在 Linux 上也不会漏。它是隐患(哪天有人交个.WEM就中招),不是现存问题。对照组:
.FBX这类大写文件真实存在 771 个,但它们被字符类规则*.[fF][bB][xX]覆盖着, 所以安全——这恰好证明字符类写法当初是被真实需求逼出来的,不是过度设计。
另外
*.bnk定义了两次(*.[bB][nN][kK]和第 53 行的*.bnk)。无害,是分批追加的又一证据。
缺口 2:44–50 行有 7 条规则被 # 注释掉了 ⚠️ 需要你确认
44:#client/Assets/SDK/SDKFacebook/.../Bolts filter=lfs diff=lfs merge=lfs -text
45:#client/Assets/SDK/SDKFacebook/.../FBSDKCoreKit filter=lfs ...
46:#client/Assets/SDK/SDKFacebook/.../FBSDKLoginKit filter=lfs ...
47:#client/Assets/SDK/SDKFacebook/.../FBSDKShareKit filter=lfs ...
48:#client/Map/7200/mapData.config filter=lfs ...
49:#client/Map/7200_empty/mapData.config filter=lfs ...
50:#client/Assets/TFWCore/** -filter diff merge ← 这条是「排除 LFS」,也被注掉了
# 在 .gitattributes 里是注释符,这 7 条一条都不生效。
✅ 已查清(2026-08-06 实读):仓库根目录没有 client/,现在是 Assets/ Map/ Packages/ DLC/ … 的结构。
所以这 7 条是仓库改结构后留下的历史遗留,路径前缀 client/ 早已对不上——
即使取消注释也不会生效。逐条追查结果:
| 规则 | 目标现状 | 结论 |
|---|---|---|
44–47 Facebook SDK 的 4 个 .framework |
已从仓库删除(find 无结果) |
✅ 无害,可清理 |
50 TFWCore/** -filter(排除 LFS) |
现在在 Assets/P2/Editor/TFWCore,478 个文件全是 .cs/.meta 文本 |
✅ 无害——本来就没有规则会匹配它们,排除与否结果相同 |
48–49 Map/*/mapData.config(本意:应走 LFS) |
文件仍在,且 filter: unspecified |
⚠️ 本意未达成,见下 |
唯一有实质影响的是 48–49 行:
Map/7200/mapData.config 148 KB filter: unspecified
Map/7200_empty/mapData.config 3.9 MB filter: unspecified ← 以真二进制存在 git 里
当年有人写规则想让它走 LFS,后来规则连同路径一起失效了,这两个文件至今是普通 git 对象。
不过危害有限:实测 git log 显示相关 .config 文件只有 1 个版本(没被反复修改),
所以它占的是「一次性 4 MB」而不是「4 MB × N 版」。
缺口 3:没有文件锁 lockable(0 条规则)
你已经配了 merge=unityyamlmerge,而且 .gitattributes 里那句注释写得很清楚:
# 场景/Prefab/Asset 额外挂 UnityYAMLMerge(团队需在 ~/.gitconfig 配 driver;未配会 fallback 普通 merge,不阻塞)
但 YAML 合并只是「尽力而为」,lockable 才是「从源头避免」。 两者解决的是同一个问题的两端:
| 手段 | 作用时机 | 局限 |
|---|---|---|
merge=unityyamlmerge |
冲突发生后尝试自动合并 | 复杂场景/prefab 仍会失败;队友没配 driver 就直接退化 |
lockable + git lfs lock |
冲突发生前独占 | 需要服务端支持锁 API |
加锁的写法是在现有规则后追加 lockable:
*.unity text eol=lf merge=unityyamlmerge lockable
对应的日常命令:
git lfs lock Assets/Scenes/Main.unity # 锁定,别人推不上去
git lfs locks # 看谁锁了什么
git lfs unlock Assets/Scenes/Main.unity # 解锁
⚠️ 这套流程我完全没验证过。实测到你的 LFS endpoint 是内网自建服务端 (
http://<内网服务端IP>:10000/<项目名>/<项目名>.git,端口 10000,看着像 Gitea 但我没确认), 它支不支持锁 API,只有问管服务器的人或者试一次才知道。 别直接改生产的.gitattributes,先在测试仓库走通。另外
lockable会让未锁定的文件在本地变成只读,这会改变全组的日常手感—— 属于团队约定,不是你一个人能定的事。
立刻能做、零风险的一件事
你的 .git/lfs 缓存有 21 GB。第 9 章那个 git lfs prune 在这里才真正有意义
(我在 demo 仓库跑时是 1 retained,一个都没删——那种输出说明不了任何问题):
cd /Users/leo/_Data/_Work/Tap/<项目名>_dev
git lfs prune --dry-run # ← 先看它打算删什么,不动手
实测输出(2026-08-06):
35125 local objects, 16429 retained, done.
18698 files would be pruned (18 GB), done.
21 GB 缓存里 18 GB 可清,.git 会从 34 GB 降到约 16 GB。
内网 LFS 服务端 <内网服务端IP>:10000 实测可达(prune 需要它验证对象已上传)。
📌 但你并不缺空间:实测磁盘剩余 860 GB(该仓库工作区+仓库共占 193 GB)。 所以这件事是「顺手可做」,不是「该做」。 别因为数字大就觉得非清不可—— 清掉的是本地缓存,下次 checkout 到老版本时会重新下载。
确认无误后再去掉 --dry-run。这个操作只动本地缓存,不动远端,也不改历史,
是全文所有「会改变东西」的操作里风险最低的一条(纯只读的 migrate info、check-attr 当然更安全)。
全仓库扫描:谁漏了 LFS(2026-08-06 实测,10 万文件全查)
用的方法(纯只读,不改仓库):
git ls-files -z > /tmp/tracked.z
git check-attr --stdin -z filter < /tmp/tracked.z | tr '\0' '\n' | paste -d'\t' - - -
归属分布(100,137 个跟踪文件):
| filter 值 | 数量 | 含义 |
|---|---|---|
lfs |
19,242 | 走 LFS ✅ |
unspecified |
80,404 | 不走(绝大多数是 .cs/.meta 等文本,正常) |
unset |
491 | 被 Packages/com.tfw.avprovideo/** -filter 明确排除(第 57 行,规则有效) |
>1 MB 且不走 LFS 的文件,按扩展名汇总(工作区 2915 个大文件里筛出):
| 体积 | 个数 | 扩展名 | 性质判断 |
|---|---|---|---|
| 825.1 MB | 340 | .prefab |
🟡 Unity YAML 文本,不该进 LFS(进了就没法 merge),但单个 29 MB 是设计问题 |
| 278.3 MB | 16 | (无扩展名) | 🔴 真该进 LFS 却漏了——Wwise 的 dSYM/DWARF、.bundle 目录里的 Mach-O |
| 193.6 MB | 85 | .tsv |
🟡 文本配置,git 压缩尚可,不建议进 LFS |
| 189.3 MB | 18 | .asset |
🟡 Terrain 数据(17.6 MB × 8),文本 YAML |
| 142.0 MB | 23 | .json |
🟡 同 .tsv |
| 119.5 MB | 4 | .tgz |
🔴 该进 LFS 或干脆不该进仓库(包管理器产物) |
| 87.9 MB | 7 | .config |
🔴 含上文那两个 mapData.config + 68 MB 的 polygonRiverLayer.config |
| 63.3 MB | 6 | .unity |
🟡 场景文本 |
| 18.7 MB | 2 | .dylib |
🔴 规则有 *.so 却漏了 *.dylib |
🔴 最值得注意的一个坑:*.bundle 规则形同虚设
.gitattributes 里有 *.bundle filter=lfs,但实测 AkUnitySoundEngine.bundle/Contents/MacOS/AkUnitySoundEngine
(23.7 MB)没走 LFS。原因:
macOS 上
.bundle是目录不是文件。 git 只跟踪文件, 而目录里的实际文件叫AkUnitySoundEngine——文件名不带.bundle后缀,规则匹配不上。
同理 .framework、.dSYM 也都是目录。凡是「后缀其实是目录名」的格式,*.xxx 规则一律无效,
要改成路径模式,例如 **/*.bundle/**。这个坑对 Unity + Wwise/iOS 项目非常典型。
但危害没有想象中大:实测这些文件都只有 1 个版本
| 文件 | 体积 | 历史版本数 |
|---|---|---|
com.google.firebase.app-12.2.0.tgz |
90 MB | 1 |
Map/7200_level/polygonRiverLayer.config |
68 MB | 1 |
librealm-wrappers.dylib |
15 MB | 1 |
21201116.prefab(最大的 prefab) |
29 MB | 2 |
按第 1 章的判据——危害取决于「改过多少版」,不是「多大」——这些是「一次性占用」, 不是「滚雪球」。所以这不是紧急问题,值得修但不必今天修。
⚠️
git log -- <file>的版本数在文件被 rename/move 时会断,这里只作量级参考,未加--follow。
11. 反模式清单
| # | 反模式 | 后果 | 正确做法 |
|---|---|---|---|
| 1 | 先提交大文件,事后 track |
仓库体积一点不降(实测 15.04→15.06 MiB) | git lfs migrate import --everything |
| 2 | .gitattributes 忘了提交 |
队友提交的是真二进制,仓库悄悄变胖 | git add .gitattributes 和首次 track 一起提交 |
| 3 | git lfs track *.psd(无引号) |
shell 展开成死文件名,新文件不生效 | 永远加引号 |
| 4 | migrate info 不加 --everything |
漏看历史,误判「没东西可迁」(实测整行消失) | 永远加 --everything |
| 5 | migrate 后不 gc |
体积不降,误以为迁移失败 | reflog expire + gc --prune=now |
| 6 | 在共享分支上直接 migrate + force push | 全组本地仓库作废 | 先在副本上练,约好时间再推 |
| 7 | 把 .meta 塞进 LFS |
Unity 资产系统受累,无收益 | 只有二进制大文件进 LFS (你已做对) |
| 8 | 把 Library/ 放进 LFS |
该 ignore 的东西进了仓库 | Library/ 进 .gitignore (你已做对) |
| 9 | 不管配额就往里推 | 超额被限制,团队推不上去 | 动手前算清存储 + 带宽 (你是内网自建,不适用) |
| 10 | 以为 LFS 会让 clone 变快 | 期待落空 | 它减的是历史包袱,不是当前文件体积 |
| 11 | 规则写 *.png 而不是 *.[pP][nN][gG] |
Mac 上没事、Linux/CI 上静默漏过,最难查 | 字符类枚举大小写,不依赖平台 |
| 12 | 小文件 / 纯文本塞进 LFS | 多一次网络往返且丢掉 diff 能力,收益为负 | 判据是「会不会被反复改」,不是「大不大」 |
| 13 | 用 grep png 检查字符类写法的 .gitattributes |
静默漏报,会误判「没配」 | 先 sed 解码字符类再比对(第 10 章有命令) |
12. 命令速查表
# ---- 环境 ----
git lfs version # 装没装
git lfs install # 每台机器一次
git lfs env # 看当前仓库的完整 LFS 配置
# ---- 日常 ----
git lfs track "*.psd" # 声明(记得加引号)
git lfs track # 列出当前所有规则
git lfs untrack "*.psd" # 取消声明
git lfs ls-files # 当前版本哪些走了 LFS ← 第一诊断命令
git lfs ls-files --all # 所有历史版本
git lfs status # 待提交的 LFS 变更
# ---- 验证 ----
git cat-file -p HEAD:文件名 # 是指针还是二进制? ← 最硬的判据
git count-objects -vH # 仓库体积
# ---- 克隆端 ----
GIT_LFS_SKIP_SMUDGE=1 git clone URL # 只拉指针,省流量
git lfs pull # 补齐真文件
git lfs fetch --recent # 只拉最近的历史版本
# ---- 治理 ----
git lfs migrate info --everything # ① 扫描
git lfs migrate import --include="*.psd" --everything # ② 迁移
git reflog expire --expire-unreachable=now --all # ③ 清理
git gc --prune=now
git lfs prune --dry-run # 先看要删什么(推荐先跑这个)
git lfs prune # 清本地缓存(不动远端,安全)
git lfs migrate export --include="*.dat" --everything # 反向:退出 LFS
# ---- 排查 .gitattributes(字符类写法用 grep 会漏报)----
grep "filter=lfs" .gitattributes | awk '{print $1}' | sed 's/\[\(.\)[^]]*\]/\1/g'
git check-attr filter -- 文件名 # 直接问 git:这个文件走不走 LFS(文件不存在也能查)
# 输出 "filter: lfs" = 走
# 输出 "filter: unspecified" = 不走
# ⚠️ 它反映【当前机器】的 core.ignorecase,Mac 上查不出 Linux 的大小写问题(见第 10 章)
# ---- Unity 协作(需服务端支持,未验证)----
git lfs lock 文件 / git lfs locks / git lfs unlock 文件
13. 自测题
按文档里方法③的规矩,一次只答一道,答完再看下一道。答不上来的回对应章节。
- 为什么已经提交过的大文件,事后
git lfs track救不回仓库体积?(→ 第 1、6 章) - 怎么用一条命令判断某个文件到底有没有走 LFS?(→ 第 3 章)
- 同事说「图片打不开,内容是三行文本」,列出两种不同成因和各自的修复。(→ 第 8 章)
git lfs migrate info不加--everything会漏掉什么?(→ 第 7 章)- migrate 跑完了,
git count-objects显示体积没变,出了什么问题?(→ 第 7 章第 3 步) - 什么情况下你会主动用
GIT_LFS_SKIP_SMUDGE=1?(→ 第 8 章) *.png这种写法在什么情况下会漏掉Logo.PNG、什么情况下不会?为什么这种「有时漏有时不漏」比「总是漏」更难查?(→ 第 10 章)- 你在 Mac 上跑
git check-attr filter -- Logo.PNG得到filter: lfs,能否据此断定这条规则在全组都没问题?(→ 第 10 章) - (针对你的仓库) 已经配了
merge=unityyamlmerge,为什么还需要lockable?两者分别在什么时机起作用?(→ 第 10 章) - 一个 500 MB 但只提交过一次的安装包,和一个 5 MB 但改了 200 版的贴图,哪个更该进 LFS?(→ 第 1 章)
14. 学习路径
30 分钟版(够用了)
| 时间 | 做什么 |
|---|---|
| 5 min | 第 1 章心智模型 + 第 2 章确认环境 |
| 10 min | 照第 3 章敲一遍,敲到 git cat-file 看见三行指针为止 |
| 10 min | 照第 6 章复现一次事故,亲眼看见 15.04 → 15.06 |
| 5 min | 扫一眼第 11 章反模式清单 |
2 小时版(要动真仓库就走这个)
| 时长 | 做什么 |
|---|---|
| 30 min | 30 分钟版全部 |
| 30 min | 第 7 章 migrate 三步法,在 /tmp 副本上完整跑通,包括 gc 后的体积对比 |
| 20 min | 第 8 章:自己 clone 两遍(正常 / skip smudge),把三种「打不开」的成因验一遍 |
| 40 min | 读第 10 章体检报告 → 在主仓库跑 git lfs prune --dry-run(零风险)→ 想清楚 lockable 要不要上(这是团队决策,不是技术决策) |
清理实验目录
rm -rf /tmp/lfs-lab
附:本文档的验证状态
| 章节 | 验证状态 |
|---|---|
| 1–8 章、11–12 章 | ✅ 本机实测(2026-08-06, git-lfs 3.7.1,/tmp/lfs-lab、/tmp/lfs-exp) |
7 章 migrate export |
✅ 实测,含「.gitattributes 追加否定行而非删除原行」这个细节 |
| 10 章 大小写跨平台差异 | ✅ 判决性实测(/tmp/lfs-audit 与 lfs-audit2,只改 core.ignorecase 一个变量) |
10 章 check-attr 及其局限 |
✅ 三个环境各测一次 |
| 10 章「体检结果」全表 | ✅ 读了你的真实仓库(只读,未修改):135 行 / 48 条规则 / 19232 文件 / 34G / 21G |
| 7 章「远端 GC」 | ⚠️ 未验证,需真实远端仓库 |
| 9 章 GitHub 配额数字 | ⚠️ 未联网核实,且对你不适用(你是内网自建 endpoint) |
| 10 章 文件锁 | ⚠️ 未验证;你的服务端是否支持锁 API 我不知道(<内网服务端IP>:10000,看着像 Gitea 但没确认) |
| 10 章 缺口 2(7 条被注释的规则) | ✅ 已查清:client/ 不存在→历史遗留;逐条追踪了 3 类目标的现状 |
| 10 章 全仓库扫描 | ✅ 10 万文件全查(check-attr --stdin,只读):19242 lfs / 80404 unspecified / 491 unset |
| 10 章 漏网文件版本数 | ⚠️ 用 git log -- 统计,未加 --follow,rename 会断;只作量级参考 |
| 10 章「.git 里 13 GB 的精确构成」 | ⚠️ 仍未精确拆解。从扫描结果推断主要是文本资产(prefab/asset/json/tsv)的历史累积, |
但没跑 migrate info --everything(24242 次提交,耗时长且属于要动主仓库的操作)。这是推断不是实测。 |
修订记录
-
v1 → v2(同日):第 10 章原本是一份「Unity
.gitattributes起步模板」。 读了真实仓库后发现前提就是错的——你早在用 LFS,且配置比模板更严谨。整章改写为体检报告。 教训:给建议前先看现状。 -
v2 → v3(同日,自审后):查出 4 类问题,其中两处是我自己写错:
- 🚨 技术论证错误:v2 写「
.gitattributes大小写敏感」——在 Mac 上实测是错的。 真相是行为随core.ignorecase而变(Mactrue/ Linuxfalse), 即「Mac 上不漏、Linux 上漏」。结论(该用字符类)没变,但理由整个换掉了, 而且真实风险比我原来写的更隐蔽——不一致比一致地错更难查。 - 🚨 「唯一缺口」不成立:v2 只查了 4 个维度就说「唯一」。补查后是 3 个缺口
(12 条规则没做大小写覆盖 / 7 条被
#注释 / 无lockable),且仍非穷尽。 - 检查方法本身出错两次:用
grep png查字符类写法的.gitattributes,静默漏报, 差点得出「他没配 png」的错误结论。已把正确方法(sed解码 +check-attr)写进正文和速查表。 - 补齐内容缺口:什么时候不该用 LFS(第 1 章)、怎么退出 LFS(第 7 章)、
.lfsconfig等三处配置(第 5 章)、GitHub 配额对你不适用(第 9 章)。
这一版的通用教训:凡是「所以你应该这样做」的技术论证,落笔前必须有一个能跑的实验支撑。 v2 里那句「大小写敏感」听上去天经地义,我就没测——结果它在写文档的这台机器上就是错的。
- 🚨 技术论证错误:v2 写「
-
v3 → v4(同日,全仓库实扫后):把 v3 里所有「⚠️ 没查」的项目真查了一遍,结果又推翻了 v3 的两处判断:
- 🚨 v3 说「52–54 行风险最高,
.WEM/.Bytes最容易出现」——实测不成立。 那 12 个扩展名一个大小写变体都没有,是隐患不是现存问题。 反倒是.FBX有 771 个真实文件,但它们被字符类规则覆盖着—— 这说明字符类写法当年是被真实需求逼出来的。 - 🚨 v3 把 7 条被注释规则整体标为「需你确认」——现在逐条查清了:
4 条目标已删除、1 条无害、只有 2 条(
mapData.config)本意未达成。 - 新增全仓库扫描结果:19242/80404/491 的归属分布、9 类漏网大文件、
以及
*.bundle规则因「后缀其实是目录名」而形同虚设这个典型坑。 - 危害重估:漏网大文件实测都只有 1 个版本,属于「一次性占用」而非「滚雪球」, 优先级从「该修」降到「值得修但不急」。
这一版的教训:「没查」和「查了没事」是两回事,前者不能当后者用。 v3 里我把没查的东西按最坏情况写成了「高风险」——听起来严谨,实际是另一种不准确。
- 🚨 v3 说「52–54 行风险最高,