Git教程:基本概念

Posted by lili on

本文介绍Git的基本概念。

目录

Git的基本概念

Repository(Repo)

Git的Repository是一个数据库,它包含了用于维护和管理一个项目的所有修订(revision)和历史(history)的全部信息。一些中心化的版本控制系统(Version Control System, VCS)通常把全部版本和历史存放在一个中央的服务器上,而在客户端通常只有某个版本的全部或者部分数据,而Git是去中心化的系统,所有的历史版本都保持在本地。

除了版本信息之外,Git还会保存一些配置,比如前面的user.name,它会保存在.git下(.git/config),这些值不会被clone(否则每次clone别人的repo都得到别人的user.name和user.email就很麻烦了)。

在一个repo里,Git主要维护两个数据结构:object store和index。object store用于存储object(废话),因为Git会保存所有的历史版本,所以object store会设计的非常高效的来存储一个文件的多个版本。

Git的对象(object)类型

Git的repo的核心就是object store。它包含了所有的原始数据文件以及它们的log信息、作者信息、日期和其它一下用于重构各个历史版本和分支的其它数据。

object store只支持4种类型的对象:blob、树(tree)、提交(commit)和tag。

Blob

一个每个版本都存储为一个blob。blob是binary large object的缩写,是一个数据库常用的术语,表示大的二进制对象,相对于数据库表的其它字段,blob的特点是它在外部存储的,表里只存储一些meta data,比如blob外部存储的路径。在Git里也是,blob就是一个二进制的文件,它不包含任何meta data,甚至文件的名字都不包含在blob里。

树对象表示一个一层的目录,它包括blob的id、路径以及这个目录下的所有文件的meta data,此外它也递归的包含其它的零个或者多个树对象,这样就可以形成多层的目录结构。

提交

一个提交包含是一次修改的meta data,包括作者(author)、提交者(committer)、提交日期和log信息。对于作者和提交者有什么区别的读者可以参考stackoverflow的问题。每个提交都有指向一个树对象,这个树对象是repo的一个状态(版本)的一个快照。除了repo的第一个提交,其它提交都有一个或者多个parent的提交,通常的commit只有一个parent,但是merge的commit可以包含两个或者多个parent。

tag

tag对象是给其它对象,尤其是提交,起一个更加好记的名字。比如”9da581d910c9c4ac93557ca4859e767f5caf5169”可以精确的代表一个提交,也就是repo的某一个具体版本,但是用Ver-1.0-Alpha这样的tag可能对于人类来说更有意义。

除了这4种对象,为了高效的利用磁盘空间,Git也会用pack文件来高效的存储blob文件,后面也会介绍。

index

index是一个临时的动态的二进制文件,它代表了repo的一个(临时)状态。为了避免丢失修改,我们通常会频繁的保持修改,但是如果把所有这些修改都commit,则会让版本很多很乱,而index作为一个暂存区可以保持这些临时的修改。

基于内容的命名(定位)

Git内部的对象都是根据其内容来定位的,对于每一个对象,Git都会使用SHA1算法生成一个唯一的160位的ID,我们通常用40个16进制的字母来表示,比如9da581d910c9c4ac93557ca4859e767f5caf5169。理论上,SHA1的Hash算法也会有冲突,但是小到可以忽略不计。

Git只track内容

和别的VCS不同,Git只关心对象(比如文件)的内容而不是其它诸如文件名和路径之类的东西。因此如果一个repo的两个文件的内容一模一样,则Git只会在object store里保存一份。

另外一个让很多人吃惊的事实是:对于一个文件的多个版本,Git并不是保存其Diff,而是会完整的保存每一个版本。这个事实可能让很多人感到不安:前面说过Git在本地(其实并没有本地和远程中心服务器的概念)会保存每一个版本,如果每个版本都保存完整的文件,那岂不是很浪费空间。比如我们有一个1GB的文件,然后十次提交每个提交增加一行,那么岂不是这个文件需要10GB的空间来存储?还好Git的作者也想到了这一点,在逻辑上Git会保存10个文件,Git的Pack文件的压缩算法会选择类似的文件来计算Delta。因此和存储Diff的VCS类似,Git实际存储的也是Diff,只不过这个Diff放到了Pack文件这一层,而且Git的diff可以和repo里的所有对象进行。比如a.txt有一亿行,而b.txt有一亿零一行,并且前一亿行都完全相同,则Git的Pack文件在存储b.txt时会存储b.txt和a.txt的diff。而其它的VCS则不会存储diff,它们之会比较一个文件的不同版本,而不同文件直接是不会比较的。更多Pack文件的原理,感兴趣的读者可以参考Git Tip of the Week: Objects and PackfilesGit seems to store the whole file instead of diff, how to avoid that?

因此如果我们运行git diff两个版本的文件,Git实际是取出这两个文件的内容”online”的进行diff,而不是像有的VCS本身存储的就是diff。

此外,在Git里文件的名字和路径都是第二位的,树对象会保持名字和路径,如果我们把一个文件放到另外一个路径下,则这个对象是不会发生任何改变的。类似的,如果一个repo下有两个完全相同的文件,则它们都会指向同一个object store里的对象。

另外如果我们创建一个空的目录,我们是没有办法用git add和commit把它加到repo里,因为在Git里,文件名和路径是第二位的,它依附于文件。也就是说文件的内容是它的第一重要的东西,其Key是SHA1,而文件名和路径都是附属于文件的。

Object Store图解

Blob是Object Store里最底层的对象,在图中通常用长方形框来表示。树对象通常会指向(引用)Blob或者其它树对象(子目录),在图中用三角形表示。而一个提交会指向一个树对象,表示这个提交之后的状态,这个树对象代表了repo的某一个快照。除了初始化提交,一个提交通常会有一个或者多个parent,表示这个提交是的上一个提交。tag是给一个对象起一个名字,通常是对一个提交起名字,在图中用菱形表示tag,它会指向一个对象(比如提交对象)。最后的概念是分支,图中用圆角矩阵表示,它指向一个提交。和tag不同,tag是不变的量,而分支会随着状态的变化和变化,我们有新的提交有分支可能指向新的提交。

图:Git对象

上图是一个repo的某个状态,这个图中只有一次初始的提交,因此它没有parent的提交,这次提交指向了一个树对象,这个树对象又包含两个文件(blob)对象。master分支和名为V1.0的tag都指向ID为1492的提交。

接下来我们创建一个新的子目录,在里面添加一个新的文件,提交后得到如下的图:

图:第二次提交后的对象图

和前面的图相比,老的内容完全没有变化,只是多了一个新的提交(11235),而且这个新的提交的parent是之前的那个提交(1492)。这个新的提交指向一个新的树对象(cafed00d),这棵树仍然包含(指向)原来的两个文件,并且还多了一个子树(1010220),这个子树包含一个新的文件(1010b)。

名字为V1.0的tag仍然指向老的提交,而master分支指向最新的提交。

Git概念实战

下面我们通过一些简单的命令来复习和验证前面学到的一些概念。

.git目录下的内容

$ mkdir /tmp/hello
$ cd /tmp/hello
$ git init
初始化空的 Git 仓库于 /tmp/hello/.git/

查看.git目录下的所有文件:

$ find .
$ find .
.
./.git
./.git/config
./.git/objects
./.git/objects/info
./.git/objects/pack
./.git/info
./.git/info/exclude
./.git/branches
./.git/HEAD
./.git/description
./.git/refs
./.git/refs/tags
./.git/refs/heads
./.git/hooks
./.git/hooks/pre-rebase.sample
./.git/hooks/pre-commit.sample
./.git/hooks/applypatch-msg.sample
./.git/hooks/update.sample
./.git/hooks/pre-push.sample
./.git/hooks/pre-applypatch.sample
./.git/hooks/commit-msg.sample
./.git/hooks/post-update.sample
./.git/hooks/prepare-commit-msg.sample

我们发现.git目录下有很多东西,这些文件都是由模板生成的,我们也可以调整模板。这些文件也会随git版本的变化而不同,比如老的版本里.git/hooks下的文件名都没有后缀而新的版本都增加了.sample的后缀。通常我们都不需要手动创建或者修改这些文件,git的命令会帮助我们维护它们。.git/objects包含的所有对象,目前的repo是空的,因此里面没有什么内容:

$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack

下面我们加入一些内容:

$ echo "hello world" > hello.txt
$ git add hello.txt

再来看一下:

$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad

这些对象看起来有些奇怪?我们会仔细的来看一下。

对象、Hash和Blob

当要保存hello.txt对应的对象是,Git并不关心这个文件的名字,而是根据内容计算出SHA1的”3b18e512dba79e4c8300dd08aeb37f8e728b8dad”,然后会保存到.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad,因为操作系统的文件系统通常不能在一个目录下放太多文件,因此为了避免出现这种情况,git把3b18e512dba79e4c8300dd08aeb37f8e728b8dad这个对象存在.git/objects/3b目录下,然后文件名是18e512dba79e4c8300dd08aeb37f8e728b8dad。我们可以用git cat-file查看文件内容:

$ git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
hello world

好奇的读者可以会想自己用命令行来验证一下git的sha1算法:

$ cat hello.txt | sha1sum 
22596363b3de40b06f981fb85d82312e8c0ed511  -

但是git在计算SHA1的时候会在内容前面增加”blob [file_length]\0”,其中[file_length]是文件的大小,因此应该这样:

$ echo -n -e "blob 12\0hello world\n" | sha1sum 
3b18e512dba79e4c8300dd08aeb37f8e728b8dad  -

上面的echo的-n选项让它不要自动加换行(\n),-e选项让它能够使用\进行转义。当然也可以使用printf命令替代。

文件和树

前面我们发现hello.txt的内容都安全的存放到了.git/objects下,那这个文件的名字呢?如果我们不知道”hello world”这个内容是哪个文件的话也没有什么意义。Git会把文件名存放到树对象里,因此我们可以理解树对象构成了目录结构,树对象可以包含子树,从而形成多层的结构,而文件的meta data诸如名字等会保存在树对象里,而文件的本身(blob)是存在.git/objects下,树上会有一个指针指向对应的blob。

我们之前用git add hello.txt会在.git/objects里创建对应的blob,并且会在index(注意git add还没有修改repo,只是修改index)里创建对应的信息,保存在.git/index里。我们可以使用下面的命令查看index里暂存的文件:

$ git ls-files -s
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0	hello.txt

git ls-files命令会列举工作目录和index里的文件,读者可以用git ls-files –help查看完整帮助。-s选项告诉它我们想看index里暂存的文件。

我们可以发现在index里保存了文件名”hello.txt”和blob的对应关系。我们也可以根据index自己来创建树对象(我们通常不需要自己用这么底层的命令,git commit会帮我们,这里只是为了探索git的内幕):

$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60

我们再看看object pool里有哪些对象:

$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/68
.git/objects/68/aba62e560c0ebc3396e8ae9335232cd93a3f60

我们发现多了git write-tree得到的树对象68aba62e560c0ebc3396e8ae9335232cd93a3f60,为了方便,我们可以用这个hash的前六位”68aba6”来表示它,通常如果没有相同前缀的情况下就可以用这个短的前缀表示。那68aba6这个树对象长什么样子呢:

$ git cat-file -p 68aba6
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	hello.txt

因此这个对象只包含一个blob,也就是前面的3b18e5,并且这里会保存文件名hello.txt。我们可以这样解读:当前目录(根目录)下包含一个文件hello.txt,这个文件的内容是一个blob其hash是3b18e5。

层次结构

为了展示层次结构,我们在下面创建一个子目录并且在子目录里创建一个文件:

$ pwd
/tmp/hello
$ mkdir subdir
$ cp hello.txt subdir/
$ git add subdir/hello.txt
$ git write-tree
492413269336d21fac079d4a4672e55d5d2147ac

下面我们来看新增了子目录的树对象:

git cat-file -p 4924132693
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	hello.txt
040000 tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60	subdir

这个新的树对象除了包含hello.txt这个文件(blob)之外,还包含名字为subdir的目录(树/tree对象)。这是递归的定义,我们可以再看这个子树对象:

$ git cat-file -p 68aba6
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	hello.txt

我们发现subdir这个目录下也包含一个hello.txt的文件,它的blob对象还是3b18e5,因为两个hello.txt的内容完全相同,所以它们指向的blob对象是同一个。另外一个非常值得注意的是subdir的SHA1(68aba62e560c0ebc3396e8ae9335232cd93a3f60)和第一次的树对象的SHA1完全相同!为什么?因为这两个目录(树对象)的内容完全相同:都只包含一个名字为hello.txt的文件。

提交

通常我们用git commit来提交index里暂存的内容,因为前面我们用底层的命令git write-tree来生成树对象了,因此这里我们也要用底层的git commit-tree来提交这个树。如果我们用git commit,它会帮我们执行这两个底层命令:首先根据index的内容生成树对象,然后提交它。

$ echo  "Commit a file that says hello" | git commit-tree 4924132693
0e6012835cbf9d61b2108d4679af5ffb46c21597

我们再来查看这次提交的内容:

$ git cat-file -p 0e60128
tree 492413269336d21fac079d4a4672e55d5d2147ac
author lili <fancyerii@gmail.com> 1580907090 +0800
committer lili <fancyerii@gmail.com> 1580907090 +0800

Commit a file that says hello

注意:读者即使按照完全相同的命令得到的提交(commit)对象也可能是不同的,因为您的user.name和user.email和作者不同!而且此外即使您把user.name和user.email设置成和作者相同也没有用,因为提交对象还包括提交时间,您不太可能和作者在同一个瞬间提交!

我们看到这个提交对象指向的树对象正是前面我们创建的树对象(4924132693),因此一个提交就对应一个提交,也就是对于repo的一个特定版本。

另外在提交里包含author和committer,通常这两个值是相同的,但是在某些特殊情况下它们也会不同。

Tag

tag就是给一个对象起一个好记的名字,比如我们可以个一次提交打一个tag(V1.0):

$ git tag -m "Tag version 1.0" V1.0 0e60128

怎么验证V1.0这个tag对应的就是0e60128这个提交呢?我们首先可以用git rev-parse命令”反向”解析得到这个tag对象的sha1:

$ git rev-parse V1.0
d10342ee947d1a101d90ad438fb64f4a37b84799

然后可以查看它的内容:

$ git cat-file -p d10342e
object 0e6012835cbf9d61b2108d4679af5ffb46c21597
type commit
tag V1.0
tagger lili <fancyerii@gmail.com> 1580907514 +0800

Tag version 1.0

我们可以看到这个tag对应的对象正是0e6012835cbf9d61b2108d4679af5ffb46c21597!