为什么 git 在找出要推送的最小增量时忽略 HEAD?解决此问题的任何方法(无需创建命名引用)



tl;DR:这是该问题的重现:

#!/usr/bin/env bash
set -euo pipefail
git -c advice.detachedHead=false clone --depth=1 -b v2.38.0 https://github.com/git/git.git dummy_git1
cp -arT dummy_git1 dummy_git2
git -C dummy_git2 tag -d v2.38.0
git -C dummy_git1 branch dummy_branch1
echo "The command below should finish immediately, but it actually slowly copies all the blobs:" 1>&2
(set -x && git -C dummy_git1 push ../dummy_git2 dummy_branch1:dummy_branch1)
rm -rf dummy_git1 dummy_git2  # clean up

git push决定发送哪些 blob 时,它似乎会考虑所有(?)现有引用来传输最少的数据量,除了HEAD.

当远程HEAD附加到某个分支时,这不是问题,因为该分支仍被视为增量的基础。 但是,当远程HEAD被分离并因此成为指向相关提交的唯一指针时,由于某种原因,git不会考虑它。相反,它会将所有内容向上推送,就好像远程没有任何 blob 一样,即使远程实际上已经拥有所有 blob。

基本上,这意味着 git 在计算增量时从refs开始而不是提交,当远程上已经存在必要的 blob 时,会导致不必要的大而缓慢的数据传输。

我有两个问题:

  1. 为什么会这样?

  2. 有什么方法可以在不创建远程命名 ref 的情况下解决此问题?

当远程HEAD附加到某个分支时,这不是问题,因为该分支仍被视为增量的基础。

这有点接近,但不太正确。 这不是远程仓库HEAD是否附加到分支名称的问题。 相反,这是一个问题,即您的本地 Git 是否可以找出合适的"瘦包"。

  1. 为什么会这样?
  2. 有什么方法可以在不创建远程命名 ref 的情况下解决此问题?

1的答案很复杂,但2的答案很简单:"不"。

这里有大量的细节,很容易错过(或故意掩盖)某些项目,所以我将要给出的描述可能会遗漏一些东西,但你需要记住,Git在本地完成所有工作。 此外,即使使用file://URL 或等效项,"本地"和"远程"Git 实例仍然使用通常的协议(大多数)相互通信。

让我们从这个开始:

对象 ID (OID) 是通用的

Git 存储库由两个数据库组成,一个保存分支和标记以及其他名称(将它们映射到 OID,每个名称一个 OID)和一个对象数据库。 两者都是简单的键值存储,我们在这里大多不关心第一个(我们只使用它来查找 OID),所以我们专注于第二个数据库。

第二个数据库使用 OID 作为键;与每个 OID 关联的数据是提交、树、blob 或带批注的标记对象数据(包括类型和长度前缀)。 在对象访问级别,对象数据始终是完整的:一个大对象,例如 100 MBytes,始终是完整的 100 MB。 对象的 OID 本身只是对完整数据(包括标头)运行某些校验和算法的结果。

Git 目前使用 SHA-1,可以选择使用 SHA-256。 SHA-256 支持目前还有点参差不齐,并且无法相互转换,因此实际上 OID 都是 SHA-1 哈希。 这在这里并不是那么重要,但它有助于作为一个具体的例子:两个不同的 Git 软件实现将在不同的时间交换 OID,以便一个 Git 可以判断另一个 Git 是否有某个对象。

智能与哑运输;Git 提交图

在我们进一步讨论之前,我们需要提到传输(ssh,https等)在Git中有两种形式:哑传输一次处理单个对象,并且总是发送或接收整个对象。 你在这里永远不会得到任何增量压缩。 出于这个原因,哑运输的使用并不多。

智能传输允许两个 Git 实现更紧密地交互。 服务器(接收git push,发送git fetch)和客户端(反向这些角色)可以发送"有"和"想要"消息。 (在此之前,他们也可以就功能达成一致,但我们在这里不需要担心这一点。

接下来,我们需要看一下 Git 提交图。 该图由四种对象类型的对象组成,但这四种对象类型中的三种可以引用其他对象:

  • 带注释的标签有一个目标对象:它存储它所标记的对象的哈希 ID(可以是任何其他对象类型,但通常是提交);
  • 提交具有父提交和一个树对象:它存储一些先前提交集和一棵的哈希 ID;
  • 对象包含<模式、哈希>元组的平面列表,其中模式定义哈希 ID 引用的对象类型,名称是名称组件(例如,正斜杠之间的部分,在path/to/file中);
  • Blob对象包含未解释的数据。

树是指子树,也指文件(模式100644和模式100755)、符号链接(模式120000)和 gitlinks(模式 '160000)。 子树表示更多的树对象;文件和符号链接使用 Blob 对象来保存文件数据或符号链接目标;gitlinks 是终端数据(不要连接到存储库中的任何内容 - 它们是哈希 ID,只是假定存在于其他存储库中)。

通过从适当的名称(分支名称、标签名称、替换对象名称等)开始并遍历此图以生成传递闭包,我们可以找出使这组名称"工作"所需的对象。 添加深度限制器(--depthn对于某些整数n)可以限制图遍历。

要复制所有存储库,我们只需对所有可访问的对象进行完全遍历,即可从所有名称中获得完整的传递闭包。 如果我们想从较小的名称集获取或推送,我们只能从这些名称进行完全遍历。 这将生成一组必须存在的 OID。

显然,这不是超级高效,但这是我们的基本起点。

压缩、松散和包装的物体

因为提交的文件通常与以前提交的文件非常相似(虽然不是 100% 完全相同),所以我们希望 Git 进行某种花哨的压缩。 压缩有很多选项;Git 的两个用途是zlib和增量编码。

Zlib 压缩通常非常快,所以 Git 总是为每个对象做压缩。 这包括 Git 所说的松散对象,否则这些对象将存储为完整的对象数据。 无论对象有多小或多大,Git 都会在计算未压缩数据的哈希 ID 以查找 OID 期间或之后压缩它(包括其标头),假设这是一个新对象,然后将该对象作为名称由 OID 组成的文件存储在计算机的文件系统中。

因此,松散对象不是增量编码的,如果我们制作了某个中等大小的源文件的十个版本,创建了十个单独的松散对象,Git 可能能够更有效地存储它们。 因此,Git 将不时运行git pack-objects,以从各种对象创建一个包文件。 为了制作这样的文件,Git 采用类似的对象并将它们组合在一起,并执行无损的二进制数据增量压缩,从"早期"对象中获取字节运行以用于"后期"对象。 这些增量可以链接,例如,"最新"对象可能会说"从较早的对象中获取 4000 字节",但打包的较早对象以"从较早的对象中获取 500 字节"开头。

请注意,这里的"更早"和"以后"是任意的:就增量压缩而言,没有特别的理由在 4 月创建的对象之前使用在 5 月创建的对象。 在实践中,我们倾向于使用较新的提交而不是旧的提交,因此 Git 试图将较新的对象保持在增量链的头部,而较旧的对象朝向后面。 这很棘手,因为对象本身没有日期信息:相反,Git 在图形遍历期间将提交和标记日期信息向后传播到对象中,作为一种尽力而为的事情。 它在实践中实际上效果很好。

现在,任何一个给定的存储库中,还有一个次要规则:如果一个打包对象(例如,哈希 ID P3)引用另一个引用另一个对象(哈希 ID P1)的打包对象(哈希 ID P2),则所有这三个打包对象都必须存在于该包文件中。 这意味着,如果您打开了一个包文件,并尝试从中读取对象,则不必打开任何其他对象:您需要的一切都在这里,在此包文件中。

这对获取/推送不利,因此 Git 在此规则中添加了一个例外:精简包可能通过其哈希 ID 引用对象,但不包含这些对象。

智能运输使用薄包装

我们现在可以考虑git push如何适用于您的案例。 假设智能传输(这是通常的情况,并且在这里有效),我们将发送 Git 作为客户端启动,并将服务器调用为接收 Git。

发送方现在要求接收方列出分支、标记和其他名称,以及相应的 OID。 对于每个 OID,我们(作为发送方)可以假定它们具有这些对象。

如果他们告诉我们他们不是一个浅层存储库,我们可以做出进一步的假设:1如果他们有一些提交对象,他们也会有所有早期的提交它们具有与这些提交和所有早期提交一起使用的所有对象(树和 blob对象)。

但是,如果它们是一个浅层存储库,我们只能假设它们具有这些特定的提交。

我们现在查看自己的存储库,以查看要发送哪些提交和/或其他对象。 当然,我们需要发送我们明确git push-ing的最新提交;但是我们还需要发送我们之前拥有的每个提交,直到我们达到他们拥有的提交为止。 如果他们说他们已经提交了 X,对于某些 X,并且我们的图遍历命中X,并且我们不是在做浅层克隆情况,我们可以到此为止!

我们现在有一个本地对象列表,我们认为他们需要这些对象。 我们用这些对象制作一个薄包,对我们检测到的对象进行增量压缩,如果我们有这些对象的话。 如果我们是一个浅层克隆,我们可能一开始就没有对象,在这种情况下,我们无法进行良好的增量压缩。

无论如何,我们制作一个薄包装并将其发送过来。 服务器接收精简包作为git push的一部分,通过使用增量所需的任何增量基本对象"加肥"它来"修复"精简包,使用它在本地已有的对象。

(接收方现在像往常一样运行任何预接收和更新钩子,如果一切顺利,则在发送方在git push结束时请求时更新其名称数据库中的名称。 在现代 Git 中,现在养肥的包被移动到接收者的对象数据库中,离开隔离区。 在旧的 Git 中,它已经存在:没有隔离区。


1Git 新奇的部分克隆可能会在这方面炸出一个大洞:这是一个设计选择,是否将部分克隆视为浅层克隆,或者甚至可能只是禁止推送到部分克隆。 我还没有看这里做出了哪个选择。


这是您第一个问题的答案

之所以出现缺乏增量、非常胖的"瘦包",是因为我们的本地 Git 没有意识到它们(接收 Git 存储库)有足够的基本对象集。

请注意,当我们(客户端)有一个浅层克隆时,我们通常需要深度 2 或更深的克隆才能获得正确的检测,即使服务器具有正确的名称和哈希 ID。 Git 的算法在这里可能会更好——可以只看提交哈希 ID ——但 Git 作者对图遍历做了一些特意的选择,以支持在罕见(浅克隆)情况下使用较少的 CPU。

最新更新