快速理解 Git 的部分克隆与浅克隆

Git 仓库越来越大时,新开发者克隆仓库并开始工作的难度也越来越高。Git 是分布式版本控制系统,这意味着即使不连接控制仓库访问的中央服务器,也能在本机工作。要完整实现这一点,本地必须具有全部可达数据。

有没有办法不下载整个历史中每个文件的每个版本,就开始工作?部分克隆和浅克隆提供了选择,但都有代价:每一种至少改变了普通分布式 Git 的某项预期行为。对于极大的单体仓库,这些取舍更可能值得,甚至是必要的。

开始前,需要理解 Git 如何保存 commit、tree 和 blob。作者在 GitHub Universe 的“Optimize your monorepo experience”演讲中也介绍了这些概念及相关技巧。

快速概览

对于 GitHub 托管的仓库,原文讨论三种减少克隆体积的方式:

  • git clone --filter=blob:none <url>:无 blob 的部分克隆,下载全部可达 commit 和 tree,按需获取 blob。适合开发者以及跨多次构建复用的环境。
  • git clone --filter=tree:0 <url>:无 tree 的部分克隆,下载全部可达 commit,按需获取 tree 和 blob。适合构建后即删除仓库、但仍需提交历史的环境。
  • git clone --depth=1 <url>:浅克隆,截断提交历史以减少体积,会改变一些命令行为,并可能增加后续 fetch 的负担。原文不建议开发者日常使用,但认为它适合某些一次性构建环境。

完整克隆

原文对象图使用方框表示 blob,即文件内容;三角形表示 tree,即目录;圆形表示 commit,即某一时刻的快照。

箭头表示对象之间的引用关系。如果 commit 或 tree A 内含对象 B 的 OID,就从 A 画箭头指向 B。若从 A 沿箭头能够到达 C,就称 C 从 A 可达。沿这些引用追踪对象的过程,也称对象遍历。

普通 git clone 中,客户端请求服务器上的最新提交,服务器返回这些提交以及它们能到达的全部对象,包括整个提交历史中的 tree 和 blob。

原文示意图的时间从左向右推进,因此提交指向父提交的箭头从右向左。每个提交有一棵根 tree;HEAD 提交下方的根 tree 完整展开,其他 tree 也引用这些对象。实际大型仓库有大量 commit、tree、blob,历史数据很可能占据大部分体积。你真的都需要吗?

如今许多开发者工作时一直联网,必要时再向服务器索取少量数据,是可以接受的取舍。这就是部分克隆的核心设计变化。

部分克隆

通过 git clone 的 –filter 选项启用部分克隆。完整过滤条件见 git rev-list 文档,也可以用 git rev-list --filter=<filter> --all 检查本地哪些对象满足过滤条件。服务器可以拒绝客户端的过滤条件,退回完整克隆。

原文描述的 GitHub.com 和 GitHub Enterprise Server 2.22 及以上版本提供两种方式:

git clone --filter=blob:none <url>
git clone --filter=tree:0 <url>

无 blob 克隆

使用 --filter=blob:none 后,初始 clone 下载全部可达 commit 和 tree,只有 checkout 时才下载相应 blob,包括 clone 本身触发的第一次 checkout。

结果是:HEAD 所需的文件内容已经存在,历史文件内容尚未下载。历史很深且含有大量大文件时,这能显著缩短 clone 时间。提交和目录数据仍然完整,因此以后 checkout 只需补齐缺少的 blob;Git 客户端会合并请求,只索取缺失对象。

这类仓库执行 fetch 时,服务器只发送新增 commit 和 tree;新 blob 等到 checkout 才下载。pull 先 fetch 再 merge,因此会在 merge 阶段下载所需 blob。

需要文件实际内容的操作会触发 blob 下载,但只需要文件 OID 的操作不会。例如 log 可以判断哪些提交修改了某路径,而不获取额外内容。因此 git merge-base、git log、git log -- <path> 可以像完整克隆一样高效。

git diff 和 git blame <path> 需要计算内容差异,第一次运行时会下载 blob。下载后保存在本地,无须再次下载。多数开发者只对少量文件使用 blame,因此首次稍慢,往往值得换取更快的 clone 和 fetch。

作者指出,这是使用最广泛的部分克隆选项,并报告自己当时已经使用数月而没有遇到问题。

无 tree 克隆

有些仓库的 tree 数据也占历史体积的很大一部分。--filter=tree:0 先下载全部可达 commit,再按需下载 tree 和 blob。

首次 checkout 后,HEAD 所需数据完整,其余历史主要只保留提交数据。初始克隆可能比无 blob 克隆或完整克隆更快,fetch 也只需下载新提交。但是缺失 tree 的补取成本更高,日常使用更受限制。

例如 checkout 改变 HEAD 时,本地通常没有目标提交的根 tree。客户端按 OID 请求根 tree 及其全部可达 tree。原文指出,这种请求当时不会告知服务器客户端已经有哪些根 tree,因此服务器可能重复发送已有 tree。tree 到达后,客户端再确定缺少哪些 blob,并批量请求。

不需要文件历史的 git merge-base、普通 git log 只使用提交数据,不触发额外下载。相反,git log -- <path> 可能导致几乎每个历史提交的根 tree 都被下载。

因此原文强烈不建议开发者日常使用无 tree 克隆。它主要适合快速克隆、编译、随即删除仓库的自动化构建。GitHub Actions 公共运行器等环境希望把机器时间留给构建,这类克隆可能很合适。

原文还报告了当时测试发现的子模块问题:无 tree 仓库执行 fetch 时,用于检测子模块变化的 Git 逻辑会为每个新提交请求 tree。文中给出的规避方式是:

git config fetch.recurseSubmodules false

作者说明当时正在为 Git 客户端开发更稳健的修复。

浅克隆

部分克隆相对较新,而浅克隆是更早的功能。它通过 git clone --depth=<N> 截断提交历史。常用 --depth=1 表示只关心最新提交,通常还应结合 --single-branch --branch=<branch>,只下载立即需要使用的分支数据。

浅克隆中 HEAD 提交存在,但它与父提交及更早历史之间的连接被截断。父连接被隐藏的提交称为浅提交,共同构成浅边界。提交对象本身没有改变,而是客户端元数据指示 Git 忽略这些父连接。客户端保留下来的提交所需的 tree 和 blob 则都会下载。

历史被截断后,git merge-base 和 git log 可能得到与完整克隆不同的结果,不能指望它们总符合完整历史的预期。部分克隆保留提交历史,不存在同样问题;无 blob 克隆的 blame 即使首次较慢,仍能计算正确历史,而浅克隆缺少必要历史时无法做到。

另一个重要差别是 fetch。浅克隆获取新提交时,服务器必须相对于浅边界计算哪些 tree 和 blob 是新的。这可能比普通 fetch 更昂贵,因为维护良好的服务器原本可以利用可达性位图等优化。

取决于远端其他人的贡献方式,一次 fetch 甚至可能下载接近完整历史。客户端会将浅边界告知服务器,并请求最新提交以及从这些提交可达、直到遇到浅边界为止的对象。

如果其他人从浅边界之前分出主题分支,浅克隆客户端再获取这个分支,或者该分支合并进默认分支,服务器可能必须遍历完整历史,传输近似完整克隆的数据,而且计算时无法利用可达性位图等性能优势。

基于这些原因,原文仅推荐在构建后立即删除仓库的情形采用浅克隆,强调后续 fetch 的代价可能抵消初始收益。

比较各种克隆

除了看单个对象,还可以按对象类型比较初始 clone、经过一段时间的 fetch,以及 checkout 新提交分别下载什么。

完整克隆下载全部可达对象,blob 通常占大部分体积。部分克隆把某些对象延后到真正需要时才下载:无 blob 克隆只获取 checkout 必需的 blob;无 tree 克隆跳过历史 tree,在每次 checkout 时再获取所需的完整 tree。

原文分别用图比较完整克隆、无 blob 克隆、无 tree 克隆,以及以下两种浅克隆后续策略:

git clone --depth=1
git fetch
git clone --depth=1
git fetch --depth=1

这些图用于说明不同阶段下载对象的构成,具体数值以原文图示及其后续实验文章为准。

实验结果说明了什么

GitHub 工程师 @solmazabbaspour 设计并运行实验,在多个开源仓库上比较不同克隆方式。原文预告次日发布详细实验及数据,并先给出总体发现。

如果确实需要分布式工作流,希望全部数据都在本地,应继续使用完整克隆。专注于一个体积合理的仓库的开发者,也通常应选择完整克隆。

如果仓库包含许多历史大 blob,可以使用无 blob 部分克隆更快开始工作;代价是在 checkout、blame 等操作需要时补取文件内容。

总体而言,计算浅 fetch 比完整 fetch 更昂贵。作者建议完整仓库和浅仓库都优先采用普通完整 fetch,而不要反复浅 fetch;同时对长期复用浅仓库持谨慎态度。

CI 等只克隆一次、用完即删的流程适合浅克隆。它通常是取得最新提交工作目录最快的办法,但后续 fetch 更贵,因此不适合作为开发者长期仓库。如果构建需要提交历史,无 tree 部分克隆可能比完整克隆更合适。

实际效果依仓库而异。理解对象模型后,可以结合自己的使用方式试验,并记住各类取舍:

  • 浅克隆省略历史,log、merge-base 等命令无法提供完整历史语义;原文强调应避免需要持续 fetch 的浅克隆工作流。
  • 无 tree 克隆保留提交历史,普通 log 和 merge-base 可用,但按路径查看历史或 blame 所需的 tree 下载可能非常昂贵。
  • 无 blob 克隆保留全部可达 commit、tree;按路径 log 可用,blame 首次可能稍慢。对于历史大文件很多的仓库,这常是合理选择。
  • 完整克隆符合通常的 Git 使用预期,代价是初始下载时间和本地磁盘空间。

作者建议使用较新的 Git 版本,以获得性能改进。

来源与版权

作者:Derrick Stolee(@derrickstolee)。原文:Get up to speed with partial clone and shallow clone,2020 年 12 月 21 日发布,2021 年 4 月 28 日更新。本文为中文翻译,保留原文的版本与时间背景;其中当时的客户端限制和性能建议不应自动视为所有后续版本的结论。版权归原作者及 GitHub 所有,未另行声称其使用开放许可证。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容