Git 重命名了文件和索引节点



考虑我们将以下命令应用于在 git 下跟踪的文件 (hello.txt) (在干净的工作副本中):

echo "hi" >> hello.txt
mv hello.txt bye.txt
git rm hello.txt
git add bye.txt
git status

结果:

On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
renamed:    hello.txt -> bye.txt

因此,git 知道它是同一个文件,即使它被重命名了。 我有一些模糊的记忆,git 检查索引节点以确定新文件与旧的已删除文件相同。 但是,这个和这个 SO 答案表明 git 只检查文件的内容,而不会以任何方式检查它是否是同一个 inode。(我的结论(*):如果我对文件进行了更大的修改,git 不会检测到重命名,即使 inode 仍然相同。

在我看来很明显,我错了,git 不检查 inode(或任何其他文件系统信息),只检查内容。但后来,我找到了另一个答案,它声称

除了时间戳之外,它还记录了大小、索引节点和其他 来自 LSTAT 的信息,以减少误报的可能性。什么时候 你执行 git 状态,它只是在 工作树并比较元数据,以便快速确定 哪些文件保持不变。

我实际上对此有两个问题:

  1. 我下面的理解正确吗?

Git 确实(也)依赖 inode 来检测文件是否已更改,但它不使用 inode 来检测文件重命名。

  1. 假设 1. 是正确的。为什么 git 不依赖 inode 来检测文件重命名? 如果是这样,那么我们就不会出现上面标有 (*) 的问题。(即,无论内容更改有多大,它都会检测到重命名。

(我想答案是类似于"以便在没有索引节点的系统上行为相同,例如Windows"。但是,如果是这种情况,那么这种"相同的行为"已经通过依赖 inode 来检测更改而被打破。

完整的答案很复杂,但这里没有理由担心。 有一个真正的问题,我将在最后讨论,但它与 inode 无关。

让我们从侧面开始讨论 - 尽可能简短,并且仍然保持独立 - Git 的HEAD、索引和工作树。 让我们也简要地看一下文件/对象存储模型。 然后,让我们谈谈git diff,然后谈谈git status。 然后,我们将准备好查看索引如何作为缓存工作,以及索引节点的用武之地。 最后,我们将准备好看看真正的问题是如何发生的。

不过,在这里,我将插入以下摘要:通常,这一切都是完全看不见的。 缓存的数据是正确的,git status运行的第二个git diff运行速度很快。 或者,缓存的数据已过期,Git 会注意到缓存的数据已过期,并且第二个git diff速度变慢,并且作为副作用 - 更新它所能更新的任何缓存数据,以便另一个git status运行的另一个git diff将很快运行。因此,通常,您不必关心这些。


HEAD、索引和工作树

当然,工作树只是普通(非 Git)格式的文件树,您和计算机上的所有代码都可以在其中使用它们。 最初,您克隆存储库和/或运行git checkoutbranch,您的工作树现在填充了与某些分支提示相对应的文件,例如masterbranch。 您还可以运行git checkouthash或类似的方式来获得 GIt 所谓的"分离的 HEAD";在这种情况下,当前提交是一些历史提交,但和以前一样,您的工作树中填充了与该提交对应的文件。 (此规则有一些例外:例如,您可以有未跟踪的文件;当当前分支上有未提交的更改时,请参阅签出另一个分支。

根据定义,HEAD提交是当前提交。 与所有其他提交一样,此提交是只读的;它有一些元数据(作者和提交者、父提交哈希和提交消息);它存储一个树对象哈希ID,通过它(间接)存储文件的完整快照。 由于这是当前的提交,因此至少在最初,并且有各种特殊情况可能会干扰此处,因此您将在工作树中看到的内容。 请注意,当前提交中的所有文件不仅仅是只读的,就像对象数据库中的所有文件一样;它们也采用特殊的仅限 Git 格式。 很少有非 Git 命令可以读取这些文件。

但是,在HEAD和工作树之间,有一点 Git 与其他版本控制系统(如 Mercurial 和 Subversion)完全不同。 Git 公开(实际上迫使您了解)Git 的索引,也称为暂存区缓存。 这个索引确实,至少在比喻上,站在HEAD和工作树之间。HEAD(当前提交)包含特殊仅限 Git 形式的文件快照。 工作树包含普通形式的所有文件。 如果我们把HEAD放在左边,把工作树放在右边,索引就会占据两者之间的空间。 如果您在一个新存储库中,只提交了自述文件,您可能会遇到这种看起来相当愚蠢的情况:

HEAD     index     w.tree
------    ------    ------
README    README    README

HEAD中的README是只读的。 它是特殊的 Git 形式。 您无法更改它。

索引中的README也是特殊的 Git 形式,但它是读/写的:您可以更改它。 不过,您实际上根本无法使用它,因为它是那种特殊的仅限 Git 的形式。

工作树中的README是普通(非 Git)形式。 它是读/写的:你可以用它做任何你想做的事情。不过,Git还不能使用它,因为它不是特殊的仅限 Git 的形式。

索引的全部目的很复杂,但是在我们进入inode之前,它的简短版本是,它是您构建下一次提交的地方。如果要更改README添加新文件,可以先在工作树中进行更改。 假设您更改了README并创建了一个新的(尚未跟踪的)a.txt

HEAD     index     w.tree
------    ------    ------
README-   README-   README+
a.txt

出于此图的目的,我用-(旧的)和+(新的)标记了README的两个变体。 新的、修改后的README仅在您的工作树中

如果你现在要运行git add README,这会将工作树README复制到特殊的仅 Git 格式中,并将其放入索引中。 相反,如果您运行git add a.txt,则会将工作树a.txt复制到特殊的仅限 Git 格式并将其放入索引中。 最终结果是:

HEAD     index     w.tree
------    ------    ------
README-   README-   README+
a.txt     a.txt

如果你现在运行git commit- 没有先运行git add README- Git 现在将从索引中的任何内容进行新的提交。 那是旧的README和新的a.txt. 这个新提交成为当前(HEAD)提交,所以现在我们有:

HEAD     index     w.tree
------    ------    ------
README-   README-   README+
a.txt     a.txt     a.txt

如果你现在运行git add README,索引将获得新版本的README;提交将使一个新的HEAD使用新README提交,以便所有内容都匹配:

HEAD     index     w.tree
------    ------    ------
README    README    README
a.txt     a.txt     a.txt

在每种情况下,git commit只是立即获取索引中的任何内容,并将其转换为新提交的冻结只读快照。 由于文件已经采用特殊的仅限 Git 格式,因此速度非常快。 这是 Git 用来获得速度的技巧之一:从普通格式转换为特殊压缩 Git 格式的慢速部分发生在git add期间,而不是在git commit期间。 如果你有数百万个文件,但只修改了两三个,Git 永远不必重新压缩所有数百万个文件。

文件和对象存储

让我们看看 Git 存储提交和文件的方式(Git 称之为blob),以及它的另外两种中间对象类型,Git 称之为注释标记。 Git 可以对这些数据使用多个级别的压缩,但我们不会讨论其中任何一个;我们只来看看 Git 如何使用哈希 ID。

Git 对所有这四件事(Git 称之为对象)所做的是将它们全部简化为加密校验和(目前为 SHA-1,但最终会移动到新的校验和)。 Git 在对象类型(committreeblobtag和大小(以字节为单位)前面加上,并计算哈希值。 结果保证是唯一的(另请参阅新发现的 sha1 冲突如何影响 git? Git 使用它作为键值存储中的键,将(压缩的)数据填充到存储库数据库中。 因此,Git 可以在给定密钥的情况下快速提取对象数据。

这对我们来说意味着,在提交中(由其唯一的哈希ID标识),每个文件实际上都存储为一个<名称,ID>对。 (更准确地说,它是<模式、名称、ID>三重。 在索引中也是如此,尽管在那里,Git 存储了更多的数据。 这使得判断文件是否完全不变变得非常容易:如果是,它具有相同的哈希 ID,因为相同的输入数据总是减少到相同的哈希 ID。

由于实际内容位于 ID 下的键值存储中,因此提交可以只列出 ID。 如果数千个提交列出具有相同 ID 的READMEa.txt,则实际文件仅在 ID 下存储一次;每个提交仅存储 ID。

当然,如果一个提交有一个版本的README和一个 ID,而另一个提交有一个不同版本的README,那么这两个提交将为名为README的文件提供两个不同的 ID。

git diff和重命名检测

关于git diff有很多细节——其中一些将在一会儿击中我们——但让我们暂时忽略它们,而是专注于当你给它两次特定的提交时git diff是如何工作的。 Git 可以查找这两个提交,获取它们存储的快照树,并比较 ID。任何匹配的 ID 都意味着文件匹配,因此git diff只需查看具有不同 ID 的文件。这节省了大量时间。

假设我们要求 Git 比较提交/树L(左)与提交/树R(右),并且README之外的每个文件都具有相同的 ID。 也就是说,La.txt有ID12345...,它的b.dat有ID6789a...,但LREADMEccccc...Ra.txt也是12345...的,它的b.dat也是6789a...的,但RREADMEeeeee...。 Git 只需要提取两个READMEblob(文件ccccc...eeeee...),并比较这两个 blob 以生成上下文差异。

现在假设我们有 Git 比较两棵树,除了L 有一个名为README的文件和 R 有一个名为README.md的文件之外,LR之间的一切都是一样的。文件是否已重命名? 本来可以的! 首先,Git 可以比较两个哈希。 如果它们完全匹配,则文件肯定已重命名。 如果它们不完全匹配,Git 可以提取两个 blob 并比较它们的相似性。 如果它们看起来非常相似(例如,97% 相似),Git 可以假设文件已重命名。

简而言之,这就是git diff重命名检测的方式:取左边L上的树和右边R上的树。LR中存在的所有文件要么"相同",要么"修改"。L中但不在R 中的文件可能与仅在R中的文件匹配。首先快速检查它们的哈希值并配对完全匹配。 然后,对剩下的所有内容进行相似性扫描,并将那些足够相似的内容配对:它们被重命名(也可能略有修改)。 从L中删除或R中新增的任何剩余文件都已删除或新添加。

快速git diff是工作树的问题

上面概述的方案适用于实际提交,因为提交中的文件采用特殊的、仅限 Git 的形式。 它甚至可以与索引一起使用,因为索引中的文件也是特殊的、仅限 Git 的形式:它们已经被简化为哈希 ID。 在这种情况下,索引就像一棵扁平的树。 唉,工作树不是特殊的、仅限 Git 的形式。 我们很快就会回到这个问题,因为....

git status命令只运行两个git diff

当你运行git status时,Git 会运行两个内部差异。 第一个比较HEAD与指数。 由于我们在上面看到的原因,这非常快:一切都已经采用这种理想格式,文件减少为唯一的哈希 ID。 Git 可以将HEAD扫描为L,将索引扫描为R,并非常快速地计算差异。 (由于我们不关心更改本身 - 只关心哪些文件是相同的,哪些被重命名,哪些被修改 - Git 可以省略大多数此类差异中最慢的部分,即计算要打印的上下文差异。

唉,第二个差异要慢得多:Git 必须比较索引和工作树。 工作树不是特殊的仅 Git 格式。 Git可以创建第二个临时索引并向其中添加所有内容,但这会非常慢,所以它不会这样做。 为了使这个差异更快,Git 秘密地将缓存数据添加到索引中,这就是索引节点的用武之地。 索引节点编号是此缓存数据的一部分。 但这(通常,至少;见下文)只是一个速度黑客。 如果索引节点编号发生变化,git status只会变慢

作为缓存的索引

在前面显示HEAD、索引和工作树的图中,请注意所有三个文件完全相同的情况,或者(一旦我们修改了工作树中的文件,然后对其进行git add——让索引与工作树匹配,这是很常见的。 如果 Git 可以通过某种方式快速知道工作树文件是否被更改,因为 Git 非常仔细地查看工作树文件时,它肯定与索引版本完全相同,或者不是完全相同?

事实证明,虽然没有完美的方法,但有一种方法足够好(至少在大多数人的估值中)。 Git 可以在每个工作树文件上使用操作系统的lstat系统调用,并在索引中保存调用中的一些数据(根据技术说明中的索引格式文档,ctime、mtime、ino、mode 、uid、gid 和大小的一部分但不是全部)。 如果更高lstat调用中的数据与较早调用中的数据匹配,则假定工作树文件具有与以前相同的文件内数据。

这些数据的确切用途有点棘手。 一些存储的数据用于确定工作树文件是否"干净",即与索引中的版本匹配。 存在一秒钟的粒度问题和争用条件,其中 Git 可能必须暂时假定工作树文件干净,然后对该文件执行昂贵的清理操作以确定它是否真的干净。但是请注意,一般情况是 Git 只是做额外的工作, 即,放慢速度以检查干净的文件是否应被视为干净。 它不会导致 Git 认为文件是干净的,因为它实际上是脏的。当您设法将mtime和ctime设置回来,同时保持(低32位)大小不变时,可能会欺骗检测器的一种情况,但这样做通常需要重新设置计算机的时钟。1


1这是因为将 mtime 更改为您选择的任何值的系统调用都将 ctime 设置为 "now",其中 "now" 取自系统时钟。 因此,要将 mtime 设置为(例如)昨天,同时将 ctime 设置为昨天,您必须首先将系统本身设置为昨天。


一个真正的问题

但是,还有一个更重要的问题确实出现在真实的存储库中。 假设索引的缓存属性告诉您工作树文件是干净的,即工作树版本与文件的索引版本匹配。 还假设您正在使用带有清洁和污迹过滤器或行尾转换的.gitattributes。 在这种情况下,将文件从索引复制到工作树将应用污迹过滤器:

read-from-index :0:$path | $smudge > $path

(其中read-from-index是一个有点假设的程序,实际上是由git cat-file -p实现的,$smudge是此文件的过滤器,$path是您想要的文件路径名 -:0:是 Git 用于"索引槽零"的特殊语法)。

同时,将文件从工作树复制到索引会应用干净过滤器:

$clean < $path | write-to-index $path

(其中可以使用git update-index编写write-to-index;您还需要提供模式和阶段编号)。

问题分为两部分:

  • $clean$smudge选择的过滤器取决于行尾转换选择、.gitattributes内容和您的配置;
  • $clean$smudge采取的行动不受 Git 的控制

如果 Git 根据其统计信息和索引数据确定文件是"干净的",但你更改了应用的$clean筛选器或$clean执行的操作,则重新清理文件并将结果写入索引将生成不同的索引数据。 换句话说,即使索引的缓存属性声明文件是干净的,它实际上是脏的。

当您将行尾更改添加到配置和/或编辑.gitattributes以更改应用行尾更改的文件时,通常会显示这种情况。请注意,如果您从未有过 Git 触摸行结尾,这永远不会成为问题。

有两种补救措施,一种是通过删除和重新创建索引来集体工作,另一种是更简单的补救措施:

  • 如果您知道尚未暂存任何文件,则可以删除索引文件 (.git/index) 并运行git reset(执行--mixed重置,从HEAD重新创建索引)。 如果您暂存文件并遇到此问题,您仍然可以使用此补救措施,您只需要重新暂存即可。 如果您已仔细暂存某些文件的某些部分,则不想使用此方法,但可以使用更简单的一次一个文件的补救措施。

  • 如果您只想强制 Git 将某些文件$path视为脏文件,请将其修改时间更新为"现在",例如:

    $ touch $path
    

    现在文件被标记为脏,Git 将强制运行当前定义的清理过程,然后再查看文件是否干净。

我认为您在这里混合了两个不同的概念:

  • git 存储
  • Git 客户端行为

首先是关于git 中文件的内部存储。 简而言之:当文件存储在 git 中时,根本没有对 inode 和 diffs 的引用。

如您所知,git 在提交树上运行。每个提交都有对树的引用(类似于文件系统中的目录):

$ git cat-file commit HEAD  # example for some random git repo on my disk
tree e68e0f9afad22357e47d0a341770f2315ee16b2c
parent 6d13fea5d0c1d0b4aedf96b7141c05c73bf9c9cb
author Timur Batyrshin <erthad@gmail.com> 1590062438 +0300
committer Timur Batyrshin <erthad@gmail.com> 1590062438 +0300
add icon to the workflow

这里e68e0f9afad22357e47d0a341770f2315ee16b2c是一个哈希引用树对象附加到此提交。您可以浏览其内容:

$ git ls-tree e68e0f9afad22357e47d0a341770f2315ee16b2c
100644 blob 2dd98d7ddcdb1c24d5fa368c349614baec840167    .gitignore
100644 blob 71cf7988bc6ca7e38fbb8d0490cb0b9f2368d3dc    LICENSE
100644 blob 67ed24d3dd5ed71a9b03180d0540276c659e71c3    README.md
100644 blob 5ab2fb346e9bf27d048bad4725ae1180a0d1fffc    icon.png
100644 blob 198e0a4a3df7eedc752643d1a7d21b825ff5f2b2    info.plist
100755 blob 9969b7006112d4d25a7af472cd63ba61e6fd3736    login.sh
100755 blob 834e97824d38849d9254aa4607e636dc5ef7bae4    populate.sh
100755 blob 48bf586e84f820c1434959e8064fe8331a0ff5e3    show.rb

正如你在 git store 文件名、文件模式(类似于 unix 文件模式,尽管有点不同)和对存储此文件内容的二进制 blob 的引用中看到的树。 例如,这是.gitignore具有哈希2dd98d7ddcdb1c24d5fa368c349614baec840167的特定提交的文件的前 3 行内容:

$ git cat-file blob 2dd98d7ddcdb1c24d5fa368c349614baec840167 | head -n 3
*.gem
*.rbc
/.config

总结一下:每个 git 提交都指向一个树对象。树对象依次指向具有文件内容的特定 Blob(或其他子树)。

Git 存储没有对差异的引用,也没有对索引节点的引用。

Git 存储甚至没有对重命名的引用:不同的树指向不同的 blob,当你需要差异时,git 客户端只是比较 2 个 blob 并为你生成差异。人类通常有兴趣看到重命名,所以 git 也会为你生成这些信息。我想最初它仅在引用相同 blob 的文件名在下一次提交中变得不同时才显示重命名,并在几个版本后开始显示小差异作为重命名。

现在进入第二部分:git 客户端行为。Git 能够非常快速地遍历历史和树结构,但是当您浏览差异时,git 客户端必须计算您需要的每个差异,并且在大型存储库中可能非常耗时。

出于这个原因,git 客户端通常采用各种缓存机制和其他方法来加快该过程。这可以是缓存文件统计信息,比较索引节点以及您能想到的任何其他内容。@torek的回答很好地描述了问题和方法。

相关内容

最新更新