记一次Git仓库同步时大小超限问题的解决
最近我尝试了将一个Git仓库上传到另一个Git服务器,本来是挺简单的一个步骤,但是遇到了一个大小超限的问题,搞得很麻烦:
1remote: fatal: pack exceeds maximum allowed size 2error: remote unpack failed: unpack-objects abnormal exit
解决过程很是繁琐,涉及到了Git的一些高级应用,在这里记录一下。
TL;DR
本文是流水账形式,主要涉及到的技术点如下,对具体过程不感兴趣的只看此节即可:
问题
Git仓库中单个Commit中提交了多个视频文件(7 * 10MB),在原来的Git服务中正常提交,在新的Git服务器上限制了50MB,所以提交不上去。
解决方案
解决方案笼统地来说是分批上传,操作包括:
- 分批push,解决一次性推送Commit过多导致的超大问题
- 拆分commit,解决单个commit体积过大导致的超大问题
- 定位到问题commit(du + git log,或 git bisect + git branch + git push)
- 获取commit内部详情 (git cat-file 或 git ls-tree)
- 截取commit内部分内容,生成新的tree(git mktree)
- 根据tree生成commit (git commit-tree)
- 推送commit & Merge 解决单次commit过大的问题
具体过程
问题产生
因为工作需要,要将一个Git仓库同步到公司的另外一个Git服务器上。
这个事情一般来说是比较简单的,涉及到的示例代码如下:
1git remote rename origin upstream 2git remote add origin URL_TO_GITHUB_REPO 3git push origin master
但是在具体执行过程中,最后一步git push
时,发生了错误:
1remote: fatal: pack exceeds maximum allowed size 2error: remote unpack failed: unpack-objects abnormal exit
原因剖析
从报错中可以看出,是pack的大小超出了限制。
那首先要理解pack是什么东西,以及它的使用流程。
图片来自https://stefan.saasen.me/articles/git-clone-in-haskell-from-the-bottom-up/
Git在push操作中,是先将要推送的内容打包成一个pack,再调用send-pack
传送到服务器上,服务器上使用receive-pack
处理客户端发送上来的pack。
客户端和服务端中都可以对pack文件的大小做出限制,上面的报错是因为pack文件超出了服务端允许的最大值。
什么原因会导致pack文件超大呢?因为Git是在推送时将所有需要的内容(根据Commit决定)一次打包,然后上传的。所以当commit数量很多时(比如第一次同步仓库时)或者commit中包含的文件内容很多时(如带有视频文件等),就可能会导致pack超大。
解决思路
分批推送
Stackoverflow上有一个相关回答,它提到了一种解决思路,将Commit分批提交。
核心代码为:
1git push remoteB <some previous commit on master>:master 2... 3git push remoteB <some previous commit after the last one>:master 4git push remoteB master
可以按如下的方式来理解:
从原来的一把梭推送:
换成了多次推送:
图中是用的创建tag的方式,不过tag并不是必须的,可以按示例代码中的方式来操作:
1git push origin <commit id>:refs/heads/<分支名>
这种方式简单易操作,而且StackOverflow中还有现成的脚本,应该能解决大多数类似的问题了。
然而我遇到的问题并不能用这种方式解决,因为它存在单条超大Commit。
好在这个超大Commit是因为存在多个视频文件,而非一个独立大文件,所以还是有可操作空间的。
我在解决单条超大Commit时有两种思路:
- 重写历史,将此Commit拆分成多个小Commit(如每个文件拆分成一个commit)
- 拆分上传,从更细的粒度,拆分上传此Commit,不重写历史。
重写历史
重写历史是为了将大的commit拆分成多个小的commit,即从一个Commit中上传了7个文件,每个文件10MB,改成每一个或多个文件一个Commit,使每一个Commit的大小都小于最大限制。
可以使用的工具是git-filter-branch
,也可以考虑使用bfg-repo-cleaner。
它的好处是步骤相对来说稍简单一些,也容易理解。坏处是修改了Git仓库的历史,在很多项目中可能是不可接受的。
出于不想修改历史的原因,我并没有采用这个方案,而是尝试通过拆分上传解决这个问题。
拆分上传
Git存储的粒度是各种各样的Blob,存储在 .git/objects
目录中。虽然上面提到的7个视频文件都在同一个commit之中,但是它们各自有自己的blob对象,那么是否能基于这一点,分批上传Blob对象,最终使得git push
时需要推送的内容减少呢?
原理图如下:
我做了一次尝试,首先通过git ls-tree
或 git cat-file
命令查找超大Commit中包含的blob对象。
git ls-tree <commit-id> path/to/files
可以找到类似如下的结果:
1100644 blob 9687f4c6202de35256ak89sc1381497ec18c29fc video/1.mp4 2100644 blob 5ffc1e655702a4898sakfkk90a116a37c819d684 video/2.mp4 3100644 blob a2b785a998223e0918138akkfjla965b36fcb762 video/3.mp4 4100644 blob dea68deeae131022kkf4a3eef7f5c89ab827d4de video/4.mp4 5100644 blob ee5ff963d697571c4366a20f00d532937e3b4f82 video/5.mp4 6100644 blob 6356akk0099aa5b7a11c1055118c351691f0927b video/6.mp4 7100644 blob 55f62d2737c83dd9dc0987f7123kaka09j18baaf video/7.mp4
那么如何将这些blob分批上传呢?
我的想法是分别为每个(或一组)blob创建一个commit,要求其中包含的blob对象小于服务端的pack限制,然后为这些commit创建tag或分支,然后推送到远程服务器。
具体操作上来说,是用类似下面的脚本:
1git ls-tree <commit-id>|head -n 3|git mktree
这个命令是过滤了前3个文件,并将它们传递给git mktree
命令,生成了只包含前3个文件的一个tree。
此时控制台中会输出这个tree的id信息。
然后再使用git commit-tree
命令将其提交成一个commit:
1git commit-tree <上条命令中返回的tree id> -p <parent commit id> -m 'commit message'
这样就会再生成一条commit。
之后再基于这个commit做分支或tag,然后推送到服务端就好了。
理论是很好的,但是实际操作起来,发现并没有解决掉这个问题。即使先上传了部分的object,下次再回到原来的分支git push
时,还是会出现相同的问题。
查了一圈资料,原因是在于git push
时,计算要推送的内容是基于commit维护的,而不是基于objects维度。所以虽然超大commit中包含的部分object已经存在于服务器上,git push的时候还是会把它们打包进去。
这就很尴尬!!!
曲线救国
在知道git push
时pack的最小维度是commit之后,问题就又回到了拆分commit上来了。
但是我还是不太想重写历史,有没有办法曲线救国呢?
这里我想到的办法是这样:
- 首先按上一步的做法,使用超大Commit内部的部分Object创造出一个或多个新的Commit
- 基于上一步创建出的commit,新建一个分支 tmp
- 将超大Commit所在的分支合并到此新分支中(如 master -> tmp)
- 将新分支推送到远程(git push origin tmp)
- 将原分支推送到远程 (git push origin master)
- 删除临时分支 (git push origin -d tmp)
它的原理如下图:
tmp分支第一个新的Commit中包含了4个objects,它可以直接push,没有超过大小限制。然后再merge一次之后,产生了一个merge commit,它中间包含着剩余的3个object,也没有超出限制。待Merge Commit推送上去之后,Git会认为超大Commit已经在远程服务器中存在,因此再重新推送master分支时,将不再打包超大commit中的内容,也就曲线地将超大commit推送了上去。
这种方式的好处是没有重写master分支的历史,操作完成后删除掉临时的tmp分支即可。
新的尝试
在上面的方案成功之后,我开始思考能否使用更简便的方式来实现上述操作。
之前使用git mktree
和git commit-tree
来生成commit的操作,是想直接利用git仓库中已有的object,那如果使用git add && git commit
的方式提交部分文件,替代git mktree && git commit-tree
的方式,能否实现相同的目的呢?
这里我在服务端新建了一个空白仓库,重新复现问题。然后执行下面的操作:
- 添加新的远程仓库:git remote add test-upstream <git仓库地址>
- 在master分支中执行 git push -u test-upstream master,返回失败信息
- 基于超大Commit之前的一条Commit,新建分支 tmp
- 使用 git push -u test-upstream tmp
- 在tmp分支的基础上,git add 超大分支中的部分文件,然后commit & push
- 在 tmp 分支上执行 git merge master,合并超大Commit
- 再次git push -u test-upstream tmp
- 此时切换到master分支,再将执行 git push -u test-upstream master,成功
原因分析
为什么直接git add 部分文件 && git commit
也能起到和git mktree && git commit-tree
相同的效果呢?
前者并没有使用原来的object id,而后者是直接用的原来的object-id的。
这里我查了下,是因为git hash-object
命令,对于同样位置中的相同的文件,它总能生成相同的 objectId,所以这里只要保证文件路径和文件内容不变,就可以直接复用之前的objectId,不必使用git mktree
和git commit-tree
人为同步。
相关算法为:
1Commit Hash (SHA1) = SHA1("blob " + <size_of_file> + "\0" + <contents_of_file>)
参见StackOverflow中的相关问题。
总结
在同步Git仓库时,如果遇到了大小超限的问题,可以分批上传、分拆上传等方式来解决。
深入理解Git的内部原理,有助于解决此类复杂问题,如非必要,应尽量避免重写历史。