Git内部原理

Git是一个快速,可扩展的分布式版本控制系统。从根本上来说,Git是一个内容寻址(content-addressable)文件系统。

Git提供了非常丰富的指令集,根据功能划分为底层命令和高层命令,平时工作中使用最多的add、commit、reset等就属于高层命令。大部分情况下我们不会接触到Git的底层命令,但是为了了解Git的工作原理,了解底层命令就很重要了。

接下来我们来探索Git的内部工作原理

.git目录结构

首先我们新建一个空文件夹,执行git init初始化命令,创建git版本库。

$ mkdir test1
$ cd test1
$ git init
$ cd .git
$ ls -F1

.git目录结构如下:
HEAD         // HEAD指针,指向当前分支
branches/    // 分支
config       // 项目特有的配置选项
description  // 仅供 GitWeb 程序使用,无需关心
hooks/       // 客户端或服务端的钩子脚本
info/        
objects/     // 存储所有数据内容
refs/        // 存储指向数据(分支)的提交对象的指针
index        // 保存暂存区信息(尚待创建)

.git的目录结构及作用上面已经添加了备注,其中有四个条目很重要:HEAD文件、尚未创建的index文件,和objects目录、refs目录。这些条目是Git的核心组成部分。

Git对象

Git一共有四种类型的对象:

  • Blob object
  • Commit object
  • Tree object
  • Tag object

Blob对象存储:

  • 数据内容(文本文件、源代码、图片等)

Commit对象存储:

  • tree对象
  • parent指针(如果有)
  • 作者对象(姓名、邮箱、提交时间)
  • 提交者对象(姓名、邮箱、提交时间)

Tree对象存储:

  • 指向数据内容或者Tree对象的指针(SHA-1)
  • 模式
  • 类型
  • 文件名

Tag对象一般是Commit对象

Tree对象对应文件目录,blob对象对应文件,commit对象对应当前分支的快照(snapshot)

执行git add和git commit时发生了什么?

上面已经创建一个test1的空目录,接下来我们添加一个文件到test1目录。

echo 'hello' > test.txt

执行git status命令,可以看到目录下多了个未跟踪的文件test.txt,这时候执行git add命令,观察.git目录有什么变化。

git add test.txt
cd .git
→ tree
.
├── HEAD
├── branches
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── objects
│   ├── ce
│   │   └── 013625030ba8dba906f756967f9e9ca394464a
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

10 directories, 17 files

可以看到,执行git add命令后,.git/object/目录下新增了一个目录和一个文件(其实是一个40位哈希值,前两位是目录名,后38位是文件名),其实只是多出了一个blob对象,同时创建了一个index文件。后面我们会讲解这个新增的blob对象以及index文件是如何生成的。

我们接着执行git commit命令,看看会发生什么。

→ tree
.
├── COMMIT_EDITMSG
├── HEAD
├── branches
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── 2b
│   │   └── f705f913222f2032e114e609dfe7f2e97f23bd
│   ├── 92
│   │   └── 0512d27e4df0c79ca4a929bc5d4254b3d05c4c
│   ├── ce
│   │   └── 013625030ba8dba906f756967f9e9ca394464a
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    └── tags

15 directories, 23 files

观察控制台的输出可以看到,commit之后多出了5个目录和5个文件,我们只关注objects/目录和refs/目录。执行commit之后,objects目录下新增了两个对象,同时refs/heads/目录下新增了一个master文件。

总结一下,执行git add和git commit命令,objects目录下新增了3个对象,新增了一个index文件和master文件。

这些对象和文件是如何产生的呢,下面我们通过git的底层命令来复盘上面的操作。

使用Git底层命令演示git add和git commit的原理

下面我们新建一个空目录test2,并执行git init命令

mkdir test2
cd test2
git init

使用git has-object往Git数据库写入内容

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

可以看到,控制台输出了一个40位的哈希值,与上面对比发现是一样的。为什么是一样的呢?因为Git是通过头部信息(header)+数据内容(content)通过SHA-1检验计算校验和生成的。

执行完has-object命令后,观察objects/目录的变化,我们用find命令来查看

→ find .git/objects -type f
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a

可以看到object/目录下多了一个对象,正是我们刚刚写进Git数据后返回的哈希值。

Git数据库是一个简单的key-value data store,哈希值作为key,数据内容作为value。我们可以通过cat-file命令查看这个key对应的内容以及类型。

→ git cat-file -p ce013625030ba8dba906f756967f9e9ca394464a
hello
→ git cat-file -t ce013625030ba8dba906f756967f9e9ca394464a
blob

可以看到,输出的内容正是我们之前写入的,类型是blob类型。

接着我们执行git status命令看看发生了什么

→ git status
On branch master

No commits yet

nothing to commit (create/copy files and use "git add" to track)

这里有一个问题,我们之前仅仅保存了文件内容,并没有指定文件名。我们使用tree命令,查看.git目录,可以发现,index文件还没有被创建。

下面我们使用update-index命令来创建一个暂存区,并为之前写入的文件内容指定一个文件名。

git update-index --add --cacheinfo 100644 ce013625030ba8dba906f756967f9e9ca394464a test.txt

再次使用tree命令查看

→ tree .git
.git
├── HEAD
├── branches
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── objects
│   ├── ce
│   │   └── 013625030ba8dba906f756967f9e9ca394464a
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

10 directories, 17 files

可以发现index文件被创建出来了。这个时候,再次执行git status

→ git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached ..." to unstage)

    new file:   test.txt

Changes not staged for commit:
  (use "git add/rm ..." to update what will be committed)
  (use "git checkout -- ..." to discard changes in working directory)

    deleted:    test.txt

可以发现,test.txt文件已经加入暂存区了,下面还有一个标记为删除的test.txt,为什么呢?
因为我们本地没有test.txt文件,而暂存区有这个文件,使用git status的时候是对比工作目录和暂存区的文件。

如果介意这个deleted的log,可以已通过git checkout -- test.txt命令让暂存区的文件覆盖工作区的文件,我们先不管。

执行git add的时候,除了上面两步,还有一步是将文件写入一个树对象。可以通过git write-tree命令来完成

git write-tree ce013625030ba8dba906f756967f9e9ca394464a
920512d27e4df0c79ca4a929bc5d4254b3d05c4c

执行完后发现,输出了一个40位的哈希值,与上面git add之后对比,可以发现是同一个哈希值。
我们验证下这个对象的类型:

→ git cat-file -t 920512d27e4df0c79ca4a929bc5d4254b3d05c4c
tree

可以发现这确实是个树对象。

至此,我们使用底层命令完成了git add所做的工作。git add的时候做什么,我们可以总结下:

  1. 将文件内容写入Git数据库
  2. 更新暂存区并指定文件名,如果是首次提交到暂存区则是创建暂存区(对应.git目录下的index文件)
  3. 将文件写入树对象

到这里我们还没有提交对象,是时候创建一次提交了。我们可以使用commit-tree命令创建一次提交。

→ echo 'first commit' | git commit-tree 9205
c4cf32be0098f786df455e3fee67ba21779dd70a

可以发现这里输出的哈希值与test1演示项目中的值不同,其他两个都相同,为什么呢?因为commit对象包含时间戳信息,所以计算出来的哈希值肯定是不一样的。

验证下这个对象的类型:

→ git cat-file -t c4cf
commit

可以发现这确实是一个commit对象。

使用git log c4cf命令查看:

commit c4cf32be0098f786df455e3fee67ba21779dd70a
Author: yfm 
Date:   Mon Apr 29 16:30:31 2019 +0800

    first commit

可以看到我们在不使用高层命令的情况下,也完成了一个完整的提交历史。

别高兴的太早,我们我们使用git log命令查看,发现似乎少了点什么东西

→ git log
fatal: your current branch 'master' does not have any commits yet

使用git log命令查看,发现master分支还没有任何提交信息,为什么呢?

对照test1项目,可以发现我们refs/heads目录下还少了个master文件。master文件执行最近一次提交的引用。不可能每次通过哈希值去追溯提交历史,我们可以起个简单的名字方便我们记忆,Git默认的分支名是master,我们就用这个名字代替提交对象的哈希值。

echo 'c4cf32be0098f786df455e3fee67ba21779dd70a' > .git/refs/heads/master

再次运行git log,发现已经与test1项目完全一样了,至此我们使用底层命令完成了git add和git commit所做的所有工作。

git log --pretty=oneline
c4cf32be0098f786df455e3fee67ba21779dd70a (HEAD -> master) first commit

总结下git commit时做了什么:

  1. 创建一个提交对象
  2. 创建一个指向改提交对象的master指针

最后用一张图总结:

版本库

SHA-1检验计算

哈希(hash)使用数据摘要算法(或称散列算法),是信息安全领域中重要的理论基石。该算法将任意长度的输入经过散列运算转换为固定长度输出。固定长度的输出可以称为对应输入内容的数组摘要或哈希值。

前文看到的哪些哈希值是如何计算的呢?

下面内容摘抄自:https://git-scm.com/book/zh/v2/Git-%E5%86%85%E9%83%A8%E5%8E%9F%E7%90%86-Git-%E5%AF%B9%E8%B1%A1

可以通过 irb 命令启动 Ruby 的交互模式:

$ irb
>> content = "what is up, doc?"
=> "what is up, doc?"

Git 以对象类型作为开头来构造一个头部信息,本例中是一个“blob”字符串。 接着 Git 会添加一个空格,随后是数据内容的长度,最后是一个空字节(null byte):

>> header = "blob #{content.length}\0"
=> "blob 16\u0000"

Git 会将上述头部信息和原始数据拼接起来,并计算出这条新内容的 SHA-1 校验和。 在 Ruby 中可以这样计算 SHA-1 值——先通过 require 命令导入 SHA-1 digest 库,然后对目标字符串调用 Digest::SHA1.hexdigest():

>> store = header + content
=> "blob 16\u0000what is up, doc?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"

Git 会通过 zlib 压缩这条新内容。在 Ruby 中可以借助 zlib 库做到这一点。 先导入相应的库,然后对目标内容调用 Zlib::Deflate.deflate():

>> require 'zlib'
=> true
>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"

最后,需要将这条经由 zlib 压缩的内容写入磁盘上的某个对象。 要先确定待写入对象的路径(SHA-1 值的前两个字符作为子目录名称,后 38 个字符则作为子目录内文件的名称)。 如果该子目录不存在,可以通过 Ruby 中的 FileUtils.mkdir_p() 函数来创建它。 接着,通过 File.open() 打开这个文件。最后,对上一步中得到的文件句柄调用 write() 函数,以向目标文件写入之前那条 zlib 压缩过的内容:

>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32

就是这样——你已创建了一个有效的 Git 数据对象。 所有的 Git 对象均以这种方式存储,区别仅在于类型标识——另两种对象类型的头部信息以字符串“commit”或“tree”开头,而不是“blob”。 另外,虽然数据对象的内容几乎可以是任何东西,但提交对象和树对象的内容却有各自固定的格式。

参考

https://git-scm.com/book/zh/v2

你可能感兴趣的:(Git内部原理)