logo

鱼肚的博客

Don't Repeat Yourself

记一次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是什么东西,以及它的使用流程。

Pack commands on client and server

图片来自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

可以按如下的方式来理解:

从原来的一把梭推送:

image-20210324122627743

换成了多次推送:

image-20210324122653963

图中是用的创建tag的方式,不过tag并不是必须的,可以按示例代码中的方式来操作:

1git push origin <commit id>:refs/heads/<分支名>

这种方式简单易操作,而且StackOverflow中还有现成的脚本,应该能解决大多数类似的问题了。

然而我遇到的问题并不能用这种方式解决,因为它存在单条超大Commit。

好在这个超大Commit是因为存在多个视频文件,而非一个独立大文件,所以还是有可操作空间的。

我在解决单条超大Commit时有两种思路:

  1. 重写历史,将此Commit拆分成多个小Commit(如每个文件拆分成一个commit)
  2. 拆分上传,从更细的粒度,拆分上传此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时需要推送的内容减少呢?

原理图如下:

image-20210324144312900

我做了一次尝试,首先通过git ls-treegit 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的时候还是会把它们打包进去。

这就很尴尬!!!

img

曲线救国

在知道git push时pack的最小维度是commit之后,问题就又回到了拆分commit上来了。

但是我还是不太想重写历史,有没有办法曲线救国呢?

这里我想到的办法是这样:

  1. 首先按上一步的做法,使用超大Commit内部的部分Object创造出一个或多个新的Commit
  2. 基于上一步创建出的commit,新建一个分支 tmp
  3. 将超大Commit所在的分支合并到此新分支中(如 master -> tmp)
  4. 将新分支推送到远程(git push origin tmp)
  5. 将原分支推送到远程 (git push origin master)
  6. 删除临时分支 (git push origin -d tmp)

它的原理如下图:

image-20210324151351432

tmp分支第一个新的Commit中包含了4个objects,它可以直接push,没有超过大小限制。然后再merge一次之后,产生了一个merge commit,它中间包含着剩余的3个object,也没有超出限制。待Merge Commit推送上去之后,Git会认为超大Commit已经在远程服务器中存在,因此再重新推送master分支时,将不再打包超大commit中的内容,也就曲线地将超大commit推送了上去。

img

这种方式的好处是没有重写master分支的历史,操作完成后删除掉临时的tmp分支即可。

新的尝试

在上面的方案成功之后,我开始思考能否使用更简便的方式来实现上述操作。

之前使用git mktreegit commit-tree来生成commit的操作,是想直接利用git仓库中已有的object,那如果使用git add && git commit的方式提交部分文件,替代git mktree && git commit-tree的方式,能否实现相同的目的呢?

这里我在服务端新建了一个空白仓库,重新复现问题。然后执行下面的操作:

  1. 添加新的远程仓库:git remote add test-upstream <git仓库地址>
  2. 在master分支中执行 git push -u test-upstream master,返回失败信息
  3. 基于超大Commit之前的一条Commit,新建分支 tmp
  4. 使用 git push -u test-upstream tmp
  5. 在tmp分支的基础上,git add 超大分支中的部分文件,然后commit & push
  6. 在 tmp 分支上执行 git merge master,合并超大Commit
  7. 再次git push -u test-upstream tmp
  8. 此时切换到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 mktreegit commit-tree人为同步。

相关算法为:

1Commit Hash (SHA1) = SHA1("blob " + <size_of_file> + "\0" + <contents_of_file>)

参见StackOverflow中的相关问题

总结

在同步Git仓库时,如果遇到了大小超限的问题,可以分批上传、分拆上传等方式来解决。

深入理解Git的内部原理,有助于解决此类复杂问题,如非必要,应尽量避免重写历史。