关于版本控制:当Linus Torvalds说Git“永远不会”跟踪文件时,他的意思是什么?

What does Linus Torvalds mean when he says that Git “never ever” tracks a file?

2007年,当被问及Git在谷歌(Google)的技术演讲中可以处理多少文件时,他引用了LinusTorvalds的话(43:09):

…Git tracks your content. It never ever tracks a single file. You cannot track a file in Git. What you can do is you can track a project that has a single file, but if your project has a single file, sure do that and you can do it, but if you track 10,000 files, Git never ever sees those as individual files. Git thinks everything as the full content. All history in Git is based on the history of the whole project…

(此处为成绩单。)

然而,当你深入到Git的书中时,首先要告诉你的是,Git中的文件可以被跟踪,也可以不被跟踪。此外,在我看来,整个Git体验都是面向文件版本控制的。当使用git diffgit status时,输出按文件显示。使用git add时,您还可以根据每个文件进行选择。您甚至可以基于文件查看历史记录,而且速度非常快。

该如何解释这一说法?在文件跟踪方面,Git与其他源代码控制系统(如cvs)有何不同?


在cvs中,历史是以每个文件为基础跟踪的。分支可能由不同的文件组成,每个文件都有自己的版本号。cvs基于rcs(修订控制系统),它以类似的方式跟踪单个文件。

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

当git提到跟踪一个文件时,它只是意味着它将被包含在项目的历史中。Linus的谈话并不是指在Git上下文中跟踪文件,而是将cvs和rcs模型与Git中使用的基于快照的模型进行对比。


我同意BrianM.Carlson的回答:Linus确实在一定程度上区分了面向文件和面向提交的版本控制系统。但我认为还有更多的事情要做。

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

Git不这么做。Git只有提交的历史记录提交是其原子性单元,历史记录是存储库中的提交集。提交所记住的是数据——一整棵树,其中包含文件名和每个文件附带的内容以及一些元数据:例如,提交者、提交时间和原因,以及提交父提交的内部git散列ID。(存储库中的历史就是这个父级,以及通过读取所有提交及其父级而形成的定向非循环图。)

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

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

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

Git真正要做的是浏览提交历史,这是Git唯一拥有的历史,但没有向您显示任何这些提交,除非:

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

(但这些条件中的一些可以通过附加的git log选项进行修改,并且很难描述一种称为历史简化的副作用,这使得git完全忽略了历史行走中的一些承诺)。在某种意义上,您在这里看到的文件历史并不完全存在于存储库中:相反,它只是实际历史的合成子集。如果使用不同的git log选项,您将获得不同的"文件历史记录"!


令人困惑的是:

Git never ever sees those as individual files. Git thinks everything as the full content.

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

但是160位散列唯一地标识内容(在Git数据库的范围内)。因此,将哈希作为内容的树包含其状态中的内容。

如果更改文件内容的状态,则其哈希值将更改。但如果散列值改变,与文件名内容相关联的散列值也会改变。从而改变"目录树"的散列值。

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

它被组织在一个树结构中,具有指向blob或其他树的(不可变、可重用)指针,但在逻辑上,它是整个树的整个内容的单一快照。Git数据库中的表示不是平面数据内容,而是逻辑上的所有数据,而不是其他任何数据。

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

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

如果您围绕它构建了一个应用程序,那么文档就是一组页面,其中包含层,其中包含组,其中包含对象。

当您想要更改一个对象时,您必须为它创建一个全新的组。如果要更改组,则必须创建一个新层,该层需要一个新页面,而该页面需要一个新文档。

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

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

Git回购就是这样。

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

这些父级更改一直包含其父级更改。

git repo中包含历史的部分就是变化链。这个变更链在"目录"树之上的一个级别上改变它——从"目录"树,您不能唯一地得到变更集和变更链。

要了解文件发生了什么,可以从变更集中的该文件开始。那个变更集有历史。通常在那个历史中,存在相同的命名文件,有时具有相同的内容。如果内容相同,则文件没有更改。如果它是不同的,有一个变化,并且需要做工作来精确地计算出什么。

有时该文件会消失;但是"目录"树可能有另一个具有相同内容(相同哈希代码)的文件,因此我们可以这样跟踪它(注意:这就是为什么您希望提交将文件与提交分开来进行编辑)。或者是同一个文件名,检查后文件就足够相似了。

所以Git可以将"文件历史"修补在一起。

但是这个文件历史来自对"整个变更集"的有效分析,而不是从文件的一个版本到另一个版本的链接。


"Git不跟踪文件"基本上意味着Git的提交包括将树中的路径连接到"blob"的文件树快照和跟踪提交历史的提交图。其他的一切都是通过诸如"git-log"和"git-blank"之类的命令即时重建的。这种重构可以通过不同的选项来告诉我们,寻找基于文件的更改有多困难。默认的启发式方法可以确定一个BLOB在文件树中何时更改而不更改,或者文件何时与其他BLOB相关联。Git使用的压缩机制不太关心blob/文件边界。如果内容已经在某个地方,这将使存储库的增长保持在较小的范围内,而不会关联各种blob。

现在这就是存储库了。Git还有一个工作树,在这个工作树中有跟踪和未跟踪的文件。只有跟踪的文件记录在索引中(临时区域?缓存?)只有在那里被跟踪的东西才会进入存储库。

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

由于Git不跟踪文件历史和重命名,其效率也不依赖于这些历史记录,因此有时您必须使用不同的选项尝试几次,直到Git生成您对非平凡历史感兴趣的历史/差异/指责。

这不同于像颠覆这样记录而不是重建历史的系统。如果它没有被记录在案,你就不能听到它。

实际上,我曾经构建过一个差异安装程序,通过将发布树检入Git,然后生成一个复制其效果的脚本,来比较发布树。由于有时会移动整棵树,因此产生的差异安装程序比覆盖/删除所有可能产生的差异安装程序要小得多。


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

这里有一个方法来看看它。

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

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


顺便说一下,跟踪"内容"是导致不跟踪空目录的原因。这就是为什么,如果您将文件夹的最后一个文件git rm,文件夹本身就会被删除。

情况并非总是如此,只有Git 1.4(2006年5月)使用commit 443f833强制实施了"跟踪内容"策略:

git status: skip empty directories, and add -u to show all untracked files

By default, we use --others --directory to show uninteresting directories (to get user's attention) without their contents (to unclutter output).
Showing empty directories do not make sense, so pass --no-empty-directory when we do so.

Giving -u (or --untracked) disables this uncluttering to let the
user get all untracked files.

几年后的2011年1月,commit 8fe533、git v1.7.4也反映了这一点:

This is in keeping with the general UI philosophy: git tracks content, not empty directories.

同时,使用git 1.4.3(2006年9月),git开始将未跟踪的内容限制为非空文件夹,使用commit 2074cb0:

it should not list the contents of completely untracked directories, but only the name of that directory (plus a trailing '/').

跟踪内容是Git的罪魁祸首(Git 1.4.4,2006年10月,Commit Cee7f24)早期表现更出色的原因:

More importantly, its internal structure is designed to support content movement (aka cut-and-paste) more easily by allowing more than one paths to be taken from the same commit.

这(跟踪内容)也是将git-add放入git API的原因,使用git 1.5.0(2006年12月,commit 366bfcb)

make 'git add' a first class user friendly interface to the index

This brings the power of the index up front using a proper mental model without talking about the index at all.
See for example how all the technical discussion has been evacuated from the git-add man page.

Any content to be committed must be added together.
Whether that content comes from new files or modified files doesn't matter.
You just need to"add" it, either with git-add, or by providing git-commit with -a (for already known files only of course).

这就使得EDOCX1[0]成为可能,使用相同的git 1.5.0(commit 5cde71d)

After making the selection, answer with an empty line to stage the contents of working tree files for selected paths in the index.

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

查看文件内容而不是文件本身是允许合并场景的原因,如commit 1de70db(git v2.18.0-rc0,2018年4月)中所述。

Consider the following merge with a rename/add conflict:

  • side A: modify foo, add unrelated bar
  • side B: rename foo->bar (but don't modify the mode or contents)

In this case, the three-way merge of original foo, A's foo, and B's bar will result in a desired pathname of bar with the same mode/contents that A had for foo.
Thus, A had the right mode and contents for the file, and it had the right pathname present (namely, bar).

commit 37b65ce,git v2.21.0-rc0,2018年12月,最近改进了冲突解决方案。commit bbafc9c firther通过改进重命名/重命名(2to1)冲突的处理,说明了考虑文件内容的重要性:

  • Instead of storing files at collide_path~HEAD and collide_path~MERGE, the files are two-way merged and recorded at collide_path.
  • Instead of recording the version of the renamed file that existed on the renamed side in the index (thus ignoring any changes that were made to the file on the side of history without the rename), we do a three-way content merge on the renamed path, then store that at either stage 2 or stage 3.
  • Note that since the content merge for each rename may have conflicts, and then we have to merge the two renamed files, we can end up with nested conflict markers.