【问题标题】:How does git matches blobs to files across commit trees?git 如何跨提交树将 blob 匹配到文件?
【发布时间】:2019-04-10 15:30:57
【问题描述】:

Chapter 3.1 of the the Git book 明确指出,只有暂存文件才能作为 blob 存储在提交树中。

如果像提交对象一样,blob 获得其内容唯一的哈希 ID,Git 如何管理跨提交跟踪 blob 和文件之间的对应关系?不同提交中相同文件 blob 的哈希 ID 不能匹配,因为它们的内容不同。


一个简单的例子:

假设我刚刚创建了一个没有提交的空仓库。我创建一个文件 README.md,暂存它并提交它。 Git 存储一个树对象,该对象具有一个由 README.md 内容的哈希标识的 blob。

假设我修改了 README.md,暂存并提交。 Git 存储一个树对象,该对象具有一个由 README.md 修改内容的哈希标识的 blob。自然地,我们可以预期第二个散列不同于在第一个提交树中标识 README.md 的 blob 的散列。

Git 将如何回答有关 README.md 历史记录的请求?

git log README.md

我的预感是它会遍历提交历史并比较相关的 blob,但我不知道 Git 如何知道这些 blob 对应于同一文件的不同版本,除非是在微不足道的情况下。


【问题讨论】:

    标签: git version-control


    【解决方案1】:

    这实际上是一个很好的问题。

    提交的内部存储形式是部分相关的,所以让我们考虑一下。单个提交实际上非常小。这是 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 会将这些读入一个称为 indexcache 的数据结构。 (从技术上讲,它的内存版本是 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.fd/e.c

    假设我们正在提交a123456,文件名为a/b.c。然后我们开始提交f789abc。第二次提交没有a/b.c,但有d/e.f。 Git 将简单地从我们的索引(缓存的磁盘形式)和工作树中删除 a/b.c,并将新的 d/e.f 填充到我们的索引和工作树中,一切都很好。

    但假设我们要求 Git 比较 a123456f789abc。 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 匹配。如果它找到了一些文件,它会将这些文件配对(并将它们从不匹配文件的队列中取出——这个队列,保存来自 LR 的文件,是“重命名检测队列”)。

    那些具有不同名称的文件已被识别为同一个文件。小表弟鲍勃毕竟和大表弟鲍勃一样——除了这种情况,你们俩都还需要很小。

    因此,如果此重命名检测尚未L 中的文件与 R 中的文件配对,Git 会更加努力。现在它将提取实际的 blob,并计算一种“匹配百分比”。这使用了一个复杂的小算法,我不会在这里描述,但是如果两个文件中有足够多的子字符串匹配,Git 会将文件声明为 50、60、75 或更多百分比相似

    在重命名队列中找到一对文件,例如,72% 相似,Git 继续将文件与所有其他文件进行比较。如果它发现这两个中的一个与另一个相似度为 94%,则该相似性配对优于 72% 的相似性配对。如果不是,72% 的相似度就足够了——至少是 50%——所以 Git 会将这两个文件配对并声明它们具有相同的身份。

    在任何情况下,如果匹配足够好并且是所有未配对文件中最好的一个,则采用该特定匹配。再说一遍,小表弟鲍勃毕竟和大表弟鲍勃是一样的。

    在对所有个不匹配的文件对运行此测试后,git diff 获取匹配的结果并调用这些文件重命名。同样,只有在您使用 --find-renames(或 -M)时才会发生这种情况,如果您愿意,您可以将 阈值 设置为 50% 以外的值。

    打破不正确的匹配

    git diff 命令提供另一项服务。请注意,我们从假设开始,如果提交 LR 有具有相同 name 的文件,则这些文件是相同的文件,即使内容不同。但如果他们不是呢?如果L中的fileR中重命名为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 来决定。在某些情况下——例如检查提交 LR——这一点都没有关系。在某些情况下,例如区分两个提交,这很重要,但仅对我们作为试图了解发生了什么的人类而言。但在少数情况下,例如合并,它非常重要

    【讨论】:

    • 这太好了,感谢您彻底回答问题并加倍努力以涵盖文件身份的微妙之处。
    【解决方案2】:

    您的意思是,如果文件已更改?好吧,文件是否已更改实际上并不重要。每个revision 都指向一个,即该修订所代表的项目的根目录在那个时刻。树是一个递归结构,它包含更多树(根树的相同概念)或文件的名称。因此,您将获得树(目录)或文件的名称......以及 content 的 ID。如果对象是一个文件,你会直接得到内容……如果对象是一棵树,那么……你会得到另一棵具有不同结构和内容的树……以此类推,以此类推。现在...每个 revisionalso 指向其父修订(或父母,如果它是合并提交)。并且该修订还包含一棵树,该树当然会及时映射到项目的内容,等等。瞧!没有技巧。

    那么,如果文件更改了内容会发生什么?好吧....在构成您正在谈论的修订的树的结构中,您将拥有具有相同“名称”的树...但是由于文件的内容会更改,因此 ID 会更改。因此,名称将相同,ID 将更改。我认为您必须使用一点git cat-file -p 从您的修订开始,然后是对象 ID(树、blob),以便您完全了解发生了什么。

    【讨论】:

    • 添加示例以进一步解释自己。
    • @torek 的回答包含所有血淋淋的细节,我想。 :-)
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2014-10-04
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多