本文试图用底层命令来实现高层命令add和commit操作来介绍git底层原理。

底层命令与高层命令

  • 高层命令(porcelain):诸如checkout、branch、remote 等大约 30 个完整的、用户友好的命令,它们是我们日常操作git的命令。
  • 底层命令(plumbing):用于完成底层工作的命令。 这些命令被设计成能以 UNIX 命令行的风格连接在一起,抑或藉由脚本调用,来完成工作。

首先,我们分别使用高层命令与底层命令完成从创建仓库到添加文件,到commit的过程:

高层命令

1
2
3
4
5
6
mkdir gittest
cd gittest
git init
echo aaa>a.txt
git add a.txt
git commit -m "init"

我们看下add和commit命令在git官方的定义:

  • git add 命令将内容从工作目录添加到暂存区(或称为索引(index)区),以备下次提交。
  • git commit 命令将所有通过 git add 暂存的文件内容在数据库中创建一个持久的快照,然后将当前分支上的分支指针移到其之上。

从中,我们可以提取出关键词:工作目录、暂存区、数据库、快照、指针。

底层命令

1
2
3
4
5
6
7
8
9
mkdir gittest
cd gittest
git init
echo aaa>a.txt
git hash-object -w a.txt
git update-index --add --cacheinfo 100644 72943a16fb2c8f38f9dde202b7a70ccc19c52f34 a.txt
git write-tree
git commit-tree 37057b2e8a9041ef88b805a5b7c4e0e668a03be4 -m "init"
git update-ref refs/heads/master 48d2ea321a0f1c35b2799ad853c93115e8ab98d7

仓库文件(.git目录)内容

当在一个新目录或已有目录执行git init 时,Git 会创建一个 .git 目录。 这个目录包含了几乎所有 Git 存储和操作的对象。

.git目录默认内容如下:

HEAD
config
description
hooks/
info/
objects/
refs/

  • description文件: 仅供 GitWeb 程序使用
  • config文件: 包含项目特有的配置选项
  • info目录: 包含一个全局性排除(global exclude)文件,用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns)
  • hooks目录: 目录包含客户端或服务端的钩子脚本(hook scripts)
  • HEAD文件:指示目前被检出的分支。
  • (尚待创建的)index文件:保存暂存区的信息。
  • objects目录:存储所有数据内容。所谓的数据库即是指这里。
  • refs目录:存储指向数据(分支的)提交对象的指针。
    • /refs/heads:存储本地分支
    • /refs/remotes:存储追踪分支
    • /refs/tags/: 存储tags

所以,本质上:

  • 分支:一个指向某一系列提交之首的指针或引用
  • 当前位置:git status或者git branch命令看到的当前位置就是HEAD文件的内容
  • 切换分支:就是修改HEAD文件的内容,设置新的引用
  • 新建分支:就是在refs/heads新建一个文件,内容为原分支的最新commit对象
  • fetch和push:实际上就是和远程server交换objects目录里的内容
  • add:就是把文件放入index文件
  • commit:就是把index文件的内容生成一个commit对象

git对象

Git 是一个内容寻址文件系统。 这意味着,Git 的核心部分是一个简单的键值对数据库(key-value data store)。

每个对象(object) 包括三个部分:类型,大小和内容。大小就是指内容的大小,内容取决于对象的类型。

Git对象类型

  • 数据对象(blob):一个“blob”通常用来存储文件的内容。一个“blob”对象就是一块二进制数据,它没有指向任何东西或有任何其它属性,甚至没有文件名。
  • 树对象(tree):有点像一个目录,它管理一些“tree”或是 “blob”(就像文件和子目录)。一般用来表示内容之间的目录层次关系。
  • 提交对象(commit):一个“commit”只指向一个”tree”,它用来标记项目某一个特定时间点的状态。它包括一些关于时间点的元数据,如时间戳、最近一次提交的作者、指向上次提交(commits)的指针等等。
  • 标签对象(tag):标记某一个提交(commit)

可以使用git cat-file -p <sha1值>命令查看对象内容。

git对象图示

git对象存储

详见官方说明: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

前文曾提及,在存储内容时,会有个头部信息一并被保存。 让我们略花些时间来看看 Git 是如何存储其对象的。 通过在 Ruby 脚本语言中交互式地演示,你将看到一个数据对象——本例中是字符串“what is up, doc?”——是如何被存储的。

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

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

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

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

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

1
2
3
4
5
6
>> 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():

1
2
3
4
>> 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 压缩过的内容:

1
2
3
4
5
6
7
8
>> 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”。 另外,虽然数据对象的内容几乎可以是任何东西,但提交对象和树对象的内容却有各自固定的格式。

快照

每次提交git保存所有文件。

快照存储

Git 对当时的全部文件制作一个快照并保存这个快照的索引。 为了高效,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。

即:对全部文件做快照(hash-object)->只保存修改的文件,为修改的文件通过链接的方式识别(commit的parent)->通过打包的方式,只保存文件不同版本之间的差异内容来压缩空间(可通过git gc触发)。

底层命令解释

我们回顾开头用底层命令完成从创建仓库到添加文件的例子

  • git-hash-object - 计算对象ID并可选择从文件创建一个blob。-w参数将对象写入对象数据库。
  • git-update-index - 将工作树中的文件内容注册到索引
  • git-write-tree - 从当前索引创建一个树形对象
  • git-commit-tree - 创建一个新的提交(commit)对象,即生成快照。
  • git-update-ref - 安全地更新存储在ref中的对象名称

流程图:

参考资料