Git 有很多很棒的功能,但是其中一个特性会导致问题,git clone 会下载整个项目的历史,包括每一个文件的每一个版本。 如果所有的东西都是源代码那么这很好,因为 Git 被高度优化来有效地存储这种数据。 然而,如果某个人在之前向项目添加了一个大小特别大的文件,即使你将这个文件从项目中移除了,每次克隆还是都要强制的下载这个大文件。 之所以会产生这个问题,是因为这个文件在历史中是存在的,它会永远在那里。

当你迁移 Subversion 或 Perforce 仓库到 Git 的时候,这会是一个严重的问题。 因为这些版本控制系统并不下载所有的历史文件,所以这种文件所带来的问题比较少。 如果你从其他的版本控制系统迁移到 Git 时发现仓库比预期的大得多,那么你就需要找到并移除这些大文件。

警告:这个操作对提交历史的修改是破坏性的。 它会从你必须修改或移除一个大文件引用最早的树对象开始重写每一次提交。 如果你在导入仓库后,在任何人开始基于这些提交工作前执行这个操作,那么将不会有任何问题——否则, 你必须通知所有的贡献者他们需要将他们的成果变基到你的新提交上

为了演示,我们将添加一个大文件到测试仓库中,并在下一次提交中删除它,现在我们需要找到它,并将它从仓库中永久删除。 首先,添加一个大文件到仓库中:

1
2
3
4
5
6
7
8
9
10
11
12
13
cd ~ && rm -rf ~/gitBigObjTest

git init gitBigObjTest && cd gitBigObjTest

curl https://mirrors.edge.kernel.org/pub/software/scm/git/git-1.8.5.1.tar.gz > git.tgz

git add git.tgz

git commit -m 'add git tarball'

[master(根提交) 49af15f] add git tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 git.tgz

哎呀——其实这个项目并不需要这个巨大的压缩文件。 现在我们将它移除:

1
2
3
4
5
6
7
git rm git.tgz

git commit -m 'oops - removed large tarball'

[master 9448928] oops - removed large tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 git.tgz

现在,我们执行 gc 来查看数据库占用了多少空间:

1
2
3
4
5
6
7
8
git gc

枚举对象中: 5, 完成.
对象计数中: 100% (5/5), 完成.
使用 16 个线程进行压缩
压缩对象中: 100% (3/3), 完成.
写入对象中: 100% (5/5), 完成.
总共 5(差异 0),复用 0(差异 0),包复用 0

你也可以执行 count-objects 命令来快速的查看占用空间大小:

1
2
3
4
5
6
7
8
9
10
git count-objects -v

count: 0
size: 0
in-pack: 5
packs: 1
size-pack: 4645
prune-packable: 0
garbage: 0
size-garbage: 0

size-pack 的数值指的是你的包文件以 KB 为单位计算的大小,所以你大约占用了 5MB 的空间。 在最后一次提交前,使用了不到 2KB ——显然,从之前的提交中移除文件并不能从历史中移除它。 每一次有人克隆这个仓库时,他们将必须克隆所有的 5MB 来获得这个微型项目,只因为你意外地添加了一个大文件。 现在来让我们彻底的移除这个文件。

查找大文件

首先你必须找到它。 在本例中,你已经知道是哪个文件了。 但是假设你不知道;该如何找出哪个文件或哪些文件占用了如此多的空间? 如果你执行 git gc 命令,所有的对象将被放入一个包文件中,你可以通过运行 git verify-pack 命令, 然后对输出内容的第三列(即文件大小)进行排序,从而找出这个大文件。 你也可以将这个命令的执行结果通过管道传送给 tail 命令,因为你只需要找到列在最后的几个大对象。

1
git rev-list --objects --all | grep "$(git verify-pack -v .git/objects/pack/*.idx | sort -k 3 -n | tail -5 | awk '{print$1}')"
1
2
3
4
33385338edecc08e6c1b3dd3b754a31b2c9f3598
bf227d7632026b291b8815d1fa795a1e87dfa2ce
abd739d6b0e81d09e81308b2e4e9fc66da405eff 
f09c45e90367235df753e99d1917891b9572dd9e git.tgz

你可以看到这个大对象出现在返回结果的最底部:占用 5MB 空间。 为了找出具体是哪个文件,可以使用 rev-list 命令,我们在 指定特殊的提交信息格式 中曾提到过。 如果你传递 --objects 参数给 rev-list 命令,它就会列出所有提交的 SHA-1、数据对象的 SHA-1 和与它们相关联的文件路径。 可以使用以下命令来找出你的数据对象的名字:

定位问题提交

现在,你只需要从过去所有的树中移除这个文件。 使用以下命令可以轻松地查看哪些提交对这个文件产生改动:

1
2
3
git log --oneline --branches -- git.tgz
9448928 (HEAD -> master) oops - removed large tarball
49af15f add git tarball

移除文件

现在,你必须重写 49af 提交之后的所有提交来从 Git 历史中完全移除这个文件。 为了执行这个操作,我们要使用 filter-branch 命令,这个命令在 重写历史 中也使用过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
git filter-branch --index-filter \

  'git rm --ignore-unmatch --cached git.tgz' -- --all         

WARNING: git-filter-branch has a glut of gotchas generating mangled history

rewrites.  Hit Ctrl-C before proceeding to abort, then use an

alternative filtering tool such as 'git filter-repo'

(https://github.com/newren/git-filter-repo/) instead.  See the

filter-branch manual page for more details; to squelch this warning,

set FILTER_BRANCH_SQUELCH_WARNING=1.

Proceeding with filter-branch...


Rewrite bf227d7632026b291b8815d1fa795a1e87dfa2ce (1/2) (0 seconds passed, remaining 0 predicted)    rm 'git.tgz'

Rewrite 33385338edecc08e6c1b3dd3b754a31b2c9f3598 (2/2) (0 seconds passed, remaining 0 predicted)    

Ref 'refs/heads/master' was rewritten

--index-filter 选项类似于在 重写历史 中提到的的 --tree-filter 选项, 不过这个选项并不会让命令将修改在硬盘上检出的文件,而只是修改在暂存区或索引中的文件。

你必须使用 git rm --cached 命令来移除文件,而不是通过类似 rm file 的命令——因为你需要从索引中移除它,而不是磁盘中。 还有一个原因是速度—— Git 在运行过滤器时,并不会检出每个修订版本到磁盘中,所以这个过程会非常快。 如果愿意的话,你也可以通过 --tree-filter 选项来完成同样的任务。 git rm 命令的 --ignore-unmatch 选项告诉命令:如果尝试删除的模式不存在时,不提示错误。

你的历史中将不再包含对那个文件的引用。 不过,你的引用日志和你在 .git/refs/original 通过 filter-branch 选项添加的新引用中还存有对这个文件的引用,所以你必须移除它们然后重新打包数据库。 在重新打包前需要移除任何包含指向那些旧提交的指针的文件:

通过上面的例子,可以看出命令git filter-branch通过针对不同的过滤器提供可执行脚本,从不同的角度对Git版本库进行重构。该命令的用法:

1
2
3
4
5
6
7
git filter-branch [--env-filter <command>] [--tree-filter <command>]
[--index-filter <command>] [--parent-filter <command>]
[--msg-filter <command>] [--commit-filter <command>]
[--tag-name-filter <command>] [--subdirectory-filter <directory>]
[--prune-empty]
[--original <namespace>] [-d <directory>] [-f | --force]
[--] [<rev-list options>...]

这条命令异常复杂,但是大部分参数是用于提供不同的接口,因此还是比较好理解的。

  • 该命令最后的<rev-list>参数提供要修改的版本范围,如果省略则相当于HEAD指向的当前分支。也可以使用–all来指代所有引用,但是要在–all和前面的参数间使用分隔符–。

彻底删除历史

  • 运行git filter-branch命令改写分支之后,被改写的分支会在refs/original中对原始引用做备份。对于在refs/original中已有备份的情况下,该命令拒绝执行,除非使用-f或–force参数。
  • 其他需要接以的参数都为git filter-branch提供相应的接口进行过虑,在下面会针对各个过滤器进行介绍。
1
2
3
4
5
6
7
8
9
$ rm -Rf .git/refs/original
$ rm -Rf .git/logs/
$ git gc
枚举对象中: 3, 完成.
对象计数中: 100% (3/3), 完成.
使用 16 个线程进行压缩
压缩对象中: 100% (2/2), 完成.
写入对象中: 100% (3/3), 完成.
总共 3(差异 1),复用 1(差异 0),包复用 0

让我们看看你省了多少空间。

1
2
3
4
5
6
7
8
9
$ git count-objects -v
count: 4
size: 4656
in-pack: 3
packs: 1
size-pack: 1
prune-packable: 0
garbage: 0
size-garbage: 0

打包的仓库大小下降到了 8K,比 5MB 好很多。 可以从 size 的值看出,这个大文件还在你的松散对象中,并没有消失;但是它不会在推送或接下来的克隆中出现,这才是最重要的。 如果真的想要删除它,可以通过有 --expire 选项的 git prune 命令来完全地移除那个对象:

1
2
3
4
5
6
7
8
9
10
$ git prune --expire now
$ git count-objects -v
count: 0
size: 0
in-pack: 3
packs: 1
size-pack: 1
prune-packable: 0
garbage: 0
size-garbage: 0

让远程仓库变小

1
git push origin --force --all

因为不是 fast forward,所以需要指定 --force 参数。

这里的 --all 会将所有分支都推送到 origin 上。当然你也可以只推送 master 分支: git push origin master --force。但是!如果其它远程分支有那些大文件提交的话,仍然没有瘦身!

参考

https://www.worldhello.net/gotgit/06-migrate/050-git-to-git.html