yaml
title: 记一次已推送仓库启用 Git LFS 的完整迁移与验证过程
type: knowledge
status: evergreen
tags:
- Git
- Git LFS
- 仓库治理
- 版本控制
created: 2026-03-24
updated: 2026-03-24
aliases: []
记一次已推送仓库启用 Git LFS 的完整迁移与验证过程
摘要
这篇笔记记录了一次已推送仓库启用 Git LFS 的完整迁移过程:一个已经推送到 GitHub 的 Obsidian 中文知识库,因为图片附件较多而开始考虑 Git LFS。重点在于梳理历史重写、远端覆盖和 force push 风险之间的取舍,以及如何验证迁移结果已经落实到远端历史。
背景
这次处理的对象不是代码构建项目,而是一个已经推送到 GitHub 的 Obsidian 中文知识库。随着内容持续积累,仓库体积越来越大,压力主要集中在 99_assets/ 里的图片附件上。
如果这是一个多人协作、分支频繁交错的仓库,我不会轻易对已经推送的历史做重写;但这次仓库主要由我单人使用,协作边界清晰,因此可以接受改写历史和后续 force push 的成本。本文按评估、迁移、验证、收尾四个阶段展开,便于后续复用这条操作链路。
先评估,而不是直接改
这次没有直接执行 git lfs track 或 git lfs migrate import,而是先确认仓库现状,再判断是否值得做历史迁移。迁移前的几个关键事实如下:
- 仓库总大小约为
59M .git中的 pack 大小约为25.24 MiB99_assets/下约有205个文件- 最大的图片文件大多落在约
0.4--1.3 MB这个区间 - 仓库当时没有
.gitattributes - 仓库当时还没有使用 Git LFS
评估命令如下。这里沿用命令输出中的单位表示;59M、25.24 MiB、0.4--1.3 MB 分别来自不同命令的输出口径,仅用于描述量级:
bash
du -sh "/vault"
git -C "/vault" count-objects -vH
find "/vault/99_assets" -type f | wc -l
find "/vault/99_assets" -type f -printf '%s %p\n' | sort -nr | head
ls -l "/vault/.gitattributes"
git lfs ls-files
这些观测说明:仓库总体量级不算夸张,但 99_assets/ 已有 205 个文件,附件规模已经稳定;同时仓库中没有 .gitattributes,git lfs ls-files 也没有输出,说明当时尚未启用 Git LFS。是否值得做历史迁移,就基于这几个事实判断。
方案选择:为什么最后选"迁移历史"
在真正动手之前,我先比较了三种可选路径:
| 方案 | 优点 | 代价 | 最终结论 |
|---|---|---|---|
| 只对未来新增文件启用 LFS | 改动最小,不需要重写已有历史 | 旧历史里的大文件仍然保留在 Git 对象中,历史体积问题不会真正缓解 | 否 |
| 将现有历史一起迁移到 LFS | 未来新增文件和历史中的二进制资产都能统一管理 | 需要重写历史,并承担 force push 和本地旧引用清理成本 | 是 |
| 暂不处理 | 当前没有任何操作风险 | 仓库会继续按原方式增长,问题不会消失 | 否 |
最后选择第 2 种,不只是为了控制未来增长,更是为了统一历史中的二进制资产管理方式。如果只对未来新增文件启用 LFS,那么旧提交里的图片仍然会继续留在原有 Git 历史里,仓库的历史负担并没有真正解决。
这次之所以能接受"迁移现有历史"这一成本更高的方案,也和仓库的使用边界直接相关:它主要由单人维护,历史重写带来的协作成本相对可控,不会波及一个已经稳定协作的多人团队。在这个前提下,历史迁移的收益高于 force push 的代价,因此最终决定把 *.png、*.jpg、*.jpeg、*.gif、*.webp 和 *.pdf 一并纳入 Git LFS 管理。涉及历史改写和后续回滚时,我也会联想到 一次完整的 Git 提交撤销与代码恢复经历 与 记一次严重的 Git 分支提交错误与修复复盘 里那些"先保留恢复抓手,再动历史"的原则。
实施前保护:为什么要做双重备份
这里说明的是迁移前的备份策略:本地备份 tag 用于快速回到迁移前状态,bundle 备份用于保留一份独立于当前仓库的完整历史快照。
这次使用的标识分别是 backup/pre-lfs-migration-2026-03-24 和 /vault-pre-lfs-2026-03-24.bundle。
lua
git -C "/vault" tag "backup/pre-lfs-migration-2026-03-24"
git -C "/vault" bundle create "/vault-pre-lfs-2026-03-24.bundle" --all
关键点只有一个:单独的 tag 会继续钉住旧历史,bundle 才是独立于当前仓库的最终回滚点。即使后面执行了引用清理、reflog expire 或 git gc --prune=now,只要 bundle 还在,仍然可以脱离当前仓库的引用状态进行恢复。涉及"回滚点应该放在哪里"这个问题时,如何从 Git 提交中恢复被删除的文件 里的恢复思路同样适用。
实施过程:真实步骤与中途问题
首次失败暴露了一个明确前置条件:git lfs migrate import 要求工作区必须是干净的。
先执行 git lfs track,生成 .gitattributes 并写入需要跟踪的文件类型规则:
arduino
git -C "/vault" lfs track "*.png" "*.jpg" "*.jpeg" "*.gif" "*.webp" "*.pdf"
执行后,工作区出现了一个未提交的新文件 .gitattributes。第一次执行历史迁移时,命令直接失败,关键输出如下:
sql
override changes in your working copy? All uncommitted changes will be lost! [y/N]
working copy must not be dirty
根因很明确:工作区不干净,未提交的 .gitattributes 阻塞了 git lfs migrate import --everything。
失败后,为了恢复 clean working tree,本次实际执行的命令链是先再次创建 bundle 备份,再删除工作区中未提交的 .gitattributes。这里没有重做 tag,因为迁移前创建的 tag 已经存在,继续创建同名 tag 没有额外意义。
lua
git -C "/vault" bundle create "/vault-pre-lfs-2026-03-24.bundle" --all
rm "/vault/.gitattributes"
工作区恢复干净后,再次执行历史迁移:
python
git -C "/vault" lfs migrate import --everything --include="*.png,*.jpg,*.jpeg,*.gif,*.webp,*.pdf" --object-map="/vault/.git/lfs-migrate-object-map.csv"
git -C "/vault" push --force-with-lease origin main
这一次迁移成功,随后把改写后的 main 强推到远端;实际使用的是 git push --force-with-lease origin main 这一更安全的形式。这个失败过程也暴露了一个容易忽略的前置条件:对已推送仓库做 LFS 历史迁移时,除了考虑 记一次严重的 Git 分支提交错误与修复复盘 中那类历史改写风险,还必须先确保工作区没有未提交阻塞项,否则命令会在真正开始改写前停止。
如何验证迁移真的生效
本地迁移命令成功,并不等于远端历史已经切换完成。验证至少要覆盖四件事:本地已跟踪文件数量、远端规则是否存在、远端文件内容是否已变成 pointer、本地与远端分支状态是否一致。
首先,git lfs ls-files | wc -l 的结果是 205:
bash
git -C "/vault" lfs ls-files | wc -l
其次,用 git show origin/main:.gitattributes 可以直接看到远端 main 上已经存在 6 条 LFS 规则:
bash
git -C "/vault" show origin/main:.gitattributes
还需要再抽样检查一张远端图片文件:
bash
git -C "/vault" show "origin/main:99_assets/ElasticSearch 7.6.0 学习笔记/16fc2485335af34de3b8030383c480a8.png" | head -n 3
输出是标准的 LFS pointer:
arduino
version https://git-lfs.github.com/spec/v1
oid sha256:54f2710cdf4c130a030b243514d9f7164e5302bf57e18acaa7c285fa0004cf9c
size 48742
出现这种 pointer 形式,才能证明远端存放的已经不是原始二进制内容,而是由 Git LFS 接管后的指针对象。
最后,检查分支状态是否已经收敛:
css
git -C "/vault" status --short --branch
git -C "/vault" rev-list --left-right --count origin/main...main
这两条命令的实际结果分别是 ## main...origin/main 和 0 0,说明本地 main 与远端 origin/main 已经没有领先或落后差异。结合前面的检查,可以判断这次迁移已经落实到远端历史。
收尾:为什么还要清理本地旧历史
历史迁移完成后,并不意味着旧对象会立刻自动从本地仓库消失。只要旧引用还在,或者 reflog 里仍然能追到迁移前的对象,Git 就没有理由立刻回收这些内容。因此收尾阶段仍然很重要。
这次收尾动作包括:删除本地备份 tag、删除迁移产生的对象映射文件、保留 bundle 备份,并通过 reflog expire 和 git gc --prune=now 触发旧引用回收:
lua
git -C "/vault" tag -d "backup/pre-lfs-migration-2026-03-24"
rm "/vault/.git/lfs-migrate-object-map.csv"
git -C "/vault" reflog expire --expire=now --all
git -C "/vault" gc --prune=now
这里保留 bundle、删除本地备份 tag,是为了不再让 tag 继续钉住旧历史,同时继续保留一个独立于当前仓库的最终回滚点。
清理前后,.git pack 大小从 25.33 MiB 下降到了 852.13 KiB。这也说明一件事:LFS 历史迁移真正想获得的本地收益,不只是"规则写进去了",而是后续还要把旧引用清理掉,否则旧 pack 体积不会自然消失。
经验总结
这次流程最值得记住的,是几条风险控制原则。
第一,对已推送仓库做历史重写之前,必须先确认协作边界。单人维护的仓库和多人并行开发的仓库,能承受的代价完全不同。
第二,最终回滚点应尽量独立于当前仓库的内部引用体系,因此 bundle 比单纯的 tag 更可靠。
第三,本地迁移成功不等于远端已经切换完成;只有规则、pointer 和分支状态都验证通过,迁移才算真正落地。
第四,git lfs migrate import 对工作区洁净度有严格要求。未提交的 .gitattributes 就足以阻塞整个迁移流程。
这次流程可复用的最小 checklist
- 评估现状
- 创建 tag 与 bundle 备份
- 确认 working tree clean 后执行迁移
- 验证 LFS 规则、pointer 与分支状态
- 清理本地旧引用并保留 bundle