这篇文章解释了 Git 是如何工作的。(如果相关内容的谈话更吸引你,你可以观看链接中的 视频。)
本文假设你已经对 Git 理解到了可以对你的项目进行版本控制的程度。本文专注于支撑 Git 的图结构以及这些图的性质影响 Git 行为的方式。通过了解底层,你可以将你心中对 Git 的模型建立在事实之上,而不是基于通过积
累使用经验而建立的假设上。这个更真实的模型可以让你更好的理解 Git 做了什么,正在做什么以及将要做什么。
本文由一系列针对单个项目的 Git 命令构成。时不时的,还将有一些对于 Git 所建立的图数据结构的观察。这些观察阐述了图的性质和相应性质所产生的影响。
读完本文后,如果你希望更深入的了解 Git,可以阅读我关于 Git 的 JavaScript 实现 gitlet.js(heavily annotated source code)。
创建项目
~ $ mkdir alpha
~ $ cd alpha
用户为项目建立一个名为 alpha
的目录。
~/alpha $ mkdir data
~/alpha $ printf 'a' > data/letter.txt
进入目录 alpha
,并在下面建立名为 data
的目录。在这个目录中建立一个名为 letter.txt
的文件,其中包含一个字符 a
。此时目录结构看起来像这样:
~/alpha $ git add data/letter.txt
alpha
└── data
└── letter.txt
初始化版本库
~/alpha $ git init
Initialized empty Git repository
git init
命令使得当前目录成为一个 Git 版本库。为此这条命令建立了一个名为的 .git
目录并且向其中写入了一些文件。这些文件定义和记录了关于Git配置和项目历史的所有相关内容。它们只是普通的文件,其中并没有什么类似魔法的神奇之处。用户可以使用文本编辑器和命令行阅读或编辑这些文件。也就是说:用户可以像获取和修改项目文件一样简单地获取和修改项目历史。
这时候目录 alpha
的结构看起来像这样:
alpha
├── data
| └── letter.txt
└── .git
├── objects
etc...
.git
目录和其中的内容属于 Git。所有其他的文件一起被称为工作副本,属于用户。添加一些文件
~/alpha $ git add data/letter.txt
用户对文件 data/letter.txt
运行命令 git add
。这一操作产生两个效果。
首先,这一操作在目录 .git/objects/
新建一个BLOB(binary large object 二进制大对象)文件。
这个BLOB文件包含了文件 data/letter.txt
压缩过的内容。文件名由内容的散列值得到。散列一个文本片段,意
味着对其内容运行一个程序将其转变为一段更短小[^1]并且唯一地[^2]代表原先文本的文本片段。例如,Git
将 a
散列为 2e65efe2a145dda7ee51d1741299f848e5bf752e
。最前面的两个字符被用于对象数据库中目录的命
名:.git/objects/2e/
。散列值的其余部分被用于包含添加文件内容的BLOB文件的命名:.git/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e
。
注意到将一个文件添加到 Git 中时 Git 是如何将文件的内容保存到 objects
目录中的。即便用户从工作副本
中删除文件 data/letter.txt
,它的内容在 Git 库中也是安全的。
其次,git add
命令将文件添加到索引中。索引是一个包含所有 Git 所要跟踪文件的列表。它以文、
件 .git/index
保存。这个文件每一行建立起一个被跟踪文件与这个文件被添加时散列值的对应关系。这是
在 git add
命令被执行之后的索引文件:
data/letter.txt 2e65efe2a145dda7ee51d1741299f848e5bf752e
用户建立一个叫做 data/number.txt
的文件,内容为 1234
。
~/alpha $ printf '1234' > data/number.txt
工作副本看起来像这样:
alpha
└── data
└── letter.txt
└── number.txt
用户将这个文件添加到 Git 中。
~/alpha $ git add data
命令 git add
创建一个包含 data/number.txt
内容的BLOB文件。同时添加一条文件 data/number.txt
的索引项,指向对应的BLOB文件。在 git add
命令第二次被执行之后索引文件如下:
data/letter.txt 2e65efe2a145dda7ee51d1741299f848e5bf752e
data/number.txt 274c0052dd5408f8ae2bc8440029ff67d79bc5c3
注意到即便用户运行 git add data
,也只有 data
目录中的文件在索引文件中被列出。文件夹 data
没有被单独列出。
~/alpha $ printf '1' > data/number.txt
~/alpha $ git add data
当用户最初建立 data/number.txt
时,他想要写入 1
,而不是 1234
。用户做了更改并且将文件添加到索引
中。这次的命令创建了一个新的包含了新内容的BLOB文件。并且更新了文件 data/number.txt
的索引项指向新的
BLOB文件。
进行一次提交
~/alpha $ git commit -m 'a1'
[master (root-commit) 774b54a] a1
用户做提交 a1
。Git 显示出一些有关此次提交的数据。很快我们将看懂这些信息。
提交命令分三步执行。首先,命令建立了一个树图来表示被提交的项目版本的内容。其次,建立一个提交对象。
最后,将当前分支指向新的提交对象。
创建一个树图
Git 通过从索引建立一张树图来记录项目的当前状态。这张树图记录了项目中每一个文件的位置和内容。
图由两种对象组成:BLOB 文件和树。
BLOB 文件由 git add
存储。它们表示了文件的内容。
树是在提交被进行时被存储的。树表示了工作副本中的目录。
下面就是记录新提交中 data
目录内容的树对象:
100664 blob 2e65efe2a145dda7ee51d1741299f848e5bf752e letter.txt
100664 blob 56a6051ca2b02b04ef92d5150c9ef600403cb1de number.txt
第一行记录了再现 data/letter.txt
文件所需的一切内容。第一部分表明文件权限。第二部分表明此项内容由BLOB文件表示而不是一个树对象。第三个部分表明对应BLOB的散列值。第四部分表明文件名。
第二行记录了关于 data/number.txt
文件的相同内容。
下面是代表 alpha
的树对象,即项目的根目录:
040000 tree 0eed1217a2947f4930583229987d90fe5e8e0b74 data
树中只有一行并指向 data
树。
a1
提交的树图
在上面的图中,root
树指向 data
树。data
树指向 data/letter.txt
和 data/number.txt
对应的BLOB文件。
创建一个提交对象
git commit
在创建树图之后创建一个提交对象。提交对象是 .git/objects/
目录中另一个文本文件:
tree ffe298c3ce8bb07326f888907996eaa48d266db4
author Mary Rose Cook <mary@maryrosecook.com> 1424798436 -0500
committer Mary Rose Cook <mary@maryrosecook.com> 1424798436 -0500
a1
第一行指向树图。散列值对应于代表工作副本根目录的树图,这里是 alpha
目录。最后一行是提交信息。
a1
提交对象指向它的树图
将当前分支指向新提交
最后,提交命令将当前分支指向新的提交对象。
哪一个分支是当前分支?Git 在 .git/HEAD
目录中的 HEAD
文件中寻找相关信息:
ref: refs/heads/master
这代表 HEAD
指向 master
。master
分支是当前分支。
HEAD
和 master
都是引用。引用是 Git 或用户用来标识特定分支的标签。
代表 master
分支的引用并不存在,因为这是版本库的第一次提交。Git 在路径 .git/refs/heads
/master
创建文件并且将内容写为提交对象的散列值:
74ac3ad9cde0b265d2b4f1c778b283a6e2ffbafd
(如果你在 Git 中输入你所读到的命令,a1
提交的散列值将与我这里的不同。内容对象例如 BLOB 文件和树总
是散列到与本文相同的值上。提交对象并不如此,因为其中包含日期和创建者的名字。)
让我们将 HEAD
和 master
添加到 Git 图中:
HEAD
指向 master
并且 master
指向 a1
提交
HEAD
指向 master
,如提交之前一样。但是现在 master
开始存在并且指向新的提交对象。
创建一个非首次提交
下面是 a1
提交之后的 Git 图。其中包括了工作拷贝和索引。
包含工作拷贝和索引的 a1
提交
注意,工作拷贝,索引以及 a1
提交中的 data/letter.txt
和 data/number.txt
文件内容是一样的。索引
和HEAD
提交的散列值都指向的都是 BLOB 对象,但是工作拷贝的内容是作文文本文件存放在不同的地方的。
~/alpha $ printf '2' > data/number.txt
用户将 data/number.txt
的内容设置为 2
。该操作更新了工作拷贝,但是没有改变 HEAD
提交以及索引。
工作拷贝中的 data/number.txt
设置为 2
~/alpha $ git add data/number.txt
用户将文件添加到 Git。该操作在 object
目录中创建了一个内容为 2
的 BLOB 文件。在新的 BLOB 文件中添加了一条指向 data/number.txt
的索引项。
工作拷贝和索引中的 data/number.txt
设置为 2
~/alpha $ git commit -m 'a2'
[master f0af7e6] a2
用户提交。步骤同上。第一步,创建了一个代表索引内容的树图。
data/number
的索引项发生了改变。旧的 data
树不能再反映当前的 data
目录的索引状态。所以必须创建
一个一个新的 data
树:
100664 blob 2e65efe2a145dda7ee51d1741299f848e5bf752e letter.txt
100664 blob d8263ee9860594d2806b0dfd1bfd17528b0ba2a4 number.txt
新的 data
树与旧的 data
树散列值不同。必须创建一个新的 root
树来记录当前散列值:
040000 tree 40b0318811470aaacc577485777d7a6780e51f0b data
第二步,创建一个新的提交对象。
tree ce72afb5ff229a39f6cce47b00d1b0ed60fe3556
parent 774b54a193d6cfdd081e581a007d2e11f784b9fe
author Mary Rose Cook <mary@maryrosecook.com> 1424813101 -0500
committer Mary Rose Cook <mary@maryrosecook.com> 1424813101 -0500
a2
提交对象的第一行指向了新的 tree
对象。第二行指向 a1
提交:当前提交的父提交。要找到父提交,Git
首先找到头指针 HEAD
,然后顺着找到 master
然后获得 a1
提交的散列值。
master
分支文件的内容设置为新提交的散列值。
a2
提交
不包括工作拷贝和索引的 Git 图
图属性: 内容被储存为一个树对象。这表明只有差异被储存在对象数据库中。从上图可以看出。a2
提交重复使
用了在 a1
提交之前创建的 a
BLOB 文件。类似的,如果整个工作目录的内容在一次次提交中没有发生改
变,树对象以及所有的 BLOB 文件都能够被重复使用。通常来说,各次提交之间只有很小的改动。这也就意味着 Git
能够使用很小的空间来储存大量的提交历史。
图属性:每个提交都有一个父提交,也就是说仓库可以储存项目的历史改动。
图属性:引用(refrences 或者 refs,译者注)是指向一部分提交历史或者其他的条目。也就是说可以给提交取一
个有意义的名字。用户将它们的工作组织成单行的固定短语,比如 fix-for-bug-376
。Git 使用了一些
像 HEAD
,MERGE_HEAD
和 FETCH_HEAD
的符号引用来支持一些用来处理提交历史的命令。
图属性:objects/
目录中的节点都是不可变的。也就是说其中的内容能编辑但是不能删除。你添加到仓库中的所
有内容以及你所做的每一个提交都存放在 objects
目录[^3]中的某个地方.
图属性:引用是可变的。因此,可以改变一个引用的意义。master
所指向的提交可能是当前项目的最好的版本,
但是过段时间,它将会被一个更新的或者更好的提交所取代。
图属性:由引用所指向的工作拷贝以及提交很容易获取,但是获取其他的引用就不那么简单了。也就是说调出最近
的提交历史更加容易,但是那也会时常会改变。
工作拷贝是最容易在历史提交中调出的,因为它是仓库的根节点。调出它甚至不需要执行 Git 命令。同时它也是
提交历史中的最早的永久节点。用户可以创建一个文件的许多版本,但是如果没有对它们执行 add
操作的
话,Git 将不会记录它们。
头指针 HEAD
所指向的提交很容易被调出。它在检出分支的顶端。要查看其中的内容,用户只需执行
stash[^4] 然后检出工作拷贝。同时,HEAD
改变频率最高的引用。
有固定引用指向的提交很容易被调出。用户可以轻易的检出那个分支。分支的顶端通常没有 HEAD
的改变频率
高,but often enough for the meaning of a branch name to be changeable.
要调出没有被任何引用所指向的提交很困难。用户在某个引用上提交得越多,操作之前的提交就越不容易。但我
们通常很少操作很久之前的提交[^5]。
检出一个提交
~/alpha $ git checkout 37888c2
You are in 'detached HEAD' state...
用户通过对应的散列值来检出 a2
提交。(你过你照搬了以上的 Git 命令,在你的电脑上不会起作用。请使
用 git log
命令来查找 a2
提交的散列值。)
检出有以下 4 个步骤。
第一步,Git 获得 a2
提交以及其指向的树图。
工作区的内容已经和树图保持一致了,因为我们的HEAD之前就已经通过master指向a2提交了。
第二步,将树图中的文件写入工作拷贝中。这不会产生什么变化。工作拷贝的内容已经和树图中的保持一致了,
因为头指针 HEAD
早已经通过 master
指向了 a2
提交。
第三步,Git 将树图中的文件写入索引。同样,这也不会产生什么变化,index 早就有 a2
提交的内容了。
第四步,头指针 HEAD
的内容被设置为 a2
提交的散列值:
f0af7e62679e144bb28c627ee3e8f7bdb235eee9
对 HEAD
写入一个散列值会导致仓库进入头指针分离状态。注意下图中的 HEAD
直接指向了 a2
提交,而不是指向 master
。
分离头指针到 a2
提交
~/alpha $ printf '3' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m 'a3'
[detached HEAD 3645a0e] a3
用户将 data/number.txt
的值设置为 3
然后提交更改。Git 找到 HEAD
然后找到 a3
提交的父提
交。这回返回 a2
提交的散列值而不是查找一个分支引用。
Git 将 HEAD
更新,使其直接指向 a3
提交的哈希值。此时仓库仍然处于头指针分离状态,而没有在一个
分支上,因为没有引用指向 a3
提交亦或是它之后的提交。这意味着它很容易丢失。
从现在起,图示中大多的树和 BLOB 都会省略。
没有在分支上的 a3
提交
创建一个分支
~/alpha $ git branch deputy
用户创建了一个新的叫做 deputy
的分支。这个操作在 .git/refs/heads/deputy
目录下创建了新文件,其包含
了 HEAD
指向的散列值:a3
提交的散列值。
图属性:分支其实就是引用,而引用其实就是文件。这也就是说 Git 的分支是很轻量的。
创建 deputy
分支的操作实际上将新的 a3
提交安全地放在了一个新的分支上。HEAD
指针目前仍处于分
离状态,因为它现在仍是直接指向了一个提交。
处于 deputy
分支的 a3
提交
检出分支
~/alpha $ git checkout master
Switched to branch 'master'
---
检出'master'分支
用户检出了'master'分支
首先, Git 找到'master'分支所指向的a2
提交对象并获取该提交对象所指向的树对象.
接下来 Git 会将树对象储存的文件写到当前工作副本中, 该操作将覆写data/number.txt
为2
.
第三步, Git 将树对象中的文件入口写入 index, data/number.txt
的文件入口将会被更新为2
blob 的 hash 值
最后 Git 通过将HEAD
中的 hash 值替换为如下内容来使HEAD
指向master
分支:
ref: refs/heads/master
master
分支被检出, 指向'a2'提交
检出与当前工作副本相冲突的分支
~/alpha $ printf '789' > data/number.txt
~/alpha $ git checkout deputy
Your changes to these files would be overwritten
by checkout:
data/number.txt
Commit your changes or stash them before you
switch branches.
---
对以下文件做出的更改将在检出中被覆盖:
data/number.txt
请在检出前将这些更改提交或储藏.
用户无意中将data/number.txt
的内容更改为789
, 此时他尝试检出deputy
分支, Git 没有执行这次检出.
当前HEAD
指向 master
分支, 其所指向提交a2
中data/number.txt
的内容为2
. deputy
分支指向的提交a3
中data/number.txt
的内容为3
. 当前工作副本中data/number.txt
的内容为789
. 这个文件的各个版本各不相同, 必须通
过一种方法来消除这些差异.
如果 Git 将当前版本的data/number.txt
替换成将要检出的分支中的版本则会造成数据遗失, Git 不允许这种情况发
生.
如果 Git 将要检出的分支与当前工作副本合并则会导致冲突
因此 Git 中断了此次检出.
~/alpha $ printf '2' > data/number.txt
~/alpha $ git checkout deputy
Switched to branch 'deputy'
用户注意到了对data/number.txt
的意外操作, 在把它的内容修改回2
后成功检出了deputy
分支.