由于篇幅较长,我先回答最后一部分,再把其他部分整理一下:
... 例如,假设发布分支上的某个人执行了 a、b、c 的 squash 提交以生成 d。发布分支是否知道 a、b、c 并在合并回 dev 时是否也会传递该信息或仅传递 d 的存在?
一个 squash,无论是通过 git rebase -i 还是 git merge --squash 获得的,都是一个新的和不同的提交。在大多数情况下,没有简单的方法来判断这个新提交 d 是否等同于 a+b+c。涉及复制提交或其效果的操作,即将提交d 或其效果复制到dev,或将a+b+c 复制到release,可能会看到合并冲突,但可能不会!这取决于操作和更改。
长
我多次听说 git 会保留“它知道的更改”的内部记录。
这甚至在模糊的意义上都不是真的。 Git 所做的是根据需要计算 更改集。理解这一点是使用 Git 的关键之一。特别是我们需要了解当您要求 Git 执行合并操作时,Git 将如何计算变更集(合并,或者我喜欢说的合并作为动词) .
Git 存储一个提交图,其中每个提交链接回其直接前任,即父,提交,或者对于合并提交,两个或多个这样的前辈。每个提交本身还间接存储一个快照——不是更改,而是整个快照。
在这个级别上,精确的细节并不重要,但为了具体起见,主 Git 数据库中的内容是一组 对象,每个对象都是以下四种类型之一:commit em>、tree(允许 Git 在提交中定位文件)、blob(主要存储文件内容)和 注释标签(专门用于带注释的标签)。 Git作为一个整体是一系列数据库:这个主要的,它是一个由哈希ID索引的简单键值存储,加上一堆辅助的。一个关键的辅助数据库是另一个将名称转换为哈希 ID 的键值存储。
关于更改与快照
在某种程度上,“变化”与“快照”是无关紧要的,在某种程度上则不是。举一个代数例子(如果你用力过猛,它会崩溃,但应该明白这一点):假设我告诉你,今天比昨天暖或冷 5 度。现在我问你:今天的温度是多少?单凭这一点你是看不出来的!如果我告诉你昨天是 16ºC,现在你可以知道,因为现在你有一个与 5º delta 相匹配的快照值。另一方面,如果我说一天是 16ºC,第二天是 21ºC,您可以找到从这两个快照中的增量。
简而言之,给定提交中的快照,您(或 Git)可以生成 delta,但仅给定 delta,您无法生成快照。同时,使用从提交到其父项的链接,您还可以生成提交图:图在数学上定义为一对集合 G = (V, E) 其中 V 是顶点集,E 是边集。 Git 将各个边存储在代表顶点的节点中。这些节点是对象数据库中的提交对象。
使用图表
我上面提到的辅助数据库就在这里发挥作用。为了进入 图表,Git 需要一些起点哈希 ID。还有另一个选项,像git fsck 和git gc 这样的维护命令使用:它们只是在主数据库中找到每个 对象。但这对于正常工作来说太慢了,并且会使丢弃不需要的对象变得更加困难,因此 Git 有一个辅助的 name-to-hash-ID 数据库:像 master 这样的分支名称变成一个,并且只有一个 hash ID。对于分支名称,这个特定的哈希 ID 会定位一个提交,然后 Git 将其称为 分支的提示。
主数据库中的任何对象都不能更改。这意味着任何提交都不会改变——没有一个位。 files 也永远不会更改:要更改文件,我们只需将 new 版本的副本存储为新的 blob 对象。保存旧文件的旧提交链接到旧 blob。带有新文件的新提交链接到新 blob。一般来说——有特定的例外——我们只添加东西到主数据库。为了向分支添加新的提交,我们写出新的提交,它链接到它的所有文件以及它的父提交。这为我们提供了一个新的哈希 ID,然后我们将其存储到分支名称中。
效果是分支name总是只存储分支的last哈希ID。我们使用它来查找提交,然后使用提交的 parent 哈希 ID 来查找之前的提交,依此类推。
...当 git 更改 sha1 时会发生什么
Git 从不 更改哈希 ID。虽然哈希 ID 目前是 SHA-1,但有一个迁移计划来切换到另一种哈希算法,并且实际上没有必要假设 SHA-1,所以我们将这些称为“哈希 ID”或“对象 ID”,如 Git开始在内部做。 TLA 是 OID,所以我们在这里使用 OID。任何对象的 OID 只是对象内容的校验和,包括 Git 粘贴在前面的类型标头。1 OID 哈希算法必须非常好,以防止哈希冲突(请参阅 @987654322 @)。每个提交都有一个时间戳,以保证每个提交都会获得唯一的 OID。2
1这是必需的,以便提交对象和 blob 对象具有不同的校验和,即使您提取提交的内容(带或不带标头)并将其存储为 blob。这两个对象需要有不同的哈希ID,否则无法存储blob!
2时间戳的粒度为一秒,因此如果您在一秒钟内对两个不同的分支进行两次 100% 相同的提交,您会得到两个名称指向同一个提交。效果是您已经“快进合并”了两个分支。但是,分支必须开始合并才能达到这个结果,所以从技术意义上说,这实际上是可以的;这真是令人惊讶。 (“快进合并”也有点用词不当,但这已经是一个脚注,所以让我停在这里......)
三路合并
在所有现代版本控制系统(甚至许多旧版本控制系统)的核心,我们都有用于执行所谓的三路合并的算法。为此,我们需要将快照转换为变更集。另请参阅Why is a 3-way merge advantageous over a 2-way merge?,尤其是VonC's answer,它说明了单个文件的三向合并。
Git 的聪明之处在于——尽管其他一些现代 VCS 现在也这样做了——它使用图表自动找到正确的合并基础快照。如果我们绘制图表,我们可以看到它是如何工作的。要将feature 合并到mainline 中,我们运行git checkout mainline 将HEAD 附加到它,并使L(无论其实际哈希ID 是什么)成为当前提交:
...--o--o--B---o--L <-- mainline (HEAD)
\
o--o--R <-- feature
然后我们运行git merge feature 来选择提交R 进行合并。 Git 现在使用 commit graph 来找到最佳的共同祖先提交,这成为我们的合并基础 B。
Git 现在将提交 L(一个快照)转换为要应用于提交 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 的特别之处在于它链接回 both L 和 R:
...--o--o--B---o--L--M <-- mainline (HEAD)
\ /
o--o--R <-- feature
没有现有的提交更改(这是不可能的),但mainline 现在定位提交M,它的快照是合并(作为动词)L 和R 中的更改的结果,关于合并基础B。 Commit M 是一个merge commit——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 从两个提示提交开始——现在分别是 T 和 U——然后按照向后看的链接向后工作。 T 回到无聊的提交 o,然后到 M,从 M 到 L 和 R。 U 通过两个无聊的os 回到R。我们也可以继续返回并找到B,但R接近尾声,所以它是新的合并基地。
Squash 不是真正的合并
为了进行 squash 合并(如在 git merge --squash 中),Git 执行与之前相同的 merge as a verb 步骤,获得两个差异并组合更改 -套。但是现在,Git 不是让 merge 提交M,而是让3普通的单亲 提交S:
...--o--o--B---o--L--S <-- mainline (HEAD)
\
o--o--R <-- feature
由于提交S 仅链接回L,而不链接回R,因此仅从图表中无法判断S 是合并的结果。结果是feature,作为一个分支,可能应该被杀死:从我们的绘图中删除,该分支上的三个提交被允许逐渐消失并最终被删除(通过维护——而且非常慢!——@987654392 @ 操作,只要合适,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 中的更改现在与 @987654402 中的更改发生冲突@。当我们进行真正的合并时,合并基础是 R,而不是 B,因此我们看到的冲突更改要少得多。
3没有什么特别好的理由,--squash 总是打开--no-commit,因此git merge 不会自己进行提交。您必须手动运行git commit 才能完成作业。 (我相信这是原始实现的产物。这个 stop-after-verb-part 行为确实应该被删除,因为你现在可以运行git merge --squash --no-commit,但这会改变命令的可观察行为,而 Git 的人不喜欢这样做。)
樱桃采摘
cherry-pick 背后的基本思想是复制一些更改。为此,我们必须像往常一样将提交(快照)转变为变更集。这意味着使用git diff,就像我们使用合并一样。例如,假设我们有以下分支名称和提交图片段:
...--o--o--H <-- us (HEAD)
\
o--o--B--C <-- them
这里 H 是我们的 HEAD 提交,我们想从分支 them 中挑选提交 C。我们只需运行git cherry-pick them,然后在后台运行 Git:
git diff --find-renames <hash-of-B> <hash-of-C> # what they changed
Git 查找提交 B 的方式很简单:它是 C 的父级!
找到这些更改后,Git 需要将它们应用到我们的快照中。 可以尝试直接应用它们,4 但事实证明,使用以下方法将完整的三向合并作为动词合并到H 中效果更好B 作为合并基础,这就是 Git 所做的。一旦merge-as-a-verb 完成,Git 会进行一个普通的(非合并)提交,它与C 具有相同的change,但应用于H,因为它与B-vs-H 变更集。
结果看起来很像壁球“合并”,因为动作本质上是相同的。然而,由于 Git 默认复制提交 message(和作者!),我们可以调用新提交 C' 以表明它是 C 的副本:
...--o--o--H--C' <-- us (HEAD)
\
o--o--B--C <-- them
与 squash "合并" 一样,从一个分支反复挑选提交到另一个分支会使您在以后发生潜在的合并冲突,如果被合并的变更集被稍后提交。
4事实上,git cherry-pick 和 git rebase 在遥远的过去(Git 1.5-ish)曾经是这样做的。不过,如上所述,一般来说,真正的三向合并效果更好,因此cherry-pick 现在使用三向合并。同时git rebase 可选地 使用三路合并:git rebase -i 字面上重用了cherry-pick 代码,git rebase -m 运行三路合并,但有些情况下是旧的非交互式@ 987654433@ 仍然使用git format-patch 和git apply。
总结
Git 存储快照,并动态计算更改集来自这些快照。
Git 将提交存储为一个图——具体来说,一个有向无环图,它在数学上具有某些很好的属性——并使用 图 来查找 合并基计算变更集。
Git 使用 分支名称 来识别图中的特定提交,它称为 tip commits。该名称始终指向被视为分支中包含的 last 提交的任何提交。由于图本身有分歧(分支)和重新加入(合并)的地方,commits 通常属于一次不止一个分支。 包含提交的分支集会随着分支名称的添加和删除而不断变化。 图表本身保持不变!名称只是指针,指向图形。
虽然这个答案没有涵盖它,但对于每次提交来说,可从某个名称访问是至关重要的。维护git gc 操作最终将从数据库中删除任何无法访问(来自任何名称:分支、标记或其他引用)的提交。有关可达性的更多信息,请参阅Think Like (a) Git。