git 拒绝我推送的那一天
有一天,git 干脆不让我保存自己的工作了。我的推送失败了,报出一个我从未见过的服务器错误——不是合并冲突,不是权限问题,只是对端一口回绝。我已经愉快地提交了好几个星期,而现在,我一天中最例行的命令却无法完成。
原因并不是 git 里的 bug。而是在那几个星期里,我悄悄往自己的仓库塞进了成千上万本不该出现在那里的东西,它终于变得太重,抬不动了。
那次推不出去的推送
起初我以为只是个偶然——一次网络抽风,服务器闹了一会儿脾气。但它一再发生,每次都可靠地出现。仓库累积起来的体量似乎就是症结所在:推送每次都以同样的方式失败,再多的重试也修不好一个本质上是结构性的、而非短暂性的问题。
当我终于去看这个仓库究竟装了些什么时,答案让人难堪。代码——仓库本该承载的东西——只占其中很小一部分。压倒性的大头是数据:成千上万个生成的文件、缓存和编译产物,每一个都被忠实地追踪、版本化,并在每一次操作中被一并带着走。
一个仓库是如何悄悄变成仓库间的
没人会下定决心把数 GB 的数据放进版本控制。它是一步一步、每步看起来都合情合理地发生的。你生成了几个文件,它们落进了你的项目文件夹。你运行了一个 add 命令,也许有点漫不经心,它们就跟着进来了。它管用。没有谁抱怨。于是你又这么做了一次,又一次,仓库就像抽屉积攒杂物一样积攒起数据——悄无声息,直到有一天它再也合不上为止。
到 git 拒绝我推送的时候,我正在追踪成千上万个数据文件。我从未有意识地选择把它们纳入版本控制。我只是从来没有做出过把它们挡在外面的那个选择。
数据和代码想要的恰恰相反
把它们混在一起之所以会带来伤害,是因为它们被版本化的理由完全不同。代码体量小、有意义,并且会以你想逐行看清的方式变化——git 正是为此而生。数据体量大、不透明,往往是整体性地变化;对它做逐行 diff 几乎什么也告诉不了你,而把它过去的每一个版本永远存下来大多是浪费。git 在第一件事上很出色;它的标准历史记录往往并不适合第二件事,至少在数据变大或不断变化之后是如此。
把两者放在一起,你得到的是两边最糟的部分。你的历史记录被你永远无法有意义地读懂的庞大改动撑得臃肿,每一次克隆和推送都拖着这一切到处跑,而那个小而珍贵的东西——真正的源代码——最终被埋没在一个大部分都是压舱物的仓库里。
修复办法:把你跳过的那条界线画出来
修复在概念上无足轻重,又略显繁琐:明确地告诉 git,什么不要去追踪。那些生成的文件、缓存、编译产物——统统被排除在外,仓库回到了它本来就该是的样子:一个安放我有意创作之物的家——代码、配置、文档、测试——而不是成批的生成输出。数据被挪到了大而可再生之物该去的地方——按需产出,或存放在某个为大文件而建的地方,但再也不会被像源代码那样逐行版本化。
推送第一次尝试就通过了。这个修复并不高明。它只是一条我本该在第一天就画好、却从未画过的界线。
底下的那个习惯:源 versus 派生
真正持久的教训其实无关 git。它是一个我现在会对项目产出的每一个文件都去问的问题:这是我创作的,还是我的代码生成的?它是源——一个人写下的、无可替代的东西——还是派生物,一个只要重新运行源代码就能再造出来的输出?
源通常该进版本控制。大块头或臃肿的派生文件通常不该——只有当可复现性或工作流确实有此需要时才该提交,而且理想情况下应当随时可以从源重新生成出来。许多腐烂成一团无法收拾的仓库,正是因为弄丢了这个区分——把某个派生物当成珍宝来对待,又任由那些永远能被重新生成的东西,堆在那些永远无法被替代的东西旁边。
那天 git 并没有坏。它只是终于让我注意到,我已经不再把源和输出各自摆在它们该在的地方,而一直在要求一个工具去做一件它从来就不是为之而造的活儿。
— 没有信号,没有回报,非投资建议。