Linus Torvalds说Git“永远不会”是什么意思?跟踪文件?

引用Linus Torvalds在2007年谷歌科技讲座(43:09)期间被问及Git可以处理多少文件时的话:

Git跟踪你的内容。它从来没有跟踪过一个文件。在Git中无法跟踪文件。你能做的是跟踪一个只有一个文件的项目,但如果你的项目只有一个文件,你当然可以这样做,但如果你跟踪10,000个文件,Git永远不会把它们视为单个文件。Git认为一切都是完整的内容。Git中的所有历史都是基于整个项目的历史。

(Transcripts here.)

然而,当你深入研究Git的书时,你被告知的第一件事是Git中的文件可以是跟踪开始回升的。此外,在我看来,整个Git体验都是面向文件版本控制的。当使用git diffgit status时,输出以每个文件为基础。当使用git add时,你也可以在每个文件的基础上进行选择。您甚至可以以文件为基础查看历史记录,速度非常快。

这句话应该如何解释?在文件跟踪方面,Git与其他源代码控制系统(如CVS)有何不同?

30069 次浏览

在CVS中,历史记录是基于每个文件进行跟踪的。分支可能由具有不同版本的各种文件组成,每个文件都有自己的版本号。CVS基于RCS (修订控制系统),它以类似的方式跟踪单个文件。

另一方面,Git对整个项目的状态进行快照。文件不会被独立地跟踪和版本化;存储库中的修订指的是整个项目的状态,而不是一个文件。

当Git提到跟踪一个文件时,这仅仅意味着它将被包含在项目的历史记录中。Linus的演讲并没有提到Git上下文中的跟踪文件,而是将CVS和RCS模型与Git中使用的基于快照的模型进行了对比。

我同意布莱恩·m·卡尔森的回答: Linus确实区分了面向文件的版本控制系统和面向提交的版本控制系统,至少在一定程度上是这样。但我认为事情远不止于此。

我的书中,这是停滞的,可能永远不会完成,我试图提出一个分类版本控制系统。在我的分类法中,我们在这里感兴趣的术语是版本控制系统的原子性。看看现在第22页是什么。当VCS具有文件级原子性时,实际上每个文件都有历史记录。VCS必须记住文件的名称以及它在每个点上发生了什么。

Git不会这样做。Git只有一个提交历史——commit是它的原子性单位,而历史是存储库中的提交集。提交所记住的是数据——一棵充满文件名和每个文件的内容的树——加上一些元数据:例如,谁提交的,何时提交的,为什么提交的,以及提交的提交的内部Git哈希ID。(正是这个父节点,以及通过读取所有提交及其父节点而形成的有向循环图,了存储库中的历史。)

注意,VCS可以是面向提交的,但仍然可以逐文件存储数据。这是一个实现细节,尽管有时很重要,但Git也没有这样做。相反,每次提交都会记录一个,树对象编码文件的名字模式(即,这个文件是否可执行?)和指向实际文件内容的指针。内容本身是独立存储在blob对象中。与提交对象一样,blob获得一个对其内容唯一的哈希ID——但与只能出现一次的提交不同,blob可以在多次提交中出现。因此Git中的底层文件内容直接存储为blob,然后间接存储在树对象中,树对象的哈希ID被(直接或间接)记录在提交对象中。

当你要求Git显示文件的历史记录时,使用:

git log [--follow] [starting-point] [--] path/to/file

Git真正要做的是遍历提交历史,这是Git拥有的唯一历史,而不是显示

  • 该提交是非合并提交,并且
  • 该提交的父节点也有该文件,但父节点中的内容不同,或者该提交的父节点根本没有该文件

(但是其中一些条件可以通过额外的git log选项来修改,并且有一个非常难以描述的副作用,称为历史简化,它会使Git完全忽略历史遍历中的一些提交)。在某种意义上,您在这里看到的文件历史并不完全存在于存储库中:相反,它只是真实历史的一个合成子集。如果你使用不同的git log选项,你会得到不同的“文件历史”!

Git并不直接跟踪文件,而是跟踪存储库的快照,而这些快照恰好由文件组成。

我们可以这么看。

在其他版本控制系统(SVN, Rational ClearCase)中,您可以右键单击一个文件并获得其更改历史记录

在Git中,没有直接的命令可以做到这一点。看到这个问题。你会惊讶于有这么多不同的答案。没有一个简单的答案,因为Git不是简单地跟踪文件,不是以SVN或ClearCase的方式。

“git不跟踪文件”基本上意味着git的提交包含一个文件树快照,将树中的路径连接到一个“blob”,以及一个跟踪提交历史的提交图。其他的一切都是通过“git log”和“git blame”等命令实时重建的。这种重建可以通过各种选项来告知它应该如何查找基于文件的更改。默认的启发式方法可以确定blob何时在文件树中改变位置而没有改变,或者文件何时与以前不同的blob相关联。Git使用的压缩机制不太关心blob/文件的边界。如果内容已经在某个地方,这将使存储库增长较小,而不关联各种blob。

这就是存储库。Git还有一个工作树,在这个工作树中有跟踪文件和未跟踪文件。只有被跟踪的文件被记录在索引(暂存区?缓存?)并且只有在那里被跟踪的内容才会进入存储库。

索引是面向文件的,有一些面向文件的命令用于操作它。但最终在存储库中的只是以文件树快照、相关blob数据和提交的祖先的形式提交的文件。

由于Git不跟踪文件历史和重命名,它的效率也不依赖于它们,有时你必须尝试几次不同的选项,直到Git生成你感兴趣的历史/差异/错误。

这与Subversion这样的系统不同,Subversion的历史是记录而不是重建。如果没有记录,你就没机会听到。

实际上,我曾经构建了一个不同的安装程序,通过将它们签入Git,然后生成一个复制它们效果的脚本,来比较发布树。由于有时整个树都要移动,因此这产生的差异安装程序比覆盖/删除所有内容产生的差异安装程序要小得多。

令人困惑的是:

Git从来不会将它们视为单独的文件。Git认为一切都是完整的内容。

Git经常在自己的repo中使用160位哈希来代替对象。文件树基本上是与每个文件的内容(加上一些元数据)相关联的名称和散列的列表。

但是160位散列唯一地标识了内容(在git数据库的范围内)。因此,以哈希值为内容的树的状态为包括内容

如果你改变一个文件内容的状态,它的哈希值也会改变。但是如果它的哈希值改变了,那么与文件名内容相关联的哈希值也会改变。这反过来又改变了“目录树”的哈希值。

当git数据库存储目录树时,该目录树暗示并包含所有子目录的所有内容以及其中的所有文件

它以树结构组织,带有指向blob或其他树的指针(不可变的、可重用的),但从逻辑上讲,它是整个树的全部内容的单个快照。git数据库中的表示不是平面数据内容,但从逻辑上讲,它是它的所有数据,而不是其他数据。

如果您将树序列化到文件系统,删除所有.git文件夹,并告诉git将树添加回数据库,那么您最终不会向数据库添加任何东西——元素已经在那里了。

将git的散列看作指向不可变数据的引用计数指针可能会有所帮助。

如果你围绕它构建应用程序,文档就是一堆页面,有层,有组,有对象。

当你想要改变一个对象时,你必须为它创建一个全新的组。如果你想改变一个组,你必须创建一个新的层,这需要一个新的页面,这需要一个新的文档。

每次更改单个对象时,它都会生成一个新文档。旧文档继续存在。新文档和旧文档共享大部分内容——它们具有相同的页面(除了1)。该页面具有相同的层(除了1)。该层具有相同的组(除了1)。该组具有相同的对象(除了1)。

同样,我指的是逻辑上的复制,但在实现方面,它只是指向同一个不可变对象的另一个引用计数指针。

git回购很像这样。

这意味着一个给定的git变更集包含它的提交消息(作为哈希代码),它包含它的工作树,它包含它的父变更。

这些父更改包含它们的父更改,一直往回。

git repo中包含历史的部分就是这个更改链。这个更改链在以上级别“目录”树——从“目录”树,你不能唯一地得到一个更改集和更改链。

要了解文件发生了什么,可以从更改集中的该文件开始。这个变更集有历史。在该历史记录中,通常存在同名文件,有时具有相同的内容。如果内容相同,则文件没有更改。如果是不同的,那么就有了变化,需要做一些工作来弄清楚到底是什么。

有时文件不见了;但是,“目录”树可能有另一个具有相同内容的文件(相同的散列代码),所以我们可以以这种方式跟踪它(注意;这就是为什么要将提交到移动的文件与提交到编辑的文件分开)。或者文件名相同,并且检查后文件是否足够相似。

所以git可以拼凑一个“文件历史”。

但是,这个文件历史记录来自于对“整个更改集”的有效解析,而不是来自从一个文件版本到另一个文件版本的链接。

顺便说一句,跟踪“内容”是导致不跟踪空目录的原因 这就是为什么,如果你git rm一个文件夹的最后一个文件,文件夹本身被删除.

但情况并非总是如此,只有Git 1.4(2006年5月)通过提交443年f833强制执行“跟踪内容”策略:

Git状态:跳过空目录,并添加-u显示所有未跟踪的文件

默认情况下,我们使用--others --directory来显示无趣的目录(以引起用户的注意),而不显示目录的内容(以整理输出) 显示空目录没有意义,因此在这样做时传递--no-empty-directory

给出-u(或--untracked)将禁用此清理操作

.用户获取所有未跟踪文件

这在几年后的2011年1月的提交8 fe533中得到了回应,Git v1.7.4:

这与一般的UI理念是一致的:git跟踪内容,而不是空目录。

同时,在Git 1.4.3(2006年9月)中,Git开始将未跟踪的内容限制在非空文件夹中,使用提交2074年cb0:

它不应该列出完全未跟踪的目录的内容,而只列出该目录的名称(加上后面的'/')。

跟踪内容是让git在早期(git 1.4.4, 2006年10月,提交cee7f24)更高效的原因:

更重要的是,它的内部结构被设计为通过允许从同一个提交中获得多个路径来更容易地支持内容移动(也就是剪切和粘贴)。

这(跟踪内容)也是git在git 1.5.0中添加的内容。

让'git add'为索引添加一个一流的用户友好界面

这是在不谈论索引的情况下,使用适当的心理模型将索引的力量放在前面 例如,查看如何从git-add手册页中抽取所有技术讨论

任何提交的内容都必须添加在一起 这些内容来自新文件还是修改过的文件并不重要 你只需要“添加”它,要么用git-add,要么用-a提供git-commit(当然只针对已知的文件)

这就是使用相同的Git 1.5.0 (提交5 cde71d)实现git add --interactive的原因。

在做出选择之后,用空行回答,为索引中所选的路径执行工作树文件的内容

这也是为什么,要递归地从目录中删除所有内容,你需要传递-r选项,而不仅仅是<path>的目录名(仍然是Git 1.5.0, 提交9 f95069)。

看到文件内容而不是文件本身是允许像提交1 de70db中描述的那样合并场景的原因(Git v2.18.0-rc0, 2018年4月)

考虑下面带有重命名/添加冲突的合并:

  • A:修改foo,添加不相关的bar
  • side B:重命名foo->bar(但不修改模式或内容)
在这种情况下,原始foo、A的foo和B的bar的三向合并将产生一个所需的bar路径名,其模式/内容与A的foo相同 因此,A具有文件的正确模式和内容,并且它具有正确的路径名(即bar)

提交37 b65ce, Git v2.21.0-rc0, 2018年12月,最近改进了碰撞冲突解决方案 而提交bbafc9c通过改进对rename/rename(2to1)冲突的处理,进一步说明了考虑文件内容的重要性:

  • 文件不是存储在collide_path~HEADcollide_path~MERGE,而是双向合并并记录在collide_path
  • 我们不是在索引中记录存在于重命名一侧的重命名文件的版本(因此忽略历史记录一侧没有重命名的文件所做的任何更改),而是在重命名路径上执行三向内容合并,然后将其存储在阶段2或阶段3中。
  • 请注意,由于每个重命名的内容合并可能存在冲突,然后我们必须合并两个重命名的文件,因此可能会出现嵌套的冲突标记。