2.6 版本控制与源代码管理
概述与动机
把版本控制想象成你代码库的记录系统。它记录每一次变更,包括是谁做的、什么时候做的、为什么做,并让许多人能够在同一份软件上工作,而不会相互覆盖对方的成果。对大型组织而言,它远不止是一份备份。它是协作、持续集成(CI)、审计和发布管理所依赖的基础。你在分支策略、仓库结构和提交纪律方面所做的选择,决定了你的团队能以多快、多安全的速度前进。
对大型团队而言,源代码管理实际上是一个规模化的协调问题。当数百名工程师向共享代码推送变更时,他们需要一种策略,使合并保持小巧、使主线保持可发布、使历史记录保持可读。持续集成的团队运转流畅。而让分支分歧数周的团队,则会从一场集成危机跌跌撞撞地走向下一场。你的仓库结构()一个大仓库还是多个仓库()同样塑造着团队共享代码和协调工作的方式。
企业和政府环境还带来了一些额外的要求:可追溯性、访问控制和留存。一项变更可能需要关联到一个经过批准的工作项以供审计。密钥绝不能进入历史记录。仓库访问权限必须尊重安全边界。在这里,你的版本控制实践成为组织控制框架的一部分,而一个诸如泄露密钥或不可审计历史之类的失误,可能带来严重后果。
关键原则
- 频繁地集成小的变更;长时间的分歧是合并痛苦的根源。
- 让主线始终保持可发布状态。
- 历史记录就是文档;要为日后必须理解”为什么”的读者而撰写提交说明。
- 绝不提交密钥;把任何进入了历史记录的密钥都视为已被泄露。
- 用自动化手段(钩子、CI 检查)来强制执行卫生规范,而不是仅仅依靠自觉。
- 依据团队实际共享代码和协调工作的方式来选择仓库结构(单仓库还是多仓库),而不是依据流行趋势。
- 把变更关联到其依据(工作项、工单或决策)以实现可追溯性。
建议
优先选择配合短生命周期分支的主干开发
倾向于采用主干开发:频繁地集成到共享主线,使用以小时或天为单位、而不是以周为单位的短生命周期功能分支。短分支能使合并保持小巧、集成保持持续,而这一习惯与高交付绩效密切相关。当工作尚未完成时,不要把它搁置在一个长生命周期的分支上。要使用功能开关(feature flag)()一种在运行时隐藏未完成工作的开关()这样你就能安全地把它合并进去。要把长生命周期的发布分支留给真正需要多版本支持的场景,并且要清楚地了解它们所带来的维护成本。
选择与发布节奏相匹配的分支模型
要让你的分支模型与你实际的发布方式相匹配。如果你持续部署,采用分支极少的主干开发方式对你很合适。如果你向客户交付带版本号的发布,或者同时支持多个线上版本,你可能需要发布分支和回移(back-porting)。除非你的发布模式确实需要,否则要远离拥有众多长生命周期分支的重量级模型,因为它们会成倍增加合并和维护开销。
有意识地决定单仓库还是多仓库
当团队大量共享代码、需要跨项目的原子性变更、并希望统一工具链和可见性时,可以选用单仓库(monorepo)()一个容纳许多项目的单一仓库。作为回报,你需要接受对规模化构建工具和访问控制的需求。当团队和服务真正相互独立、希望拥有隔离的访问权限和发布周期、且不需要跨仓库的原子性变更时,可以选用多仓库()每个项目或服务一个独立仓库。作为回报,你需要接受协调跨仓库变更所带来的成本。两者在规模化场景下都行得通。真正制造持续摩擦的,是选错了不匹配你耦合模式的方案。
强制执行提交卫生规范和约定式提交
要求提交说明解释一项变更”为什么”被做出,而不仅仅是”做了什么”。采用诸如约定式提交(conventional commits)之类的规范,使提交信息结构化、可被机器解析,从而能够自动生成变更日志和版本号。保持提交的原子性,每次提交只包含一个逻辑变更,使历史记录保持可二分查找(bisectable)且易于回退。让钩子和 CI 检查来强制执行提交格式和基本的卫生规范,而不是依赖记忆。
让大型二进制文件和生成的代码远离普通历史记录
不要把大型二进制资产直接提交进主历史记录,因为它们会永久性地使每一次克隆变得臃肿。应改用大文件存储机制或制品仓库。作为一项原则,也要避免提交生成的代码;应在构建过程中生成它。当你确实必须提交一个生成的制品时,要把它隔离开来并清晰地标记,使其不至于污染评审和差异比对。
防止密钥进入仓库
要在你的预提交钩子和 CI 中加入自动化的密钥扫描,使凭证在真正落地之前就被拦截。要为工程师提供一个正规的密钥管理系统,使他们从一开始就无需硬编码任何凭证。并且要把任何确实进入了历史记录的密钥都视为已被泄露:立即将其轮换。一旦密钥被推送并被克隆,要把它从历史记录中彻底清除既困难又不可靠。
建立访问控制和可追溯性
要设置仓库访问权限,使其尊重安全边界和最小权限原则。把提交或拉取请求关联到工作项,使每一项变更都能追溯到其依据,这既有助于日常的工程上下文理解,也有助于审计。要用必需的检查和评审来保护你的关键分支,使任何内容都无法在未通过你所约定的关卡的情况下被合并。
权衡:优点与缺点
| 选择 | 优点 | 缺点 |
|---|---|---|
| 主干开发 | 持续集成;小规模合并;高流动性 | 需要功能开关和纪律;隔离性较弱 |
| 长生命周期功能分支 | 对进行中的工作有很强的隔离性 | 合并痛苦;集成延迟;产生漂移 |
| 单仓库 | 跨项目的原子性变更;统一的工具链;可见性高 | 需要规模化的构建工具;默认下访问控制较为粗放 |
| 多仓库 | 独立的发布;隔离的访问权限;每个仓库的工具链简单 | 跨仓库变更困难;版本协调开销 |
| 约定式提交 | 自动生成变更日志和版本号;历史记录一致 | 需要前期约定;需要强制执行 |
这里最大的权衡在于集成频率与隔离性之间。长生命周期分支之所以让人感觉更安全,是因为你的工作独立存在于一旁,但正是这种隔离性,日后导致了昂贵的合并和意外的集成问题。主干开发放弃了那种隔离感,换来了持续、廉价的集成,并要求你带来功能开关和纪律。单仓库与多仓库的决策,则是在跨项目便利性与团队独立性之间进行权衡。要选择与你的代码实际耦合紧密程度相匹配的那一种。
与团队讨论的问题
在任何内容被合并到你受保护的主线之前,必须通过哪些检查,这条主线是否真的始终保持可发布? 本章把一条可发布的主线当作一项核心原则,并把一条不受保护的主线()损坏的或未经评审的代码进入了每个人都依赖的分支()称为一种反模式。在一个大型团队中,一条变红的主线会同时阻塞所有人,因此你所要求的这道关卡是一项共享的安全属性,而不是个人的偏好。请带来证据:你的分支保护今天实际强制执行了什么,以及主线目前多久损坏一次。要决定必需的检查集合()通过的测试、安全扫描和评审()并通过策略而不是靠祈祷来让主线保持可发布。正是这道关卡,才使许多人能够持续集成而不心存恐惧。
考虑到约定式提交所能自动化的东西,采纳它所带来的约定开销对你的团队而言是否值得? 本章推荐使用结构化的、可被机器解析的提交信息,正是因为它们能让你自动生成变更日志和版本号,并要求原子性提交,使历史记录保持可二分查找和可回退。这里的权衡是真实的:你需要付出前期的约定成本并需要强制执行,以换取自动生成的发布说明和可靠的历史记录。请带来你今天手动完成的工作信号,例如手写变更日志,或者追查究竟是哪次提交引入了一次回归。如果你发布频繁,或者维护多个版本,这种自动化通常能收回成本;如果你很少发布,一种更轻量的约定可能就足够了。要让钩子和 CI 强制执行格式,而不是依赖记忆。
你是否接受了你的仓库结构所要求的运营成本,无论是单仓库的工具链,还是跨仓库的协调? 本章指出,单仓库和多仓库在规模化场景下都行得通,而真正制造持续摩擦的,是选错了不匹配你耦合模式的方案。一个单仓库需要规模化的构建工具和更细粒度的访问控制,而多仓库则会让任何跨越仓库的变更变成一个带有版本偏差风险的协调项目。请带来具体的信号:你的变更多久跨越一次项目边界,以及你的构建和访问工具链能否承载你当前的结构。如果跨项目的原子性变更很常见,就要投资于单仓库工具链;如果团队和服务真正相互独立,就要有意识地接受跨仓库协调的成本。要点在于让结构匹配你的代码实际的耦合紧密程度,然后为那种结构所需要的工具链投入资金。
如果此刻一个正在使用中的凭证被提交进一个繁忙的仓库,你能多快检测到它,轮换是真正自动化的,还是仅仅寄望于此? 本章把任何进入了历史记录的密钥都视为已被泄露,并警告说,事后把它清除既困难又不可靠,因此预防和快速轮换是唯一真正的防线。对大型团队而言,暴露的风险会成倍放大:一个被推送到共享仓库的密钥,会在几分钟之内被克隆到几十台机器上,并被镜像进 CI 缓存,因此缓慢的人工响应必然导致一次泄露事件。与之相抗衡的考量是摩擦:激进的预提交扫描和强制轮换会拖慢人们的速度,并产生误报,因此你必须去调优这些控制措施,而不是干脆关掉它们。请带来证据:密钥扫描是否同时在预提交钩子和 CI 中运行、你检测并轮换一次已知泄露的平均耗时,以及工程师是否拥有一个能消除硬编码冲动的密钥管理系统。在企业和政府环境中,要把这一点与你的事件响应流程和留存规则关联起来,因为一份可审计历史中的一次密钥泄露,既是一次安全事件,也是一次合规事件,监管者会问是谁知情、又多快采取了行动。
你的分支是否真的生命周期很短,如果不是,为什么未完成的工作被搁置在一个分支上,而不是隐藏在一个功能开关之后? 本章大力倾向于主干开发,因为长时间的分歧是合并痛苦的根源,并且提供了功能开关这种机制,使你能够安全地合并未完成的工作,而不是把它隔离数周。在一个大型团队中,这是一项协调属性,而不是个人偏好:每一个存活数周的分支,都会变成现实的一个私有分叉,最终必须由某个人去调和,而这种调和的成本会随着人数的增加而增长。与之相抗衡的考量是,功能开关自身也带有成本,包括运行时复杂度、测试组合数量,以及必须被退役的陈旧开关。请带来数据:你实际的分支生命周期分布、集成产生冲突或意外的频率,以及目前存在多少个长生命周期分支、原因是什么。对大型或受监管的组织而言,还要加上发布方面的情况,因为真正的多版本支持,可能证明带有严谨回移纪律的长生命周期发布分支是合理的,而这与把日常功能工作搁置在主线之外是一个不同的决策。
你历史记录中的每一次变更,是否都能在正确的安全边界内追溯到其作者和依据,这能否经得起一次审计的检验? 本章把访问控制、最小权限,以及把变更关联到工作项,都当作组织控制框架的一部分,而不是可有可无的修饰。对大型团队而言,可追溯性正是把一串不透明的提交流,变成一件你能在事件响应或合规评审中据以推理的东西的关键,而访问边界正是阻止一个被攻破的账号触及本不该接触的代码的关键。与之相抗衡的考量是开发者的速度:强制性的工作项关联、细粒度权限,以及必需的评审,都会增加仪式感,而一个快速移动的小团队或许可以合理地跳过它们。请带来证据:受保护的分支是否真的要求了你所声称的检查和评审、提交是否真的引用了经批准的工作项,以及访问权限今天是如何映射到你真实的安全边界的。在企业和政府场景中,要把这一点与密级分类、留存和审计义务联系起来,因为一份不可审计的历史记录,或一项过度宽泛的访问授权,都可能变成一项能够叫停一个项目、或使一次认证失败的发现项。
行业视角
初创企业。 速度和生存压倒一切。使用一个仓库、以主干方式工作,每天多次合并短生命周期分支,并把未完成的工作隐藏在简单的功能开关之后,而不是长分支之中。要从第一次提交起就开启密钥扫描,因为一个公开仓库中泄露的密钥,可能在没有安全团队去控制局面的情况下拖垮整个公司。要跳过复杂的分支模型和繁重的流程;一条受保护的主分支和有意义的提交信息,就足以支撑快速前进所需的纪律。
小型企业。 由于没有专职的平台或 DevOps 专家,预算也很紧张,应购买托管的默认方案,而不是自己构建。一个托管的 Git 服务商能开箱即用地提供分支保护、必需的评审和密钥扫描,因此应依赖这些能力,而不是自己托管一个你无法维护的服务器。要把这一决策定位为数据卫生问题:了解哪些仓库保存着敏感配置,把凭证保存在服务商的密钥管理器中,并让平台强制执行你实际需要的那几条规则。
企业。 难点在于跨众多团队的一致性。要把分支保护、提交约定和密钥扫描标准化为全组织范围的策略,使各团队停止重新发明它们,并依据耦合模式有意识地做出单仓库还是多仓库的选择,为该结构所需要的规模化构建工具或跨仓库协调投入资金。要用代码所有权规则把变更路由给正确的评审者,把提交关联到工作项以实现可追溯性,并把版本控制卫生当作一项由负责人和指标治理的受控事项,而不是个人习惯问题。
政府。 采购规则、透明度和公共问责,塑造着整套体系。要求每一次提交都引用一个经批准的工作项,依据密级边界控制访问权限,并在一套有文档记录的事件响应流程下,把密钥扫描和立即轮换设为强制要求。要在各站点无法同时升级的情况下,用长生命周期的发布分支和严谨的回移纪律来支持多个已部署版本,并保持历史记录可审计且得到留存,使认证、信息公开和监督方面的请求能够被从容答复,而不必手忙脚乱。
示例
初创企业。 一家三人初创公司出于习惯和必要性都采用主干方式工作,每天多次把短生命周期分支合并到主线,并把尚未完成的功能隐藏在简单的开关之后。他们从第一次提交起就在 CI 中开启了密钥扫描,因为一个公开仓库中泄露的 API 密钥,可能拖垮一家没有安全团队去控制后果的公司。一个仓库、一条受保护的主分支,以及有意义的提交信息,让他们拥有了足够的纪律来快速前进,而不会被自己的历史记录绊倒。
企业。 一家大型科技公司运营着一个拥有数百个服务和共享库的单仓库。规模化的构建工具和代码所有权规则,把每一项变更路由给正确的评审者。一次提交就能原子性地同时更新一个共享库及其所有使用者,从而绕开了困扰分布式仓库的版本偏差问题。配合功能开关的主干开发保持了主线的可发布性,密钥扫描则在整个仓库范围内、在提交时就拦截凭证。
政府。 一家国防承包商坚持严格的可追溯性。每一次提交都必须引用一个经批准的工作项。分支保护要求通过安全扫描和独立评审,访问权限依据密级边界被严格控制。密钥扫描是强制性的,任何被暴露的凭证都会在一套事件响应流程下触发立即轮换。长生命周期的发布分支支持着跨多个无法同时升级的站点的多个已部署版本,安全修复会经过严谨的回移。
商业论证:动机、投资回报率与总拥有成本
健全的源代码管理几乎是零成本可采纳,而缺失它的代价却很高昂。主干开发和持续集成是与高软件交付绩效关联最密切的实践之一,而这种绩效又与更好的组织成果相关联。干净的、可追溯的历史记录能缩短诊断事件和满足审计所需的时间,而有纪律的分支策略能让你免于反复出现的、未列入预算的集成危机和合并马拉松式的成本。
最大的一项不对称风险是版本控制中的密钥泄露。单一一个泄露的凭证就可能引发一次事故,其代价远远超过任何工具投入,而历史记录会让这类泄露长期挥之不去。预防它们成本低廉;而事后清理则不然。糟糕的结构选择会表现为长期的摩擦:每一次跨仓库的变更都变成一个协调项目,或者每一次单仓库的构建都变成一个瓶颈。要向管理层说明理由,可以把你的分支策略与交付指标和事件诊断耗时联系起来,并把密钥扫描和访问控制定位为针对高代价泄露和审计风险的低成本控制措施。
反模式与陷阱
- 长期分歧的分支: 数周的孤立工作最终合并成痛苦、高风险的集成事件。
- 历史记录中的密钥: 硬编码的凭证会永久留存在克隆中,一旦暴露就必须轮换。
- 把大型二进制文件提交进主历史记录: 永久性地使每一次克隆变得臃肿,拖慢所有操作。
- 无意义的提交信息:“fix”、“wip”、“changes” 之类的信息,摧毁了历史记录作为文档的价值。
- 把生成的代码当作手写代码提交: 产生嘈杂的差异比对、合并冲突,以及对真实来源的困惑。
- 与耦合模式不匹配的仓库结构: 对紧密耦合的代码使用多仓库,或在没有规模化工具链的情况下使用单仓库。
- 不受保护的主线: 没有必需的检查,导致损坏的或未经评审的代码进入了每个人都依赖的分支。
成熟度模型
- 第 1 级,启动: 临时性且被动。分支策略是临场发挥的,分支存活数周,提交信息写着”fix”或”wip”,没有密钥扫描,集成从一场合并危机跌跌撞撞地走向下一场。
- 第 2 级,发展: 基本实践开始出现,但因团队而异。某些地方存在分支模型和提交信息约定,但分支仍然存活过久,强制执行不完整,密钥扫描时有时无,仓库结构是继承来的,而不是有意选择的。
- 第 3 级,标准化: 实践被记录并在全组织范围内强制执行:配合短分支的主干开发、一条受保护且始终可发布的主线、被强制执行的提交约定、同时在钩子和 CI 中运行的密钥扫描、最小权限访问,以及一个经过深思熟虑的单仓库或多仓库选择。
- 第 4 级,管理: 源代码实践依据数据被度量和控制。你会依据既定基线追踪分支生命周期、集成频率、主线损坏率、检测并轮换一次泄露密钥的平均耗时,以及变更与工作项的可追溯性,并在数字出现漂移时采取行动,而不是等待下一次事故的发生。
- 第 5 级,编排: 实践在整个组织范围内被持续改进和集成。分支策略、仓库结构和工具链随团队和代码耦合关系的变化而调整,自动化端到端地强制执行卫生规范,版本控制数据为整个组织的交付、安全和风险决策提供输入。
讨论提纲
- 你团队的分支生命周期是否真的很短,如果不是,是什么阻碍了持续集成?
- 你的单仓库或多仓库选择是否匹配你的代码实际的耦合紧密程度?
- 你今天如何处理大型二进制文件和生成的制品,这给你带来了什么成本?
- 如果此刻一个正在使用中的凭证被提交,会发生什么,你能多快检测并轮换它?
- 对你的场景而言,值得强制执行多大程度的提交信息和可追溯性纪律?
- 功能开关如何改变了你的分支策略,它们又引入了哪些新的风险?
关键要点
- 用短生命周期分支频繁集成;长时间的分歧会造成它看似要避免的那种痛苦。
- 让主线保持可发布,并受必需检查的保护。
- 绝不让密钥进入历史记录;一旦发生,要自动扫描并立即轮换。
- 依据你真实的耦合和协调需求来选择单仓库还是多仓库。
- 把提交历史当作文档来对待,撰写有意义的、遵循约定的、原子性的提交。
参考文献与延伸阅读
- Nicole Forsgren, Jez Humble, Gene Kim, Accelerate: The Science of Lean Software and DevOps
- Jez Humble and David Farley, Continuous Delivery
- Scott Chacon and Ben Straub, Pro Git
- Paul Hammant and others, writings on trunk-based development
- Conventional Commits specification (as a reference standard)
- Martin Fowler, articles on branching patterns and continuous integration