完整的答案很复杂,但这里没有什么可担心的。有一个真正的问题,我将在最后讨论它,但与inode无关。
让我们先来简要讨论一下Git的HEAD、索引和工作树。让我们简要地看一下文件/对象存储模型。然后,让我们谈谈git diff,再谈谈git status。然后我们就可以准备好看索引如何作为缓存工作,以及inode的作用。最后,我们准备好看到真正的问题是如何发生的。
在这里,我会插入一个总结:通常情况下,所有这些都是完全不可见的。缓存数据是正确的,第二个 git diff
运行得很快。或者,缓存数据已过期,Git 注意到缓存数据已过期,第二个 git diff
运行得更慢,作为一个副作用,更新任何它可以更新的缓存数据,以便另一个 git diff
由另一个 git status
运行时将运行得很快。所以,通常情况下,您不需要关心这些。
HEAD
,索引和工作树
工作树当然就是一个普通文件树(非 Git 格式),你和你电脑上的所有代码都可以在其中工作。最初,你克隆了一个仓库和/或运行了 git checkout branch
命令,你的工作树现在被填充了与一些分支末端对应的文件,如 master
或 branch
。你也可以运行类似于 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称之为
trees和
annotated 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。也就是说,
L的
a.txt
具有ID
12345...
,它的
b.dat
具有ID
6789a...
,但
L的
README
是
ccccc...
。
R的
a.txt
也是
12345...
,它的
b.dat
也是
6789a...
,但
R的
README
是
eeeee...
。Git 只需要提取两个
README
blob(文件
ccccc...
和
eeeee...
),并将这两个 blob 进行比较以生成上下文差异。
现在假设我们让 Git 比较两棵树,
L 和
R 之间除了
L 有一个名为
README
的文件,而
R 有一个名为
README.md
的文件,其他一切都相同。这个文件被重命名了吗?可能是!Git 可以首先比较这两个哈希值。如果完全匹配,则文件肯定被重命名了。如果不完全匹配,Git 可以提取这两个 blob 并比较它们的相似性。如果它们看起来非常相似(例如相似度达到了 97%),Git 可以“假定”该文件已被重命名。
简而言之,这就是
git diff
如何进行重命名检测的过程:拿左边的树
L 和右边的树
R。所有同时存在于
L 和
R 的文件要么“相同”,要么“被修改”。那些曾经在
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.