Git 重命名文件和 inode

Att*_*lio 3 git file-rename

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

echo "hi" >> hello.txt
mv hello.txt bye.txt
git rm hello.txt
git add bye.txt
git status
Run Code Online (Sandbox Code Playgroud)

结果:

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

    renamed:    hello.txt -> bye.txt
Run Code Online (Sandbox Code Playgroud)

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

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

除了时间戳之外,它[即git]还记录lstat的大小、inode和其他信息,以减少误报的机会。当您执行 git-status 时,它只需对工作树中的每个文件调用 lstat 并比较元数据,以便快速确定哪些文件未更改。

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

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

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

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

(我想答案类似于“这样在没有 inode 的系统上,行为是相同的,例如 Windows”。但是,如果是这样的话,那么这种“相同的行为”已经通过依赖 inode 被破坏了用于检测变化。)

tor*_*rek 5

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

\n\n

让我们先尽可能简短地讨论一下 xe2x80x94,同时仍然保持独立的 xe2x80x94Git、HEAD索引和工作树。让我们也简要地看一下文件/对象存储模型。那么,我们就来说说git diff,再来说git status。然后我们将准备好看看索引如何作为缓存工作,以及索引节点在哪里出现。最后,我们将准备好看看真正的问题是如何发生的。

\n\n

不过,我将在这里插入这个摘要: 通常,这一切都是完全不可见的。缓存的数据是正确的,第二个git diff运行git status速度很快。或者,缓存的数据已过期,Git 注意到缓存的数据已过期,第二个git diff会变慢,\xe2\x80\x94 作为副作用\xe2\x80\x94 会更新它可以更新的任何缓存数据,这样另一个人的另一次git diff奔跑git status会跑得很快。 因此,通常情况下,您不必关心这些。

\n\n
\n\n

HEAD、索引和工作树

\n\n

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

\n\n

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

\n\n

然而,在HEAD工作树和工作树之间,Git 与 Mercurial 和 Subversion 等其他版本控制系统有很大的不同。Git 公开了\xe2\x80\x94,实际上迫使您了解\xe2\x80\x94Git\ 的索引,也称为暂存区缓存。该索引确实(至少象征性地)位于HEAD工作树和工作树之间。(当前提交HEAD)包含特殊的仅 Git 形式的文件快照。工作树包含普通形式的所有文件。如果我们将HEAD工作树放在左侧,将工作树放在右侧,则索引将占据中间的空间。如果您位于一个仅提交了 README 文件的新存储库中,您可能会遇到这种看起来相当愚蠢的情况:

\n\n
 HEAD     index     w.tree\n------    ------    ------\nREADME    README    README\n
Run Code Online (Sandbox Code Playgroud)\n\n

in是只读的READMEHEAD它采用特殊的 Git 形式。你无法改变它。

\n\n

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

\n\n

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

\n\n

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

\n\n
 HEAD     index     w.tree\n------    ------    ------\nREADME-   README-   README+\n                    a.txt\n
Run Code Online (Sandbox Code Playgroud)\n\n

为了这张图的目的,我已经README-(旧的)和+(新的)标记了两种变体。新的、修改的README在您的工作树中

\n\n

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

\n\n
 HEAD     index     w.tree\n------    ------    ------\nREADME-   README-   README+\n          a.txt     a.txt\n
Run Code Online (Sandbox Code Playgroud)\n\n

如果您现在运行git commit\xe2\x80\x94 而没有先运行git add README\xe2\x80\x94Git 现在将从索引中的任何内容进行提交。这就是旧的和新的。这个新提交成为当前 ( ) 提交,所以现在我们有:READMEa.txtHEAD

\n\n
 HEAD     index     w.tree\n------    ------    ------\nREADME-   README-   README+\na.txt     a.txt     a.txt\n
Run Code Online (Sandbox Code Playgroud)\n\n

如果您现在运行git add README,索引将获取README;的新版本。提交将会用新的HEAD提交进行新的提交README,以便一切都匹配:

\n\n
 HEAD     index     w.tree\n------    ------    ------\nREADME    README    README\na.txt     a.txt     a.txt\n
Run Code Online (Sandbox Code Playgroud)\n\n

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

\n\n

文件和对象存储

\n\n

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

\n\n

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

\n\n

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

\n\n

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

\n\n

当然,如果一个提交具有一个READMEID 的一个版本,而另一个提交具有不同版本的README,则这两个提交将具有名为 的文件的两个不同 ID README

\n\n

git diff并重命名检测

\n\n

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

\n\n

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

\n\n

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

\n\n

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

\n\n

加快git diff速度是工作树的问题

\n\n

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

\n\n

git status命令只运行两个git diffs

\n\n

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

\n\n

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

\n\n

索引作为缓存

\n\n

在那些显示HEAD、索引和工作树的早期图表中,请注意所有三个文件完全相同是多么常见,或者\xe2\x80\x94一旦我们修改了工作树中的文件,然后git add它\ xe2\x80\x94 使索引与工作树匹配。如果有某种方法让 Git 能够快速知道工作树文件自早些时候以来是否已更改(当 Git 仔细查看工作树文件并确定其已更改或未更改时)会怎样?和索引版本完全一样吗?

\n\n

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

\n\n

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

\n\n
\n\n

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

\n\n
\n\n

一个真正的问题

\n\n

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

\n\n
read-from-index :0:$path | $smudge > $path\n
Run Code Online (Sandbox Code Playgroud)\n\n

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

\n\n

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

\n\n
$clean < $path | write-to-index $path\n
Run Code Online (Sandbox Code Playgroud)\n\n

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

\n\n

问题分为两部分:

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

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

\n\n

当您向配置添加行结束更改和/或编辑.gitattributes以更改应用行结束更改的文件时,通常会出现这种情况。 请注意,如果您从未有过 Git 触摸行结尾,这绝不是问题。

\n\n

有两种补救措施,一种是通过删除并重新创建索引来整体起作用,另一种更简单:

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

  • \n
  • 如果你只是想强制 Git 将某些文件视为$path脏文件,请将其修改时间更新为“现在”,例如:

    \n\n
    $ touch $path\n
    Run Code Online (Sandbox Code Playgroud)\n\n

    现在该文件被标记为脏,并且在查看文件是否干净之前,Git 将被迫运行当前定义的清理过程。

  • \n
\n