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 <file>..." to unstage)
new file: test.txt
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git checkout -- <file>..." 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的时候做什么,我们可以总结下:
- 将文件内容写入Git数据库
- 更新暂存区并指定文件名,如果是首次提交到暂存区则是创建暂存区(对应.git目录下的index文件)
- 将文件写入树对象
到这里我们还没有提交对象,是时候创建一次提交了。我们可以使用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 <imyangfm@gmail.com>
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时做了什么:
- 创建一个提交对象
- 创建一个指向改提交对象的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”。 另外,虽然数据对象的内容几乎可以是任何东西,但提交对象和树对象的内容却有各自固定的格式。