引用Linus Torvalds在2007年谷歌的Tech Talk中被问及Git可以处理多少文件时的回答:

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

(成绩单)。

然而,当您深入阅读Git书籍时,首先被告知的是Git中的文件既可以跟踪也可以不跟踪。此外,在我看来,整个Git体验都是面向文件版本控制的。当使用git的差异或git的状态输出显示在每个文件的基础上。当使用git add时,你也可以在每个文件的基础上进行选择。您甚至可以以文件为基础查看历史记录,速度非常快。

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


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

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

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


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

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

Git doesn't do that. Git has only a history of commits—the commit is its unit of atomicity, and the history is the set of commits in the repository. What a commit remembers is the data—a whole tree-full of file names and the contents that go with each of those files—plus some metadata: for instance, who made the commit, when, and why, and the internal Git hash ID of the commit's parent commit. (It is this parent, and the directed acycling graph formed by reading all commits and their parents, that is the history in a repository.)

Note that a VCS can be commit-oriented, yet still store data file-by-file. That's an implementation detail, though sometimes an important one, and Git does not do that either. Instead, each commit records a tree, with the tree object encoding file names, modes (i.e., is this file executable or not?), and a pointer to the actual file content. The content itself is stored independently, in a blob object. Like a commit object, a blob gets a hash ID that is unique to its content—but unlike a commit, which can only appear once, the blob can appear in many commits. So the underlying file content in Git is stored directly as a blob, and then indirectly in a tree object whose hash ID is recorded (directly or indirectly) in the commit object.

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

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

Git真正做的是遍历提交历史,这是Git拥有的唯一历史,但不会显示任何这些提交,除非:

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

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


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

我们可以这么看。

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

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


"git does not track files" basically means that git's commits consist of a file tree snapshot connecting a path in the tree to a "blob" and a commit graph tracking the history of commits. Everything else is reconstructed on-the-fly by commands like "git log" and "git blame". This reconstruction can be told via various options how hard it should look for file-based changes. The default heuristics can determine when a blob changes place in the file tree without change, or when a file is associated with a different blob than before. The compression mechanisms Git uses don't care a whole lot about blob/file boundaries. If the content is somewhere already, this will keep the repository growth small without associating the various blobs.

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

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

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

这与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月)在commit 443f833中强制执行了“跟踪内容”策略:

Git状态:跳过空目录,并添加-u显示所有未跟踪的文件 默认情况下,我们使用——others——directory来显示无趣的目录(以引起用户的注意),而不显示其内容(以整理输出)。 显示空目录没有意义,因此在执行此操作时传递——no-empty-directory。 给出-u(或——untracked)将禁用这种整理 用户获取所有未跟踪的文件。

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

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

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

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

跟踪内容是git在早期(git 1.4.4, 2006年10月,commit cee7f24)性能更好的原因:

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

这(跟踪内容)也是git在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).

这就是为什么git在1.5.0版本中添加了交互功能(commit 5cde71d)

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

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

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

考虑下面带有重命名/添加冲突的合并: 边A:修改foo,添加不相关栏 边B:重命名foo->bar(但不修改模式或内容) 在这种情况下,原始foo、A的foo和B的bar的三向合并将导致bar的期望路径名具有与A的foo相同的模式/内容。 这样,A就有了文件的正确模式和内容,并且有了正确的路径名(即bar)。

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

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.