跳至内容

删除 Git 历史中的大文件

用 git-filter-repo 找出并删除历史中的大文件,完成备份、分支与标签推送、GitLab 空间回收和清理验证。

  • Git
  • git-filter-repo
  • GitLab

大文件删除后,仓库仍然很大,通常是因为旧版本还在 Git 历史中。 用 git-filter-repo 移除这些历史记录,再更新远端分支和标签,才能清理这部分占用。

本文清理的是直接提交到 Git 的普通文件,范围包括远端的所有分支和标签。先在新克隆中处理,验证通过后再更新远端:

操作流程 Text
1
备份 → 定位 → 删除 → 验证 → 推送

推送后还要重新克隆检查,确认远端也已清理。本文不处理 Git LFS 文件;GitLab 页面仍显示较大占用时,再看末尾的服务端清理说明。

准备:安装 git-filter-repo

以下命令适用于 macOS / Linux 的 bash、zsh。全程使用同一个终端,后面的命令会继续使用前面设置的变量。

如果在交互式 zsh 中粘贴含 # 注释的命令,先执行 setopt interactivecomments,让它识别注释。

先新建操作目录,保留原来的开发目录:

代码块 Shell
1
2
3
4
# 清理用的新克隆和备份都放在这里
mkdir git-large-file-cleanup
cd git-large-file-cleanup
# 结果:进入新目录,成功时没有输出。

在当前使用的 Python 环境中直接安装。使用 Conda 的话,先激活你平时使用的环境;这里不再新建环境。

代码块 Shell
1
2
3
4
5
# 安装清理工具;已经安装过可以跳过这一条
pip install git-filter-repo
# 检查 Git 能否找到并运行这个工具
git filter-repo --version
# 输出示例:a40bce548d2c

a40bce548d2c 是正常输出。git-filter-repo 2.47.0 用工具脚本的内容哈希前 12 位作为版本标识,不是当前仓库的提交 ID。见官方源码。

要看 2.47.0 这样的包版本号,用 pip show:

代码块 Shell
1
2
3
4
5
# 查看当前 Python 环境中安装的包信息
pip show git-filter-repo
# 结果节选:
# Name: git-filter-repo
# Version: 2.47.0

如果提示 git: 'filter-repo' is not a git command,说明 Git 还找不到这个工具。先确认安装成功,并保持在安装时使用的 Python 环境中。

1. 备份原始历史

克隆前先暂停团队推送、合并和自动写入任务,直到清理完成。 期间新增的提交可能被后面的强推覆盖。

第一行直接在终端执行,只替换引号里的网址,等号两边不要加空格。当前终端会保存 repo_url,后面的 "$repo_url" 原样保留即可,会自动读取这个地址。

关闭终端或新开标签页后,需要重新赋值;切换目录不影响它。

代码块 Shell
1
2
3
4
5
6
7
8
9
# 把引号中的示例网址换成你的仓库地址
repo_url='https://git.example.com/team/project.git'
# 使用刚才保存的地址,克隆到 repo-cleanup 目录
git clone "$repo_url" repo-cleanup
# 进入新克隆的仓库,后面的备份和删除都在这里执行
cd repo-cleanup
# 补齐所有远端标签
git fetch origin --tags
# 结果:得到一份包含各分支历史和标签的本地仓库。

这里需要完整克隆,不加 --depth、--single-branch 或 --filter。--tags 用于补齐标签,见 Git fetch 文档。

不必先 git switch。后面的清理会处理所有分支和标签,不是只处理当前分支。

删除前,先把这个仓库的分支、标签、提交记录和文件内容存成一个备份文件。万一删错,还能从备份中找回原来的历史。

代码块 Shell
1
2
3
4
5
6
# 生成备份;--all 包含所有分支和标签,../ 表示放在上一级目录
git bundle create ../before-cleanup.bundle --all
# 检查刚生成的备份是否有效
git bundle verify ../before-cleanup.bundle
# .git 存放提交记录和文件历史;记录它现在占用多少空间
du -sh .git
运行结果示例:节选 Text
1
2
3
# The bundle records a complete history.
# ../before-cleanup.bundle is okay
# 1.1M    .git

运行后,上一级目录会多出 before-cleanup.bundle。is okay 表示备份校验通过,请保留这份文件,它不参与后面的删除。

1.1M 是清理前 .git 目录的大小,后面会再测一次作比较。结果块开头的 # 是本文添加的标记,不是终端原始输出。

备份需要额外磁盘空间,也不包含未提交的改动、LFS 文件或 GitLab 项目设置。这些内容要另外保存。见 Git bundle 文档。

2. 定位大文件

Git 把每个不同版本的文件内容保存为一个 blob,也叫文件内容对象。下面按大小列出最多十个这样的对象,最大的排在最后:

代码块 Shell
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 列出所有分支和标签保存过的对象,包括文件的旧版本
git rev-list --objects --all |
  # 显示每个对象的类型、ID、字节数和路径
  git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
  # 只留下文件内容这一类对象,并去掉行首的 blob 字样
  sed -n 's/^blob //p' |
  # 按第二列的字节数从小到大排序
  sort -n -k2,2 |
  # 取末尾十条,也就是最大的十个对象
  tail -n 10
运行结果示例:十个对象 Text
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# f802b222a794feee695272db5a01dbc320223099 72 README.md
# 8482225b4fa43fe254bc66184a2a4c81164fe903 128 docs/guide-01.txt
# 0598b9f19d38de1b158296010fa1e9ce8a0df213 256 docs/guide-02.txt
# bd7bb27e90608bc4170d66a5fb30beec81025357 512 docs/guide-03.txt
# 83e86af5cb78ca6bb6ab41e434afeb3344afc54d 1024 docs/guide-04.txt
# 3d37c724d43e7f2d8f18fac33ca8dd8943bb49c8 2048 data/sample-01.txt
# 83ec7458215ec456bad013c09e4faf5b37d79b8a 4096 data/sample-02.txt
# ea15f0e93c0ba82922fc5190fa493d264a35927d 8192 data/sample-03.txt
# 0187201976c4e547d11fec59af685b34fe45c5f7 16384 data/sample-04.txt
# 9d869db166dd8bdc0f0bbdbd573de25fcd3f1ebb 1048576 data/large-file.bin

这是一个小型演示仓库的实际结果,不代表真实项目的文件规模。如果仓库不足十个文件内容对象,输出也会少于十行。

每行的三列分别是对象 ID、字节数、文件路径。以最后一行为例:

字段示例含义
对象 ID9d869db… 开头的完整字符串,相当于这份文件内容的编号,后面验证时要用
大小1048576 字节,即 1 MiB;这是未压缩大小
路径提示data/large-file.bin,相对于仓库根目录

同一个文件改过多次,可能会出现多个对象 ID;相同内容也可能被不同文件使用,所以列表中的路径不一定列全。

字节数是未压缩大小,不等于磁盘占用;路径是定位线索。字段定义见 cat-file 和 rev-list。

选好要删的文件后,把查到的路径和完整 ID 分别填进下面两行的单引号中。与 repo_url 一样,变量名不用改:

代码块 Shell
1
2
3
4
5
# 把引号中的内容换成要删除的文件路径
target_path='data/large-file.bin'
# 把引号中的内容换成该文件对应的完整对象 ID
blob_id='9d869db166dd8bdc0f0bbdbd573de25fcd3f1ebb'
# 结果:变量赋值成功时没有输出。

后面的 "$target_path" 会使用你填好的路径,"$blob_id" 会使用你填好的 ID,不需要每次重新替换。其中,路径用于删除,ID 只用于验证。

3. 删除文件及其旧版本

这一步会删除指定路径下的文件及其所有旧版本。如果文件仍要使用,先另存一份,并调整项目的下载或部署流程。

清理会改写提交记录,受影响的提交及后续提交会换 ID,提交和标签上的签名也会被移除。如果 CI 或文档中写死了旧提交 ID,需要一并调整。见工具限制。

文件曾改名或移动过,要在同一次命令中包含旧路径,例如在下方命令末尾追加 --path 'old/large-file.bin'。 工具不会自动追踪重命名,见路径过滤规则。

删除命令中的两个参数要一起使用:

  • 只写 --path:保留指定文件,删除其他文件的历史。
  • 同时加上 --invert-paths:删除指定文件,保留其他文件的历史。本次要用这种写法。
代码块 Shell
1
2
3
# 删除前面选好的文件,包括它的所有旧版本
git filter-repo --invert-paths --path "$target_path"
# 结果:改写本地提交记录,删除这个路径的文件内容,并整理仓库数据。

工具执行时,还会检查你是否在一份刚克隆的仓库中操作。这是为了避免你误在开发目录里运行,删掉尚未备份的工作。

如果检查不通过,先确认目录,必要时重新克隆。不要看到报错就直接加 --force。 它的意思是“跳过这项检查,继续改写历史”,不会帮你备份或恢复数据。见安全检查说明。

提示或变化含义与处理
Refusing to destructively overwrite repo history工具拒绝执行,先按上面的说明检查当前目录
Removing 'origin' remote工具删除了本地保存的远端连接配置,没有删除远端仓库;第 5 步会重新添加
origin/feature 变成 feature远端分支在本地有了对应分支,后面可以把清理后的版本推回去

移除 origin 和转换分支都是工具的正常处理步骤。

4. 验证清理结果

代码块 Shell
1
2
3
# 查找这个文件的提交记录;--all 表示把所有分支和标签都查一遍
git log --all -- "$target_path"
# 预期结果:没有输出。

如果仍列出提交,先别推送,检查是否删对了路径。

删除时指定过旧路径,也要逐个查。下面换成自己的旧路径;没有则跳过:

代码块 Shell
1
2
3
# 检查改名或移动前的文件路径
git log --all -- 'old/large-file.bin'
# 预期结果:没有输出。
代码块 Shell
1
2
# 用第 2 步记录的 ID 检查旧文件内容是否还在
git cat-file -t "$blob_id"
运行结果:旧对象已移除 Text
1
# fatal: git cat-file: could not get object info

确认 ID 没抄错后,这里的 fatal 表示旧对象已找不到,符合预期。若输出 blob,说明内容还在,可能另一个路径也存了相同内容。

删过多个大对象就逐个检查,或重跑第 2 步查看剩余对象。

代码块 Shell
1
2
# 与备份时记录的 .git 大小比较
du -sh .git
运行结果示例 Text
1
# 144K    .git

演示仓库从 1.1M 降到 144K,实际降幅取决于历史版本、压缩方式和剩余内容。

工具会自动整理数据,通常不用再运行 git gc。推送前还要确认保留的分支能正常构建、部署,未因删文件缺少必要资源。

5. 推送并复核远端

代码块 Shell
1
2
3
4
5
# 让当前仓库重新连接原来的远端
git remote add origin "$repo_url"
# 列出清理后保留下来的本地分支和标签
git branch
git tag
运行结果示例 Text
1
2
3
#   feature
# * main
# v1.0

feature、main 是分支,* 标记当前分支,v1.0 是标签。只包含被删内容的分支或标签可能已消失。

下面的推送会覆盖远端历史,还会删除远端有、本地没有的分支和标签。

确认最初是完整克隆、备份校验通过,且远端没有新增提交。预览不会锁住远端,团队仍须暂停写入。

GitLab 项目还需管理员检查保护设置:

  • 要改写 main 等受保护分支:临时开启“允许强制推送”。
  • 要删除受保护分支,或更新、删除受保护标签:先确认具体名称,再临时取消这些分支或标签的保护。

清理完成后恢复原来的保护设置。见 GitLab 分支保护和标签保护。

代码块 Shell
1
2
3
4
5
# --dry-run 只预览,--force 允许覆盖旧历史
# --prune 删除远端有、本地清理后已没有的分支和标签
# 最后一行把推送范围限定为分支和标签
git push --dry-run --force --prune origin \
  'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
运行结果示例:节选 Text
1
2
3
# + f23969a...7c7bb37 feature -> feature (forced update)
# + b342a4b...2f3dad7 main -> main (forced update)
# + 2b9dce1...cdd117c v1.0 -> v1.0 (forced update)

(forced update) 表示强制更新,[deleted] 表示删除。逐条核对,出现意料之外的条目先停下。

--prune 会同步删除这些已消失的分支或标签,防止它们在远端继续保留旧文件。见 Git push 文档。

确认预览范围正确后,去掉 --dry-run 正式执行:

代码块 Shell
1
2
3
4
# 实际覆盖远端分支和标签,并删除远端多余的分支和标签
git push --force --prune origin \
  'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
# 结果:实际更新远端,继续核对每条更新、删除和拒绝信息。

出现 [rejected]、[remote rejected] 或 failed to push,说明推送未全部成功。保持暂停写入,按报错解决权限或保护设置问题后重试。

不要改用 --mirror,它还会同步分支、标签之外的其他 Git 记录。

重新克隆,验证远端历史

推送全部成功后,重新克隆检查:

代码块 Shell
1
2
3
4
5
6
7
8
9
# 回到最初的操作目录,保留 repo-cleanup
cd ..
# 从远端重新获取一份仓库
git clone "$repo_url" repo-verify
cd repo-verify
# 补齐标签,再查一次这个文件的提交记录
git fetch origin --tags
git log --all -- "$target_path"
# 预期结果:最后一条命令没有输出。

在新克隆中重复第 4 步:查删过的旧路径、对象 ID 和 .git 大小。通过后说明重新下载的仓库已清理,但服务器可能还有待回收的旧数据。

GitLab 的服务端空间回收(按需)

新克隆已清理,但 GitLab 占用仍较大时,再考虑服务端清理:合并请求可能还关联着旧提交,或旧数据尚未回收。

服务端清理可能让部分合并请求提交、流水线和变更详情不可用。

项目若有 fork 关系,可能与其他仓库共用数据,先让管理员确认能否回收。见清理说明和 fork 限制。

commit-map 是工具生成的新旧提交 ID 对照表,GitLab 用它找到旧提交。确认可以清理后,在当前的 repo-verify 目录执行:

代码块 Shell
1
2
3
# 复制新旧提交 ID 对照表,稍后上传到 GitLab
cp ../repo-cleanup/.git/filter-repo/commit-map ../cleanup-commit-map.txt
# 结果:上一级目录多出 cleanup-commit-map.txt,成功时没有输出。

接着按 GitLab 官方流程操作:

  1. 推送完成后至少等 30 分钟。
  2. 由维护者进入 Settings → Repository → Repository maintenance。
  3. 上传前面保存的 cleanup-commit-map.txt,执行 Start cleanup。
  4. 查看完成通知,再核对仓库大小。

自建 GitLab 的入口和回收策略以当前实例版本为准。

恢复协作

协作者先保存未提交、未推送的工作,再重新克隆并迁移改动。检查旧合并请求和 CI 副本,避免带回旧历史,再恢复正常写入。

仍需使用的大文件可放到对象存储或 Git LFS,记录获取方式与版本;用 .gitignore 防止误提交,但它不能清除已有历史。

Git LFS 的实体文件需要单独管理。如果涉及密码、密钥泄露,还要撤销凭据并处理平台缓存,不能只做仓库瘦身。

评论

使用 GitHub 登录后参与讨论,评论将公开保存在 GitHub。

阅读到这里时加载评论。