可能你每天都在用Git,但是你真的了解Git的内部是如何工作的吗?也许你跟之前的我一样,对Git的工作原理不甚了解,导致出现什么问题都必须通过搜索Stack Overflow来解决。为了摆脱这些困扰,我阅读了Scott Chaccon写的《Pro Git》。这本书不仅讲了很多平常使用Git的技巧,还深入剖析了Git的原理,是一本非常值得一看的好书。正因为读了这本书,我对Git原理的理解增进了许多,现在的我对Git的操作更有自信,不再像以前一样每步操作都心中没数。今天我就想通过例子来跟大家分享我的理解,希望能对各位有所帮助。
Git是怎么储存数据的
想要了解Git的工作原理,就必须首先了解Git是怎么储存数据的。你可以把Git理解成一个存放着不同对象(object
)的hash table, 而这个hash table的键值(key)则就是对象的hash值。这样保证了不同的对象不会互相覆盖。Git当中一共有3种主要的对象:
blob
tree
commit
每个blob
类型的对象对应的就是repository里面的一个文件。对象的内容就是该文件的内容。现在我们来验证一下。首先建立一个新的Git repo:
$ mkdir git_test
$ cd git_test
$ git init
我们创建一个新文件,并把文件添加到Git里面:
$ echo "version 1" > index.txt
$ git add index.txt
注意,当我们运行git add
以后,该文件实际上已经被加入到了Git的Hash table中了,而并不是仅仅在cache当中。我们可以运行一下命令来列出Git的hash table里面的对象:
$ git cat-file --batch-check --batch-all-objects
16f4c48319f2bb70f5289fc1ea325dabc9d14729 blob 8
我们可以看出,在Git的hash table中有一个对象,他的Hash值是16f4c48319f2bb70f5289fc1ea325dabc9d14729
,类型是blob
。我们可以运行git cat-file
来查看这个对象的内容:
$ git cat-file -p 16f4c48319f2bb70f5289fc1ea325dabc9d14729
version 1
那么,从这里我们可以看出,该文件确实被作为blob
被存储起来了。
那么我们接下来试着在一个文件夹里面建立一个新文件:
$ mkdir dir
$ echo "version 1, inside" > ./dir/inside.txt
$ git add ./dir/inside.txt
我们再来看看现在的hash table:
$ git cat-file --batch-check --batch-all-objects
16f4c48319f2bb70f5289fc1ea325dabc9d14729 blob 8
bf97e71de76bcff2bd8aba44710aa5e665eacb99 blob 21
因为新添加了一个文件的缘故,现在有两个blob
了。一切都符合我们的预期。现在让我们尝试着git commit
,然后再查看hash table:
$ git commit -m "Commit 1"
$ git cat-file --batch-check --batch-all-objects
16f4c48319f2bb70f5289fc1ea325dabc9d14729 blob 8
312940d2b3d55f429bd796ddb3155566dc06b0c7 tree 67
39bdea2300491cc90f47551887bfe9503cd4a9d9 tree 38
bddef1a9efb1a84bcc6de74882ebe93b6336ade1 commit 203
bf97e71de76bcff2bd8aba44710aa5e665eacb99 blob 21
Wow,怎么突然冒出了那么多东西?除了我们之前的两个blob
以外,还多了两个tree
和一个commit
。我们来看看tree
是什么东西:
$ git cat-file -p 312940d2b3d55f429bd796ddb3155566dc06b0c7
040000 tree 39bdea2300491cc90f47551887bfe9503cd4a9d9 dir
100644 blob 16f4c48319f2bb70f5289fc1ea325dabc9d14729 index.txt
很明显,这一个tree
中指向另一个叫dir
的tree
,和一个叫index.txt
的blob
。这么这个tree
不就是跟我们的根目录结构一样吗?同样,再看看另一个tree
的内容:
$ git cat-file -p 39bdea2300491cc90f47551887bfe9503cd4a9d9
100644 blob bf97e71de76bcff2bd8aba44710aa5e665eacb99 inside.txt
这个tree
不就是代表我们dir
文件夹吗?确实没错,tree
类型的对象在Git里面代表的就是一个文件夹,而这个对象的内容就是面向它所包含的文件夹和文件的指针。
还有最后一个commit
对象,我们也来看看:
$ git cat-file -p bddef1a9efb1a84bcc6de74882ebe93b6336ade1
tree 312940d2b3d55f429bd796ddb3155566dc06b0c7
author Jo <jo@gmail.com> 1541076281 +0100
committer Jo <jo@gmail.com> 1541076281 +0100
Commit 1
我们可以看到,这个commit
对象首先包含了一个面向根目录tree
对象的指针,而且还包含了committer的个人信息还有commit的注释。
说到这里,你应该大致了解这三个对象之间的指向关系。总结来说是这样的:
commit
→ tree
→ tree
或者 blob
好了,现在让我们尝试修改一下文件的内容并做一个新的commit:
$ echo "version 2" > index.txt
$ git add index.txt
$ git commit -m "Commit 2"
我们再来看看新的hash table:
$ git cat-file --batch-check --batch-all-objects
16f4c48319f2bb70f5289fc1ea325dabc9d14729 blob 8
1c5c8a007e7e4abd6fc7c80d9ef01effe30fd7b1 tree 67
312940d2b3d55f429bd796ddb3155566dc06b0c7 tree 67
39bdea2300491cc90f47551887bfe9503cd4a9d9 tree 38
8231f0fdc862f06b2bd7b7bfd2f42082d3086b71 blob 13
82b70486b425018a6f9250f5e50ffdbc3db24359 commit 251
bddef1a9efb1a84bcc6de74882ebe93b6336ade1 commit 203
bf97e71de76bcff2bd8aba44710aa5e665eacb99 blob 21
比起之前,现在我们又多了一个blob
,一个tree
和一个commit
。我们先看看新添加的blob
是什么:
$ git cat-file -p 8231f0fdc862f06b2bd7b7bfd2f42082d3086b71
version 2
也就是说,我们新版本的index.txt
是被当做一个独立的文件添加到了hash table中了。这里我们得出一个很重要的结论:Git并不储存一个文件不同版本之间的diff,Git把同一个文件的每个版本都当做一个独立的文件来储存。
然后我们来看看新的tree
对象:
$ git cat-file -p 1c5c8a007e7e4abd6fc7c80d9ef01effe30fd7b1
040000 tree 39bdea2300491cc90f47551887bfe9503cd4a9d9 dir
100644 blob 8231f0fdc862f06b2bd7b7bfd2f42082d3086b71 index.txt
这个tree
对象指向的dir
的指针还是同一个,因为文件夹dir
里的内容没有变化。指向index.txt
的指针却变成了新的blob
的Hash值了。正因为如此,这个tree
对象的内容变了,它的Hash值也变了,成为了一个新的对象。
最后来看看新的commit
对象:
$ git cat-file -p 82b70486b425018a6f9250f5e50ffdbc3db24359
tree 1c5c8a007e7e4abd6fc7c80d9ef01effe30fd7b1
parent bddef1a9efb1a84bcc6de74882ebe93b6336ade1
author Jo <jo@gmail.com> 1541076281 +0100
committer Jo <jo@gmail.com> 1541076281 +0100
Commit 2
跟第一个commit不同的是,这里的tree
项指向了新的根目录tree
对象,而且还多了一个parent
项,指向的正是第一个commit。从此以后每个新的commit都会指向一个原有的commit,形成了一个单向链表。
我们之前所建立的repo中对象的关系,可以用以下的图来概括:
Branch, Head和Tag
从上面的结论我们得知,commit之间就是一个单向链表,新的commit总是会指向一个原有的commit。如果我们知道最新的commit的位置,就能顺藤摸瓜地找到之前所有的commit。以上图为例,如果我们知道Commit 2
的所在,那么我们就能够通过指针获得所有对象的数据。问题是我们总不能靠记住Hash值来定位它吧,这也太不科学了。因此,我们有了branch
这个概念。所谓的branch
,其实就是一个指向某个commit的指针文件,一般被存放在./.git/refs/heads
里面。我们知道,一个新建的Git repo会有一个默认的master
branch。这个branch的信息实际上就存在./.git/refs/heads/master
这个文件里。回到我们上文的例子中,我们可以直接查看这个文件:
$ cat ./.git/refs/heads/master
82b70486b425018a6f9250f5e50ffdbc3db24359
很显然,master
就是一个普通指针,指向的正正是我们的Commit 2
。每当我们想定位Commit 2
,只需要通过master
就可以了。
HEAD
是一个指向目前工作目录(working tree)所基于的commit的指针。它的信息存储在./.git/HEAD
这个文件中。让我们来查看一下这个文件:
$ cat ./.git/HEAD
ref: refs/heads/master
正如我们看到的,HEAD
通常指向一个branch,在这里是我们的master
。如果我们执行一个新的commit,那么HEAD
所指向的branch就会自动往前移。就这样Git保证了branch的指针一直指向最新的commit。
但有时候HEAD
也可能不指向一个branch。例如当我们执行:
$ git checkout bddef1a9efb1a84bcc6de74882ebe93b6336ade1
Note: checking out 'bddef1a9efb1a84bcc6de74882ebe93b6336ade1'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:
git checkout -b <new-branch-name>
HEAD is now at bddef1a Commit 1
$ cat ./.git/HEAD
bddef1a9efb1a84bcc6de74882ebe93b6336ade1
$ git status
HEAD detached at bddef1a
nothing to commit, working tree clean
这个时候HEAD
直接指向一个commit,Git会警告我们出现了所谓的detached HEAD
现象。如果你在这种情况下创建新的commit,由于HEAD
没有指向任何branch,Git没法通过移动branch来追踪最新的commit。因此一旦你切换到另外一个branch,那么你在detached HEAD
情况下创建的commit将很难被找回。解决方法是创建一个新的branch:
$ git checkout -b new-branch
$ git status
On branch new-branch
nothing to commit, working tree clean
上面所说的情况可以概括为下图:
最后我们稍微提一下tag
。tag
相当于是静态的 branch ,被存储在./.git/refs/tags
里面的文件中。它并不跟随HEAD
移动。通常用于标记一个特定的commit,例如某个版本的代码,以方便checkout。
好的,Git的基础数据模型讲到这里就差不多了。在下一篇文章里,我们来探讨一下Git的index
空间。