Git 重命名文件和 inode

Git renamed files and inodes

假设我们将以下命令应用于 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 检查 inode 以确定新文件与旧的已删除文件相同。 This and 所以回答,不过,建议 git 只检查文件的 内容 ,而不以任何方式检查它是否是同一个 inode。 (我的结论 (*):如果我对文件进行更大的修改,git 将不会检测到重命名,即使 inode 仍然相同。)

在我看来很明显,我错了,git 不检查索引节点(或根本不检查任何其他文件系统信息),只检查内容。但是后来,我发现 this other answer,它声称

In addition to the timestamp, it [i.e., git] records the size, inode, and other information from lstat to reduce the chance of a false positive. When you perform git-status, it simply calls lstat on every file in the working tree and compares the metadata in order to quickly determine which files are unchanged.

其实我有两个问题:

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

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

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

(我想答案是"so that the behaviour is the same on system that don't have inodes, e.g. Windows"。但是,如果是这样的话,那么这个"same behaviour"已经被依赖于检测变化的inode破坏了。)

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

让我们从附带讨论开始——尽可能简短地讨论 stand-alone——Git 的 HEAD、索引和 work-tree。让我们也简要地看一下 file/object 存储模型。然后,再说git diff,再说git status。然后我们将准备好了解索引如何作为缓存工作,以及索引节点从何而来。最后,我们将准备好了解真正的问题是如何发生的。

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


HEAD、索引和 work-tree

当然,work-tree 只是普通 (non-Git) 格式的文件树,您和计算机上的所有代码都可以使用它们。最初,您克隆存储库 and/or 运行 git checkout <em>branch</em> 和您的 work-tree现在填充了对应于某些分支提示的文件,例如 masterbranch。您还可以 运行 git checkout <em>hash</em> 或类似的得到 GIt 所谓的 "detached HEAD";在这种情况下,当前提交是一些历史提交,但和以前一样,您的 work-tree 填充了与该提交对应的文件。 (此规则有一些例外情况:例如,您可以有 未跟踪的文件; 并查看 Checkout another branch when there are uncommitted changes on the current branch。)

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

HEAD 和 work-tree 之间,有一个点 Git 与其他版本控制系统(如 Mercurial 和 Subversion)有很大的不同。 Git 公开——事实上 迫使 你知道——Git 的 index,也称为 staging-area 缓存 。这个索引确实,至少在形象上,正好位于 HEAD 和 work-tree 之间。 HEAD(当前提交)包含特殊 Git-only 形式的文件快照。 work-tree 包含所有普通格式的文件。如果我们把HEAD放在左边,work-tree放在右边,索引就占据了中间的space。如果您在一个新的存储库中只提交了一个 README 文件,您可能会遇到这种看起来相当愚蠢的情况:

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

HEAD中的README是read-only。它采用特殊的 Git 形式。你不能改变它。

索引中的README也是特殊的Git形式,但它是read/write:你可以改变它。你实际上根本无法使用它,因为它是那种特殊的Git-only形式。

您的 work-tree 中的 README 是普通 (non-Git) 形式。它是read/write:你可以用它做任何你想做的事。 Git 还不能使用它,因为它 不是 特殊的 Git-only 形式。

索引的完整用途很复杂,但它的简短版本(在我们完全进入 inode 之前)是 您构建下一次提交的地方。 如果您想 更改 README,或添加 文件,您可以先在 work-tree。假设您更改 README 并创建一个新的(尚未跟踪)a.txt:

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

出于此图表的目的,我将 README 的两个变体标记为 -(旧的)和 +(新的)。修改后的新 README 仅在您的 work-tree.

如果您现在要 运行 git add README,这将 复制 work-tree README 到特殊的 Git-only 格式,并将其放入索引中。相反,如果您 运行 git add a.txt,那将 复制 work-tree a.txt 到特殊的 Git-only 格式并且将其放入索引中。最终结果是:

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

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

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

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

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

在每种情况下,git commit 只是获取索引 中的任何内容 并将其转换为冻结的 read-only 新提交的快照.由于文件已经是特殊的 Git-only 格式,所以速度非常快。这是 Git 用来提高速度的技巧之一:缓慢的部分,从纯格式转换为特殊压缩 Git 格式,发生在 git add 期间,而不是 git commit 期间。如果您有数百万个文件,但只修改了两三个,Git 永远不必 re-compress 所有数百万个文件。

文件和对象存储

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

Git 对所有这四个东西所做的事情——Git 调用 objects——是将它们全部简化为加密校验和(当前为 SHA -1 但最终移动到新的校验和)。 Git 前置对象类型——committreeblobtag 以及以字节为单位的大小,并计算哈希值。结果保证是唯一的(另请参阅 How does the newly found sha1 collision affect git?). Git uses this as the key in a key-value store 将(压缩的)数据填充到存储库数据库中。Git 因此可以根据密钥快速提取对象数据。

这对我们来说意味着在一次提交中(由其唯一的哈希 ID 标识),每个文件实际上仅存储为一个 对。 (更准确地说,它是一个 三元组。在索引中也是如此,尽管 Git 存储了更多数据。)这使得判断文件是否完全未更改变得非常容易: 如果是,则它具有相同的哈希 ID,因为相同的输入数据总是归约到相同的哈希 ID。

由于实际的内容在ID下的key-value存储中,提交可以只列出ID。如果成千上万的提交列表 READMEa.txt 具有相同的 ID,则实际文件仅存储一次,在 ID 下;每个提交只存储 ID。

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

git diff 并重命名检测

有很多关于 git diff 的详细信息——其中一些很快就会打动我们——但让我们暂时忽略它们,而不是专注于 git diff 在你给出时是如何工作的它有两个特定的提交。 Git 可以查找两个提交,获取它们存储的快照树,并比较 ID。 任何匹配的 ID 都意味着文件匹配,因此 git diff 只需查看具有不同 ID 的文件。这是一个巨大的 time-saver。

假设我们要求 Git 比较 commit/tree L(左)与 commit/tree R(右),每个文件 除了 for README 都具有相同的 ID。也就是说,La.txt 的 ID 为 12345...,它的 b.dat 的 ID 为 6789a...,但是 L READMEccccc...Ra.txt也是12345...,它的b.dat也是6789a...,但是RREADMEeeeee...。 Git 只需要提取两个 README blob(文件 ccccc...eeeee...)并比较这两个 blob 以产生上下文差异。

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

简而言之,这就是 git diff 重命名检测的方式:取左边的树 L 和右边的树 RLR 中存在的所有文件都是 "the same" 或 "modified"。 L中是但在R[=315=中不是的文件], 可能与 R 的文件相匹配。首先快速检查他们的哈希值并配对你完全匹配。然后,对剩下的所有内容进行相似性扫描,并将足够相似的配对:它们被重命名(也可能略有修改)。 L 中消失的任何剩余文件或 R 中的新文件已被删除或 newly-added.

使 git diff 变快是 work-tree

的问题

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

git status命令只是运行两个git diff

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

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

作为缓存的索引

在那些显示 HEAD、索引和 work-tree 的早期图表中,请注意所有三个文件完全相同是多么普遍,或者——一旦我们在work-tree 然后是 git add 它——让索引匹配 work-tree。如果有某种方法 Git 可以快速知道 work-tree 文件是否已被更改 更早的时候 Git 看起来非常好密切关注 work-tree 文件是否确定它与索引版本完全相同?

事实证明,虽然没有完美的方法,但有一种方法足够好(至少在大多数人的评价中)。 Git 可以在每个 work-tree 文件上使用 OS 的 lstat 系统调用,并在索引中保存来自调用的一些数据(部分但不是全部ctime、mtime、ino、模式、uid、gid 和大小,根据 index format documentation in the technical notes)。如果稍后 lstat 调用中的数据与先前调用中的数据匹配,则假定 work-tree 文件具有与之前相同的 in-file 数据。

这些数据的确切用途有点棘手。一些存储的数据用于判断一个 work-tree 文件是否是 "clean",即匹配索引中的版本。 There is a one-second granularity issue and a race condition 其中 Git 可能不得不暂时假设一个 work-tree 文件 干净,然后做一个昂贵的 对文件进行 clean 操作以确定它是否真的干净。 但是请注意,一般情况下 Git 只是做了额外的工作,即放慢速度来检查一个干净的文件是否应该被认为是干净的。它不会导致 Git 认为一个文件实际上是脏的。 当您设法设置 both 时,可能会在这里欺骗检测器的一种情况mtime 和 ctime 返回,同时保持(低 32 位)大小相同,但这样做通常还需要 re-setting 计算机时钟。1


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


一个真正的问题

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

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

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

同时,将文件从 work-tree 复制到索引会应用清理过滤器:

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

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

问题分为两部分:

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

如果 Git 根据其统计数据和索引数据确定文件是 "clean",但是您 更改 $clean 过滤器是应用,或者 $clean 做什么,然后 re-cleaning 文件并将结果写入索引将产生 不同的索引数据 。换句话说,即使索引的缓存属性宣称文件是干净的,但它实际上是脏的。

这通常出现在您向配置添加 line-ending 更改时 and/or 编辑 .gitattributes 以更改应用 line-ending 更改的文件。 请注意,如果您从来没有 Git 触摸行尾,这绝不是问题。

有两种补救措施,一种通过删除并重新创建索引来 en-masse,另一种更简单:

  • 如果您知道您没有暂存任何文件,您可以删除索引文件 (.git/index) 和 运行 git reset(它执行 --mixed 重置,re-creating 来自 HEAD 的索引)。如果你 暂存文件并遇到这个问题,你仍然可以使用这个补救措施,你只需要re-stage。如果您已经小心地暂存了某些文件的 部分 ,您不想使用此方法,但您可以使用更简单的 one-file-at-a-time 补救措施。

  • 如果你只是想强制Git认为某些文件$path是脏的,更新它的修改时间为"now",例如:

    $ touch $path
    

    现在文件被标记为脏,在查看文件是否干净之前,Git 将被迫 运行 无论 currently-defined 清理过程如何。

我认为你在这里混淆了两个有点不同的概念:

  • git 存储
  • git 客户行为

首先 关于 文件在 git 中的内部存储。 简而言之:当文件存储在 git 中时,根本没有对索引节点和差异的引用。

如您所知,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 中看到的树存储文件名、文件模式(类似于 unix 文件模式,尽管有点不同)和对存储此文件内容的二进制 blob 的引用。 例如,这里是来自特定提交的 .gitignore 文件内容的前 3 行,它具有散列 2dd98d7ddcdb1c24d5fa368c349614baec840167:

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

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

Git 存储没有对 diff 的引用,也没有对 inode 的引用。

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

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

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