基本概念
概述
Git 是一个基于快照的文件版本管理系统。它会为每个文件计算一个 hash 值,并压缩存储到 .git/objects 目录。普通文件是 blob 对象,文件夹会生成 tree 对象。一个版本的文件由根目录 tree 串起来,它的上层是 commit 对象。commit 会有一个或多个 parent 指针,指向上一个提交。
commit 的上层还有一个概念叫分支,分支是一个指向 commit 的指针,相当于是对这一系列 commit 的抽象。
这是一个最常用的模型,后面很多命令都在这三层之间移动内容:
工作区 --git add--> 暂存区(index) --git commit--> commit/tree/blob
这是一个简单仓库的示意图,我们会在文章末尾详细分析其构成。

下面我基于 go 语言版本的 git 实现源码(相较于C语言版更面向对象,可读性更好)来分析一下这些概念的底层实现。
Object
Object 是这个文件管理系统中最基本的单元,代表一个普通文件。每一个被 git 管理的文件都会计算出 hash 值,再压缩放到 .git/objects 目录中。
同时,提交、目录、标签也都被抽象成对象。
CommitObject = 1 // 提交
TreeObject = 2 // 目录
BlobObject = 3 // 文件
TagObject = 4 // 标签
Object 的数据结构如下:
type Object interface {
ID() plumbing.Hash // 哈希值
Type() plumbing.ObjectType // 类型
Decode(plumbing.EncodedObject) error // 写入
Encode(plumbing.EncodedObject) error // 读取
}
Commit
Commit 是一个特殊的对象,代表了一个版本,指向了属于该版本对应的目录以及所有的文件。同时还记录着提交人、时间、注释等其他信息。
type Commit struct {
Hash plumbing.Hash // 哈希值
Committer Signature // 提交者
Message string // 注释
TreeHash plumbing.Hash // 对应目录对象的哈希值
ParentHashes []plumbing.Hash // 上一个 commit 哈希值的数组
}
同时 commit 也包含了指向上一个 commit 的指针,这样所有的 commit 就能串成一条线。上面结构中的 parent 是一个数组,也就意味着它可能有多个,比如两个 commit merge 成一个。换句话说,commit 其实是一个网状结构,而不只是单纯的一条线。
这里可以多记一句:普通提交通常只有一个 parent,merge 提交一般会有两个 parent。
Blob
Blob 即一个普通文件,不过是压缩过的。
type Blob struct {
Hash plumbing.Hash
Size int64
obj plumbing.EncodedObject
}
Tree
Tree 代表一个目录,包含一个 entries 数组,指向当前目录下的文件或者其他目录,构成一棵文件树,所以被称为 Tree。
另外文件名是存储在 Tree 中的。所以如果一个目录下有十个文件,只有名字不一样,也只会产生一个 blob 对象,因为内容的 hash 值都一样。
type Tree struct {
Entries []TreeEntry
Hash plumbing.Hash
}
type TreeEntry struct {
Name string
Mode filemode.FileMode
Hash plumbing.Hash
}
Branch
branch 的底层结构非常简单,在 .git/refs/heads 中的每一个文件都代表一个分支,文件里则是对应 commit 的 hash 值。
远端分支则存放在 .git/refs/remotes 中,轻量级 tag 的实现也和分支一样。
Working Directory
工作区其实就是一个目录路径,代表你当前实际可见的文件集合,区别于备份在 .git 目录中的文件。
Index
暂存区指向的是工作区文件。它和工作区最大的区别是:只有被 git add 过的文件才会出现在 index 中,被 git 托管。所以 index 的实现也非常简单,保存一个 add 了的文件列表即可。
补充一点,git commit 读取的是 index,不是直接读取工作区。
所以你可以用 git diff 看工作区和暂存区的差异,用 git diff --cached 看暂存区和最新提交的差异。
type Index struct {
Entries []*Entry
}
常见命令
Git Commit 原理
在调用 git commit 命令时,git 会将 index 引用的所有文件计算 hash 值,再将当前指向的 commit 作为父节点,创建一个新的 commit 对象,并让当前分支指向这个新对象。
顺手补一句,git commit -am 只会处理已跟踪文件;新文件还是要先 git add。
Git Merge 原理
假设当前分支为 master,当前 git 树是这样的,有一个 master 分支和一个 topic 分支:
如果我在 master 分支执行了:git merge topic
这里还有一个常见情况:如果 master 没有自己的新提交,merge 会直接快进(fast-forward),只移动 master 指针,不会创建新的 merge commit。
git 会把 topic 相对于它和 master 公共 commit 之后的修改打包成一个新的 commit,提交到 master 分支上。这个过程对 topic 没有影响。这个新的 commit 会有两个父节点,一个指向 master,一个指向 topic,新的 git 树会长这样:

这也是很多人吐槽 merge 的一点。它会让本来直直的一条提交线出现分叉。如果只有一两条分支还好,但如果上图中 E、F、G 这几个 commit 也是其他分支合入的,甚至 topic 分支上的 A、B commit 也是其他分支合并进去的,那么分支树可能会变成这样,真是一场噩梦啊:

可以通过 git cat-file -p <commit-hash> 来查看 commit 内容。比如这是一个 merge commit,同时有两个 parent。对应到上面的 commit 结构中,commit 的 parent 确实是一个数组:ParentHashes []plumbing.Hash。

Git Rebase 原理
实现原理,假设当前分支为:topic,当前的 git 树是这样的:

git rebase master 的实现逻辑是这样的:
- 将本分支独有的 commits 移到一个临时区域
- 将本分支 reset 重置成和 master 一模一样(将 topic 指针指向 master)
- 将临时区域的 commit 再一个一个提交回来
这样,一次 rebase 就完成了,当前的 git 树就变成了这个样子。

补充一个容易忽略的点:rebase 之后这些提交的 hash 会变化,因为它们是“重新生成”的 commit。
如果是使用 GitLab,这个时候我们就可以在 GitLab 上合并了。因为分支相对于 master 而言只有新增,所以可以进入 Fast-forward merge 模式,也就是直接把 master 指针指向 topic 指针所指向的 tree 对象。
不过 GitLab 默认会把 Fast-forward 禁用。即使你的分支只有新增,也还是会保留一棵分支树,需要手动打开这个配置。
另外对于 topic 分支来说,如果之前的提交已经推送到远端仓库,会产生不兼容,需要使用 git push -f 才能推送上去,或者废弃这条分支。
实际使用里可以优先用 git push --force-with-lease,比 -f 更安全一些。
Git Push 原理
Git 本地和远端仓库可以通过 git、ssh、http 三种协议进行传输。
以 master 分支为例,Git push 是尝试将本地 master 指针内容覆盖掉远端 master 指针,再把本地指针指向但远端没有的对象全部推送到远端。 默认仅在 fast-forward 状态下才可以合并,即 git push 在远端指针不是本地指针祖先时会拒绝覆盖。
而 –force,可以让 Git 不进行这个检查,直接覆盖远端对应 master 指针的内容。
Git Reset 原理
格式:git reset [--soft | --hard | --mixed | --keep] [<commit>]
reset 可以让当前分支 head 指向指定的 commit,并且根据选项改动暂存区和工作区。
- soft: 暂存区和工作区都不重置,仅仅改一下 head 指向的 commit。
- mixed(默认): 暂存区会被重置,但是工作区不会。
- keep:暂存区和工作区都会重置,但是变更会被重新应用。
- hard: 暂存区和工作区都会重置到指定提交的状态。
Git Cherry Pick 原理
cherry pick 会复制一个 commit 的变更应用到当前分支创建一个新的 commit。
Git Checkout 原理
checkout 可以将当前工作区切换至指定的 commit 位置。
如果直接 checkout 到某个 commit hash(不是分支名),会进入 detached HEAD 状态,这时提交不会挂在分支名下。
Git RM 原理
基本用法:git rm [--cached | -r ] <file>...
rm 用于删除一个文件,git rm 默认会将文件从暂存区和工作区都移除,可以加 –cached 选项只从暂存区移除。
不过没有选项只移除工作区,这个时候直接使用操作系统提供的原生 rm 命令即可。
Git Add 原理
add 可以将一个文件或者目录加入到暂存区中,不过配置在 .gitignore 中的文件会被忽略。
实例分析
创建仓库
我们可以从创建一个非常简单的 git 仓库开始分析:
- git init
- echo “123” > 1.txt
- echo “123” > 2.txt
- git add .
- git commit -m “first commit”
- echo “123” >> 1.txt
- git commit -am “second commit”
- git branch dev
- echo “345” >> 1.txt
- git commit -am “third commit”
这里 git branch dev 只是创建分支,当前还在 master 上。
我们通过以上命令,就可以创建一个具有三个 commit 两个分支的仓库,下面我们来逐步分析一下这个仓库的构成。
分析
我们来到 .git/refs/heads 目录下面,会发现有 dev 和 master 两个文件。heads 目录下的文件代表一个分支,每个分支里都是对应 commit 对象的哈希值。
我们可以在 objects 目录下找到对应的 commit 对象文件。

git 对象都是经过压缩的,所以直接查看会是乱码,可以通过 cat-file 工具查看里面的内容。

可以看到这个 commit 对象里包含了提交者、提交时间、提交注释之类的元信息,还包括一个指向前一个 commit 对象的指针。这样这些 commit 就能通过这个字段串起来成一条线。
tree 字段则指向了当前 commit 对应文件目录的目录对象,这样就能通过这个指针找到这个版本对应的所有文件。
继续跟踪这个目录对象,它就像一个普通目录,指向两个 blob 文件对象。目录里也可能还有目录。查看一个文件对象,就能得到具体的文件内容。

继续查看上一个 commit 的信息,我们就能彻底了解整个仓库的历史,最终整理出来的指向图如下。
如果你想自己在本地快速验证,可以直接跑:
git log --oneline --graph --decorate --all
示意图
这个仓库我推送到了远端,可以拉下来自己对比:https://github.com/Huweicai/gitdemo。
