如何组织数百个Git存储库

为什么组织存储库是必要的?

你可以想象一个有数百个Git存储库,但却完全不组织它们的超人。此人将所有存储库平等视之,当他需要的时候,他会从他那令人敬畏的记忆宫殿里找到需要的存储库。他的大脑工作效率是如此之高,以至于当他需要在几个存储库里跳转来,跳转去的时候,完全没有任何障碍。
显然大多数人都不是超人。对于我们这些无法在大脑里建立起索引目录的凡夫俗子来说,将所有存储库平等视之只会成为未来无尽痛苦的源泉。让我们离痛苦远一点。

问题的根源是什么?

问题的根源是两方面的。
一方面,问题出在你的存储库太多。这可以有很多种原因,比如对小型软件包的推崇很容易导致更多的存储库(在这种情况下,每写一个有通用价值的函数都可能会导致一个新的存储库)。削减存储库的数量可以在一定程度上缓解问题,比如将存储库聚合成一个Monorepo就是一种解决方案,但大多数情况下并不足以解决问题。除非在你削减存储库后,存储库的数量只剩不到20个,否则我会认为它们仍然需要被组织。
另一方面,问题出在你对存储库如何组织缺乏概念。如何组织大量数据一直是一个值得单独拎出来讨论的问题。对于书籍,有一门学问叫做图书馆学;对于互联网上公开的信息,有巨型企业为你提供搜索引擎。但是对于存储库的组织,以及其他类似数据的组织,长久以来都没有一个好的解决方案。

简单的解决方案:Monorepo

Monorepo是存储库数量太多的解决方案之一,只要我们把存储库合并进一个Monorepo,问题不就完美的……咦?
大部分情况下,一个Monorepo可以帮你隐藏掉很多存储库,但这样的问题解决方式无异于把脏东西扫到地毯下面,问题可能只是在表面上得到解决。在转向Monorepo的过程中会引发工具链更换、用户权限控制、CI/CD、软件包发布、提交记录查询等一系列全新问题,这些问题都是与组织问题无关的,但你仍然需要解决它们。而在你解决这些问题之后,组织问题没有被解决。
位于Monorepo下的子存储库仍然需要组织,几乎每个人都会通过创建一个树形结构把子存储库以他认为的理想形态组织起来。但这其实只是新问题的起点,因为随着时间的推移,很可能会需要重新组织这个树形结构,而存储库之间的互相引用以及相关路径的配置文件修改可能会成为一场噩梦。树形结构还带来经典的分类问题,例如有一个该死的子存储库竟然同时属于两个地方,关于树形结构的纠结可能会持续到永远。

复杂的解决方案:符号链接

对文件系统有所了解的人们,很容易想到利用符号链接来组织位于本地的Git存储库(如果你对符号链接缺乏了解,只要把它当成一种被文件系统承认的快捷方式就行了)。
符号链接在组织本地文件和目录方面有其独特的优势:
  • 链接只是一种标记,而不是被链接的事物本身,这允许我们在不移动实际文件或目录的前提下组织它们。这是很大的优势,因为本机文件和目录可以尽可能保持路径不变。
  • 链接是被文件系统承认的,因此使用起来几乎无痛。一些不够成熟的工具链会在支持符号链接方面有问题,但我至今只遇到过一次,并且相关问题在你能够访问源代码的情况下解决起来并不困难。
尽管我们有符号链接这样的利器,但我们却很难正确利用它。一个尝试通过符号链接解决问题的人会很快会因为采取这种天真的做法而碰壁:
  • 由于缺乏统一的原则,你会发现手工创建的符号链接正在演化为一切混乱的源头。
  • 由于需要组织数百个存储库,你会在手工创建符号链接时很快发现自己作为人类的瓶颈。
利用自动化工具可以缓解符号链接造成的问题,但并不能真正解决问题,因为实际的问题是缺乏一套有用的组织哲学来正确利用符号链接。

不一样的解决方案:一套组织哲学

在介绍这种解决方案之前,有一些需要说明的:
  • 我得承认,这并不是一种只适用于Git存储库的解决方案。作为一种哲学式的解决方案,你总是可以把它移植到你觉得可能有用的地方,尽管它被移植后的有效性是缺乏保证的。我只能向你保证它被用在Git存储库上的效果。
  • 尽管这套方案需要使用一些工具,但工具本质上只是这套哲学的应用,你可以编写自己的工具以贴合自己的需求。

单一事实来源

在我们使用符号链接之前,我们需要有一套规则来约束我们保存本地Git存储库的方式,因为我们需要确保符号链接来源的路径尽可能不变。
如果你的Git存储库一开始就是位于本地的,那么问题解决起来可能很简单,只需要将Git存储库存到一个目录下就可以了。如果你的Git存储库是来源于远程服务器,问题会开始复杂起来,因为会出现两个存储库使用相同名称的情况。将远程存储库改名以消除冲突是一个坏主意,因为你总是应该避免修改远程存储库的名称,当然更大的可能性是你没有权限这么做。我们会很自然地想到将本地存储库改名以消除冲突,但由于我们不想记住远程存储库和本地存储库的联系,所以我们会想到按照固定的规则来改名字,比如如果在org-a下有一个叫做repo的存储库,在org-b下也有一个叫做repo的存储库,就把它们改名为org-a-repoorg-b-repo以消除冲突。但这仍然留下了两个隐患:
  • 虽然概率很小,但你可能会在未来有一个叫做org-a-repoorg-b-repo的存储库,届时又需要重新消除冲突。
  • 你可能会有两个org-aorg-b,其中一个在公有网络上,另一个在私有网络上,它们保存着不同版本的repo存储库。
那么有没有什么真正好用的规则呢?不难想到,答案是存储库的URL。存储库的URL有较好的唯一性,可以用来避免冲突,因为它不仅包含存储库的名称和存储库的所有者名称,还包含存储库的主机名。于是,我们可以将host-a下的org-arepo存储为host-a/org-a/repohost-b下的org-arepo存储为host-b/org-a/repo
将URL作为规则,你可以建立起一个无冲突的,唯一用途是保管Git存储库的目录,并且你还可以从相对路径反推出存储库URL。我将这种目录称为Git存储库的单一事实来源
创建这样的目录当然会用到工具。

git-list

市面上有很多对Git存储库进行批量管理的工具,但没有一个工具是在将Git存储库视作单一事实来源来管理的。这就是为什么需要为此创建一个专门工具:git-list
在git-list的语境下,将Git存储库视作单一事实来源来管理意味着:
  • Git存储库的URL被认为具有唯一性和稳定性,git-list只会根据存储库的URL来识别本地存储库。受益于此,git-list不需要扫描本地文件系统就可以知道有哪些存储库。但由于git-list在执行命令前不知道相应本地存储库是否真的存在,用户需要在执行克隆以外的命令前保证相应本地存储库的存在。
  • 由git-list维护的树形目录结构不应该被手动修改,而是应该通过git-list修改。
在使用git-list前,需要由用户手动创建一个YAML文件,该文件记录了一个列表,每个列表项都是存储库的URL。在使用git-list时,需要首先调用git-list的clone子命令,基于事先定义的列表在当前目录下批量克隆所有存储库,之后所有批量操作都将基于此列表执行。
由git-list克隆的存储库,其本地路径将以远程URL为基础按规则创建。例如克隆https://github.com/BlackGlory/git-list.git将会在github.com/BlackGlory/git-list创建存储库,很容易推理。这个路径只有在Git存储库发生所有权转移和重命名的情况下会发生变化,因此是相当稳定的。

标签系统

单一事实来源是一种组织,但这种组织通常是不够的,因为单一事实来源的目录结构是基于URL设计的,而URL未必符合人体工学。为了让Git存储库的访问符合人体工学,我们需要用符号链接创建一个供人类访问Git存储库时使用的树形结构。
在试图为Git存储库设计树形结构时,最终会遇到分类方法的这些固有缺陷:
  • 归属两难问题:存储库应该属于树形结构的哪个目录?似乎都属于。
  • 嵌套层次问题:哪个目录在外层?哪个目录在里层?似乎都可以。
  • 演进问题:如何让树形结构具有足够大的变更灵活性?
最终,你会发现自己需要一个标签系统来克服上述问题:通过标签系统给Git存储库打上标签,然后根据标签自动生成树形结构。
需要注意的是,标签系统的设计过程具有本质复杂性,很容易设计出一个标签很多但完全派不上用场的无用标签系统。因此,我会建议从最少的标签开始设计标签系统,你总是可以根据自己的经验来慢慢添加所需要的标签,至少比尝试一次成功然后遭遇失败要强。
很好的想法,需要工具的支援。

garland

garland是一个基于标签系统来生成树形结构目录的工具,它通过引入可计算的标签系统来以可维护的方式解决树形结构生成的问题。
garland依赖于事先编写的配置文件来工作,初次创建时可能很麻烦,但创建好后就很容易维护了,因为它本质上是简单的。
garland需要两个文件:
  1. 1.
    标签定义文件:该文件定义了能被garland识别的目录及其具有的标签。我们在此利用git-list生成的单一实施来源路径作为根目录,为其下的每一个存储库打上标签。
  2. 2.
    蓝图文件:该文件定义了用户需要的树形结构的模板。模板中的每一层都可以包含一个条件表达式,条件表达式的计算结果将决定是否会在此层创建符合条件的存储库的符号链接。条件表达式是我在garland里创建的一种用于标签系统的逻辑计算领域特定语言,可以通过andornot这样的逻辑运算符来计算标签。举例来说,如果一个目录在标签定义文件里有librarypublic两个标签:library and public的计算结果为真,在此创建目录的符号链接;library and private的计算结果为假,不在此创建目录的符号链接。为了便于使用,树形结构的里层将自动具有其外层的条件表达式,相当于用and运算符将条件合并了起来。