Adamwu1992 / adamwu1992.github.io

My Blog
2 stars 0 forks source link

【Git】Git简单剖析——记一次内部分享 #10

Open Adamwu1992 opened 6 years ago

Adamwu1992 commented 6 years ago

概览

Git是一个版本控制工具,版本控制在我们的日常工作,尤其是多人协作的场景下非常重要。

最开始当我们意识到有版本控制需求的时候,我们通过复制真个目录,加上不同的命名来表示一个版本。这种方法可以记录我们的工作历史,但是没有办法协作,而且会占用很多的空间

为了解决这个痛点,出现了很多本地版本控制的软件,大多原理都是基于某种简单的数据库记录每次的更新差异,通过应用这些差异,可以计算出每一个版本的文件内容。

后来,为了解决多人协作的问题,出现了集中化的版本控制系统,比如有Subversion,这类系统用一个集中的服务器来存储所有的版本,参与协作的人都要通过客户端连接到中心服务器,取出最新的版本文件或者提交自己的修改。

再后来,大家发现这样的系统也存在一些问题。最直接的就是中心服务器宕机了,所有的协作者都无法工作;中心服务器磁盘损坏了,项目变更的历史就全部丢失了,你只剩下你机器上保留的单独快照。于是分布式版本控制系统出现了,这种系统,客户端并不是指保存某一版本的快照,而是将整个仓库完成的镜像拷贝下来,每一个客户端都是一个完成的版本控制系统。而且每一个仓库都可以和若干不同的远端仓库交互,以此来实现多人协作。

Git存储原理

在以往的版本控制系统中,为了减少体积,每次提交系统只会记录这次版本和上次版本变化的部分。Git选择了另一种方式,每一次提交都会对全部的文件保存一个快照,如果一个文件在某次提交中没有修改,Git会保存一个连接指向上一个版本。

这么做的区别是,以某一个提交版本角度横向来看,以往的系统记录的是一系列文件的变化,而Git记录的是所有文件的快照。从这个角度看Git更像是一个小型的文件系统而非仅仅是一个CVS。

Git的存储核心是一个键值对数据库,在Git的数据库中,记录的是每一个文件的校验和而不是文件名,理论上任何的文件变更都会引起校验和的变化,而且,我们日常执行的绝大部分Git操作都是往数据库中添加记录,所以,只要将数据加入Git,就很难丢失。

在Git中,所有的文件都处于以下三种状态之一:

Git内部原理

我们使用git init命令新建一个Git仓库,这条命令会在文件夹下创建一个.git隐藏文件夹。如果我们是用的git clone命令,也是从对应的远端仓库克隆.git文件夹到我们的目录下,并且把最新版本的文件签出。所以.git这个目录就藏着Git的一切秘密。

.git的目录结构如下所示:

Git对象

Git所有的对象都存在.git/objects这个目录下,在一个全新的Git仓库目录中运行find .git/objects会发现只有两个空文件夹:

数据对象(blob object)

我们可以用底层命令写入一条数据:

echo 'hello world' | git hash-object -w --stdin

# -w 执行存储操作,否则只会返回校验和
# --stdin 从标准输入读取文件内容,否则需要指定被存储的文件路径

这条命令会返回一个40位的校验和,这是个SHA-1的哈希值,由存储的数据外加一个header信息计算得出,我们再次检查.git/objects会发现多了一条记录:

find .git/objects -type f

# -type f 只显示文件

多了一个文件,以校验和的前两位作为文件夹名,后38位作为文件名。我们还可以查看这个文件的内容,如果直接用cat之类的命令,只能看到一串乱码,因为文件的内容不仅仅是我们写入的内容(包含一个header),而且文件进过了压缩,Git提供了底层命令来查看文件:

git cat-file -p [SHA-1]

# -p 自动判断文件内容并显示友好的格式

这样就得到了我们写入的数据。

至此,我们完成了对Git的一次写入以及读取操作,但是我们的工作目录下没有产生任何文件,所有的一切都是基于Git的底层命令来完成的。

我们也可以将Git中的数据应用到我们的文件中:

git cat-file -p [SHA-1] > hello.txt

我们会发现在工作目录下多了一个文件,文件内容就是我们一开始写入Git的内容。

我们同样可以创建一个文件,并将内容写入Git:

echo 'version 1' > test.txt
git hash-object -w test.txt

echo 'version 2' > text.txt
git hash-object -w test.txt

通过find .git/objects我们发现多了两条记录,就是test.txt这个文件的两个版本,而通过

git cat-file -p [SHA-1] > test.txt

这个命令,可以将版本1或者版本2的内容应用到文件中。

目前为止我们完成了Git的一小部分功能,但是,现在Git中存储的仅仅是文件内容,没有文件的任何信息(文件名、文件类型等),这种对象被称作数据对象

git cat-file -t [SHA-1]
> blob

事实上,对象的类型就是存储在header中的。

树对象(tree object)

树对象解决了文件信息的存储问题。

每个树对象包含一条或者多条树对象记录(tree entry),每一条记录含有一个数据对象或者另一个树对象的SHA-1索引,以及相应的模式,类型,文件名等信息。

Git的文件模式借鉴了常见的UNIX文件模式: 100644表示普通文件,100755表示可执行文件,120000表示一个符号链接,还有其他一些不常用的(表示目录和子模块)

树对象的创建,就是记录下某一时刻暂存区的状态,因此我们需要先创造一个暂存区,我们可以将之前写入的数据对象加入暂存区,也可以创造一个文件加入:

# -add 表示将记录加入index
# --cacheinfo 从Git数据库中读取,而不是文件
# 100644 UNIX文件模式,表示普通文件
# [SHA-1] 数据对象记录索引
# test.txt 文件名
git update-index --add --cacheinfo 100644 [SHA-1] test.txt

# or

echo 'new file' > newfile.txt
git update-index --add newfile.txt

这样暂存区中存在了两个文件,现在我们将暂存区的状态作为一个树对象写入Git:

git write-tree

这条命令返回的哈希值就是新增那条树对象对应的索引,我们可以查看这条记录来证明他的确是不同于之前的数据对象的:

git cat-file -p [SHA-1]

# or
git cat-file -t [SHA-1]
> tree

我们也可以将已有的树对象写入暂存区,作为一个新的树对象的子树对象:

# 找到第一条树对象的索引并且读取到index
git read-tree --prefix=bak [SHA-1]
# 此时index中有三条记录(bak, newfile.txt, test.txt),注意write-tree操作并不会清空index

# 写入
git write-tree

此时我们做的离我们日常使用的Git功能又进了一步,但是树对象仅仅储存了文件和文件夹的信息,版本控制中重要的提交信息还是没有。

提交信息(commit object)

提交对象是基于一个树对象创建的,如果不是第一次提交的话,还需要上一次提交对象的SHA-1值作为父提交对象,这样多个提交才可以联系在一起:

# 找到刚才的第一条树对象索引
echo 'first commit' | git commit-tree [SHA-1]

# 找到第二条树对象索引以及上一个提交对象的索引
echo 'second commit' | git commit-tree [SHA-1] -p [SHA-1]

上面的操作将两次提交联系在一起,两次提交对象都是基于两次树对象创建的,在树对象的额基础上,增加了作者/提交人信息,父提交对象信息以及注释信息,可以通过git cat-file -p [SHA-1]查看提交对象的内容。

既然提交对象是成串的,我们可以查看整个提交历史:

# 找到一条提交对象的SHA-1值,得到这个提交对象的所有历史提交
git log --stat [SHA-1]

这是一个货真价实的提交历史,和我们日常使用git addgit commit创建的是一样的,而我们只用底层命令就完成了。

Git引用

应该注意到,现在为止我们所有的操作都是基于SHA-1,记住这一串数字很难,引用就是用来保存SHA-1的文件。

所有的Git引用都存在于.git/refs下,在一个新的Git目录下运行find .git/refs只能看到两个文件夹而没有任何文件:

分支引用(head reference)

要创建一个引用很简单,就是向.git/refs目录下的文件里写入想要保存的SHA-1值:

# [SHA-1]是某个提交对象的索引
echo [SHA-1] > .git/refs/heads/master

这样就创建了一个指向某个提交对象的master引用,可以用git log来查看他的提交历史:

# 之前
git log [SHA-1]

# 现在
git log master

Git提供了更加安全的底层命令来创建或修改引用,比如我们再创建一个dev引用之前前一次提交:

git update-ref refs/heads/dev [SHA-1]

# 查看历史,将只包含第二次提交以及之前的记录
git log dev

当我们运行高级命令git branch [branch_name]时,Git底层实际上是调用了update-ref命令将当前分支最新提交的SHA-1值写入指定的(branch_name)文件中。

Git是如何知道当前分支最新提交的SHA-1呢?答案就在.git/HEAD中,这个文件记录了当前分支最新提交的SHA-1。

当我们执行类似git checkout dev这样的高级命令时,.git/HEAD会被修改为refs/heads/dev,根据之前的经验,每一个高级命令都会对应着一个或者多个底层命令,此时调用的底层命令是git symbolic-ref,我们当然可以直接修改.git/HEAD文件,但这是一个更加安全的方式,如果你指定修改为一个不存在的引用就会报错:

# 查看
git symbolic-ref HEAD

# 修改为master
git symbolic-ref HEAD refs/heads/master
分支的新建&切换&合并

经过上面的探讨,我们得知了分支其实是指向某一个提交对象的指针,本质上就是一个保存了41个字符的文件(40位校验和+1位换行符),可以说是非常的轻量了。

所以Git鼓励我们频繁地通过新建和切换分支来弯沉日常工作,因为这本质上是一个非常便宜的操作。当我们运行git branch dev时,Git做了以下操作:

两次读取文件,一次新建&写入文件,这就是全部操作。而切换分支更加的快捷,当我们checkout dev时,Git会将.git/HEAD里的内容改写成refs/heads/dev,这就是全部操作。当然,伴随这些必不可少的操作还有改写我们的工作目录,就是将索引对应的项目快照提取出来放在磁盘上供我们使用。

分支的合并本质上也是指针的操作,分支的合并可以分成两种fast-forwardno-fast-forward

假设你需要修改一个线上问题,从master拉出一个hotfix分支,进行了一些修改验证无误,需要合并到master上,切到master分支执行git merge hotfix命令,如果在你修改bug期间,master上没有产生新的提交,即master上所有的提交都能在hotfix的历史里找到,那么master就是hotfix的直接上游,此时的合并默认就是fast-forward模式,只需要把指针master移动到hotfix指向的那个提交对象就完成了合并。

还是上面的例子,如果在修改bug期间,你有且回到master分支做了一些提交,当合并hotfix时,由于两条分支有了分叉,Git会将两条分支上最新的提交对象做一次合并,并且生成一个新的提交对象,合并后的master指向这个提交对象,这个特殊的提交对象,有两个父提交,而如果用git log master查看合并后的提交历史时,会发现两个分支上的提交按照时间出现在一条线上。

Git总是优先使用fast-forward的方式合并,如果你想要分支上每一次合并都有迹可循,可以使用git merge hotfix --no-ff来强制生成一个合并提交对象。

需要注意的是,合并后,两条分支上的提交对象会被按照时间顺序安排在一起,看上去像是一条分支上的提交,但是对于提交对象本身来说,它的父提交对象不会改变。

标签引用(tag reference)

.git/refs/tags下存的是标签引用,我们有两种日常使用的创建标签的高级命令,分别称作轻量标签(lightweight)附注标签(annotated)

# 针对最新提交创建一个轻量标签
git tag v1.0

# 对应底层命令 SHA-1从 .git/HEAD 中获取
git update-ref refs/tags/v1.0 [SHA-1]

轻量标签只是简单的将传入的SHA-1写入相应的文件中。

# -a指定创建一个附注标签
git tag -a v1.1 -m 'test tag'

以上的命令创建了一个附注标签,这个命令会生成一个标签对象(tag object),这个与轻量标签是有很大的不同的,在.git/refs/tags/v1.1这个文件里存储的SHA-1,不是提交对象的索引,而是标签对象的索引。

对,除了上文提到的数据对象树对象提交对象以外,Git中还存在一个标签对象,我们可以通过以下命令查看标签对象的结构:

# 取得标签对象的SHA-1
cat .git/refs/tags/v1.1
# 查看标签对象
git cat-file -p [SHA-1]

标签对象的内容包括一个索引,通常指向一个提交对象,创建者信息、创建日期以及注释等。

标签对象不限于指向提交对象,还可以指向树对象甚至数据对象。

远程引用(remote reference)

远程引用存储的是远程仓库的信息。假设你使用下面的命令添加了一个远程仓库:

git remote add origin [url]

git push origin master

那么你的.git/refs/remotes下会多出了一个文件.git/refs/remotes/origin/master,里面存储的SHA-1值就是你执行push命令是master上的最新提交对象的索引。

远程引用和分支的最大区别是,远程引用是只读的,你可以用checkout签出远程分支,但是HEAD并不会指向远程分支,所以你无法通过commit来修改远程分支。

延伸思考

先前说道Git与其他集中式版本控制系统的却别时提到,Git在每一次项目变动时,将所有的文件都储存为一个快照,而不是只储存变化的部分,这样随着提交历史增多,会有大量的相似快照同时存在(如果我们每次提交只修改项目的一小部分),Git是如何处理这种文件体积增长的呢?