1. 从一次推送失败说起:为什么你的Git仓库越来越“胖”?

不知道你有没有遇到过这种情况:某天你像往常一样,信心满满地敲下 git push,准备把代码更新到远程仓库,结果终端却弹出了一个冷冰冰的错误提示,告诉你推送失败了。仔细一看,原因竟然是“仓库体积过大,超出了平台限制”。那一刻,你是不是有点懵,心里嘀咕着:“我明明只是写代码,仓库怎么就‘胖’得推不动了呢?”

这事儿我遇到过不止一次。记得有一次,一个项目因为需要集成一些本地编译的库,团队里的小伙伴图省事,直接把编译生成的 .so 文件(Linux下的动态链接库)和 .dll 文件一股脑儿都提交了。起初大家都没在意,觉得几个文件能有多大?结果半年下来,每次编译、每次提交,这些动辄几十兆甚至上百兆的二进制文件,就像滚雪球一样,把我们的Git仓库活生生撑成了一个“大胖子”。最后,当我们想推送到远程仓库时,直接就被拒绝了,因为仓库体积已经超过了平台免费额度的一倍还多。

Git仓库的体积膨胀,很多时候就是这么“悄无声息”发生的。它不像你电脑C盘满了会弹窗提醒,它就在那里默默增长,直到某一天给你一个“惊喜”。问题的根源,往往就在于那些被误提交进版本历史的“大文件”。这里的“大文件”不一定非得是几个G的视频,对于代码仓库来说,几兆到几十兆的二进制文件(如图片压缩包、编译产物、数据集、日志文件等)就足以成为“负担”。因为Git的设计哲学是记录每一次变更,而不是记录最终状态。这意味着,一旦你把一个10MB的文件提交进去,即使你在下一次提交中删除了它,Git的历史记录里依然会保留着这个文件曾经存在过的“痕迹”。更可怕的是,如果你修改了这个文件并再次提交,Git会保存这个文件的新版本,旧版本也不会被删除。长此以往,仓库的体积自然就失控了。

所以,给Git仓库“瘦身”,核心目标不是简单地删除当前工作目录里的大文件,而是要深入到历史提交记录中,把这些大文件的“历史痕迹”彻底抹去。这就像给房间做大扫除,不仅要扔掉现在摆在明面上的垃圾,还得把床底下、柜子顶上那些积了灰的旧物也清理干净。今天我要跟你分享的,就是一把做这种“深度清洁”的利器——git-filter-repo。它比老一辈的 git filter-branch 命令更安全、更快速,是现在处理这类问题的首选工具。

2. 磨刀不误砍柴工:安装与认识 git-filter-repo

在开始动手“手术”之前,我们得先把“手术刀”准备好。git-filter-repo 并不是Git自带的命令,它是一个用Python写的、功能强大的第三方工具。它的作者就是Git开发社区里的大神,目的就是为了替代那个又慢又危险的 git filter-branch。

2.1 两种主流的安装方式

我推荐两种安装方式,你可以根据自己的习惯和系统环境来选择。

第一种,通过Python的包管理器pip安装。 这是最省心的方法,前提是你的系统已经安装了Python和pip。打开终端,一行命令搞定:

pip install git-filter-repo

安装完成后,系统会自动把 git-filter-repo 这个命令添加到你的环境变量里。你可以通过 git filter-repo --version 来验证是否安装成功。用pip安装的好处是,后续更新也特别方便。

第二种,手动安装。 如果你对Python环境不太熟悉,或者想更直接地控制,可以手动安装。首先,你需要从它的官方GitHub仓库把项目克隆下来:

git clone https://github.com/newren/git-filter-repo.git

进入克隆下来的目录,你会发现核心就是一个名叫 git-filter-repo 的Python脚本。接下来,你只需要把这个脚本放到一个系统能够找到的路径下就行了。比如,在Linux或macOS上,可以复制到 /usr/local/bin/:

sudo cp git-filter-repo /usr/local/bin/

在Windows上,你可以把它复制到 C:\Windows\System32 或者任何在PATH环境变量里的目录。手动安装后,同样可以通过 git filter-repo --help 来测试。

注意:无论哪种方式,请确保你安装的 git-filter-repo 版本比较新,因为它在持续更新,修复了很多边界情况的问题。

2.2 理解 git-filter-repo 的工作原理

在用它之前,我们稍微花点时间理解一下它在做什么,这样操作起来心里更有底。git-filter-repo 的核心工作是“重写历史”。

想象一下,Git的提交历史就像一条由无数个快照(commit)串联起来的时间线。每个快照都记录了当时整个项目所有文件的状态。git-filter-repo 要做的事情,就是沿着这条时间线,从头到尾检查每一个快照。它会根据你设定的规则(比如“删除所有路径为 output/*.so 的文件”),把不符合规则的文件从那个时间点的快照中拿掉。然后,它用清理后的内容生成一个全新的、干净的快照,并重新链接起来,形成一条全新的、没有“大文件垃圾”的历史时间线。

这个过程是破坏性的,因为它会改变每一次提交的哈希值(commit hash)。这意味着,所有基于旧历史创建的分支、标签,在本地仓库里都会失效。所以,在进行任何操作前,对整个仓库进行完整备份,是必须且唯一重要的步骤。我通常的做法是,直接把整个项目文件夹复制一份到另一个安全的位置。

3. 诊断:如何找出让仓库“发胖”的元凶?

动手清理之前,我们得先做个“体检”,精确地找出到底是哪些文件、在哪些提交里,占用了大量的空间。盲目删除可能会误伤,或者漏掉真正的“胖子”。

3.1 使用Git命令进行“体检”

Git提供了一些底层命令来帮助我们分析仓库中的对象。下面这个命令组合是我最常用的“体检套餐”,它能列出仓库中体积最大的前N个文件:

git rev-list --objects --all | grep -E `git verify-pack -v .git/objects/pack/*.idx | sort -k 3 -n | tail -10 | awk '{print$1}' | sed ':a;N;$!ba;s/\n/|/g'`

这条命令看起来有点复杂,我们拆解一下它的工作流程:

  1. git verify-pack -v .git/objects/pack/*.idx:查看Git打包文件中的详细内容,其中第三列是对象(文件或提交)的字节大小。
  2. sort -k 3 -n:按照第三列(大小)进行数字排序。
  3. tail -10:取出最大的10个对象。
  4. awk '{print$1}':提取出这些对象的SHA-1哈希值。
  5. 后面的 sed 命令把这些哈希值用竖线 | 连接起来,形成一个正则表达式模式。
  6. git rev-list --objects --all:列出所有提交中的所有对象(文件)。
  7. grep -E:用前面生成的正则表达式,过滤出那些大哈希值对应的文件路径。

执行后,你会看到类似这样的输出:

abc123def4567890...  path/to/your/large_file.zip
def789ghi0123456...  another/big/binary.dll

第一列是文件的唯一标识(哈希值),第二列就是文件在仓库中的路径。这个列表,就是我们的“通缉令”。

3.2 更直观的图形化工具(可选)

如果你更喜欢图形化界面,或者仓库历史非常复杂,也可以借助一些工具。比如 git-sizer 或者 BFG Repo-Cleaner 的预览模式,它们能以更友好的方式展示大文件分布。但就我个人经验而言,上面那条命令行在绝大多数情况下已经足够精准和高效了。关键是,它能直接给出文件路径,为我们下一步的清理操作提供了明确的“靶子”。

4. 手术:使用 git-filter-repo 执行精准清理

拿到“通缉令”后,我们就可以开始精准“手术”了。git-filter-repo 提供了多种灵活的删除方式,你可以根据实际情况选择。

4.1 删除单个特定文件

假设我们通过诊断发现,output/lib/app.so 这个文件是个历史遗留的“巨无霸”。我们要从整个Git历史中彻底抹去它的所有痕迹,命令如下:

git filter-repo --path output/lib/app.so --invert-paths --force

我们来解释一下这几个参数:

  • --path output/lib/app.so:指定我们要操作的文件路径。注意,这里的路径是相对于仓库根目录的。
  • --invert-paths:这个参数是关键,它的意思是“反选路径”。通常 --path 是用来指定要保留的路径,加上 --invert-paths 后,就变成了删除指定的路径。你可以理解为“除了这个路径,其他都保留”,而我们要删除它,所以用了反选。
  • --force:强制运行。因为 git-filter-repo 默认在非全新克隆的仓库上运行时会给出警告,使用 --force 表示我们明确知道风险并继续。

执行这个命令后,工具会开始遍历和重写历史。根据仓库大小和历史复杂程度,可能需要几秒到几分钟。完成后,你会发现本地仓库的 .git 文件夹体积明显变小了。

4.2 批量删除某一类文件

更多时候,“胖子”不止一个,而是一类。比如所有编译生成的 .so 库文件,或者所有 .zip 压缩包。这时候,我们可以使用通配符 * 来批量操作。

删除 output/lib/ 目录下所有的 .so 文件:

git filter-repo --path 'output/lib/*.so' --invert-paths --force

注意,这里的路径模式用了引号,防止Shell提前解释通配符。

删除所有扩展名为 .log 的日志文件:

git filter-repo --path '*.log' --invert-paths --force

4.3 删除整个目录及其历史

有时候,我们可能误提交了整个目录,比如 tmp/ 或者 build_output/。删除整个目录及其下所有内容的历史记录,命令同样简洁:

git filter-repo --path 'build_output/' --invert-paths --force

这个操作会确保 build_output/ 这个目录名以及其下的所有文件和子目录,从历史中彻底消失。

4.4 一个重要的特性:自动执行GC

你可能会注意到,上面的命令都没有包含 git gc(垃圾回收)。这是因为 git-filter-repo 在成功重写历史后,会自动帮你执行 git gc --auto。这是一个非常贴心的设计。GC操作会清理那些不再被任何提交引用的“松散对象”并重新打包,从而真正释放磁盘空间。所以,我们不需要再手动去跑一遍 git gc 了。

5. 善后:推送更改与修复协作环境

本地历史重写成功了,但这只是完成了“瘦身手术”的一半。因为你的本地历史已经和远程仓库的历史完全分道扬镳了,你必须用强制推送来覆盖远程仓库。

5.1 强制推送更新

使用 git push 并加上 --force 或更安全的 --force-with-lease 选项:

git push origin master --force
# 或者更推荐使用
git push origin master --force-with-lease

--force-with-lease 比单纯的 --force 更安全一些,它会在强制推送前检查远程分支是否在你上次拉取之后有其他人推送过新提交,如果有,它会拒绝推送,防止覆盖别人的工作。但在执行瘦身操作前,你应该确保自己是唯一在操作这个仓库的人,或者已经和团队充分沟通。

5.2 处理推送失败:HTTP 413 错误

在推送一个刚刚“瘦身”但之前体积巨大的仓库时,你可能会遇到一个常见的错误:

error: RPC failed; HTTP 413 curl 22 The requested URL returned error: 413

这个错误的意思是“请求实体太大”,通常是因为Git在推送时使用的HTTP缓冲区(postBuffer)默认值太小,无法一次性传输重写后仍然较大的包文件。

解决方法很简单,增大这个缓冲区设置:

git config http.postBuffer 524288000  # 设置为500MB

这个命令修改的是当前仓库的Git配置。如果还不行,可以尝试设置得更大,比如 1048576000(1GB)。

如果调整缓冲区后问题依旧,我建议你切换为SSH协议进行推送。SSH协议通常没有这种单次传输大小的限制。首先查看当前远程仓库地址:

git remote -v

如果显示的是 https:// 开头的URL,你需要到你的代码托管平台(如Gitee、GitHub)上找到项目的SSH地址(格式如 git@gitee.com:username/repo.git),然后修改远程地址:

git remote set-url origin git@gitee.com:username/your-repo.git

之后再尝试强制推送,一般都能成功。

5.3 通知并重置团队成员的本地仓库

这是最关键、最容易出问题的一步! 因为你重写了历史,所有其他克隆了这个仓库的开发者,他们本地的历史和你推送的新历史已经不兼容了。

你必须通知团队中的每一位成员:不要直接拉取(pull)! 如果他们尝试拉取,Git会报错,提示历史冲突。

正确的做法是,让每个团队成员备份他们本地未推送的修改,然后删除旧的本地仓库,重新克隆(clone) 全新的、瘦身后的仓库。这是最干净、最不容易出错的方式。

如果他们有重要的本地分支尚未合并,可以这样做:

  1. 在强制推送前,让他们将自己的特性分支推送到远程备份(如果远程历史还没被覆盖)。
  2. 或者,在强制推送后,让他们基于自己旧的本地仓库,创建一个补丁(git format-patch),然后在全新的克隆中应用这个补丁。

无论如何,清晰的沟通是避免协作灾难的基石。务必在操作前发个公告,告诉大家你要进行仓库瘦身,预计在什么时间进行强制推送,并要求大家在此时间点前提交所有代码。

6. 治本:建立预防机制,告别重复清理

手术很成功,但谁也不想隔三差五就进一次手术室。清理历史大文件是“治标”,建立良好的开发习惯和仓库规范才是“治本”。

6.1 完善 .gitignore 文件

这是第一道,也是最重要的防线。确保你的项目根目录下有一个精心维护的 .gitignore 文件。它应该包含所有不应该进入版本控制的文件模式。

对于不同的开发语言和项目类型,你可以从 GitHub 的 gitignore 模板库中找到一个很好的起点。但最重要的是,要根据你项目的实际情况进行增补。例如,对于我前面提到的那个项目,我们就应该在 .gitignore 里加入:

# 编译输出
output/
build/
*.so
*.dll
*.exe

# 依赖目录
node_modules/
vendor/

# 环境配置/敏感信息
.env
*.key
*.pem

# 系统文件
.DS_Store
Thumbs.db

# IDE配置文件
.vscode/
.idea/
*.swp

养成一个习惯:在项目初始化时,就创建好 .gitignore 文件。每当引入新的工具、框架或构建流程时,第一反应就是去更新 .gitignore。

6.2 使用 Git Hooks 进行提交前检查

.gitignore 是防御性的,而 Git Hooks 可以是主动性的。你可以编写一个“预提交钩子”(pre-commit hook),在每次执行 git commit 之前,自动检查本次提交中是否包含了过大的文件或禁止提交的文件类型。

一个简单的示例,在 .git/hooks/pre-commit (需要赋予可执行权限)中写入:

#!/bin/bash
# 检查是否有超过10MB的文件被添加
MAX_FILE_SIZE=10485760 # 10MB in bytes
large_files=$(git diff --cached --name-only | xargs ls -l 2>/dev/null | awk -v max=$MAX_FILE_SIZE '$5 > max {print $9}')

if [ -n "$large_files" ]; then
    echo "[ERROR] 提交中止:发现以下文件超过10MB,请确认是否需要提交:"
    echo "$large_files"
    exit 1
fi
exit 0

这样,当有开发者不小心试图提交一个大文件时,提交操作会被自动阻止,并给出提示。

6.3 考虑使用 Git LFS 管理必要的二进制文件

有些二进制文件,比如设计稿的PSD、Unity的Asset、数据集等,确实是项目不可或缺的一部分,必须纳入版本管理。对于这类文件,强行用 .gitignore 排除或者事后清理都不是好办法。

这时,Git Large File Storage (LFS) 就是完美的解决方案。Git LFS 的工作原理是:它用文本指针文件替换掉仓库中的大文件,而将大文件的实际内容存储在一个单独的、专门优化过的服务器上(如GitHub、Gitee都支持LFS)。在克隆和拉取时,默认只下载指针文件,需要时再按需下载大文件内容。

使用Git LFS后,你的Git仓库本体将始终保持轻量,历史记录里存储的只是小小的指针。这对于游戏开发、多媒体项目、数据科学仓库来说,几乎是必选项。初始化LFS并跟踪特定类型文件非常简单:

git lfs install  # 在当前仓库启用LFS
git lfs track "*.psd"  # 跟踪所有PSD文件
git lfs track "assets/*.bin"  # 跟踪assets目录下的bin文件
git add .gitattributes  # 提交跟踪规则文件

之后,你就像往常一样 git add 和 git commit,LFS会在背后自动处理大文件。

7. 进阶与避坑:你可能还会遇到这些问题

在实际操作中,事情很少一帆风顺。这里分享几个我踩过的坑和对应的解决方案,希望能帮你少走弯路。

7.1 清理后仓库体积没有明显变化?

有时候执行完 git-filter-repo,本地 .git 文件夹好像没小多少。这通常有几个原因:

  1. 对象未立即清理:虽然 git-filter-repo 会触发 gc --auto,但Git的GC有个保守策略,可能不会立即清理所有松散对象。你可以手动运行一个更积极的垃圾回收:
    git reflog expire --expire=now --all
    git gc --prune=now --aggressive
    
    这两条命令会立即过期所有引用日志并执行一次彻底的清理。
  2. 清理了错误的目标:再次用第3节的诊断命令检查,确认你删除的路径确实是占用空间的大头。可能真正的“元凶”是其他你没注意到的文件。
  3. 远程仓库缓存:你本地的仓库瘦身了,但远程仓库(如Gitee、GitHub)可能还有缓存或打包文件。在强制推送后,有些平台需要一段时间异步处理,或者需要你在仓库设置里手动触发“清理垃圾文件”之类的操作。

7.2 误删了文件怎么办?

这是最可怕的情况。所以,备份!备份!备份! 重要的事情说三遍。在运行任何 --force 命令前,请确保你有一份完整的仓库压缩包放在安全的地方。

如果误删发生,而你又有备份,那么恢复起来很简单,用备份覆盖即可。如果没有备份,但操作刚刚完成,本地仓库的原始对象可能还在 .git/objects/ 目录里,只是失去了引用。这时可以尝试用 git fsck --lost-found 命令寻找并恢复“悬空”的对象,但这需要较高的Git技巧,且不能保证100%恢复。因此,备份是唯一可靠的后悔药。

7.3 如何处理标签(Tags)和分支(Branches)?

git-filter-repo 默认会重写所有分支和标签,以保持它们指向新的、清理后的历史。这是一个非常好的特性,你通常不需要额外操作。

但是,如果你有大量的标签,或者某些标签指向的提交在重写后发生了巨大变化,你可能想验证一下。命令 git tag -l 可以列出所有标签。你可以检查重要标签是否还在。

对于分支,重写后,你本地的所有分支都会基于新历史。你需要用 git branch -avv 查看分支状态,并可能需要用 git push -f origin <branch_name> 来强制更新远程的每一个特性分支,而不仅仅是 master 或 main。

7.4 与其他工具(如 BFG)的对比

你可能会听到另一个工具叫 BFG Repo-Cleaner。它也是用来清理Git历史大文件的,用Java编写,在某些场景下比 git-filter-repo 更快(尤其是处理纯文件删除)。两者的主要区别在于:

  • git-filter-repo:更通用、更灵活。它不仅能删除文件,还能重写提交信息、根据复杂条件过滤等。它是Python脚本,更易于集成和扩展。目前是Git官方社区推荐的工具。
  • BFG:速度极快,但功能相对单一,主要专注于快速删除大文件。它的命令行参数更简单。

对于绝大多数“删除历史大文件”的场景,两者都能很好地完成工作。我个人更倾向于 git-filter-repo,因为它的功能更全面,文档和社区支持也更好,一次学习,可以应对更多复杂的仓库整理需求。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐