首页 > 解决方案 > Git backmerges / rebases 和 Git 分支“知道”的提交

问题描述

我多次听说 Git 会保留“它知道的更改”的内部记录。这通常就是为什么我们需要反向合并更改(例如对开发阶段的修补程序)或变基中固有的问题。我觉得这有点像“一流的对象”,其中使用的短语有点模棱两可,使用松散,而潜在的现实更直接一些。

那么“它知道的变化”到底是什么意思呢?在 Git 源代码中,每个分支在哪里维护一个它“知道”的 sha1 列表(可能在这里:https ://github.com/git/git/blob/master/commit-graph.c某处)?当 Git 更改 sha1 时会发生什么 - 例如,假设发布分支上的某个人执行了 a、b、c 的壁球提交以生成 d?

发布分支是否知道 a、b、c 并在合并回 dev 时是否也会传递该信息或仅传递 d 的存在?如果 dev 分支已经提交了 a 怎么办——它会足够聪明地同时管理 d 和 a 的存在吗?

标签: git

解决方案


由于篇幅较长,我先回答最后一部分,再把其他的部分整理一下:

...例如,假设发布分支上的某个人执行了 a、b、c 的壁球提交以生成 d。发布分支是否知道 a、b、c 并在合并回 dev 时是否也会传递该信息或仅传递 d 的存在?

一个 squash,无论是通过git rebase -i或获得git merge --squash的,都是一个新的和不同的提交。在大多数情况下,没有简单的方法来判断这个新提交d是否等同于a+b+c. 涉及复制提交或其效果的操作,即将提交或其效果复制ddeva+b+crelease可能会出现合并冲突,但可能不会!这取决于行动和变化。

我多次听说 git 会保留“它知道的更改”的内部记录。

这甚至在模糊的意义上都不是真的。Git 所做的是根据需要计算变更集。理解这一点是使用 Git 的关键之一。特别是,我们需要了解当您要求 Git 执行合并操作时,Git 将如何计算变更集(我喜欢将其合并,或作为动词合并)。

Git 存储一个提交图,其中每个提交链接回其直接前任,即parent,提交,或者对于合并提交,两个或多个这样的前任。每个提交本身还间接存储一个快照——不是更改,而是整个快照。

确切的细节在这个级别上并不重要,但具体来说,主 Git 数据库中的对象是一组对象,每个对象都是四种类型之一:提交(允许 Git 在提交中定位文件)、blob(它主要存储文件的内容)和带注释的标签(专门用于带注释的标签)。Git作为一个整体是一系列数据库:这个主要的,它是一个由哈希ID索引的简单键值存储,加上一堆辅助的。一个关键的辅助数据库是另一个将名称转换为哈希 ID 的键值存储。

关于更改与快照

在某种程度上,“变化”与“快照”是无关紧要的,在某种程度上则不是。举一个代数例子(如果你用力过猛,它会崩溃,但应该明白这一点):假设我告诉你今天比昨天暖或冷 5 度。现在我问你:今天的温度是多少?单凭这一点你是看不出来的!如果我告诉你昨天是 16ºC,现在你可以知道了,因为现在你有一个快照值与 5º 增量相匹配。另一方面,如果我说一天是 16ºC,第二天是 21ºC,您可以从这两个快照中找到增量。

简而言之,给定提交中的快照,您(或 Git)可以生成增量,但仅给定增量,您无法生成快照。同时,使用从提交到其父项的链接,您还可以生成提交图:图在数学上定义为一对集合G = (V, E),其中V是顶点集,E是边-放。Git 将各个边存储在代表顶点的节点中。这些节点是对象数据库中的提交对象。

使用图表

我上面提到的辅助数据库就在这里发挥作用。为了进入图表,Git 需要一些起点哈希 ID。还有另一种选择,维护命令喜欢git fsckgit gc使用它:它们只是查找主数据库中的每个对象。但这对于正常工作来说太慢了,并且会使丢弃不需要的对象变得更加困难,因此 Git 有一个辅助的 name-to-hash-ID 数据库:像这样的分支名称master会变成一个,并且只有一个 hash ID。对于分支名称,这个特定的哈希 ID 会定位一个提交,然后 Git 将其称为分支的尖端

主数据库中的任何对象都不能更改。这意味着任何提交都不会改变——没有一个位。也没有文件发生变化:要更改文件,我们只需将版本的副本存储为新的 blob 对象。保存旧文件的旧提交链接到旧 blob。带有新文件的新提交链接到新 blob。一般来说——有特定的例外——我们只会东西添加到主数据库中。为了向分支添加新的提交,我们写出新的提交,它链接到它的所有文件以及它的父提交。这为我们提供了一个新的哈希 ID,然后我们将其存储到分支名称中。

效果是分支名称始终只存储分支的最后一个哈希 ID。我们使用它来查找提交,然后使用提交的哈希 ID 来查找上一个提交,依此类推。

...当 git 更改 sha1 时会发生什么

Git从不更改哈希 ID。虽然哈希 ID 目前是 SHA-1,但有一个迁移计划来切换到另一种哈希算法,并且实际上没有必要假设 SHA-1,所以我们将这些称为“哈希 ID”或“对象 ID”,如 Git开始在内部做。这个TLAOID,所以让我们在这里使用 OID。任何对象的 OID 只是对象内容的校验和,包括 Git 粘贴在前面的类型标头。1 OID 哈希算法必须相当好才能防止哈希冲突(请参阅新发现的 SHA-1 冲突如何影响 Git?)。每个提交都有一个时间戳,以保证每个提交都会获得一个唯一的 OID。2


1这是必需的,以便提交对象和 blob 对象具有不同的校验和,即使您提取提交的内容(带或不带标头)并将其存储为 blob。这两个对象需要有不同的哈希 ID,否则无法存储 blob!

2时间戳的粒度为一秒,因此如果您在一秒钟内对两个不同的分支进行两次 100% 相同的提交,您会得到两个名称指向同一个提交。效果是您已经“快进合并”了两个分支。但是,分支必须已经开始合并才能达到这个结果,所以从技术意义上来说,它实际上是可以的;这真是令人惊讶。(“快进合并”也有点用词不当,但这已经是一个脚注,所以让我停在这里....)


三路合并

在所有现代版本控制系统(甚至许多旧版本控制系统)的核心,我们都有用于执行所谓的三路合并的算法。为此,我们需要将快照转换为变更集。另请参阅为什么 3 路合并优于 2 路合并?尤其是VonC 的回答,它说明了单个文件的三向合并。

Git 的聪明之处在于——尽管其他一些现代 VCS 现在也这样做了——它使用图表自动找到正确的合并基础快照。如果我们绘制图表,我们可以看到它是如何工作的。要合并featuremainline,我们运行git checkout mainline附加HEAD到它并制作L(无论它的实际哈希 ID 是什么)当前提交:

...--o--o--B---o--L   <-- mainline (HEAD)
            \
             o--o--R   <-- feature

然后我们运行git merge feature以选择R要合并的提交。Git 现在使用提交图来找到最好的共同祖先提交,这成为我们的合并基础B

Git 现在将 commit L(快照)转换为要应用于 commit 的更改集B

git diff --find-renames <hash-of-B> <hash-of-L>   # what we changed

它与B-vs-相同R

git diff --find-renames <hash-of-B> <hash-of-R>   # what they changed

计算完这两个变更集后,Git 现在可以合并变更集,如 VonC 的答案所示,一次一个文件,将合并的变更应用于B. 假设一切顺利,结果是一个新的快照——我们称之为M合并——我们照常提交,使其成为当前分支的尖端。什么特别之处M在于它链接回L R

...--o--o--B---o--L--M   <-- mainline (HEAD)
            \       /
             o--o--R   <-- feature

没有现有的提交更改(这是不可能的),但mainline现在找到了 commit M,它的快照是合并(作为动词)L和 和中的更改R相对于合并基础的结果B。CommitM是一个合并提交——<em>merge 在这里是一个形容词——甚至只是“合并”,merge是一个名词,因为它有两个父提交,L并且R.

请注意,如果我们继续开发feature并最终运行另一个 git merge,那么这次的合并基础不是提交B,而是我们最初的提交R。让我们看看这是怎么回事:

...--o--o--B---o--L--M--o--T   <-- mainline (HEAD)
            \       /
             o--o--R--o--o--U   <-- feature

为了找到最好的共同祖先,Git 从两个提示提交开始——现在TU分别——然后逆向工作,遵循向后看的链接。 T回到一个无聊的提交o,然后到M,然后从M到两者LRU又经过两个无聊o的 s 到R. 我们也可以继续往回寻找B,但R接近终点,所以它是新的合并基地。

壁球不是真正的合并

为了进行squash 合并(如 中git merge --squash所示),Git 执行与之前相同的动词合并步骤,获得两个差异并组合变更集。但是现在,Git不再进行合并提交,而是将3设为单亲,普通提交:MS

...--o--o--B---o--L--S   <-- mainline (HEAD)
            \
             o--o--R   <-- feature

由于 commitS只链接回L,而不是R,因此仅从图表中无法判断这S是合并的结果。效果是feature,作为一个分支,可能应该被杀死:从我们的绘图中删除,该分支上的三个提交被允许逐渐消失并最终被删除(通过维护——而且非常慢!——<code>git gc操作,只要看起来合适,Git 就会在后台自动执行该操作)。

如果我们杀死feature,而是继续开发,然后进行另一个合并操作——无论是否压缩——我们得到:

...--o--o--B---o--L--S--o--T   <-- mainline (HEAD)
            \
             o--o--R--o--o--U   <-- feature

这次的合并基数仍然是B,所以 Git 比较B-vs-T看看我们做了什么,和B-vs-U看看他们做了什么。由于“我们”在 中进行了所有更改S,因此这些更改肯定会重叠。但是三路合并背后的想法是每次更改一次。如果仍然很清楚我们进行了更改而没有进行更多更改,我们会没事的!当我们或他们似乎对现有更改进行了更多更改时,我们将遇到合并冲突,因为据 Git 所知,T现在的更改与U. 当我们进行真正的合并时,合并基础是R,不是B,因此我们看到的冲突更改要少得多。


3没有特别好的理由,--squash总是打开--no-commit,这样git merge就不会做出提交本身。您必须git commit手动运行才能完成作业。(我相信这是原始实现的产物。这种在动词后停止的行为确实应该被删除,因为你现在可以运行git merge --squash --no-commit,但这会改变命令的可观察行为,Gi​​t 人不喜欢这样做.)


樱桃采摘

cherry-pick 背后的基本思想是复制一些更改。为此,我们必须像往常一样将提交(快照)转变为变更集。这意味着使用git diff,就像我们使用合并一样。例如,假设我们有以下分支名称和提交图片段:

...--o--o--H   <-- us (HEAD)
      \
       o--o--B--C   <-- them

H是我们的 HEAD 提交,我们想C从 branch中挑选提交them。我们简单地运行git cherry-pick them,在幕后,Git 运行:

git diff --find-renames <hash-of-B> <hash-of-C>   # what they changed

Git 查找提交的方式B很简单:它是C!

找到这些更改后,Git 需要将它们应用到我们的快照中。它可以尝试直接应用它们,4但事实证明,将完整的三向合并作为动词转换为用作合并基础会更好HB这就是 Git 所做的。一旦merge-as-a-verb 完成,Git 会进行一个普通的(非合并)提交,它与 具有相同的更改CH由于它与B-vs H-change-set 结合而被应用。

结果看起来很像壁球“合并”,因为动作本质上是相同的。然而,由于 Git 在默认情况下也会复制提交消息(和作者!),我们可以调用新提交C'来表明它是以下内容的副本C

...--o--o--H--C'   <-- us (HEAD)
      \
       o--o--B--C   <-- them

与壁球“合并”一样,如果被合并的更改集被稍后的提交“触及”,则从一个分支反复挑选提交到另一个分支会使您在以后发生潜在的合并冲突。


4事实上,在遥远的过去(Git 1.5-ish),两者都曾经这样做过git cherry-pickgit rebase如上所述,不过,真正的三向合并通常效果更好,所以cherry-pick 现在使用三向合并。同时git rebase 可选地使用三路合并:git rebase -i字面上重用cherry-pick代码,并git rebase -m运行三路合并,但一些旧的非交互式情况git rebase仍然git format-patchgit apply.


概括

Git 存储快照,并根据这些快照动态计算变更集。

Git 将提交存储为一个图——具体来说,一个有向无环图,它在数学上具有某些很好的属性——并使用该来查找用于计算变更集的合并基础

Git 使用分支名称来标识图中的特定提交,它称为提示提交。该名称始终指向将被视为包含在分支中的最后一个提交的任何提交。由于图本身有分歧(分支)和重新加入(合并)的地方,提交通常一次属于多个分支随着分支名称的添加和删除,包含提交的分支集不断变化。图表本身保持不变!名称只是指针,指向图形。

尽管此答案未涵盖它,但可以从某个名称访问对于每次提交都至关重要。维护git gc操作最终将从数据库中删除任何无法访问的提交(从任何名称:分支、标记或其他引用)。有关可达性的更多信息,请参阅Think Like (a) Git


推荐阅读