Git内部原理浅析

Git独特之处

Git是一个分布式版本控制系统,首先分布式意味着Git不仅仅在服务端有远程仓库,同时会在本地也保留一个完整的本地仓库(.git/文件夹),这种分布式让Git拥有下面几个特点:

1.直接记录快照,而非差异比较
在文件存储方面,Git有别于其他版本控制系统(如CVS、Subversion),Git本身关心文件的整体性是否有改变,而不是文件内容的差异。

2.近乎所有操作都是本地执行
在 Git 中的绝大多数操作都只需要访问本地文件和资源,一般不需要来自网络上其它计算机的信息。 如果你习惯于所有操作都有网络延时开销的集中式版本控制系统,Git 在这方面会让你感到速度之神赐给了 Git 超凡的能量。 因为你在本地磁盘上就有项目的完整历史,所以大部分操作看起来瞬间完成。

3.Git 保证完整性
Git 中所有数据在存储前都计算校验和,然后以校验和来引用。 这意味着不可能在 Git 不知情时更改任何文件内容或目录内容。 这个功能建构在 Git 底层,是构成 Git 哲学不可或缺的部分。 若你在传送过程中丢失信息或损坏文件,Git 就能发现。

4.Git 一般只添加数据
你执行的 Git 操作,几乎只往 Git 数据库中增加数据。 很难让 Git 执行任何不可逆操作,或者让它以任何方式清除数据。 同别的 VCS 一样,未提交更新时有可能丢失或弄乱修改的内容;但是一旦你提交快照到 Git 中,就难以再丢失数据,特别是如果你定期的推送数据库到其它仓库的话。

内部原理

这里讨论内部原理主要是讨论Git如何对文件进行存储的,就是上面第一点提到的,“直接记录快照,而非差异比较”

CVS、Subversion这类系统将保存的信息看作是一组基本文件和每个文件随时间逐步累积的差异,如下图所示:
deltas

这种记录文件差异的方式占用存储空间小,每次提交只需要保存差异部分,但是版本切换的时候会比较耗时。

Git 不按照以上方式对待或保存数据。 反之,Git 更像是把数据看作是对小型文件系统的一组快照。 每次你提交更新,或在 Git 中保存项目状态时,它主要对当时的全部文件制作一个快照并保存这个快照的索引。 为了高效,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。 Git 对待数据更像是一个 快照流,如下图所示:
snapshots

实践

git目录介绍

通过git init命令在本地初始化一个git仓库,查看.git/文件夹的结构:

-w625

首先简要介绍各个文件/目录的左右:

  • HEAD 存储HEAD所指向的分支;
  • config 保存项目的特有配置信息,对应git-config命令;
  • description 该文件仅供 GitWeb 程序使用,我们无需关心;
  • hooks 包含客户端或服务端的钩子脚本,暂不关心;
  • info 目录包含一个全局性排除(global exclude)文件;
  • objects 存储所有数据内容;
  • refs 存储指向数据(分支)的提交对象的指针。

保存数据

创建两个新文件file1.txt和file2.txt

echo 'file1' > file1.txt
echo 'file2' > file2.txt

首先介绍一个新的命令:git hash-object -w
-w 选项指示 hash-object 命令存储数据对象;若不指定此选项,则该命令仅返回对应的键值。该命令输出一个长度为 40 个字符的校验和。 这是一个 SHA-1 哈希值——一个将待存储的数据外加一个头部信息(header)一起做 SHA-1 校验运算而得的校验和。

通过该命令存储file1.txt和file2.txt:

$ git hash-object -w file1.txt
e2129701f1a4d54dc44f03c93bca0a2aec7c5449

$ git hash-object -w file2.txt
6c493ff740f9380390d5c9ddef4af18697ac9375

这样就把这两个文件保存到git仓库中了,这时候再看看objects文件夹,会发现多出了两个文件夹:
-w616
可以看到这两个文件分别以校验合的前2个字符作为目录,后38个字符作为文件名存储,同时并对文件做了压缩,那么要怎么验证这个文件就是我们之前保存的呢?

这里要介绍另一个命令:git cat-file -p 校验和
该命令用于查看git压缩后文件的内容:

$ git cat-file -p e2129701f1a4d54dc44f03c93bca0a2aec7c5449
file1

没错,就是我们之前保存的文件。

从根本上来讲,Git是一个内容寻址的文件系统,其次才是一个版本控制系统。所谓“内容寻址的文件系统”,意思是根据文件内容的hash码来定位文件。这就意味着同样内容的文件,在这个文件系统中会指向同一个位置,不会重复存储。

Git对象

Git常见对象包含三种:数据对象、树对象、提交对象

  • 数据对象(blob):Git将每个纳入版本控制的文件定义为一个数据对象;
  • 树对象(tree):纳入版本控制中的文件夹或者提交快照;
  • 提交对象(commit):每次提交commit对应的记录;

现在我们将上面创建的两个文件提交到本地master分支:

$ git add file1.txt file2.txt
$ git commit -m "add file1 file2"

现在我们通过git log 来查看此次提交的信息:
-w719

可以看到提交的commit对象的校验和是:

c0d0c95a9fc80b0dc461abc2312ab8777453e2e0

我们可以用git cat-file -t命令来查看其对象类型:
-w672
可以看到这里确实是一个commit对象。

接着我们用git cat-file -p来查看该commit的内容:
-w629
我们可以看到commit对象包含了一条tree对象记录,提交人的信息和备注。

紧接着我们继续用git cat-file -p查看这条tree的内容:
-w612
可以看到,tree中保存着当前commit中的所有文件的引用,同时包含了文件名的映射。

其中文件模式为 100644,表明这是一个普通文件。 其他选择包括:100755,表示一个可执行文件;120000,表示一个符号链接。

到这里我们可以知道一个commit的完整引用关系如下图所示
-w622

接着修改file1.txt再提交一个commit:
-w600

git log查看最新一条commit的校验和:
-w631

看到是:

fb6c9721e4402987f5615960f26200fd648e1802

这里我们再用git cat-file -p来查看其内容:
-w579

发现这条commit多了一个parent记录,该parent就是上一个commit的引用,所以,完整的commit关系图如下所示:

-w665

commit对象除了第一个对象,其他对象都会在内部指向其上一个的commit对象,形成一条链,这条链就是我们常说的分支。可以看到上图中的file2.txt的校验和是相同的,我们没有对它进行修改,它们指向的是同一个文件,而file1.txt的校验和是不同的,哪怕只是修改了一个字节,这都会保存为2个独立的文件。

手动构建提交

由前面的知识我们知道了commit对象和tree对象的关系,那么现在我们通过手动构建tree对象和commit对象来实现一个提交。

构建tree对象

tree对象它能解决文件名保存的问题,也允许我们将多个文件组织到一起。 Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化,所有内容均以树对象和数据对象的形式存储,如下图所示:

-w707

我们先修改一下file1.txt:

$ echo 'version 2' >> file1.txt

同时新建一个文件new.txt

$ echo "new file" >> new.txt

这里介绍一个底层命令:git update-index
该命令可以将文件加入暂存区,相当于使用git add命令:

-w657

这个时候用git status查看发现file1.txt已经加入到暂存区了:
-w637

现在我们将new.txt也用同样的方法加入到暂存区,不过由于new.txt没有纳入版本控制,这里需要--add参数:
-w630

接着我们要创建一个tree对象,可以通过 write-tree 命令将暂存区内容写入一个树对象,如果某个树对象此前并不存在的话,当调用 write-tree 命令时,它会根据当前暂存区状态自动创建一个新的树对象:
-w634

这里返回了创建的tree对象,我们用git cat-file 来查看其内容:
-w608

现在我们要创建一个commit对象,和这个tree对象关联起来,可以通过调用 commit-tree 命令创建一个提交对象,为此需要指定一个树对象的 SHA-1 值,以及该提交的父提交对象(如果有的话):

关联的tree对象的SHA-1值 = 7b4959adb1aa1b8daeb40a874bcbce114168ee70
父commit对象的SHA-1值 = fb6c9721e4402987f5615960f26200fd648e1802
-w535

现在commit对象就已经创建好了,我们可以用git cat-file来查看其内容:
-w656

但这时时候用git log来查看,会发现并没有出现此commit,这是因为我们当前分支(HEAD)指向并没有改变,这里我们需要将分支往后移动一个commit就可以了,有下面几种方法:

  • git merge 将新创建的commit,merge到当前分支
    -w594

  • 修改HEAD的指向:
    首先查看./git/HEAD的内容:
    -w638

    可以看到HEAD指向的是refs/heads/master这个分支,我们继续查看这个文件的内容:
    -w640
    这里名指定了当前所在分支,只需要把之前创建的commit对象的HASH-1替换到这里即可。

其实上面两个方法最终所做的事情是一样的。

包文件

这里就有一个问题?
如果一个文件是100MB,然后对它进行了一个小小的修改,是不是在本地就会出现另一个100MB的文件?
确实是,这就是git快照的存储方式,每次提交都会保存一个完整的文件,这也是git分支操作能如此快速的原因之一。那么如果 Git 只完整保存其中一个,再保存另一个对象与之前版本的差异内容,岂不更好?

事实上 Git 可以那样做。 Git 最初向磁盘中存储对象时所使用的格式被称为“松散(loose)”对象格式。 但是,Git 会时不时地将多个这些对象打包成一个称为“包文件(packfile)”的二进制文件,以节省空间和提高效率。 当版本库中有太多的松散对象,或者你手动执行 git gc 命令,或者你向远程服务器执行推送时,Git 都会这样做。 要看到打包过程,你可以手动执行 git gc 命令让 Git 对对象进行打包:
-w613

这个时候再查看 objects 目录,你会发现大部分的对象都不见了,与此同时出现了一对新文件:

-w595

我们可以看到生成了.idx(索引)和.pack(包)文件,包文件包含了刚才从文件系统中移除的所有对象的内容。 索引文件包含了包文件的偏移信息,我们通过索引文件就可以快速定位任意一个指定对象。

Git 打包对象时,会查找命名及大小相近的文件,并只保存文件不同版本之间的差异内容。 你可以查看包文件,观察它是如何节省空间的。 git verify-pack 这个底层命令可以让你查看已打包的内容:

-w630

总结

理解Git的内部存储主要是理解commit、tree和blob三者之间的关系,通过对Git存储原理的学习,也可以让我们在日常工作中更好地使用Git。Git是一个很强大的版本控制系统,还有很多很多的功能,需要我们在使用过程中去慢慢学习。

引用:
Git-内部原理-底层命令和高层命令

posted @ 2019-08-08 23:05  再见理想_  阅读(741)  评论(0编辑  收藏  举报