这实际上是一个很好的问题。
提交的内部存储形式是部分相关的,所以让我们考虑一下。单个提交实际上非常小。这是 Git 的 Git 存储库中的一个,即 commit b5101f929789889c2e536d915698f58d5c5c6b7a:
$ git cat-file -p b5101f929789889c2e536d915698f58d5c5c6b7a | sed 's/@/ /'
tree 3f109f9d1abd310a06dc7409176a4380f16aa5f2
parent a562a119833b7202d5c9b9069d1abb40c1f9b59a
author Junio C Hamano <gitster pobox.com> 1548795295 -0800
committer Junio C Hamano <gitster pobox.com> 1548795295 -0800
Fourth batch after 2.20
Signed-off-by: Junio C Hamano <gitster pobox.com>
(sed 's/@/ /' 可能只是为了减少 Junio Hamano 必须收到的垃圾邮件数量:-))。正如您在此处看到的,提交对象通过另一个提交的哈希 ID a562a11983... 引用其父提交对象。它还通过哈希 ID 引用 tree 对象,并且树对象的哈希 ID 以 3f109f9d1a 开头。我们也可以使用git cat-file -p 来查看这个树对象:
$ git cat-file -p 3f109f9d1a | head
100644 blob de1c8b5c77f7566d9e41949e5e397db3cc1b487c .clang-format
100644 blob 42cdc4bbfb05934bb9c3ed2fe0e0d45212c32d7a .editorconfig
100644 blob 9fa72ad4503031528e24e7c69f24ca92bcc99914 .gitattributes
040000 tree 7ba15927519648dbc42b15e61739cbf5aeebf48b .github
100644 blob 0d77ea5894274c43c4b348c8b52b8e665a1a339e .gitignore
100644 blob cbeebdab7a5e2c6afec338c3534930f569c90f63 .gitmodules
100644 blob 247a3deb7e1418f0fdcfd9719cb7f609775d2804 .mailmap
100644 blob 03c8e4c613015476fffe3f1e071c0c9d6609df0e .travis.yml
100644 blob 8c85014a0a936892f6832c68e3db646b6f9d2ea2 .tsan-suppressions
100644 blob 536e55524db72bd2acf175208aef4f3dfc148d42 COPYING
(树有很多数据,所以我这里只复制了前十行)。
在树内部,你可以看到模式(100644)、类型(blob——这是模式隐含的,也记录在内部 Git 对象中;它实际上并没有存储在树对象中)、哈希Blob 的 ID (de1c8b5c77f...) 和名称 (.clang-format)。您还可以看到tree 可以引用其他tree 对象,.github 子树也是如此。
如果我们使用这个特定的 blob 对象哈希 ID,我们也可以通过哈希 ID 查看该对象的内容:
$ git cat-file -p de1c8b5c77f | head
# This file is an example configuration for clang-format 5.0.
#
# Note that this style definition should only be understood as a hint
# for writing new code. The rules are still work-in-progress and does
# not yet exactly match the style we have in the existing code.
# Use tabs whenever we need to fill whitespace that spans at least from one tab
# stop to the next one.
#
# These settings are mirrored in .editorconfig. Keep them in sync.
(由于文件很长,我再次将副本剪掉了 10 行)。
为了说明,让我们也看看.github 子树:
$ git cat-file -p 7ba15927519648dbc42b15e61739cbf5aeebf48b
100644 blob 64e605a02b71c51e9f59c429b28961c3152039b9 CONTRIBUTING.md
100644 blob adba13e5baf4603de72341068532e2c7d7d05f75 PULL_REQUEST_TEMPLATE.md
然后,Git 对这些所做的就是根据需要递归地读取提交中的 tree 对象。 Git 会将这些读入一个称为 index 或 cache 的数据结构。 (从技术上讲,它的内存版本是 cache 数据结构,尽管 Git 文档对于何时使用哪些名称往往有点松散。)因此通过读取提交构建的缓存 @例如,987654340@ 会说,名称 .clang-format 具有模式 100644 和 blob-hash de1c8b5c77f7566d9e41949e5e397db3cc1b487c,而名称 .github/CONTRIBUTING.md 具有模式 100644 和 blob-hash 64e605a02b71c51e9f59c429b28961c3152039b9。
请注意,各种名称组件(.github 加上CONTRIBUTING.md)实际上已在内存缓存中合并。 (在磁盘格式中,它们是通过算法技巧压缩的。)
帮助 Git 匹配文件名的内存缓存
最后,它是保存 元组的内部(内存中)缓存。如果您要求 Git 将提交 b5101f929789889c2e536d915698f58d5c5c6b7a 与其他提交进行比较,Git 也会将其他提交读入内存缓存。另一个缓存要么有一个名为 .github/CONTRIBUTING.md 的条目,要么没有。
如果两个提交的文件具有相同的名称,Git 假设——出于 Git 现在正在进行的比较的目的,请参见下文——它们是 相同的文件。无论 blob 哈希是否相同,都是如此。
我们在这里回答的真正问题与身份有关。在版本控制系统中,文件的身份确定该文件是否是两个不同版本中的“相同”文件(但是版本控制系统本身定义了版本)。这与身份的基本哲学问题有关,正如this Wikipedia article on the thought experiment about the Ship of Thesus 中所述:我们如何知道某物,甚至是某个一个,是我们认为他们是谁或他们是什么?如果你和你的表弟鲍勃在你和他都很年轻的时候遇到了,你又遇到了一个叫鲍勃的人,他是你的表弟吗?那时你和他都很渺小;现在你长大了,有了不同的经历。在现实世界中,我们从环境中寻找线索:鲍勃是你父母兄弟姐妹的孩子吗?如果是这样,那 Bob 可能是你很久以前遇到的同一个堂兄 Bob,即使他(和你)现在看起来很不一样。
当然,Git 不会做这些。在大多数情况下,两个文件都命名为.github/CONTRIBUTING.md 的简单事实足以将它们标识为“同一文件”。名称相同,所以我们完成了。
git diff 提供额外服务
在我们的日常开发中,我们有时会重命名一个文件。出于某种原因,名为 a/b.c 的文件可能重命名为 d/e.f 或 d/e.c。
假设我们正在提交a123456,文件名为a/b.c。然后我们开始提交f789abc。第二次提交没有a/b.c,但有d/e.f。 Git 将简单地从我们的索引(缓存的磁盘形式)和工作树中删除 a/b.c,并将新的 d/e.f 填充到我们的索引和工作树中,一切都很好。
但假设我们要求 Git 比较 a123456 和 f789abc。 Git 可以告诉我们:要将a123456更改为f789abc,删除a/b.c并使用这些内容创建一个新的d/e.f。那是 git checkout 做了什么,这就足够了。但是如果内容完全匹配呢? Git 告诉我们更高效:要将a123456 更改为f789abc,将a/b.c 重命名为d/e.f。事实上,使用正确的选项,git diff 将这样做:
git diff --find-renames a123456 f789abc
Git 是如何做到这一点的?答案在于计算文件身份。
查找文件标识
假设提交 L(用于左侧)有一些文件 (a/b.c) 不在提交 R(用于右侧)中。进一步假设提交 R 有一些文件 (d/e.f) 不在提交 L 中。 Git 现在可以比较两个文件的内容,而不是立即告诉我们:你应该删除 L 文件并使用 R 文件。
由于 Git 对象哈希的性质——它们是完全确定的,基于文件内容——Git 非常容易检测到 L 中的 a/b.c 是与 R 中的d/e.f 100% 相同。在这种特殊情况下,它们将具有完全相同的哈希 ID!所以 Git 会这样做:如果有一些文件从 L 中消失了,而其他一些文件出现在 R 中,并且 Git 被要求 find 重命名,Git 检查哈希 ID 匹配。如果它找到了一些文件,它会将这些文件配对(并将它们从不匹配文件的队列中取出——这个队列,保存来自 L 和 R 的文件,是“重命名检测队列”)。
那些具有不同名称的文件已被识别为同一个文件。小表弟鲍勃毕竟和大表弟鲍勃一样——除了这种情况,你们俩都还需要很小。
因此,如果此重命名检测尚未将 L 中的文件与 R 中的文件配对,Git 会更加努力。现在它将提取实际的 blob,并计算一种“匹配百分比”。这使用了一个复杂的小算法,我不会在这里描述,但是如果两个文件中有足够多的子字符串匹配,Git 会将文件声明为 50、60、75 或更多百分比相似。
在重命名队列中找到一对文件,例如,72% 相似,Git 继续将文件与所有其他文件进行比较。如果它发现这两个中的一个与另一个相似度为 94%,则该相似性配对优于 72% 的相似性配对。如果不是,72% 的相似度就足够了——至少是 50%——所以 Git 会将这两个文件配对并声明它们具有相同的身份。
在任何情况下,如果匹配足够好并且是所有未配对文件中最好的一个,则采用该特定匹配。再说一遍,小表弟鲍勃毕竟和大表弟鲍勃是一样的。
在对所有个不匹配的文件对运行此测试后,git diff 获取匹配的结果并调用这些文件重命名。同样,只有在您使用 --find-renames(或 -M)时才会发生这种情况,如果您愿意,您可以将 阈值 设置为 50% 以外的值。
打破不正确的匹配
git diff 命令提供另一项服务。请注意,我们从假设开始,如果提交 L 和 R 有具有相同 name 的文件,则这些文件是相同的文件,即使内容不同。但如果他们不是呢?如果L中的file在R中重命名为bettername,并且有人在R中创建了一个新的file ?
为了解决这个问题,git diff 提供了-B(或“中断配对”)选项。在-B 生效的情况下,如果开始时按名称标识的文件过于相似,则它们的配对将被破坏。dis。也就是说,Git 将检查两个 blob 哈希是否匹配,如果不匹配,Git 将计算相似度索引。如果索引低于某个阈值,Git 将中断配对并将两个文件放入重命名检测队列,然后运行--find-renames 样式重命名检测器。
作为一个特殊的转折点,Git 将重新配对损坏的配对,除非它们非常不同以至于你不希望这样做。因此,对于-B,您实际上指定了 两个 相似性阈值:第一个数字是何时暂时中断配对,第二个是何时永久中断它。
git merge 使用 git diff --find-renames
当你使用git merge进行三路合并时,有三个输入:
- 合并基础提交,它是两个提示提交的祖先;和
- 左右提交,
--ours 和 --theirs。
Git 在内部运行 两个 git diff 命令。一个将碱基与 L 进行比较,另一个将碱基与 R 进行比较。
这两个差异都在启用--find-renames 的情况下运行。如果从 base 到 L 的差异找到了重命名,Git 知道使用该重命名中显示的 changes。同样,如果从 base 到 R 的差异找到了重命名,Git 知道使用这些更改。它将合并两组更改,并尝试(但通常会失败)合并两个重命名,如果两个差异都显示重命名。
git log --follow 也使用重命名检测器
当使用git log --follow 时,Git 会遍历提交历史,一次一对提交(子和父),在父子之间进行差异。它会打开一种有限形式的重命名检测代码,以查看您的 --follow-ing 文件是否在该提交对中被重命名。如果是这样,只要git log 移动到父级,它会更改它查找的名称。这种技术效果很好,但在合并时存在一些问题(因为合并提交有多个父级)。
结论
文件身份就是这一切的意义所在。由于 Git 事先不知道提交 L 中的文件 a/b.c 与提交 R 中的文件 d/e.f 是否“相同”文件, Git 可以使用 rename detection 来决定。在某些情况下——例如检查提交 L 或 R——这一点都没有关系。在某些情况下,例如区分两个提交,这很重要,但仅对我们作为试图了解发生了什么的人类而言。但在少数情况下,例如合并,它非常重要。