Git 内部原理

Posted ShadowStorm

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Git 内部原理相关的知识,希望对你有一定的参考价值。

三个状态

git 的三个状态(三个区域):

  • Working Directory:工作区,直接编辑的区域,肉眼可见,直接操作;
  • Staging Area:暂存区,数据暂时存放的区域;
  • .git directory(Repository):版本库,存放已经提交的数据;

很多命令都和这三个状态有关系,比如:

  • git diff
  • git checkout
  • git reset

《图解 Git》中对这些命令有详细讲解:https://marklodato.github.io/visual-git-guide/index-zh-cn.html

底层命令与上层命令

Git 本质上是一个内容寻址文件系统(content-addressable filesystem),并在此之上提供了一个版本控制系统的用户界面。

Git 最初是一套面向版本控制系统的工具集,而不是一个完整的、用户友好的版本控制系统,所以它包含了一部分用于完成底层工作的子命令。这些命令被设计成能以 UNIX 命令行的风格连接在一起,或是由脚本调用,来完成工作。这部分命令一半被称作“底层(plumbing)”命令,而哪些更友好的命令则被称作“上层(porcelain)”命令。

查看所有底层命令请移步:https://git-scm.com/docs

Git Objects 和 Git References

Git 本质上是一个内容寻址文件系统(content-addressable filesystem),Git Objects 和 Git References 是这个内容寻址文件系统的核心。

Git Objects

Git 通过四种 Object 来描述:文件、目录结构、提交(Commits)、附注标签(Annotated Tags)。

blob object

数据对象。 描述文件,存储压缩后的文件内容。

tree object

树对象。 描述目录结构,每个目录都是一个树对象。一个树对象包含了一条或多条树对象记录(tree entry),每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。

例子:

040000 tree 23c018695a85aaa4413817349abad68634df935c    dir_1
100644 blob b176602ea4b9de59db75aeb527bcda8507bd68ea    file_1
Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化。 所有内容均以 tree object(树对象)和 blob object(数据对象)的形式存储,其中树对象对应了 UNIX 中的目录项,数据对象则大致上对应了 inodes 或文件内容。

commit object

提交对象。 描述提交(Commit),提交对象的格式:

  • tree:顶层树对象。代表当前项目快照;
  • parent:可能存在的父提交(项目的第一次提交没有该字段);
  • author:作者信息;
  • commiter:提交者信息;
  • 换行、提交描述信息

例子:

tree 88b50ad8cdf97b5cce6fdfb29d3da5c6097c39ea
parent ab03bad2084b41191c12dd08f412b25fb5a535a0
author Lin Zhang <zhanglin3@foxmail.com> 1613702136 +0800
committer Lin Zhang <zhanglin3@foxmail.com> 1613702136 +0800

2nd commit

tag object

Git 存在两种类型的标签:附注标签和轻量标签。附注标签通过 Git Objects 来实现,轻量标签通过 Git References 来实现。

标签对象。 描述附注标签(Annotated Tags),标签对象与提交对象非常相似,它们的主要区别在于,标签对象通常指向一个提交对象,而不是一个树对象。 它像是一个永不移动的分支引用——永远指向同一个提交对象,只不过给这个提交对象加上一个更友好的名字罢了。

标签对象的格式:

  • object:Tag 指向的 Object 的 SHA-1 指针;
  • type:类型;
  • tag:Tag 名称;
  • tagger:打 Tag 的人的信息;
  • 换行、Tag 描述信息

例子:

object 935e454f8ca4bd06ca2742538722ea82392214c9
type commit
tag v0.1
tagger Lin Zhang <zhanglin3@foxmail.com> 1613702521 +0800

version v0.1

Git References

其实,Git Objects 已经实现了 Git 的核心能力,那么 Git References 的意义何在?引用 Git 官网的一段话来回答这个问题:

如果你对仓库中从一个提交(比如 1a410e)开始往前的历史感兴趣,那么可以运行 git log 1a410e 这样的命令来显示历史,不过你需要记得 1a410e 是你查看历史的起点提交。 如果我们有一个文件来保存 SHA-1 值,而该文件有一个简单的名字, 然后用这个名字指针来替代原始的 SHA-1 值的话会更加简单。

Git 通过四种 Renference 来实现:分支(Branch)、HEAD、轻量标签(Lightweight Tags)、远程引用(Remotes),而这些 Renference 可以理解为是 Object 的指针。

分支引用

Git 分支的本质:一个指向某一系列提交之首的指针或引用。

当运行类似于 git branch 这样的命令时,Git 实际上会运行 update-ref 命令, 取得当前所在分支最新提交对应的 SHA-1 值,并将其加入你想要创建的任何新引用中。

HEAD 引用

HEAD 文件通常是一个符号引用(symbolic reference),指向目前所在的分支。 所谓符号引用,表示它是一个指向其他引用的指针。

然而在某些罕见的情况下,HEAD 文件可能会包含一个 git 对象的 SHA-1 值。 当你在检出一个标签、提交或远程分支,让你的仓库变成 “分离 HEAD”状态时,就会出现这种情况。

标签引用

Git 存在两种类型的标签:附注标签和轻量标签。附注标签通过 Git Objects 来实现,轻量标签通过 Git References 来实现。

轻量标签的全部内容只是一个固定的引用。需要注意的是,标签对象并非必须指向某个提交对象,可以对任意类型的 Git 对象打标签。

远程引用

如果添加了一个远程版本库并对其执行过推送操作,Git 会记录下最近一次推送操作时每一个分支所对应的值,并保存在 refs/remotes 目录下。

远程引用和分支(位于 refs/heads 目录下的引用)之间最主要的区别在于,远程引用是只读的。 虽然可以 git checkout 到某个远程引用,但是 Git 并不会将 HEAD 引用指向该远程引用。因此,你永远不能通过 commit 命令来更新远程引用。 Git 将这些远程引用作为记录远程服务器上各分支最后已知位置状态的书签来管理。

演示

Git Objects 和 Git References 单纯的概念介绍会难以理解,所以接下来会进行演示。在演示过程中我会顺带介绍下 Index File。

新建一个项目,并初始化 Git

$ cd /home/user/my_project
$ git init

.git 目录简介

此时,我们创建了一个叫做 .git 的文件夹,关于 git 的一切都在这个文件夹中。执行命令 tree .git,可以看到 .git 文件夹的结构:

$ tree .git
.git
├── HEAD
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   ├── push-to-checkout.sample
│   └── update.sample
├── info
│   └── exclude
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

8 directories, 17 files

简单介绍下这些文件/文件夹的作用:

  • HEAD 文件:符号引用,指向目前所在的分支;
  • config 文件:git 仓库的配置文件;
  • description 文件:git 仓库的描述信息,主要给 gitweb 等 git 托管系统使用;
  • hooks 文件夹:一些 shell 脚本,在特定事件发生时自动执行;
  • info 文件夹:git 仓库的一些信息
  • objects 文件夹:git 的核心,存放所有 git 对象;
  • refs 文件夹:git 的核心,存放所有引用;

由于是新建的项目,所以 .git 文件夹下还有一些暂时未生成的文件/文件夹,后续用到了再介绍。

我们继续。

新建文件夹和文件,为了演示 tree object,我们创建多层文件夹和多个文件。

$ mkdir -p dir_1/dir_1_1; echo "file_1 content" > dir_1/dir_1_1/file_1; echo "file_2 content" > dir_1/dir_1_1/file_2

然后将其添加到 Starging Area:

$ git add .

好,此时,我们观察一下 .git 文件夹:

$ tree .git
.git
├── HEAD
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   ├── push-to-checkout.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── objects
│   ├── 2f
│   │   └── 1f63b009307da4ed7e965940a2eb843235b3a9
│   ├── b1
│   │   └── 76602ea4b9de59db75aeb527bcda8507bd68ea
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

10 directories, 20 files

会发现有一些变化:

  • .git 文件夹中多了一个文件:index
  • .git/objects 文件夹中多了两个子文件夹,以及每个子文件夹中多了一个文件;

Index File

我们先来介绍一下 index 文件。其实这个 index 文件就是 Staging Area 的实现。该文件中包含:

  • 路径名称的排序列表;
  • 每个路径名称的权限;
  • blob object 的 SHA-1 值;

可以通过命令 git ls-files -s 来查看该文件的内容:

$ git ls-files -s
100644 b176602ea4b9de59db75aeb527bcda8507bd68ea 0   dir_1/dir_1_1/file_1
100644 2f1f63b009307da4ed7e965940a2eb843235b3a9 0   dir_1/dir_1_1/file_2

对于 Index File,有一些文章的讲解非常详细,我们就不多展开了。

  • Git: Understanding the Index File:https://mincong.io/2018/04/28/git-index/#1-understand-index-via-git-ls-files
  • Understanding Git — Index:https://medium.com/hackernoon/understanding-git-index-4821a0765cf

Git Objects

在刚才的 Index File 中我们会发现,其中只包含了我们添加到 Staging Area 中的文件的路径、权限、文件的 SHA-1 值,但是并没有文件内容。而同时我们也发现在 .git/objects 文件夹中多了一个文件,其实这个多出来的文件就是 blob object,其内容就是压缩后的数据。

当我们执行 git add 命令时,如果此时有文件变动(添加或修改),都会在 .git/objects 中生成一个 blob object,Object 文件的生成有一些讲究:

  • 该文件会被放置在一个由两个十六进制字符命名的文件夹中;
  • 文件夹的名字和文件的名字拼接起来就是文件内容的 SHA-1 的值;

这里需要注意的是 blob object 只包含文件内容,并不包含文件路径,至于为何这样设计,可以自己思考一下。这里可以做一些尝试,比如,再建一个新文件,其内容也是“file_1 content”,执行 git add 后观察是否生成了新的 blob object。

查看 Git Objects

我们可以通过一个非常核心的 git 底层命令来查看 Git Object 的内容:

  • git cat-file -t <object> 查看 Git Object 的类型;
  • git cat-file -p <object> 查看 Git Object 的内容;

这里,我使用一条组合命令直接查看 Git 项目中目前所有的 Git Objects:

$ find .git/objects -type f | cut -d \'/\' -f 3,4 | tr -d \'/\' | xargs -L 1 -I {} sh -c \'echo "--- SHA-1 ---";echo {};echo "--- type ---";git cat-file -t {};echo "--- content ---";git cat-file -p {};\'
--- SHA-1 ---
2f1f63b009307da4ed7e965940a2eb843235b3a9
--- type ---
blob
--- content ---
file_2 content
--- SHA-1 ---
b176602ea4b9de59db75aeb527bcda8507bd68ea
--- type ---
blob
--- content ---
file_1 content

我们继续。

执行 git commit 命令。

$ git commit -m "1st commit"
[master (root-commit) e558897] 1st commit
 2 files changed, 2 insertions(+)
 create mode 100644 dir_1/dir_1_1/file_1
 create mode 100644 dir_1/dir_1_1/file_2

然后看一下 .git 文件夹结构:

$ tree .git
.git
├── COMMIT_EDITMSG
├── HEAD
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   ├── push-to-checkout.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── 2f
│   │   └── 1f63b009307da4ed7e965940a2eb843235b3a9
│   ├── 9b
│   │   └── 94e6dd70770e5d2427c5ac0173a15cc1368d60
│   ├── b1
│   │   └── 76602ea4b9de59db75aeb527bcda8507bd68ea
│   ├── b2
│   │   └── bff4ca9b8c7a200a3974c80ff36bc56633d339
│   ├── df
│   │   └── 31a2a5d12715d61bb07cb45e342220a410f3ef
│   ├── e5
│   │   └── 5889713caa8aa9f7dd91f1c9d3572b87229afa
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    └── tags

17 directories, 28 files

会发现又有了新的变化:

  • .git/objects 中多了四个新文件;
  • .git/refs/heads 中多了一个文件;

.git/refs/heads 我们等会再说,先来看看 .git/objects 中的新文件:

$ find .git/objects -type f | cut -d \'/\' -f 3,4 | tr -d \'/\' | xargs -L 1 -I {} sh -c \'echo "--- SHA-1 ---";echo {};echo "--- type ---";git cat-file -t {};echo "--- content ---";git cat-file -p {};\'
--- SHA-1 ---
9b94e6dd70770e5d2427c5ac0173a15cc1368d60
--- type ---
tree
--- content ---
100644 blob b176602ea4b9de59db75aeb527bcda8507bd68ea    file_1
100644 blob 2f1f63b009307da4ed7e965940a2eb843235b3a9    file_2
--- SHA-1 ---
b2bff4ca9b8c7a200a3974c80ff36bc56633d339
--- type ---
tree
--- content ---
040000 tree df31a2a5d12715d61bb07cb45e342220a410f3ef    dir_1
--- SHA-1 ---
df31a2a5d12715d61bb07cb45e342220a410f3ef
--- type ---
tree
--- content ---
040000 tree 9b94e6dd70770e5d2427c5ac0173a15cc1368d60    dir_1_1
--- SHA-1 ---
e55889713caa8aa9f7dd91f1c9d3572b87229afa
--- type ---
commit
--- content ---
tree b2bff4ca9b8c7a200a3974c80ff36bc56633d339
author Lin Zhang <zhanglin3@foxmail.com> 1613634462 +0800
committer Lin Zhang <zhanglin3@foxmail.com> 1613634462 +0800

1st commit
--- SHA-1 ---
2f1f63b009307da4ed7e965940a2eb843235b3a9
--- type ---
blob
--- content ---
file_2 content
--- SHA-1 ---
b176602ea4b9de59db75aeb527bcda8507bd68ea
--- type ---
blob
--- content ---
file_1 content

我们会发现我们刚刚介绍的 tree object 和 commit object 出现了。仔细观察这两类 Git Object 的内容,会发现它们在引用其他的 Git Object,Git 正是依靠这些引用来实现版本控制的。

Git References

现在让我们回到刚刚被跳过的 .git/refs/heads 中多出来的文件 master。查看 master 文件的内容:

$ cat .git/refs/heads/master
e55889713caa8aa9f7dd91f1c9d3572b87229afa

其实现在 .git/HEAD 文件的内容也发生了变化:

$ cat .git/HEAD
ref: refs/heads/master

.git/refs 目录下有很多这类含有 SHA-1 值的文件。这就是 Git 分支/Tag 的本质:一个指向某一系列提交之首的指针或引用。

  • .git/refs/heads 文件夹中的文件名与分支名一一对应;
  • .git/refs/tags 文件夹中的文件名与 tag 名一一对应;

而 .git/HEAD 文件是 “HEAD 引用”的实现,“通常指向目前所在分支”的说法也在这里得到了验证。

我们继续。

建立轻量标签:

$ git tag v0.1

查看 .git/refs 文件夹会发现多了一个文件。

$ tree .git/refs
.git/refs
├── heads
│   └── master
└── tags
    └── v0.1

其内容是最新的一次 commit object 的 SHA-1 值,轻量标签只是一个简单的引用。

$ cat .git/refs/tags/v0.1
e55889713caa8aa9f7dd91f1c9d3572b87229afa

再建立附注标签:

$ git tag -a v0.2 -m "version v0.2"

查看其内容,并不是最近一次 commit object 的 SHA-1 值,是一个新值。

$ cat .git/refs/tags/v0.2
21855c2aebdd3971d153b33738f0c78f903efd8a

查看 .git/objects 文件夹,会发现多了一个 Git Object:

$ tree .git/objects
.git/objects
├── 21
│   └── 855c2aebdd3971d153b33738f0c78f903efd8a
├── 2f
│   └── 1f63b009307da4ed7e965940a2eb843235b3a9
├── 9b
│   └── 94e6dd70770e5d2427c5ac0173a15cc1368d60
├── b1
│   └── 76602ea4b9de59db75aeb527bcda8507bd68ea
├── b2
│   └── bff4ca9b8c7a200a3974c80ff36bc56633d339
├── df
│   └── 31a2a5d12715d61bb07cb45e342220a410f3ef
├── e5
│   └── 5889713caa8aa9f7dd91f1c9d3572b87229afa
├── info
└── pack

查看新 Git Object,会发现它是一个 tag object(标签对象),这也是前面提到的最后一种类型的 git object。

$ echo 21855c2aebdd3971d153b33738f0c78f903efd8a | xargs -L 1 -I {} sh -c \'echo "--- SHA-1 ---";echo {};echo "--- type ---";git cat-file -t {};echo "--- content ---";git cat-file -p {};\'
--- SHA-1 ---
21855c2aebdd3971d153b33738f0c78f903efd8a
--- type ---
tag
--- content ---
object e55889713caa8aa9f7dd91f1c9d3572b87229afa
type commit
tag v0.2
tagger Lin Zhang <zhanglin3@foxmail.com> 1613636382 +0800

version v0.2

我们继续。

给仓库添加远程引用:

$ git remote add origin git@gitee.com:zhanglin5/my_project.git

这时候查看 .git/config 文件,会发现 remote 的配置多出了 [remote "origin"] 这一部分。

$ cat .git/config
[core]
    repositoryformatversion = 0
    filemode = true
    bare = false
    logallrefupdates = true
    ignorecase = true
    precomposeunicode = true
[remote "origin"]
    url = git@gitee.com:zhanglin5/my_project.git
    fetch = +refs/heads/*:refs/remotes/origin/*

将本地的 master 分支 push 到远程:

$ git push -u origin master

此时,查看 .git 文件夹的结构:

$ tree .git
.git
├── COMMIT_EDITMSG
├── HEAD
├── TAG_EDITMSG
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   ├── push-to-checkout.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│       ├── heads
│       │   └── master
│       └── remotes
│           └── origin
│               └── master
├── objects
│   ├── 21
│   │   └── 855c2aebdd3971d153b33738f0c78f903efd8a
│   ├── 2f
│   │   └── 1f63b009307da4ed7e965940a2eb843235b3a9
│   ├── 9b
│   │   └── 94e6dd70770e5d2427c5ac0173a15cc1368d60
│   ├── b1
│   │   └── 76602ea4b9de59db75aeb527bcda8507bd68ea
│   ├── b2
│   │   └── bff4ca9b8c7a200a3974c80ff36bc56633d339
│   ├── df
│   │   └── 31a2a5d12715d61bb07cb45e342220a410f3ef
│   ├── e5
│   │   └── 5889713caa8aa9f7dd91f1c9d3572b87229afa
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    ├── remotes
    │   └── origin
    │       └── master
    └── tags
        ├── v0.1
        └── v0.2

22 directories, 34 files

会发现 logs 文件夹和 refs 文件夹下都出现了相关的文件,我们先关注 refs 文件夹。

我们看到的 .git/refs/remotes/origin/master 就是远程引用(remote reference)。 如果添加了一个远程版本库并对其执行过推送操作,Git 会记录下最近一次推送操作时每一个分支所对应的值,并保存在 refs/remotes 目录下。

远程引用和分支(位于 refs/heads 目录下的引用)之间最主要的区别在于,远程引用是只读的。 虽然可以 git checkout 到某个远程引用,但是 Git 并不会将 HEAD 引用指向该远程引用。因此,你永远不能通过 commit 命令来更新远程引用。 Git 将这些远程引用作为记录远程服务器上各分支最后已知位置状态的书签来管理。

git log 和 git reflog

git log 是显示当前的HEAD和它的祖先的,递归是沿着当前指针的父亲,父亲的父亲,这样的原则。

git reflog 根本不遍历 HEAD 的祖先。它是 HEAD 所指向的一个顺序的提交列表:它的 undo 历史。reflog 并不是 repo(仓库)的一部分,它单独存储,而且不包含在 pushes、fetches 或者 clones 里面,它纯属是本地的。

git reflog 可以很好地帮助你恢复你误操作的数据,例如你错误地 reset 了一个旧的提交,或者 rebase 等操作,这个时候你可以使用 git reflog 去查看在误操作之前的信息,并且使用 git reset --hard 去恢复之前的状态。

What’s the difference between git reflog and log?:https://stackoverflow.com/questions/17857723/whats-the-difference-between-git-reflog-and-log

git reflog 存储在 .git/logs 文件夹中。

Packfiles

其实到这里,相信大家可能会有一些疑问,我们每次添加、修改文件都会生成一个新的 blob object。如果我们对一个非常大的文件做少量修改,那么新的 blob object 和老的 blob object 会有大量重复,岂不是浪费了空间?

其实确实会是这样,Git 最初想磁盘中存储对象时所使用的格式被称为“松散(loose)”对象格式,但是,Git 会时不时地将多个这些对象打包成一个称为“包文件(packfile)”的二进制文件,以节省空间和提高效率。 当版本库中有太多的松散对象,或者你手动执行 git gc 命令,或者你向远程服务器执行推送时,Git 都会这样做。

关于 pack 文件如何节约空间的,这里就不展开了,可以参照这篇文档:https://git-scm.com/book/zh/v2/Git-%E5%86%85%E9%83%A8%E5%8E%9F%E7%90%86-%E5%8C%85%E6%96%87%E4%BB%B6

这里有一点需要注意一下,执行 git gc 命令后,会生成一个 .git/packed-refs 文件,里面包含了 .git/logs/refs 里的内容。关于这个文件可以参考:https://cloud.tencent.com/dev...

The Refspec

请移步:https://git-scm.com/book/zh/v2/Git-%E5%86%85%E9%83%A8%E5%8E%9F%E7%90%86-%E5%BC%95%E7%94%A8%E8%A7%84%E8%8C%83

参考

  • 一文讲透 Git 底层数据结构和原理:https://zhuanlan.zhihu.com/p/142289703
  • Unpacking Git packfiles:https://codewords.recurse.com/issues/three/unpacking-git-packfiles
  • git pack-format:https://git-scm.com/docs/pack-format
  • Useful Git Commands:https://dev.to/lydiahallie/cs-visualized-useful-git-commands-37p1
  • 图解 Git:https://marklodato.github.io/visual-git-guide/index-zh-cn.html
  • Git Magic:http://www-cs-students.stanford.edu/~blynn/gitmagic/

以上是关于Git 内部原理的主要内容,如果未能解决你的问题,请参考以下文章

Git内部原理之深入解析传输协议

Git 内部原理

Git内部原理探索

git内部原理

VSCode自定义代码片段——git命令操作一个完整流程

git基础教程(44) 了解git内部原理(中)