基本原理

2024-12-02
2820字

版本库逻辑结构

版本库是一个有向图,有向图的顶点是提交(commit),有向图的边则是提交的parent指针。

每个提交都对应一个工作区快照,快照可以恢复成完整的工作区,也就是说每个提交都是工作区的一个版本。parent指针则记录了版本之间的发展关系。版本B的parent指针指向版本A,则说明版本B是由版本A修改而来。

下面是一个版本库的示意图:

版本库示意图

版本库中可能有成千上万的提交,直接管理每个提交是困难且无必要的,因为我们可以从某个提交开始沿着parent指针进行遍历,得到与之相关的若干个提交。因此我们只需要在某些重要的提交上打上标记,再通过这些标记管理整个提交链条即可。

上图中的main、b1和b2就是这样标记。不难理解,我们可以通过main这个标记配合parent指针管理C6→C4→C2→C1→C0这个链条上的任意一个提交。

main、b1和b2这样的标记在git中被称之为分支指针,其背后对应的提交链就被称之为分支。main后的*表示当前处于main分支。

在默认情况下,分支指针总是指向最新的提交,例如在main分支中新增了一个提交C7,main就会指向C7,C7的parent指针指向C6,从而实现main分支的更新。

在日常使用中不必区分分支指针与分支的区别,本文后续也不会刻意区分二者。

数据模型

commit是如何记录整个工作区的内容的呢?这就需要稍微深入一下git的数据模型——也就是commit、分支指针和标签指针等到底长什么样子。

在每个git仓库中都有一个.git的隐藏目录,这个目录下的objects目录就是git仓库的核心内容。

objects目录

objects目录包含了若干个目录名为2位十六进制字符的子目录,每个子目录下面有若干个文件名为38位十六进制字符的二进制文件。

这些二进制文件(通常称为对象)可以分为4类:

  1. 块对象(blob object)(blob是Binary Large Object的缩写)。每个blob object对应一个工作区内的源文件,blob的内容是源文件二进制内容压缩后的结果。blob object和源文件的路径无关,这样不管工作区有多少个重复文件,或者不同版本之间有多少个重复文件,obejcts目录内只需要保存一个blob对象就够了。
  2. 树对象(tree object)。每个tree object对应一个工作区内的目录,记录了对应的目录下有哪些子目录(tree object)和文件(blob object),以及子目录和文件对应的文件模式(与Linux的文件模式一致)。
  3. 提交对象(commit object)。commit object就是commit对应的实体,每个commit object都关联了一个对应于整个工作区的tree object。不难理解,与整个工作区对应的tree object可以还原出整个工作区,所以commit称作一个工作区快照也就合情合理了。
  4. 标签对象(tag object)。附注标签(Annotated Tag)对应的实体。

每类对象都根据其内容通过各自的方式计算出一个长度为40位十六进制字符串的SHA-1哈希值,前2位作为子目录名,后38位作为文件名。

[!TIP]

可以使用git cat-file -t <hash>来查看这些文件的类型,或者使用git cat-file -p <hash>来查看文件的内容。

<hash>不需要填入完整的40位,可以唯一确定文件即可。

从提交的底层数据模型来看,每个提交都是完整,不需要一步步计算增量就可以得到最新的提交的内容,也就是说,提交之间的父子关系更多的是逻辑上的,子提交并不依赖于父提交。也正因为此,git可以随意打乱提交顺序,方便的实现rebase和cherry pick等功能。

至于因为全量创建blob造成的空间浪费——反正git是用来管理源码的,能浪费几个字节呢。

顺便一提,分支指针在哪里呢?在.git/refs/heads/中,这个目录下有若干个文本文件,文件名就是分支名,文件内容是分支最后一个提交的哈希值。

index与暂存区

暂存区存在的意义在于——不必一下子提交所有的更改。例如修改a文件修复了bug A,又修改b文件更新了需求B,如果一起提交就会很没有调理,而有了暂存区,就可以先暂存a文件→提交a文件→暂存b文件→提交b文件,两个提交的目的和内容就可以区分地很清楚。

也就是说执行git commit的时候,只会提交在暂存区内的内容。

那暂存区到底是什么呢?

在git中,暂存区是一个抽象概念,并没有一个文件实体与之对应,“暂存”功能,是.git/index文件配合blob对象实现的。

index文件内记录了工作区文件.git/objects中blob文件的对应关系,每当使用git add <file_path>添加一个文件时,git都会新建(或者复用)一个blob文件,同时在index文件中新建一个条目或者更新一个旧的条目。

注意,index只会建立文件和blob的索引关系,并不会建立目录和tree之间的索引,也不会创建tree object和commit object。这些被add但是尚未commit的文件就是被暂存的文件,也可以说它们在暂存区中。

使用git commit提交之后git就会创建对应的tree object和commit object,形成一次完整的提交。在默认情况下,也会更新对应的分支指针。

HEAD指针

前面提到main后面的*表示当前分支是main,这个“当前”就是靠.git/HEAD文件记录的。这是一个文本文件,内容为ref: refs/heads/<branch>,表示HEAD当前和<branch>绑定在一起,也就是当前分支是<branch>

绑定的意思是分支指针会随着HEAD指针一起移动。前面提到的“main分支新增一个提交”的准确表述应该是:git commit会新增一个commit object,这个commit object的parent指针指向HEAD所指向的commit object,然后将HEAD指针指向新的commit对象,这样HEAD指向的提交链就更新了,由于main和HEAD是绑定的,main也会跟随HEAD一起更新,main分支也就随之更新了。

使用git switch切换不同分支,其实就是HEAD绑定到另一个分支上。

HEAD是可以处于分离状态的,此时HEAD不绑定任何分支,但是在绝大多数情况下不会用到。

标签

标签和分支指针类似,用于标记特定的提交。与分支指针不同的是,标签不会随commit改变,常用于标记一个固定的锚点。

标签分为轻量标签(Lightweight Tag)和附注标签(Annotated Tag)两类。前者和分支类似,记录在.git/refs/tags中,后者则在.git/objects目录下生成标签文件,记录注释信息,同时在.git/refs/tags中指向这个标签文件。

.git目录总览

git目录总览