深入浅出Git原理

准备工作

首先我们来新建一个本地的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文件,里面记录了当前项目的配置信息:

config文件内容

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

具体内容因人而异,敏感信息已打码

关于平时用git config写入配置信息的几个参数这里做个简单的介绍吧:

  • --project : 项目层级,配置只对当前项目有效,配置信息存储在.git/config目录

  • --global : 全局层级,配置对当前用户的所有项目有效,配置信息存储在 ~/.gitconfig 目录

  • --system : 系统层级,配置对所有用户、所有项目有效,配置信息存储在 /etc/gitconfig 目录。

description文件也是一个txt的文件,如标题所示,这里面就是仓库的一些描述信息,一般上传github之类的远程仓库会用到。

hooks文件夹是里面是一些脚本示例,仓库初始化后的具体内容如下:

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存储的三种对象以及他们之间的关系,如下所示:

存储结构(来源于网络)

总结一下:

  1. blob 是git对象中的最小单位,包含一个文件的内容信息(不包括文件名)。

  2. git object都是immutable的,一但创建了就不可以修改,每次提交对文件的修改都会生成一个文件快照存储起来作为备份。

  3. 每次 git add 操作就会给每一个新增以及修改过的文件生成一个blob。

  4. 执行了 git commit 操作之后,会生成一个 commit 对象和一个仓库根目录的 tree 对象。这个tree对象会根据他包含的指针去递归的寻找所有的tree对象,进而找到所有新增的blob对象,也就是具体改动的文件内容。

  5. 我们在 .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等等。但是只要你搞懂了上述的这些底层原理,其他的进阶内容应该也能很快的理解透彻啦~

参考资料:

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

  • 自2005年诞生以来,git 已经在开源世界中大受欢迎,我们中的许多人也在我们的工作岗位上使用它。 它是一个很棒的...
    zy_think123阅读 4,455评论 0 3
  • 开篇 你可能遇到过 如果你遇到这个场景,那你可能需要版本控制。 什么是版本控制 版本控制最主要的功能就是追踪文件的...
    扬州慢_阅读 3,352评论 1 0
  • 0、导读 本文适合对git有过接触,但知其然不知其所以然的小伙伴,也适合想要学习git的初学者,通过这篇文章,能让...
    程序员BUG阅读 3,365评论 0 0
  • 暂存区 从 gi t的角度来看,文件的修改涉及到以下三个区域:工作目录, stage区(暂存区)以及本地仓库. 当...
    zy_think123阅读 5,330评论 0 0
  • @TOC diff差异 说明:对于新增的文件,其只存在于工作区,且处于 Untracked 状态,Git 认为无论...
    我要进大厂阅读 3,192评论 0 0

友情链接更多精彩内容