准备工作
首先我们来新建一个本地的Git仓库来供我们实验。
$ git init test
$ cd test
$ ls
新建完了以后我们会发现新仓库里面是”空“的,因为我们还没有写入任何信息。
深入了解.git目录
其实这个时候仓库并不是真的空,我们通过ls -a就可以看到隐藏的.git文件夹了。这个文件夹非常重要,因为它记录了所有的版本信息和这个仓库的基本信息。让我们进去看看这里面都有些什么东西。
.
├── HEAD
├── config
├── description
├── hooks/
├── index
├── info/
├── objects/
└── refs/
HEAD是一个txt文件,他就是我们使用git时的那个头部指针,指向我们当前所在的commit。初始状态下的HEAD为:ref: refs/heads/master
config文件同样是一个txt文件,里面记录了当前项目的配置信息:

当然,git的配置肯定不止这么一点。其实这里的config文件只保存了作用于当前仓库的配置,我们一般执行git config --global的时候会读写一个全局的配置信息。这个全局配置是作用于当前系统登录用户的全部项目的,所以只需要在系统里保存一份就够了,不需要每个项目的config文件都去保存。那么我们怎么了解这个全局的配置文信息呢?两个办法:一个是通过命令git config --list,另外一个方法就是找到配置文件的存储位置~/.gitconfig。我们可以看到这里面有配置的用户信息、代理信息、短命令等等。

关于平时用git config写入配置信息的几个参数这里做个简单的介绍吧:
--project: 项目层级,配置只对当前项目有效,配置信息存储在.git/config目录--global: 全局层级,配置对当前用户的所有项目有效,配置信息存储在 ~/.gitconfig 目录--system: 系统层级,配置对所有用户、所有项目有效,配置信息存储在 /etc/gitconfig 目录。
description文件也是一个txt的文件,如标题所示,这里面就是仓库的一些描述信息,一般上传github之类的远程仓库会用到。
hooks文件夹是里面是一些脚本示例,仓库初始化后的具体内容如下:

这些文件都是shell脚本示例,移除了.sample的后缀之后都是可以被git调用的可执行文件。当然你也可以用自己熟悉的语言编写脚本,满足没有扩展名和可执行的条件,放入hooks文件夹就可以被git调用。
info文件夹底下是一个名为exclude的txt文件,里面记录的是不想要被git追踪的文件,功能和我们git仓库中常用的.gitignore文件一样。
objects文件夹是整个.git文件最重要的部分,因为里面记录了所有的版本变更信息。接下来讲git的存储的时候会详细讲到。
refs文件夹里面存储了各个分支的引用和标签(tag)。
Git如何存储信息
Git 是一个内容寻址文件系统,听起来很酷。但这是什么意思呢? 这意味着,Git 的核心部分是一个简单的键值对数据库(key-value data store)。 你可以向 Git 仓库中插入任意类型的内容,它会返回一个唯一的键,通过该键可以在任意时刻再次取回该内容。而前面提到的.git/objects/文件夹就是用来存储这些键的。
目前仓库里面没有任何提交记录,所以.git/objects/文件夹底下没有什么内容,我们来尝试写一下内容并且提交。
$ echo "#1 commit" > a.txt
现在我们新建了一个txt文件,内容是”#1 commit“,接下来我们把这个改动添加到暂存区(index),看看会发生什么变化。
$ git add .
$ tree .git/objects
.git/objects
├── e0
│ └── e41e21155b5bcf9574934faa6fc57a032d9a9a
├── info
└── pack
objects文件夹底下新增了一个文件名为一串哈希值的二进制文件,这其实是新增文件经过SHA-1计算后得到的一个哈希值,可以用来到git数据库兑换一个对应的文件。这时候我们再往暂存区增加一个文件看看会发生么。
$ echo "#1 commit, second add" > b.txt
$ git add .
$ tree .git/objects
.git/objects
├── e0
│ └── e41e21155b5bcf9574934faa6fc57a032d9a9a
├── e4
│ └── ed4d03c96c4c7076dacaa46306f00a3baa7e51
├── info
└── pack
对比前面的文件夹结构可以发现这里多了一个文件,每一个这样的二进制文件都对应着一个仓库里的git对象(git object),也就是说这里的每一个哈希值都对应着一个文件的快照信息。每当我们执行git add操作,git救护计算出一个由40个字符组成的校验码(哈希值),这个校验码的前两位会作为文件夹名字,剩下的38个字符用来命名文件。这就是他们的命名规则。我们可以通过git cat-file [-t] [-p]命令来取回git对象,看看这些文件都是如何存储在数据库中的:其中-t参数可以显示对象类型,-p可以显示文件内容。如下所示:
$ cd .git/objects
$ git cat-file -t e0e41e
blob
$ git cat-file -p e0e41e
"#1 commit"
可以看到这就是我们第一次添加到暂存区的文件。同时请记住 blob 这种类型,每次执行git add操作,都会将每一个文件存成一个 blob 并且生成对应的键,存储在objects文件夹底下。
按照我们日常的工作流程,接下来就该提交我们的更改看看会发生什么。
$ git commit -m "first commit on master"
$ tree .git/objects
.git/objects
├── 02
│ └── fb57e58ceb3767d6b3731b95a9432ee35c782b
├── 61
│ └── 3d94c03b513130c25d4c18d29e28a071ce6a80
├── e0
│ └── e41e21155b5bcf9574934faa6fc57a032d9a9a
├── e4
│ └── ed4d03c96c4c7076dacaa46306f00a3baa7e51
├── info
└── pack
在执行了git commit之后目录下多了两个git对象,我们通过git cat-file来看看他们都是什么类型、什么内容:
$ git cat-file -t 02fb
tree
$ git cat-file -p 02fb
100644 blob e0e41e21155b5bcf9574934faa6fc57a032d9a9a a.txt
100644 blob e4ed4d03c96c4c7076dacaa46306f00a3baa7e51 b.txt
$ git cat-file -t 613d
commit
$ git cat-file -p 613d
tree 02fb57e58ceb3767d6b3731b95a9432ee35c782b
author weixinqiu 1606728225 +0800
committer weixinqiu 1606728225 +0800
first commit on master
可以看到在执行了git commit之后,数据库中多了两个git对象。分别是一个 tree 对象和一个 commit 对象。从他们的内容可以看出来 tree 里面包含了这次提交的所有的 blob 对象,而 commit 中记录了这个 tree 的哈希值,以及作者和提交者的信息。
由此可以梳理出git存储的三种对象以及他们之间的关系,如下所示:

总结一下:
blob 是git对象中的最小单位,包含一个文件的内容信息(不包括文件名)。
git object都是immutable的,一但创建了就不可以修改,每次提交对文件的修改都会生成一个文件快照存储起来作为备份。
每次
git add操作就会给每一个新增以及修改过的文件生成一个blob。执行了
git commit操作之后,会生成一个 commit 对象和一个仓库根目录的 tree 对象。这个tree对象会根据他包含的指针去递归的寻找所有的tree对象,进而找到所有新增的blob对象,也就是具体改动的文件内容。我们在
.git/objects文件夹里找到的commit对象和tree对象都是索引,具体的文件内容是存在blob对象里面的,blob就是文件快照,其他的都可以简单理解为指针(其实也包含一些其他的信息)。
git分支的创建、切换、合并
git作为版本控制的神器,不仅让开发者有后悔药可以吃,在各种版本之间反复横跳,也成为了团队协同开发的基础设施。团队协作开发就免不了对分支的操作,包括分支的创建、合并。这里就来简单讲下分支创建与合并的底层原理。继续动手操作一下看看:
$ git checkout -b dev
Switched to a new branch 'dev'
$ tree .git/objects
.git/objects
├── 02
│ └── fb57e58ceb3767d6b3731b95a9432ee35c782b
├── 61
│ └── 3d94c03b513130c25d4c18d29e28a071ce6a80
├── e0
│ └── e41e21155b5bcf9574934faa6fc57a032d9a9a
├── e4
│ └── ed4d03c96c4c7076dacaa46306f00a3baa7e51
├── info
└── pack
如上所示,新建分支的时候并不会创建新的git对象,也就是说不会生成新的blob。那么新增的这个分支由谁来记录呢?没错,就是前文提到的refs文件夹以及那个HAED文件。我们来看看新建分支后他们都发生了什么变化。
$ tree .git/refs
.git/refs
├── heads
│ ├── dev
│ └── master
└── tags
$ cat .git/HEAD
ref: refs/heads/dev
没错,refs里面新增了"dev"的记录,并且现在的HEAD指针也指向了dev分支。显然git创建分支本身是一个非常轻量的行为,只是单纯的新增了一个指针并且移动了一下HEAD指针(HEAD就是指向当前所在位置的指针,如果这个)。
切换分支其实也是非常轻量的举动——移动一下HEAD指针,可以指向任何一个分支或者commit。这里要注意一个问题,当你直接切换到一个commit上的时候,HEAD指针会变成游离态,也就是说git无法判断它当前的操作属于哪一个分支(哪怕你切换的commit就是当前分支的最新commit也会变成游离态),这就会导致你做出的改动是无法进行合并的。如果不幸你已经写好了代码,又无法合入,可以尝试以下步骤去合入代码:
# stash 会把当前工作区的修改缓存起来,并回复工作区内容,方便你进行其他操作
$ git stash
# 新建一个分支
$ git checkout -b feature
# 将之前的修改应用到当前的工作目录和暂存区
$ git stash apply [stash@{0}]
# 分支代码合入
...
接下来一起看看分支合并的时候,git的底层到底做了什么:
# 首先在当前的 dev 分支上做一些修改,然后提交commit
$ echo "commit #1 on dev branch" > b.txt
$ git add .
# 看看新增了哪个blob(这里只列出新增的部分)
$ tree .git/objects
.git/objects
├── c0
│ └── 011d9ded2d3755002f231606f719a2ac4d4c96
├── ...
├── info
└── pack
$ git commit -m "commit #1 on dev"
# 看看新增了哪些git对象
$ tree .git/objects
.git/objects
├── 10
│ └── 7cdb845020a1586bff42c2e0e0cf594095fd8d
├── e5
│ └── 45341f0960415c9208a65222f7854cd717ce35
├── ...
├── info
└── pack
经过试验发现了新增的tree,让我们来看看它里面都有些什么内容:
$ git cat-file -p 107cdb
100644 blob e0e41e21155b5bcf9574934faa6fc57a032d9a9a a.txt
100644 blob c0011d9ded2d3755002f231606f719a2ac4d4c96 b.txt
我们可以发现,因为我们只修改了 b.txt 文件,所以只有被修改的文件生成了新的blob来存储新的文件信息,a.txt 对应的blob并没有变,新的tree中的指针依然指向了原来存储 a.txt的blob。不同dev分支上commit之后,此时的底层结构就变成了如下图所示:

接下来讲讲分支合并的过程。
Git的合并策略是”三方合并“(3-way merge),这个道理很好理解,到C4和C5进行合并时,无法判断一大堆的冲突到底要以谁为准,如果处理所有的冲突都需要人工解决的话,那git就是个半残废的工具了。所以这里需要引入一个距离A和B最近的共同祖先C2,有了这个base作为参照在很多冲突点git就可以判断哪个是最新的修改了。如下图:

合并之后会在当前分支上新增一个commit,并且它的parent指针指向两个父节点。

关于git的分支操作,这里推荐一个十分有趣的网站:Learn Git Branching 在闯关游戏中加深 git 分支操作的理解。
结语
其实git还有很多的扩展内容和高级操作,比如LFS、work-tree等等。但是只要你搞懂了上述的这些底层原理,其他的进阶内容应该也能很快的理解透彻啦~
参考资料:
- Pro Git: https://git-scm.com/book/en/v2