用 15 分钟搞懂 GIT 架构,成为 GIT 高手

Git 看起来可能是个复杂的系统。不信问问 Google。下面是随手一搜就能翻出来的一些标题:

Git 到底为什么这么难…… Git 实在是太难了…… 我们能不能别再假装 Git 简单易学了…… 为什么 Git 这么复杂、这么让人困惑……

乍看之下,这些说法似乎站得住脚;但一旦你理解了底层的那些概念,用 Git 就会变成一件愉快的事。Git 的问题在于它非常灵活。而所有灵活系统的固有特征就是复杂。我坚信,对付这种复杂唯一的办法,就是深入到它所提供的用户界面之下的基本原理,理解底层的心智模型与架构。做到这一点之后,就不会再有魔法和意外结果,你在使用这些复杂工具时会感到得心应手。

无论你此前用过 Git,还是刚开始接触这个出色的版本控制工具,本文的内容都会对你有帮助。如果你是 GIT 的老手,你会把 checkout -> modify -> commit 这个生命周期理解得透彻得多。如果你刚开始学 Git,这篇文章会给你一个很好的起点。

本文中我会使用底层的、所谓「管道」(plumbing)命令,来演示 Git 在幕后是怎么工作的。你不需要记住它们,因为在日常工作流程中很少用到,但在解释 Git 底层架构时它们不可或缺

文章有点长,我认为可以这样来学:

  • 先从头到尾快速浏览一遍,了解文章的大致脉络
  • 再仔细阅读,并动手完成我在文中使用的练习

通过动手练习,你会把在这里获得的知识巩固下来。练习中我使用 bash 命令,所以如果你用的是基于 Unix 的操作系统就没问题。如果你用 Windows,可以运行随 Git 一同安装的 git-bash,通过右键菜单中的 Git Bash here 就能打开。

Git 就是一个文件夹

当你在某个文件夹中运行 git init 时,Git 会创建 .git 目录。那我们打开终端,新建一个用来干活的目录,并在其中初始化一个空的 git:

$ mkdir git-playground && cd git-playground
$ git init
Initialized empty Git repository in path/to/git-playground/.git/
$ ls .git
HEAD config description hooks info objects refs

Git 就是在这里存放你所有的提交,以及操作这些提交所需的其他相关信息。当你克隆一个仓库时,Git 会把这个单独的目录复制到你的文件夹里,为被克隆仓库的每个分支创建远程跟踪分支,并创建并检出 HEAD 文件所指定的初始分支。HEAD 文件在 Git 架构中起什么作用,我们稍后会看到,但这里要带走的结论是:克隆一个仓库本质上就是把 .git 目录从别处复制过来

Git 就是一个数据库

Git 是一个简单的键值存储。你把一个值放进仓库,得到一个可以用来取回这个值的键。把值放进数据库的命令是 hash-object,它返回一个 40 个字符的校验和哈希,这个哈希就用作键。该命令会在 git 仓库中创建一个普通对象,称为 blob。我们把一个简单的字符串 f1 content 写进数据库:

$ F1CONTENT_BLOB_HASH=$( \
    echo 'f1 content' | git hash-object -w --stdin
  )
$ echo $F1CONTENT_BLOB_HASH
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0

对不太熟悉 shell 的读者来说,上面这段里的主命令是:

echo 'f1 content' | git hash-object -w --stdin

echo 命令输出 f1 content 字符串,我们用管道操作符 | 把这个输出重定向给 git hash-object 命令。传给 hash-object 的参数 -w 让它把对象存起来;不加的话,命令只会告诉你键会是什么。--stdin 让命令从标准输入读取内容;如果不指定它,hash-object 会期望末尾给出一个文件路径。如前所述,git hash-object 命令返回一个哈希,我随后把它存进变量 F1CONTENT_BLOB_HASH。我们也可以把主命令的执行和变量赋值分开写成这样:

$ echo 'f1 content' | git hash-object -w --stdin
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0
$ F1CONTENT_BLOB_HASH=a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0

但为了方便,后面的代码片段里我会用较短的写法把输出赋给变量。这些变量会用在需要哈希字符串的地方,读取其值时在变量名前加 $

要按键读取值,可以用带 -p 选项的 cat-file 命令。该命令需要给出待取对象的哈希键:

$ git cat-file -p $F1CONTENT_BLOB_HASH
f1 content

如前所说,.git 是一个文件夹,所有存入的值/对象都保存在这个文件夹里。所以我们可以进到 .git/objects,会看到 Git 创建的一个名为 a1 的文件夹——这正是哈希键的前两个字母:

$ ls .git/objects/ -l
a1/
info/
pack/

这就是 Git 通常存放对象的方式——一个 blob 一个文件夹。不过 Git 也可以把多个 blob 合并成一个文件以生成 pack 文件,而上面看到的 pack 目录就是存放这些文件的地方。与这些 pack 对象相关的信息,Git 放在 info 目录里。Git 根据 blob 的内容生成哈希,所以 Git 中的对象是不可变的——改动内容就会改变哈希。

我们再往仓库里写一个字符串 f2 content

$ F2CONTENT_BLOB_HASH=$( \
    echo 'f2 content' | git hash-object -w --stdin )

如预期所料,可以看到 \.git\objects 文件夹现在包含两条记录:9b/a1/

$ ls .git/objects/ -l
9b/
a1/
info/
pack/

树是不可或缺的一环

目前我们的仓库里有两个 blob:

F1CONTENT_BLOB_HASH -> 'f1 content'
F2CONTENT_BLOB_HASH -> 'f2 content'

我们需要一种办法把它们归到一起,同时把每个 blob 和一个文件名关联起来。树(tree)就在这时登场了。可以用 git mktree 命令创建树,每条「文件/blob 关联」使用如下语法:

[file-mode object-type object-hash file-name]

关于文件模式的解释,请看这个回答。我们会使用模式 100644,它把 blob 定义为一个用户可读可写的普通文件。这些权限在把文件检出到工作目录时用于正确设置文件/目录的权限。

所以,要把两个 blob 与两个文件关联起来,我们这么做:

$ INITIAL_TREE_HASH=$( \
    printf '%s %s %s\t%s\n' \
    100644 blob $F1CONTENT_BLOB_HASH f1.txt \
    100644 blob $F2CONTENT_BLOB_HASH f2.txt |
    git mktree )

hash-object 一样,mktree 命令返回所创建树对象的哈希键:

$ echo $INITIAL_TREE_HASH
e05d9daa03229f7a7f6456d3d091d0e685e6a9db

于是现在我们有了这些东西:

运行该命令之后,git 在仓库中创建了第三个对象,类型为 tree。我们来看看它:

$ ls .git/objects -l
e0   <--- initial tree object  (INITIAL_TREE_HASH)
9b   <--- 'f2 content' blob    (F2CONTENT_BLOB_HASH)
a1   <--- 'f1 content' blob    (F1CONTENT_BLOB_HASH)

使用 mktree 时,我们可以把另一个树对象而不是 blob 作为参数传进去。那个树会与一个目录关联,而不是与普通文件关联。例如,下面这条命令会创建一个带有子树的树,子树关联到 nested-folder 目录:

printf '%s %s %s\t%s\n' 040000 tree e05d9da nested-folder | git mktree

文件模式 040000 标记一个目录,而我们用类型 tree 而非 blob。git 就是这样在项目结构中存放嵌套目录的。

索引是组装树的场所

每个用 GIT 的人都应该熟悉索引(index)或暂存区(staging area)这个概念,而且大概见过下面这张图:

右侧是 git 仓库,它存放 git 对象:blob、树、提交和标签。我们用 hash-objectmktree 命令把 blob 和 tree 对象直接加进了这个仓库。左侧的工作目录是你本地的文件系统/目录,你在那里检出项目的全部文件。本节要讲的是中间那一段,我们称之为索引文件或简称索引。它是一个二进制文件(一般放在 .git/index),其结构类似于树对象。它保存着一份排好序的路径名列表,每条带有权限和某个 blob/tree 对象的 SHA1。

它是 git 在做下面两件事之前用来准备一棵树的地方:

  • 把它写入仓库,或者
  • 把它检出到工作目录

我们现在仓库里有一棵树,是上一章创建的。可以用 read-tree 命令把这棵树从仓库读进索引文件:

$ git read-tree $INITIAL_TREE_HASH

那么现在我们预期索引文件里会有两个文件。可以用 git ls-files -s 命令查看当前索引文件的结构:

$ git ls-files -s
100644 a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0 0 f1.txt
100644 9b96e21cb748285ebec53daec4afb2bdcb9a360a 0 f2.txt

由于我们没有对索引文件做任何改动,它与我们用来填充索引的那棵树完全一致。既然索引文件里已经有了正确的结构,就用带 -a 选项的 checkout-index 命令把它检出到工作目录:

$ git checkout-index -a
$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 content

好了!我们刚刚把手动加进 git 仓库的文件检出来了,而且一次提交都没有。是不是挺酷?

但索引文件通常不会停留在初始树的状态。你大概知道,可以用 git add [file path]git rm --cached [file path] 修改单个文件,或者用 git add -Agit reset 处理一批修改/删除的文件。我们就把这些知识用起来,在仓库中创建一棵新树,其中包含一个与新文本文件 f3.txt 关联的文件 blob。文件的内容将是字符串 f3 content。不过这次我们不像上一节那样手工创建树,而是借助索引文件来做。

目前索引文件的结构是这样的,它基于我们用来填充索引的 initial tree 这棵树:

这是我们将要在其上施加改动的基础。你对索引文件所做的一切改动,在你真正把树写入仓库之前都只是暂定的。不过你添加的那些对象本身会被立刻写进 git 仓库。如果你丢弃了当前对树的改动,它们之后会被垃圾回收(GC)捡走并删除。这也意味着:通常你若不小心丢弃了对某个文件的改动,在 git 运行 GC 之前它们仍然可以恢复。而 git 一般只在有太多没被任何地方引用的松散对象四处漂着时才会跑 GC。

我们先删掉工作目录里的两个文件:

$ rm f1.txt f2.txt

如果现在运行 git status,会看到下面这些:

$ git status
On branch master
Initial commit
Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
      new file:   f1.txt
      new file:   f2.txt
Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
      deleted:    f1.txt
      deleted:    f2.txt

信息可真不少。它报告了两个被删除的文件和两个新文件,还说了「initial commit」。原因如下。当你运行 git status 时,git 会做两次比较:

  • 它把索引文件与当前工作目录相比——差异报告为「not staged for commit」
  • 它把索引文件与 HEAD 提交相比——差异报告为「to be committed」

所以在我们这个例子里,git 在「Changes not staged for commit」下报告了两个被删除的文件,我们也知道它是怎么得出的——它把当前工作目录与索引文件做了比较,发现工作目录里少了两个文件(因为我们把它们删了)。

我们还看到,在「Changes to be committed」下 git 报告了两个新文件。这是因为目前仓库里还没有任何提交,所以 HEAD 文件(稍后解释)解析成了所谓的「空树」对象,里面没有文件。于是 Git 认为我们刚从一个全新的仓库开始,这就是它显示「initial commit」并把索引文件中的每个文件都当作新文件的原因。

现在如果运行 git add .,它会修改索引文件、从中移除那两个文件;再运行 git status 时就不会报告任何改动了,因为工作树和索引文件里都没有文件:

$ git add .
$ git status
On branch master
Initial commit
nothing to commit (create/copy files and use "git add" to track)

我们一开始的任务是创建一棵含有一个新文件 f3.txt 的新树。我们来创建这个文件并把它加进索引:

$ echo 'f3 content' > f3.txt
$ git add f3.txt

如果现在运行 git status

$ git status
On branch master
Initial commit
Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
      new file:   f3.txt

可以看到检测到了一个新文件。同样,改动报告在「to be committed」之下,所以现在 Git 是把索引文件与那棵「空树」做了比较。于是我们预期索引文件里会有这一个新的文件 blob。来查一下:

$ git ls-files -s
100644 5927d85c2470d49403f56ce27afd8f74b1a42589 0       f3.txt
# 保存 f3.txt 文件 blob 的哈希
$ F3CONTENT_BLOB_HASH=5927d85c2470d49403f56ce27afd8f74b1a42589

好,索引现在的结构正确了,我们可以从它在仓库中创建一棵树。用 write-tree 命令来做:

$ LATEST_TREE_HASH=$( git write-tree )

很好,我们刚借助索引创建了这棵树,并把新树的哈希放进了 LATEST_TREE_HASH 变量。我们本可以手动完成——先把 f3 content 这个 blob 写进仓库,再用 mktree 创建树——但用索引方便得多。

有意思的是,如果现在运行 git status,你仍会看到 git 认为 f3.txt 是个新文件:

$ git status
On branch master
Initial commit
Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
      new file:   f3.txt

这是因为尽管我们已经创建并把树保存进了仓库,却没有更新用于比较的 HEAD 文件。由于 HEAD 文件里只能放提交哈希或分支引用,而这两样我们现在都没有,就先让 HEAD 文件保持原样。

于是有了这棵新创建的树之后,仓库中的对象如下:

提交是树的一层包装

这一节会更有意思。在日常使用 Git 的过程中,我们通常不会碰到树或 blob,我们打交道的是提交对象。那么 git 里的提交究竟是什么?其实说白了,它就是树对象的一层包装,它:

  • 允许给一棵树(一组文件)附上一条消息
  • 允许指定父提交

我们现在 git 仓库里有两棵树——initial treelatest tree。用 commit-tree 命令把第一个树对象包装成一个提交,该命令接收要为之创建提交的那棵树的哈希:

$ INITIAL_COMMIT_HASH=$( \
    echo 'initial commit' | git commit-tree $INITIAL_TREE_HASH
  )

运行完上面这条命令,我们就有了:

而且我们可以把那个提交检出到工作目录:

$ git checkout $INITIAL_COMMIT_HASH
A       f3.txt
HEAD is now at a27a75a... initial commit

现在工作目录里能看到我们的两个文件了:

$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 content

当你运行 git checkout [commit-hash] 时,git 会做这些事:

  • 把该提交所指向的树读进索引文件
  • 把索引文件检出到工作目录
  • 用该提交的哈希更新 HEAD 文件

这正是我们在上一节手动完成的那些操作。

Git 的历史是一条提交链

现在我们知道提交只是树的一层包装了。我还提到过它可以有一个父提交。我们最初有两棵树,上一节把其中一棵包装成了提交,所以还剩下一棵「孤儿」树。现在用上面同样的 commit-tree 操作把它包装成提交,但加上 -p 选项来指定父提交:

$ LATEST_COMMIT_HASH=$( \
    echo 'latest commit' |
    git commit-tree $LATEST_TREE_HASH -p $INITIAL_COMMIT_HASH )

于是现在我们有了:

那么现在如果你运行 git log 查看历史,并传入「latest」提交的哈希,你会看到两个提交:

$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commit

而且我们可以在它们之间切换。这是初始提交:

$ git checkout $INITIAL_COMMIT_HASH
$ ls
f1.txt f2.txt

最新提交:

$ git checkout $LATEST_COMMIT_HASH
$ ls
f3.txt

HEAD 是指向已检出提交的引用

HEAD 是位于 .git/HEAD 的一个简单文本文件,它引用当前已检出的提交。由于上一节我们检出了哈希为 $LATEST_COMMIT_HASH 的「latest」提交,HEAD 文件里装的正是它:

$ cat .git/HEAD
88d3b9901d62fc1de9219f388e700d98bdb97ba9
$ [ $LATEST_COMMIT_HASH == "88d3b9901d62..." ]; echo 'equal'
equal

不过通常来说,HEAD 文件是通过分支引用来指向当前已检出的提交的。当它直接引用提交时,它处于分离状态(detached state)。但即便 HEAD 里装的是像这样的分支引用:

ref: refs/heads/master

它最终仍会解析成一个提交哈希。

你已经知道,Git 在执行 git status 时会用 HEAD 所引用的提交,来得出索引文件与当前已检出的树/提交之间的差异集合。HEAD 的另一个用途,是确定将来那个提交要用哪个提交作为父提交。

有意思的是,HEAD 文件对大多数操作而言如此关键,以至于如果你手动把它的内容清空,Git 就会认为这不是一个 git 仓库并报错:

fatal: Not a git repository (or any of the parent directories): .git

分支是指向提交的文本文件

现在我们的仓库里有两个提交,构成了如下历史:

$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commit

我们给现有历史加一处分叉。我们会检出初始提交,并修改 f1.txt 的内容。之后用你已经习惯的 git commit 命令做一个新提交:

$ git checkout $INITIAL_COMMIT_HASH
$ echo 'I am modified f1 content' > f1.txt
$ git add f1.txt
$ git commit -m "forked commit"
1 file changed, 1 insertion(+), 1 deletion(-)

上面这段代码:

  • 检出 "initial commit",它把 f1.txtf2.txt 放进工作目录
  • f1.txt 的内容替换为字符串 I am modified f1 content
  • git add 更新索引文件

正如我们已经知道的,最后那条 git commit 命令在幕后做了好几件事:

  • 从索引文件创建一棵树
  • 把这棵树写入仓库
  • 创建一个包装该树的提交对象
  • initial commit 设为新提交的父提交,因为那正是我们当前 HEAD 文件中的提交

我们还需要把这个提交的哈希存进一个变量。由于 Git 会用当前提交更新 HEAD,我们可以从那里读出来:

FORKED_COMMIT_HASH=$( cat .git/HEAD )

于是现在我们的 git 仓库里有了以下对象:

这构成了如下的提交历史:

由于出现了分叉,这里有两条工作线。这意味着我们需要引入两个分支来分别独立地跟踪各自的工作线。我们创建一个 master 分支来跟踪从 latest commit 开始的线性历史,再创建一个 forked 分支来跟踪从 forked commit 开始的历史。

分支就是一个包含某个提交哈希的文本文件。它属于 git 引用(references)的一部分——一组指向提交的对象。另一种引用类型是轻量标签。Git 把所有引用都放在 .git/refs 文件夹下,分支存放在 .git/refs/heads 目录中。既然分支就是个简单的文本文件,我们完全可以自己创建一个内容为提交哈希的文件。

这样就会指向带有「latest commit」的主分支:

$ echo $LATEST_COMMIT_HASH > .git/refs/heads/master

而这样会指向带有「forked commit」的「forked」分支:

$ echo $FORKED_COMMIT_HASH > .git/refs/heads/forked

至此我们终于回到了你所熟悉的常规工作流程——现在可以在分支之间切换了:

$ git checkout master
Switched to branch 'master'
$ git log --pretty=oneline
[some hash] latest commit
[some hash] first commit
$ ls -l
f3.txt

再看看另一个分支 forked

$ git checkout forked
Switched to branch 'forked'
$ git log --pretty=oneline
f30305a8a23312f70ba985c8c644fcdca19dab95 forked commit
f30305a8a23312f70ba985c8c644fcdca19dab95 initial commit
$ git ls
f1.txt f2.txt
$ cat f1.txt
I am modified f1 content

标签是指向提交的文本文件

你大概知道,除了用分支跟踪一条工作线之外,我们还可以用标签来跟踪单个提交。标签通常用于标记重要的开发里程碑,比如发布版本。眼下我们仓库里有 3 个提交,可以用标签给它们起名字。和分支一样,标签也是一个包含提交哈希的文本文件,同属 git 引用这一组。

如你所知,git 把所有引用放在 .git/refs 文件夹下,标签存放在子文件夹 .git/refs/tags 中。既然它就是个简单的文本文件,我们可以创建一个文件并把提交哈希写进去。

这样就会指向最新的提交:

$ echo $FORKED_COMMIT_HASH > .git/refs/tags/forked

而这样会指向初始提交:

$ echo $INITIAL_COMMIT_HASH > .git/refs/tags/initial

做完这些,我们就能用标签在提交之间切换了。这是初始提交:

$ git checkout tags/initial
HEAD is now at 285aec7... second commit
$ cat f1.txt
f1 content

而这是分叉出来的提交:

$ git checkout tags/forked
$ cat f1.txt
I am modified f1 content

另外还有「附注标签」(annotated tag),它与这种轻量标签不同。它是一个真正的对象,可以像提交一样带一条消息,并与其他对象一起存放在仓库中。

结语

这篇文章相当长,但我尽量把它写得透明清晰。当你把全文过一遍并理解了我在这里展示的所有概念之后,你就能更高效地使用 Git,也再不必害怕出现意外结果了。

如果你想进一步了解 Git,我强烈推荐这本很棒的书,它同样可以免费在线阅读。