如何组织数百个Git存储库

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

想象一个有数百个Git存储库,但却完全不组织它们的超人。当超人有需要的时候,他会从他那令人敬畏的记忆宫殿里找到需要的存储库。超人的大脑工作效率是如此之高,以至于当他需要在几个存储库里转来转去的时候,完全没有遇到任何障碍。
大多数人都不是超人,缺乏组织度的存储库是痛苦之源,让我们离痛苦远一点。

问题的根源是什么?

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

简单的解决方案:Monorepo

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

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

对文件系统有所了解的人们,很容易想到利用符号链接来组织位于本地的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设计的,并不符合人体工学。为使对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运算符将条件连接起来。