Git重命名文件和inode

3

考虑到我们对一个被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来确定新文件与旧删除文件相同。 然而,这个这个SO答案表明,git只检查文件的内容,并且不以任何方式检查它是否是相同的inode。(我的结论(*):如果我对文件进行了更大的修改,则git将无法检测到重命名,即使inode仍然相同。)
因此,对我来说很明显,我错了,git不检查inode(或任何其他文件系统信息),只检查内容。但是,我发现另一个答案,它声称:
引用: 除时间戳外,git还记录lstat中的大小、inode和其他信息,以减少伪阳性的机会。当您执行git-status时,它只是在工作树中的每个文件上调用lstat并比较元数据,以便快速确定哪些文件未更改。
我实际上有两个问题:
1.我的理解正确吗? Git确实依赖inode检测文件是否更改,但不使用inode检测文件重命名。
2.假设问题1的答案是正确的。为什么git不依赖inode来检测文件重命名?如果它这样做,那么我们就不会有上面标有(*)的问题。(即,无论内容更改有多大,它都会检测到重命名。)
(我想答案可能是“这样行为在没有inode的系统上是相同的,例如Windows”。然而,如果是这种情况,那么通过依赖inode来检测更改已经破坏了这种“相同的行为”。)

1
你假设(*)是一个问题。Git并不认为它是一个问题,这是设计上的考虑。 - Edward Thomson
@EdwardThomson: 从Git用户角度来看,这种行为的好处是什么? - Attilio
2个回答

5
完整的答案很复杂,但这里没有什么可担心的。有一个真正的问题,我将在最后讨论它,但与inode无关。
让我们先来简要讨论一下Git的HEAD、索引和工作树。让我们简要地看一下文件/对象存储模型。然后,让我们谈谈git diff,再谈谈git status。然后我们就可以准备好看索引如何作为缓存工作,以及inode的作用。最后,我们准备好看到真正的问题是如何发生的。

在这里,我会插入一个总结:通常情况下,所有这些都是完全不可见的。缓存数据是正确的,第二个 git diff 运行得很快。或者,缓存数据已过期,Git 注意到缓存数据已过期,第二个 git diff 运行得更慢,作为一个副作用,更新任何它可以更新的缓存数据,以便另一个 git diff 由另一个 git status 运行时将运行得很快。所以,通常情况下,您不需要关心这些。


HEAD,索引和工作树

工作树当然就是一个普通文件树(非 Git 格式),你和你电脑上的所有代码都可以在其中工作。最初,你克隆了一个仓库和/或运行了 git checkout branch 命令,你的工作树现在被填充了与一些分支末端对应的文件,如 masterbranch。你也可以运行类似于 git checkout hash 的命令来获取 Git 称之为 "detached HEAD" 的状态;在这种情况下,当前提交是某个历史提交,但与之前一样,你的工作树被填充了与该提交对应的文件。(有一些例外情况:例如,你可能有一些未跟踪的文件;请参见 当当前分支存在未提交的更改时切换到另一个分支。)

HEAD 提交是当前提交。与其他每个提交一样,该提交是只读的;它具有一些元数据(作者和提交者、父提交哈希和提交消息);并且它存储了一个树对象哈希 ID,通过这个 ID 它间接存储了文件的完整快照。由于这是当前提交,最初至少,在这里会看到你工作目录中的内容,当然还有各种特殊情况可能会干扰。请注意,当前提交中的所有文件不仅像对象数据库中的所有内容一样是只读的;它们还以一种特殊的 Git-only 格式存在。几乎没有非 Git 命令能够读取这些文件。

HEAD和工作树之间,Git与Mercurial和Subversion等其他版本控制系统相比存在一个非常根本的差异。Git公开并实际上强制你了解Git的索引,也称为暂存区和缓存。这个索引确实,至少在形式上,就站在HEAD和工作树之间。 HEAD(当前提交)以特殊的Git格式包含文件的快照。工作树以普通形式包含所有文件。如果我们将HEAD放在左边,工作树放在右边,则索引占据中间的空间。如果您在具有仅提交了README文件的新存储库中,则可能会出现以下非常愚蠢的情况:

 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-only 格式,并将其放入索引中。如果你运行 git add a.txt,这将把工作树中的 a.txt 复制到特殊的 Git-only 格式,并将其放入索引中。最终结果是:
 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称之为)的方式,以及它的另外两种中间对象类型(Git称之为treesannotated tags)。Git可以对这些数据使用多个级别的压缩,但我们不会涉及任何这些;我们只看一下Git如何使用哈希ID。
Git将这四个对象(Git称之为“对象”)都转换为一个加密校验和(目前是SHA-1,但最终会移动到新的校验和)。Git在对象类型(提交、树、blob或标签)和字节大小之前添加前缀,并计算哈希值。结果保证是唯一的(另请参见新发现的sha1碰撞如何影响git?)。Git使用此作为键在key-value存储中存储(压缩的)数据到存储库数据库中。因此,给定键后,Git可以快速提取对象数据。
对我们来说,这意味着在提交中(由其唯一哈希 ID 标识),每个文件实际上只存储为一个<名称,ID>对。(更正确地说,它是一个<模式、名称、ID>三元组。虽然在索引中也是如此,但 Git 在那里存储了更多的数据。)这使得很容易判断文件是否完全未更改:如果是,则具有相同的哈希 ID,因为相同的输入数据总是会减少到相同的哈希 ID。
由于实际的内容在键值存储中以 ID 为标识,因此提交可以列出该 ID。如果数千次提交使用相同的 ID 列出 README 或 a.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 只需要提取两个 README blob(文件ccccc...eeeee...),并将这两个 blob 进行比较以生成上下文差异。
现在假设我们让 Git 比较两棵树,LR 之间除了 L 有一个名为 README 的文件,而 R 有一个名为 README.md 的文件,其他一切都相同。这个文件被重命名了吗?可能是!Git 可以首先比较这两个哈希值。如果完全匹配,则文件肯定被重命名了。如果不完全匹配,Git 可以提取这两个 blob 并比较它们的相似性。如果它们看起来非常相似(例如相似度达到了 97%),Git 可以“假定”该文件已被重命名。
简而言之,这就是 git diff 如何进行重命名检测的过程:拿左边的树 L 和右边的树 R。所有同时存在于 LR 的文件要么“相同”,要么“被修改”。那些曾经在 L 中但不在 R 中的文件,可以与仅在 R 中的文件匹配。首先快速检查它们的哈希值并配对完全匹配的文件。然后,在剩余的所有文件上进行相似性扫描,并将足够相似的文件配对:它们被重命名了(也许稍微修改了一下)。任何从 L 中消失或在 R 中新添加的文件都被删除或新添加了。

使 git diff 快速的问题在于工作树

上述方案对实际提交非常有效,因为提交中的文件处于特殊的、仅适用于Git的形式。即使是索引中的文件也是如此,因为它们已经被转换为哈希ID。在这种情况下,索引就像一个扁平化的树。不幸的是,工作树并不是以特殊的、仅适用于Git的形式存在。我们很快就会回到这个问题,因为... git status命令只运行两个git diff 当你运行git status时,Git会运行两个内部diff。第一个比较HEAD和索引。由于一切都已经处于理想的格式中,文件已经被转换为唯一的哈希ID,因此这非常快。Git可以将HEAD视为L,将索引视为R,并快速计算出差异。(由于我们只关心更改的文件,而不关心更改本身——只关心哪些文件相同,哪些文件重命名,哪些文件修改了——Git可以省略大多数此类diff最慢的部分,即计算上下文diff以打印。)
哎呀,第二个差异比较慢:Git必须将索引与工作树进行比较。 工作树不是专门的Git格式。 Git可以创建第二个临时索引并将所有内容添加到其中,但这样会非常慢,因此它不会这样做。 为了使这种差异更快,Git会在索引中秘密添加缓存数据,这就是inode发挥作用的地方。 inode号码是这些缓存数据的一部分。 但这通常(至少在下面看到的情况下)只是一个速度技巧。 如果inode号码发生更改,则git status会变得更慢。
索引作为缓存
在早期显示HEAD、索引和工作树的图表中,注意到三个文件完全相同是如此普遍,或者我们修改工作树中的文件,然后使用git add让索引与工作树匹配。如果有某种方法,Git可以快速知道工作树文件是否已经自上次Git非常仔细地查看工作树文件时发生了更改,并且知道它是否与索引版本完全相同?
事实证明,虽然没有完美的方法,但有一种方法足够好(至少在大多数人的评估中)。Git可以在每个工作树文件上使用操作系统的lstat系统调用,并在索引中保存从调用中获取的部分数据(ctime、mtime、ino、mode、uid、gid和size的一部分,根据技术说明index format documentation in the technical notes)。如果稍后的lstat调用中的数据与早期调用中的数据匹配,则假定工作树文件具有与之前相同的文件数据。
这些数据的确切用途有点棘手。存储的一些数据用于决定工作树文件是否“干净”,即是否与索引中的版本匹配。Git可能不得不假定一个工作树文件暂时是不干净的,并对该文件执行昂贵的清理操作,以找出它是否真正干净。然而,需要注意的是,通常情况下,Git只是做了额外的工作,即放慢速度来检查一个干净的文件是否应该被视为干净。它不会导致Git在实际上是脏的情况下将文件视为干净。这里可能会欺骗探测器的唯一情况是当你设法将mtime和ctime同时设置回去,同时保持(低32位)大小相同,但这通常也需要重新设置计算机的时钟。1

1这是因为更改mtime的系统调用会将ctime设置为“现在”,其中“现在”取自系统时钟,而你可以选择任何值。 因此,如果要将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

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

此问题分为两个部分:

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

如果Git根据其stat和索引数据确定文件“干净”,但您更改了应用的$clean过滤器,或者$clean的操作,则重新清理文件并将结果写入索引将生成不同的索引数据。换句话说,即使索引的缓存属性宣称该文件是干净的,实际上它是脏的。

通常情况下,当您将行尾更改添加到配置文件和/或编辑.gitattributes以更改应用行尾更改的文件时,此问题会出现。请注意,如果您从未让Git触及行尾,这就不会成为问题。
有两种解决方法,一种是通过删除并重新创建索引来进行批量处理,另一种则更简单:
  • If you know you have not staged any files, you can remove the index file (.git/index) and run git reset (which does a --mixed reset, re-creating the index from HEAD). If you have staged files and hit this problem, you can still use this remedy, you just need to re-stage. If you've carefully staged parts of some files you don't want to use this method, but you can use the simpler one-file-at-a-time remedy.

  • If you just want to force Git to consider some file $path as dirty, update its modification time to "now", e.g.:

    $ touch $path
    

    Now the file is marked dirty and Git will be forced to run whatever the currently-defined cleaning process is, before seeing whether the file is clean.


如果您没有未合并的索引条目,则可以使用 git read-tree $(git write-tree) - user4003407
哇,非常感谢您详尽的回答,我学到了很多!我现在的理解是正确的吗?“1. Git确实依赖(也)于inode来检测文件是否更改,但不使用inode来检测文件重命名。” --> 是的,它首先将工作树文件的lstat(包括inode)与缓存值进行比较,如果它们有所不同,则比较内容。 “2.为什么git不依赖inode来检测文件重命名?”-->因为Git使用整个lstat以及其部分(时间戳)随重命名而改变。 - Attilio
1
1是足够的:它不符合一般规则,即“如果缓存的inode统计信息中有任何更改,我们必须仔细查看是否真正更改了”。2不是必需的:Git希望检测文件重命名,即使不完全匹配,并在没有涉及inode的情况下进行,例如比较最终存储在包文件中的两个提交。如果“已重命名”与inode的存在无关,则不能依赖于inode内部的字段。 - torek

0

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

  • git存储
  • git客户端行为

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

正如你所知,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的引用。

例如,这是特定提交的哈希值为2dd98d7ddcdb1c24d5fa368c349614baec840167.gitignore文件内容的前3行:

$ git cat-file blob 2dd98d7ddcdb1c24d5fa368c349614baec840167 | head -n 3
*.gem
*.rbc
/.config
总结一下: 每个 git 提交都指向一个树对象。树对象则指向具有文件内容的特定 blob (或其他子树)。
Git 存储中没有与差异或 inode 相关的引用。
Git 存储甚至没有对重命名的引用:不同的树指向不同的 blob,当你需要 diff 时,git 客户端只需比较两个 blob,并为您生成差异。人们通常会对重命名感兴趣,所以 git 也会为您提供这些信息。我想最初它仅在下一次提交中引用相同 blob 的文件名更改时显示重命名,并在几个版本之后作为差异显示重命名。
现在来到第二部分git 客户端的行为。Git 能够非常快地遍历历史和树结构,但是当您浏览 diff 时,git 客户端必须计算您需要的每个单独的 diff,而在大型存储库中可能非常耗时。
因此,git 客户端通常采用各种缓存机制和其他手段来加速该过程。这可以是缓存文件统计信息、比较 inodes 或您能想到的任何其他内容。 @torek 的答案非常好地描述了问题和解决方法。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接