03月24日
深入浅出 Git

作者 Mary Rose Cook
本文为 Coding 用户 @z2xy @kari__ @luanmingyi @lsbbd @zealseeker @八哥 协作翻译。如有修改建议,欢迎提交 Pull Request

这篇文章解释了 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
└── 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 树。

Tree graph for the <code>a1</code> commit
a1 提交的树图

在上面的图中,root 树指向 data 树。data 树指向 data/letter.txtdata/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 目录。最后一行是提交信息。

<code>a1</code> commit object pointing at its tree graph
a1 提交对象指向它的树图

将当前分支指向新提交

最后,提交命令将当前分支指向新的提交对象。

哪一个分支是当前分支?Git 在 .git/HEAD 目录中的 HEAD 文件中寻找相关信息:

ref: refs/heads/master

这代表 HEAD 指向 mastermaster 分支是当前分支。

HEADmaster 都是引用。引用是 Git 或用户用来标识特定分支的标签。

代表 master 分支的引用并不存在,因为这是版本库的第一次提交。Git 在路径 .git/refs/heads/master 创建文件并且将内容写为提交对象的散列值:

74ac3ad9cde0b265d2b4f1c778b283a6e2ffbafd

(如果你在 Git 中输入你所读到的命令,a1 提交的散列值将与我这里的不同。内容对象例如 BLOB 文件和树总是散列到与本文相同的值上。提交对象并不如此,因为其中包含日期和创建者的名字。)

让我们将 HEADmaster 添加到 Git 图中:

<code>master</code> pointing at the <code>a1</code> commit
HEAD 指向 master 并且 master 指向 a1 提交

HEAD 指向 master,如提交之前一样。但是现在 master 开始存在并且指向新的提交对象。

创建一个非首次提交

下面是 a1 提交之后的 Git 图。其中包括了工作拷贝和索引。

包含工作拷贝和索引的 <code>a1</code> 提交
包含工作拷贝和索引的 a1 提交

注意,工作拷贝,索引以及 a1 提交中的 data/letter.txtdata/number.txt 文件内容是一样的。索引和 HEAD 提交的散列值都指向的都是 BLOB 对象,但是工作拷贝的内容是作文文本文件存放在不同的地方的。

~/alpha $ printf '2' > data/number.txt

用户将 data/number.txt 的内容设置为 2。该操作更新了工作拷贝,但是没有改变 HEAD 提交以及索引。

工作拷贝中的 <code>data/number.txt</code> 设置为 2
工作拷贝中的 data/number.txt 设置为 2

~/alpha $ git add data/number.txt

用户将文件添加到 Git。该操作在 object 目录中创建了一个内容为 2 的 BLOB 文件。在新的 BLOB 文件中添加了一条指向 data/number.txt 的索引项。

工作拷贝和索引中的 <code>data/number.txt</code> 设置为 2
工作拷贝和索引中的 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 分支文件的内容设置为新提交的散列值。

<code>a2</code> 提交
a2 提交

不包括工作拷贝和索引的 Git 图
不包括工作拷贝和索引的 Git 图

图属性: 内容被储存为一个树对象。这表明只有差异被储存在对象数据库中。从上图可以看出。a2 提交重复使用了在 a1 提交之前创建的 a BLOB 文件。类似的,如果整个工作目录的内容在一次次提交中没有发生改变,树对象以及所有的 BLOB 文件都能够被重复使用。通常来说,各次提交之间只有很小的改动。这也就意味着 Git 能够使用很小的空间来储存大量的提交历史。

图属性:每个提交都有一个父提交,也就是说仓库可以储存项目的历史改动。

图属性:引用(refrences 或者 refs,译者注)是指向一部分提交历史或者其他的条目。也就是说可以给提交取一个有意义的名字。用户将它们的工作组织成单行的固定短语,比如 fix-for-bug-376。Git 使用了一些像 HEADMERGE_HEADFETCH_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

分离头指针到 <code>a2</code> 提交
分离头指针到 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 都会省略。

没有在分支上的 <code>a3</code> 提交
没有在分支上的 a3 提交

创建一个分支

~/alpha $ git branch deputy

用户创建了一个新的叫做 deputy 的分支。这个操作在 .git/refs/heads/deputy 目录下创建了新文件,其包含了 HEAD 指向的散列值:a3 提交的散列值。

图属性:分支其实就是引用,而引用其实就是文件。这也就是说 Git 的分支是很轻量的。

创建 deputy 分支的操作实际上将新的 a3 提交安全地放在了一个新的分支上。HEAD 指针目前仍处于分离状态,因为它现在仍是直接指向了一个提交。

处于 <code>deputy</code> 分支的 <code>a3</code> 提交
处于 deputy 分支的 a3 提交

检出分支

~/alpha $ git checkout master
          Switched to branch 'master'
		  ---
          检出'master'分支

用户检出了'master'分支

首先, Git 找到'master'分支所指向的a2提交对象并获取该提交对象所指向的树对象.

接下来 Git 会将树对象储存的文件写到当前工作副本中, 该操作将覆写data/number.txt2.

第三步, Git 将树对象中的文件入口写入 index, data/number.txt的文件入口将会被更新为2 blob 的 hash 值

最后 Git 通过将HEAD中的 hash 值替换为如下内容来使HEAD指向master分支:

ref: refs/heads/master

<code>master</code>分支被检出, 指向'a2'提交
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分支, 其所指向提交a2data/number.txt的内容为2. deputy分支指向的提交a3data/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分支.

<code>deputy</code> 分支被检出
deputy 分支被检出

合并一个祖先分支

~/alpha $ git merge master
          Already up-to-date.

用户将master分支合并到deputy中. 合并两个分支其本质为合并两个提交对象. 其一-作为接受者(receiver)-是deputy所指向的提交对象, 其二-作为给予者(giver)-是master所指向的提交对象. 在这次合并中 Git 不会执行任何操作, 而仅仅打印出Already up-to-date..

[图属性]: 图示中的一系列提交对象可以被认为是对仓库内容进行的一些列更改. 因此在一次合并当中, 若想要并入的分支是当前分支的祖先分支, Git不会执行任何操作, 因为想要并入分支中的改动已经被包含在了当前分支中.

合并后代提交

~/alpha $ git checkout master
          Switched to branch 'master'

用户检出了 master 分支.

<code>master</code> checked out and pointing at the <code>a2</code> commit
master 被检出并指向了 a2 提交

~/alpha $ git merge deputy
          Fast-forward

他们合并 deputy 分支到 master。Git 发现赠予提交的 a2 是源提交 a3 的祖先提交。这里可以执行 fast-forward 合并。

Git 获取赠予提交和它指向的树图,将树图中的文件写入工作区和 index。这将 master 分支 fast-forward 到了 a3 提交。

<code>a3</code> commit from <code>deputy</code> fast-forward merged into <code>master</code>
deputy 分支的 a3 提交 fast-forward 合并到了 master 分支

图属性:图中的提交系列被视为对仓库内容的一系列更改。这意味着,如果赠予提交是接收提交的后代提交,提交历史不会变。已经存在一序列提交来描述赠予提交和接收提交之间的变化。但是尽管 Git 历史不变,Git 的状态图是会改变的。HEAD 指向的具体引用会更新以指向赠予提交。

合并不同提交线的两个提交

~/alpha $ printf '4' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m 'a4'
          [master 7b7bd9a] a4

用户将文件 number.txt 的内容改为 4 然后提交到 master.

~/alpha $ git checkout deputy
          Switched to branch 'deputy'
~/alpha $ printf 'b' > data/letter.txt
~/alpha $ git add data/letter.txt
~/alpha $ git commit -m 'b3'
          [deputy 982dffb] b3

用户检出 deputy 分支,将文件 data/letter.txt 内容改为 b 然后提交到 deputy 分支。

<code>a4</code> committed to <code>master</code>, <code>b3</code> committed to <code>deputy</code> and <code>deputy</code> checked out
a4 提交到 master, b3 提交到 deputydeputy 被检出

图属性: 多个提交可以共用一个父提交,这意味着新提交线可以在提交历史里创建出来。

图属性: 某提交可以有多个父提交,这意味着两个不同的提交线可以被一个合并提交来合并。

~/alpha $ git merge master -m 'b4'
          Merge made by the 'recursive' strategy.

用户合并 masterdeputy.

Git 发现接收提交 b3和赠予提交 a4在不同的提交线上。它创建了一个合并提交。这个过程总共分八步。

第一步,Git 将接收提交的哈希值写入文件 alpha/.git/MERGE_HEAD。此文件的存在说明 Git 正在做合并操作。

第二步,Git 查找基提交:即接收提交和赠予提交共有的一个最近父提交。

<code>a3</code>, the base commit of <code>a4</code> and <code>b3</code>
a3a4b3 的基提交

图属性:每个提交都有一个父提交。这意味着我们可以发现两个提交线分开自哪个提交。Git 查找 b3a4 的所有祖先提交,发现了最近的公共父提交 a3,即为他们的基提交。

第三步,Git 为基提交、接收提交和赠予提交创建索引。

第四步,Git 产生接收提交和赠予提交相对于基提交的 diff,此处的 diff 是一个文件路径列表,指向一个变化:包括添加、移除、修改或冲突。

Git 获取基提交、接收提交和赠予提交的文件列表,针对每一个文件,通过对比 index 来判断它的状态与写入变更。它将对应条目写入 diff。在这个例子中,diff 包含两个条目。

第一项记录 data/letter.txt 的状态。该文件内容分别是基提交中的 a 、接收提交中的b和赠予提交中的a。文件内容在基提交和接收提交不同,但在基提交和赠予提交相同。Git 发现文件内容被接收提交修改了,而不是在赠予提交中。data/letter.txt 的状态是修改,而不是冲突。

第二项记录 data/number.txt 的变更。在这个例子中,该文件内容在基提交和接收提交中是相同的,但在基提交和赠予提交是不同的。 data/number.txt 条目的状态也是修改。

图属性:查找一个合并操作的基提交是可行的。这意味着,如果基提交中的一个文件只在接收提交或赠予提交做了修改,Git 可以自动除了合并文件,这样就减少了用户的工作量。

第五步,Git 将差异中的项被应用到工作区。data/letter.txt 内容被修改为bdata/number.txt 内容被修改为 4

第六步,Git 将差异中的项应用到 index。data/letter.txt 会指向内容为 b 的 blob,data/number.txt 会指向内容为 4 的 blob。

第七步,更新后的 index 被提交:

tree 20294508aea3fb6f05fcc49adaecc2e6d60f7e7d
parent 982dffb20f8d6a25a8554cc8d765fb9f3ff1333b
parent 7b7bd9a5253f47360d5787095afc5ba56591bfe7
author Mary Rose Cook <mary@maryrosecook.com> 1425596551 -0500
committer Mary Rose Cook <mary@maryrosecook.com> 1425596551 -0500

b4

注意,这个提交有两个父提交。

第八步,Git 将当前分支 deputy 指向新提交。

<code>b4</code>, the merge commit resulting from the recursive merge of <code>a4</code> into <code>b3</code>
a4递归合并入 b3 产生 b4

合并不同提交线且有相同修改文件的两个提交

~/alpha $ git checkout master
          Switched to branch 'master'
~/alpha $ git merge deputy
          Fast-forward

用户检出 master,他们将 deputy 合并到 master。此操作将master fast-forwards 指向 b4masterdeputy 现指向了相同的提交。

<code>deputy</code> merged into <code>master</code> to bring <code>master</code> up to the latest commit, <code>b4</code>
deputy 合并到 mastermaster 更新到新提交 b4

~/alpha $ git checkout deputy
          Switched to branch 'deputy'
~/alpha $ printf '5' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m 'b5'
          [deputy bd797c2] b5

用户检出 deputy。将 data/number.txt 内容修改为 5并提交到 deputy 分支。

~/alpha $ git checkout master
          Switched to branch 'master'
~/alpha $ printf '6' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m 'b6'
          [master 4c3ce18] b6

用户检出 master。将 data/number.txt 内容修改为 6 并提交到 master 分支。

<code>b5</code> commit on <code>deputy</code> and <code>b6</code> commit on <code>master</code>
b5 提交在 deputy b6 提交在 master

~/alpha $ git merge deputy
          CONFLICT in data/number.txt
          Automatic merge failed; fix conflicts and
          commit the result.

用户将 deputy 合并到 master。这里存在冲突故合并中止。对于有冲突的合并操作,执行步骤和没有冲突的合并的前六步是相同的:设置 .git/MERGE_HEAD,查找基提交,创建基提交、接收提交和赠予提交的索引,生成 diff,更新工作区,更新 index。由于冲突,第七步提交和第八步更新 ref 不再执行。让我们再来看看这些步骤,观察到底发生了什么。

第一步,Git 将赠予提交的哈希值写入 .git/MERGE_HEAD.

<code>MERGE_HEAD</code> written during merge of <code>b5</code> into <code>b6</code>
MERGE_HEAD 写入在 b5 合并入 b6

第二步,Git 查找到基提交, b4.

第三步,Git 创建基提交、接收提交和赠予提交的索引。

第四步,Git 生成集合了接收提交和赠予提交相对于基提交的差异列表,这个 diff 是一份指向变更的文件路径:添加、删除、修改或冲突。

在本例中,差异列表仅包含一项: data/number.txt。它的状态被标为冲突因为其内容在接收提交,赠予提交和基提交中都是变化的。

第五步,差异列表中的文件被写入工作区。对于冲突的部分,Git 将两个版本都写入工作区的文件中。data/number.txt 的内容被变更为:

<<<<<<< HEAD
6
=======
5
>>>>>>> deputy

第六步,差异列表中的文件被写入 index。index 中的项被文件路径和 stage 的组合唯一标识。没有冲突的项 stage 为 0。在该合并前,index 看起来像下面的样子,标有 0 的是 stage 值:

0 data/letter.txt 63d8dbd40c23542e740659a7168a0ce3138ea748
0 data/number.txt 62f9457511f879886bb7728c986fe10b0ece6bcb

在合并 diff 写入 index 后,index 变成:

0 data/letter.txt 63d8dbd40c23542e740659a7168a0ce3138ea748
1 data/number.txt bf0d87ab1b2b0ec1a11a3973d2845b42413d9767
2 data/number.txt 62f9457511f879886bb7728c986fe10b0ece6bcb
3 data/number.txt 7813681f5b41c028345ca62a2be376bae70b7f61

stage 0data/letter.txt 项和合并前是一样的。stage 0data/number.txt 项已经不存在,取代的是三个新项。stage 1 的项包含该文件在基提交中内容的哈希值,stage 2 包含接收提交的哈希值,stage 3包含赠予提交的哈希值。这三项表明文件 data/number.txt 存在冲突。

合并中止了。

~/alpha $ printf '11' > data/number.txt
~/alpha $ git add data/number.txt

用户通过将 data/number.txt 的内容修改为 11将两个有冲突的文件合并,将文件添加到 index,Git 创建一个包含11 的 blob,创建一个冲突文件以告诉 Git 冲突已经解决了。Git 移除 index 中的 1, 23,并添加 stage 为 0data/number.txt 项,该项指向新创建 blob 的哈希值。现在 index 变为:

0 data/letter.txt 63d8dbd40c23542e740659a7168a0ce3138ea748
0 data/number.txt 9d607966b721abde8931ddd052181fae905db503
~/alpha $ git commit -m 'b11'
          [master 251a513] b11

第七步,用户进行提交。Git 发现存在 .git/MERGE_HEAD,意味着合并还在进行中。通过检查 index 发现没有冲突。它创建了一个新提交 b11,用来记录合并后的内容。然后删除 .git/MERGE_HEAD。合并完成。

第八步,Git 将当前分支 master 指向新提交。

<code>b11</code>, the merge commit resulting from the conflicted, recursive merge of <code>b5</code> into <code>b6</code>
b5b6递归合并为b11

删除文件

这幅 Git 示意图包含对于最后一次提交的历史提交、树对象、储存对象、工作副本以及索引。

The working copy, index, <code>b11</code> commit and its tree graph
工作副本、索引, b11 提交以及它的树对象。

~/alpha $ git rm data/letter.txt
          rm 'data/letter.txt'

该用户告诉 Git 删除 data/letter.txt。 文件将从工作副本中删除,该文件的入口也将从索引中消失。

After <code>data/letter.txt</code> <code>rm</code>ed from working copy and index
After data/letter.txt rmed from working copy and index
data/letter.txt 从工作副本以及索引中 rm 过后

~/alpha $ git commit -m '11'
          [master d14c7d2] 11

该用户进行提交了。 一般说来,Git 会在提交时构建一张树图代表索引内容,data/letter.txt 并不会出现在树中,因为它不在索引里。

<code>11</code> commit made after <code>data/letter.txt</code> gfm-extraction-6ffa34b1d7961c0cf5162973174164d7ed
data/letter.txt 删除后进行 11 提交

复制一个库

~/alpha $ cd ..
      ~ $ cp -R alpha bravo

用户复制 alpha/ 库中的内容到 bravo/ 目录中,使得目录结构如下:

~
├── alpha
|   └── data
|       └── number.txt
└── bravo
    └── data
        └── number.txt

bravo 目录即生成另一张 Git 图:

New graph created when <code>alpha</code> <code>cp</code>ed to <code>bravo</code>
alpha cpbravo 后产生的新图

链接至另一个库

      ~ $ cd alpha
~/alpha $ git remote add bravo ../bravo

用户回到 alpha 库中,设置 bravo 为一个 alpha 的远程库,这会使 alpha/.git/config 多了这几行:

[remote "bravo"]
	url = ../bravo/

这几行说明有个叫 bravo 的远程库在 ../bravo 中。

获取远程分支

~/alpha $ cd ../bravo
~/bravo $ printf '12' > data/number.txt
~/bravo $ git add data/number.txt
~/bravo $ git commit -m '12'
          [master 94cd04d] 12

进入 bravo 目录. 覆写 文件 data/number.txt12 ,将改动提交到bravo仓库的分支 master.

<code>12</code> commit on <code>bravo</code> repository

bravo 仓库中的一个提交12

~/bravo $ cd ../alpha
~/alpha $ git fetch bravo master
          Unpacking objects: 100%
          From ../bravo
            * branch master -> FETCH_HEAD

进入 alpha 目录,从 bravo仓库获取 master分支到 alpha 仓库到 master 分支。这个过程包含了四个步骤。

第一步,Git 获取 bravo仓库中 master 分支所指向提交的 hash 值,即 提交 12 所对应的 hash 值。

第二步,Git 会给做出 12 这个提交依赖的所有对象组成的一个列表:包含了提交对象自身,树图中的对象,提交 12 所对应的父提交,父提交树图对象。然后从列表中移除所有在 alpha 仓库对象数据库中已有的对象。复制剩下的对象到目录 alpha/.git/objects/

第三步,将 12这个提交的 hash 值 写入文件 alpha/.git/refs/remotes/bravo/master

第四步,文件 alpha/.git/FETCH_HEAD 内容被置为:

94cd04d93ae88a1f53a4646532b1e8cdfbc0977f branch 'master' of ../bravo

文件内容表明从 bravo 仓库 master 分支获取了 12 这个提交。

<code>alpha</code> after <code>bravo/master</code> fetched

bravo/master 获取后的alpha仓库状态

图属性: 对象可以被复制。以为址不同仓库间可以共享对象。

图属性:一个仓库可以存储远程分支的引用,例如 alpha/.git/refs/remotes/bravo/master 。意味着,仓库可以在本地记录远程分支的状态。如果远程分支没有改变,这个状态就一直是正确的。

合并分支 FETCH_HEAD

~/alpha $ git merge FETCH_HEAD
          Updating d14c7d2..94cd04d
          Fast-forward

用户合并了分支FETCH_HEAD,这个分支也是一个指向某个提交的一个引用,被解析为指向 12 这个提交的一个引用。

合并前,HEAD 指向此次被合并的引用, 11 这个提交。 完成 fast-forward 合并后, HEAD指向提交 12

<code>alpha</code> after <code>FETCH_HEAD</code> merged

alpha仓库合并FETCH_HEAD后的状态

拉取远程仓库

~/alpha $ git pull bravo master
          Already up-to-date.

拉取远程仓库 bravomaster 分支到本地仓库 alphapull 操作是 "获取然后合并FETCH_HEAD " 这两个命令的一个快捷方式。执行了这个命令后,反馈 Already up-to-date, 说明本地和远程内容一样。

克隆一个仓库

~/alpha $ cd ..
      ~ $ git clone alpha charlie
          Cloning into 'charlie'

切换到 alpha 的上级目录,克隆仓库alpha 到目录 charlie,这个操作的结果,如同通过复制到到bravo仓库。

克隆仓库做的事情包括:创建新目录charlie; 在目录charlie下初始化仓库;将 alpha 作为远程仓库的 origin分支; 获取 origin 分支到本地;合并分支 FETCH_HEAD

推送本地分支到远程仓库

      ~ $ cd alpha
~/alpha $ printf '13' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m '13'
          [master 3238468] 13

切换回 alpha仓库, 覆写文件 data/number.txt13 ,将改动提交到alpha仓库的分支 master

~/alpha $ git remote add charlie ../charlie

将本地目录charlie 作为本地仓库的alpha 的一个远程仓库。

~/alpha $ git push charlie master
          Writing objects: 100%
          remote error: refusing to update checked out
          branch: refs/heads/master because it will make
          the index and work tree inconsistent

推送本地仓库的master 分支到远程仓库 charlie

13 这个提交所需要的所有对象,被复制到到远程仓库charlie中。

至此,推送操作完成。和之前一样,如果操作出错,Git 会提示出错内容。例如,Git 会拒绝推送到一个在远程切出的分支。听起来是不是很有道理 ?由于一次推送操作会更新远程索引和 HEAD的指向,如果这个时候有人正在编辑远程副本的时候,就会导致冲突,出现不一致。

现在,可以创建一个新分支,合并 13这个内容到新分支 ,推送到远程仓库 charlie。但是,希望达到的是可以推送任何想推送的内容到仓库,希望又有一个可以推送和拉取到中心仓库,但是没人能直接推送到此中心仓库。有点像 GitHub 远程操作,最后的解决方案就是,裸库。

克隆一个裸库

~/alpha $ cd ..
      ~ $ git clone alpha delta --bare
          Cloning into bare repository 'delta'

切换到 alpha上级目录,将裸库到目录 delta 。这个克隆有两点不同。一是config文件表明这是一个裸库;二是,通常位于 .git目录下的文件被现在存放在仓库的根目录下:

delta
├── HEAD
├── config
├── objects
└── refs

<code>alpha</code> and <code>delta</code> graphs after <code>alpha</code> cloned to <code>delta</code>

克隆alphadelta后的仓库图

推送分支到裸库

      ~ $ cd alpha
~/alpha $ git remote add delta ../delta

切换到 alpha目录。使用 ../delta 目录 创建远程仓库delta

~/alpha $ printf '14' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m '14'
          [master cb51da8] 14

覆写文件 data/number.txt14 ,将改动提交到alpha仓库的分支 master

<code>14</code> commit on <code>alpha</code>

alpha 仓库中的14 提交

~/alpha $ git push delta master
          Writing objects: 100%
          To ../delta
            3238468..cb51da8 master -> master

推送master到仓库delat。推送过程包含三步:

第一步,14提交需要的所有对象,从alpha/.git/objects/目录,复制到目录delta/objects/

第二步,更新文件delta/refs/heads/master内容,指向提交 14

第三步,更新文件alpha/.git/refs/remotes/delta/master内容,指向提交 14。本地仓库alpha就有了远程仓库delta的一份最新状态记录。

<code>14</code> commit pushed from <code>alpha</code> to <code>delta</code>

推送alpha仓库到提交14到仓库delta

总结

Git 是在基于图的思想上构建的,几乎所有的 Git 命令都在维护这个图。想要深入理解 Git,就需要把精力集中在这个图的属性上,而不是在 Git 操作流程 或者 Git 命令。

想要更多的理解 Git,就去详细剖析 .git 目录,看里面都有些什么文件。通过改变文件内容,观察里面这些文件的变化。手动创建提交,看看可以把这个仓库搞什么鬼样子,然后尝试修复这些问题。

  1. 通过这个案例,hash 值比原始文件内容要长。但是,所有文件的内容比 hash 值要长,这样的效果就是,表达的意思比原始文件要更加简洁明了。
  2. 有可能会出现两个不同的文件内容的 hash 值一样,但是,这个 机率很小.。
  3. git prune会删除一个引用不能获取的所有对象。如果使用此命令,可能导致文件内容丢失。
  4. git stash会在一个安全的地方存储HEAD与当前工作区的差异。因此稍后可以恢复工作区。
  5. rebase可以对历史命令进行操作,达到新增,修改,删除提交的目的。
coding227810

10条评论

学习了

Ayooz6 个月前回复

对于index的问题清晰了许多,以前一直以为index是类似工作空间的东西,运行命令倒也没出现什么问题,看了你的问题,豁然开朗,看来学习git不能只学习git具体命令和操作流程,更要搞懂背后的原理,这样出现错误才知道怎么去改正。

guanghui57 个月前回复

赞*32

szy0syz8 个月前回复

为什么不放原文链接 :]

knarfeh8 个月前回复

很全面 透彻……

Y9785432108 个月前回复

真的好文,赞!

szy0syz8 个月前回复

来晚了吗?

z2xy8 个月前回复

赞!!!

f_q8 个月前回复

有我参与,自己支持下~

zealseeker8 个月前回复

的确是好文章

kari__8 个月前回复