Git实现原理

基本概念

概述

Git 是一个基于快照的文件版本管理系统,其实现原理是为每个文件计算一个 hash 值然后压缩存储到 .git/objects 目录内,普通文件为 blob 对象,而文件夹也会生成一个对象:tree,这样一个版本的文件就能被根目录串联起来,这个版本的再上层会有一个 commit 对象,commit 对象会有一到多个 parent 指针,指向上一个提交,这样就把一个个版本串联了起来。

commit 的上层还有一个概念叫分支,分支是一个指向 commit 的指针,相当于是对这一系列 commit 的抽象。

这是一个简单仓库的示意图,我们会在文章末尾详细分析其构成。

image-20201110005626068

下面我基于 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 就能串成一条线,但是我们可以看到上面的结构中 commit 的 parrent 是一个数组,也就意味着可能会有多个,比如说两个 commit merge 成一个等场景,这也就意味着 commit 其实是一个网状结构,而不只是单纯的一条线。

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 了的文件的列表即可。

type Index struct {
	Entries []*Entry
}

常见命令

Git Commit 原理

在调用 git commit 命令时,git 会将 index 引用的所有文件全部计算 hash 值,然后将当前指向的 commit 作为父节点,创建一个新的 commit 对象,然后让当前分支指向新创建的 commit 对象。

Git Merge 原理

假设当前分支为 master ,当前 git 树是这样的,有一个 master 分支和一个 topic 分支:image-20201230231004625

如果我在 master 分支执行了:git merge topic

然后 git 会把 topic 相对于它和 master 的公共 commit 之后的修改打包成一个新的 commit 提交到 master 分支上面,不过对于 topic 没有任何影响,只是说这个新的 commit 会有两个父节点,一个指向 master,一个指向 topic,新的 git 树会长这样: image-20201230230942517

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

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

image-20201111010952453

Git Rebase 原理

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

image (3)

git rebase master 的实现逻辑是这样的:

  • 将本分支独有的 commits 移到一个临时区域
  • 将本分支 reset 重置成和 master 一模一样(将 topic 指针指向 master)
  • 将临时区域的 commit 再一个一个提交回来

这样,一次 rebase 就完成了,当前的 git 树就变成了这个样子。

如果是使用 gitlab 的话,这个时候我们就可以在 gtilab 上合并了,因为我们分支相对于 master 而言只有新增,所以可以会进入 Fast-forward merge 模式,即直接把 master 指针指向 topic 指针所指向的 tree 对象即可。

但是呢,Gtilab 默认是把这个 Fast-foward 给禁用的,所以即使你的分支只有新增,也还是会有一个分支树,保留这里,需要手动打开这个配置才行。

另外对于 topic 分支而言,如果之前的提交已经提交到了远端仓库的话,会产生不兼容,需要使用 git push -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 Check Out 原理

check out 可以将当前工作区切换至指定的 commit 位置。

Git RM 原理

基本用法:git rm [--cached | -r ] <file>...

rm 用于删除一个文件,git rm 默认会将文件从暂存区和工作区都移除,可以加 –cached 选项只从暂存区移除。

不过没有选项只移除工作区,这个时候直接使用操作系统提供的原生 rm 命令即可。

Git Add 原理

add 可以将一个文件或者目录加入到暂存区中,不过配置在 .gitignore 中的文件会被忽略。

实例分析

创建仓库

我们可以从创建一个非常简单的 git 仓库开始分析:

  • git init
  • echo “123” > 1.txt
  • echot “123” > 2.txt
  • git add .
  • git commit -am “first commit”
  • echo “123” >> 1.txt
  • git commit -am “second commit”
  • git branch dev
  • echo “345” >> 1.txt
  • git commit -am “third commit”

我们通过以上命令,就可以创建一个具有三个 commit 两个分支的仓库,下面我们来逐步分析一下这个仓库的构成。

分析

首先我们来到 .git/refs/heads目录下面,会发现下面有 dev 和 master 两个文件,heads 目录下面的文件代表了一个分支,每个分支里面都是对象 commit 对象的哈希值。

我们可以在 objects 目录下找到对应的 commit 对象文件。

image-20201110003814492

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

image-20201110004046677

可以看到这个 commit 对象里面包含了一些诸如提交者、提交时间、提交注释之类的元信息,同时还包括一个指向前一个 commit 对象的指针,这样这些 commit 就能通过这个字段串起来成一条线。

tree 字段则指向了当前 commit 对应文件目录的目录对象,这样就能通过这个指针找到这个版本对应的所有文件。

继续跟踪这个目录对象,它就像一个普通的目录一样,指向了两个 blob 文件对象,当然目录也可能有目录,在查看一个文件对象,就能得到具体的文件内容了。

image-20201110004601075

继续查看上一个commit的信息,我们就能彻底了解整个仓库的历史,最终整理出来的指向图如下。

示意图

这个仓库我推送到了远端,可以拉下来自己对比:https://github.com/Huweicai/gitdemo

image-20201110005626068