今天早上照常 git fetch --prune 获取大家写的代码,发现需要好长时间,但没怎么在意。直到下午小伙伴们才发现居然 fetch 了一个多 GB!询问才发现小伙伴 JAKE(其实我是在推荐博客)误传了 1.47GB 的垃圾文件。关键是等发现时,develop 分支上已经有 20+ 个基于这个文件的新提交了。

小伙伴说“不要紧,现在我已经删除它了!”突然一阵后背发凉,我们才 900M 的仓库肯定一下子飙到了 2000+M,必须马上处理之。


如果你想知道到底发生了什么造成突然多出这么大的文件,请阅读:一个压缩包引发的血案 - niuyanjie’s blog

问题的本质和解决思路

有的小伙伴问了,为何删了也会占用仓库空间?这是因为 Git 会记录历史的每一次提交,而且提交中包含了完整的数据。如果有一次提交中增加了一个大文件,即便后面删除了此文件再提交,之前增加文件的提交也在历史中。由于 Git 是分布式仓库,每个人都克隆了完整的 Git 仓库,包含完整的历史,于是这个大文件对空间的吞噬其实影响着每一个 Git 仓库的副本。

所以,解决问题的思路其实是——让整个 Git 历史中不存在这个文件!

一种方法是修改有问题的提交,使这个提交中不包含对此文件的修改记录;另一种方法就是将这个提交从整个提交历史记录中干掉。

后文介绍的方法中,“推荐的方法”属于前者,“不推荐的方法”属于后者。

推荐的方法

感谢小伙伴 林德熙 的帮助,帮我找到了一篇非常有价值的博客:Git如何永久删除文件(包括历史记录) - shines77 - 博客园

强烈推荐只阅读上面那篇文章而不要阅读本文,因为本文真正用到的方法比上面的更 low。

看到 filter-branch,突然想到前几天给 Git 仓库补提交一个文件用的是同一个方法:如何向整个 Git 仓库补提交一个文件,那既然如此,里面各种参数的含义也就读(si)得(dong)懂(fei)了(dong)……(/暗笑)。

命令如下:

$ git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch path-to-your-remove-file' --prune-empty --tag-name-filter cat -- --all

path-to-your-remove-file 改成那个误传的文件后,写下命令准备运行……运行……运行……

高 CPU 占用

然后过了十多分钟还不到 5% 的进度……

算了,放弃吧……我们这种数万次提交,900M 的大仓库,这样的命令似乎玩不起啊……这个命令一定还能用,比如指定版本区间什么的,但是我不会……

不推荐的方法(但能解决问题)

我在本地把 develop 分支 reset 到那次误提交文件的提交之前,然后跳过有问题的提交,将 origin/develop 分支上其它新提交一个个 cherry-pick 到本地的 develop 分支上。需要注意不能 cherry-pick 那些合并的提交(就是那种有两个 parent 的提交)。

这样,本地的 develop 分支就刚好跳过那个误传文件的提交,而包含之后的所有提交了。

不管哪种方法,执行完之后都要做这些事情

上面两种方法执行完之后,都面临一个问题——本地 develop 和远端 origin/develop 分支将不再是快进式的,而且包含相同修改的不同提交。这是因为提交虽然还是之前那些提交,但提交的信息已经改变(SHA-1 和 parent)。

我必须让远程 Git 仓库应用我的最新分支才行。必须使用一个危险的命令:

git push -f

于是去远程服务器上取消了 develop 分支的保护,执行以上命令后重新保护它避免之后的危险操作。(前面不管那种方法都会面临这个危险操作,说它危险,是因为此操作会删除远端服务器上 develop 分支上的提交,如果此前的操作做得不对,可能造成严重的代码丢失的后果!)

另外,根据误传文件那个提交的 SHA-1 值,我们找到了远端还存在着包含此提交的其他人的分支,我们需要删除那些分支,确保服务器上任何分支都不包含此提交。使用的是这个命令:

git branch --contains <commit>

然后删除上面命令找到的远端分支(当然找小伙伴确认过可以删我才敢删,不然被打死了):

$ git push -d origin <branch_name>
$ git branch -d <branch_name>

这样,远端服务器上的任何分支都不存在包含误传文件的提交了。理论上新克隆的本地仓库将不再有 2000+M 的大小,实测也是如此。但已经克隆并且包含那次提交的小伙伴该怎么办?

小伙伴 林德熙 再次提供了一组命令,我和他一起简化后整理如下:

git fetch -f -p
git checkout develop
git reset origin/develop --hard
git reflog expire --expire=now --all
git gc --prune=now

命令的解释小伙伴 林德熙有详细介绍。大体为:

  • 提取远端服务器上最新的提交(这样本地仓库才会包含我修复的那些提交)
  • 切换到 develop 分支(避免影响到小伙伴当前的工作分支,防止丢失工作)
  • 将本地 develop 分支强制重置成远端的 develop 分支(以便丢掉本地有问题的那些提交)
  • 立刻将所有无法跟踪到的提交标记为已过期(以便垃圾回收工具可以回收)
  • 立刻进行垃圾回收(这样误传文件的那次提交包含的 1.47GB 空间就被回收啦)

需要注意:必须确保本地和远端没有任何分支可以跟踪到那次误传文件的提交。

如果本地没有基于之前的 develop 分支做新的修改,则以上命令足以将本地磁盘的空间收回。如下图:

回收空间

但如果本地还有新的提交,以上命令敲完前三条后就要暂停了。需要将新的提交 cherry-pick 到新的 develop 分支上;随后删除之前提交的那个分支,确保本地也没有任何分支包含误传文件的提交。随后继续敲后面的两条命令,也可以将本地的磁盘空间回收。


至此,完结了吗?不!没有。

我们肯定还有小伙伴没有删干净,等哪一天再次把那个提交推送……今天算是白干了……

不过,好在我们必须经过代码审查才会将功能分支合并到 develop。只要我们能及时发现,告诉大家在 fetch 发现花太长时间的时候立刻 Ctrl+C 取消 fetch,那么我们还能拯救。而这次,只需要一个命令即可解决:

$ git push -d origin 死灰复燃的包含误传提交的某个分支名

另外再吐个槽,以上一切都做完了,还写完了这篇博客。结果 filter-branch 那个命令依然还跑着没有结束……


本文会经常更新,请阅读原文: https://walterlv.github.io/git/2017/09/18/delete-a-file-from-whole-git-history.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://walterlv.github.io ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系