文章

Git底层原理详解

Git底层原理详解

一. 前言

在一开始接触Git与Github时,我对Git的一些操作十分疑惑。作为一名学生,我常用的操作无非以下几种:

1
2
3
4
5
6
7
8
9
10
# 创建仓库
git init  
# 克隆仓库
git clone [repository url]
# 将修改内容保存到暂存区
git add [file path]
# 提交到本地仓库
git commit -m "commit message"
# 推送到远端仓库
git push [remote branch name] [local branch name] 

在创建仓库阶段,git init会创建一个.git文件夹。那我就很疑惑了,.git文件夹是用来干什么的?里面保存了些什么?这值得我们探究
在提交修改阶段,新手的我只会照葫芦画瓢,用最简单的方法:

1
2
3
4
5
6
# 将所有修改内容写入暂存区
git add .
# 提交到本地仓库
git commit -m "commit message"
# 推送到远端仓库
git push origin main

It looks like magic! 在终端上打上几行,到Github上看,wow,真的把修改保存并推送上去了。🙌😭🙌 Amazing!
对于一般人来讲,了解这些也许就够用。但我想,这对学计算机的人来说远远不够。毕竟,“计算机中没有魔法”。所以,就让我们详细探究一下git的底层原理,理解这个堪称伟大的版本控制系统。

二. 初探

让我们先来看看这个神秘的.git文件夹中保存着什么:

1. 准备

首先,我们先新建一个git仓库,并切换到该文件夹

1
2
3
4
5
6
7
// Terminal 1
╭─ ~ ────────────────────────────────────────────────────────────── 10:37:50 ─╮
╰─❯ git init git-demo                                                        ─╯
已初始化空的 Git 仓库于 /Users/apple/git-demo/.git/

╭─ ~ ────────────────────────────────────────────────────────────── 17:02:24 ─╮
╰─❯ cd git-demo

此时里面并没有任何东西,除了一个隐藏文件夹git(对于Mac系统来说)。我们可以用git status命令来查看当前git文件夹的状态

1
2
3
4
5
6
7
8
9
10
11
// Terminal 1
╭─ ~/git-demo main ──────────────────────────────────────────────── 17:03:46 ─╮
╰─❯ ls                                                                       ─╯

╭─ ~/git-demo main ──────────────────────────────────────────────── 17:05:01 ─╮
╰─❯ git status                                                               ─╯
位于分支 main

尚无提交

无文件要提交(创建/拷贝文件并使用 "git add" 建立跟踪)

现在我们开一个新的终端并切换到git-demo目录,通过watchtree工具来查看.git文件夹下保存了哪些内容。
我们需要安装treewatch工具,tree用于用树状结构展现文件夹的内容,watch用于监视git内容的变化。

1
2
brew install tree
brew install watch

然后,运行

1
2
3
// Terminal 2
# 0.5秒刷新一次
watch -n .5 tree .git

会显示这样的界面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Terminal 2
Every 0.5s: 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
│   ├── sendemail-validate.sample
│   └── update.sample
├── info
│   └── exclude
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

9 directories, 18 files

(该操作后,Terminal 2 用于显示信息,Terminal 1 用于输入操作) 我们可以看到.git文件夹下有很多部分组成。目前我们先关注objects部分。为了方便观察与说明,我们先去掉hooks

1
rm -r .git/hooks

显示为

1
2
3
4
5
6
7
8
9
10
11
12
.git
├── HEAD
├── config
├── description
├── info
│   └── exclude
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

2. 加入文件

现在我们往仓库中加入文件。

1
2
╭─ ~/git-demo main ──────────────────────────────────────────────── 22:07:21 ─╮
╰─❯ vim 1.txt

并在1.txt中写入hello world。我们发现.git文件夹并没有发生任何的改变。这也是合理的,我们只是在项目中加入了一个文件,还没有执行git的任何命令。现在,让我们把1.txt加入暂存区,正如我们经常做的那样:

1
2
3
╭─ ~/git-demo main ?1 ───────────────────────────────────────── 28s 22:14:54 ─╮
╰─❯ git add 1.txt                                                            ─╯
警告:在 '1.txt' 的工作拷贝中,下次 Git 接触时 LF 将被 CRLF 替换

.git文件夹变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.git
├── HEAD
├── config
├── description
├── index
├── info
│   └── exclude
├── objects
│   ├── 3b
│   │   └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

我们可以看到.git/objects文件夹下多了一个内容。那这一长串又是什么?这就涉及到git的底层设计哲学了。
在文件保存到.git文件夹中前,git会对你的文件的所有数据进行哈希处理,生成一个SHA-1的哈希值,这个哈希值就是你此次提交数据的唯一标识符。在我们的例子中,git根据1.txt的内容hello world生成了一个哈希值2e3c1c7a3faf540c6490fab43ac83bdfa17400eb用于标识该文件。我们还可以通过git hash-object命令来查看该文件的哈希值:

1
2
3
4
╭─ ~/git-demo main +1 ───────────────────────────────────────────── 22:19:58 ─╮
╰─❯ git hash-object -w 1.txt                                                 ─╯
警告:在 '1.txt' 的工作拷贝中,下次 Git 接触时 LF 将被 CRLF 替换
3b18e512dba79e4c8300dd08aeb37f8e728b8dad

可以看到,显示的哈希值与我们在.git/objects中看到的哈希值是一样的。
根据内容生成标识符有什么好处呢?首先,也是最重要的一点,它能时刻保证数据的完整性。当文件内容发生任何变化,无论该变化有多微小,哈希值都会发生非常大的改变,也就是说,git能时刻监控文件的变化。当文件在传输时变得不完整,数据损毁、缺失,git也能通过哈希值来检测到。其次,相同的文件内容会生成相同的哈希值,这就意味着git能通过哈希值来判断文件是否重复,也可以节省存储空间。比如我们在git中加入了一个文件2.txt,内容与1.txt完全相同:

1
2
vim 2.txt
git add 2.txt

我们发现git并没有任何变化。说明git并没有重复存储相同的文件。而当我们加入一个文件3.txt,内容为hello world!时,

1
2
vim 3.txt
git add 3.txt

显示为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.git
├── HEAD
├── config
├── description
├── index
├── info
│   └── exclude
├── objects
│   ├── 3b
│   │   └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
│   ├── a0
│   │   └── 423896973644771497bdc03eb99d5281615b51
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

我们可以看到,git又生成了一个新的哈希值a0423896973644771497bdc03eb99d5281615b51
通过哈希值这个唯一标识符,我们也可以访问到对应的文件内容。我们可以通过git cat-file命令来查看:

1
2
3
4
5
6
7
╭─ ~/git-demo main +2 ───────────────────────────────────────────── 22:57:02 ─╮
╰─❯ git cat-file -p 3b18                                                     ─╯
hello world

╭─ ~/git-demo main +3 ───────────────────────────────────────────── 23:11:13 ─╮
╰─❯ git cat-file -p a042                                                     ─╯
hello world!

git中,文件的内容是以blob的形式存储的。blobgit中最基本的对象类型之一,表示一个二进制大对象(Binary Large Object)。它可以存储任何类型的数据,包括文本、图片、音频等。每个哈希值对应一个git对象,我们也可以通过git cat-file命令来查看该对象的类型:

1
2
3
╭─ ~/git-demo main ────────────────────────────────────────────────── 19:28:04 ─╮
╰─❯ git cat-file -t 3b18                                                       ─╯
blob

在之后我们会遇到git的其他对象类型。

3. 提交

现在让我们试着提交一个文件。来看看.git文件夹会发生什么变化。

1
2
3
4
5
6
7
╭─ ~/git-demo main +3 ─────────────────────────────────────────────── 23:12:45 ─╮
╰─❯ git commit -m "first commit"                                               ─╯
[main(根提交) 757ba86] first commit
 3 files changed, 3 insertions(+)
 create mode 100644 1.txt
 create mode 100644 2.txt
 create mode 100644 3.txt

显示为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.git
├── COMMIT_EDITMSG
├── HEAD
├── config
├── description
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── main
├── objects
│   ├── 1e
│   │   └── f3e0cbf75b1f9063d5bf22a027cd35c3b34ae7
│   ├── 3b
│   │   └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
│   ├── 75
│   │   └── 7ba86751bfdf19169210b0bc8c9fa7ca208f07
│   ├── a0
│   │   └── 423896973644771497bdc03eb99d5281615b51
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── main
    └── tags

可以看到,.git文件夹下多了一个COMMIT_EDITMSG文件和一个logs文件夹。同时,objects文件夹下多了两个内容。 让我们使用git cat-file命令来查看一下多出来的两条哈希值。

1
2
3
4
5
6
7
8
9
╭─ ~/git-demo main ────────────────────────────────────────────────── 19:23:44 ─╮
╰─❯ git cat-file -p 1ef3                                                       ─╯
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	1.txt
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	2.txt
100644 blob a0423896973644771497bdc03eb99d5281615b51	3.txt

╭─ ~/git-demo main ────────────────────────────────────────────────── 19:33:47 ─╮
╰─❯ git cat-file -t 1ef3                                                       ─╯
tree

可以看到,1ef3是一个tree对象。在里面保存了三个blob对象,分别对应1.txt2.txt3.txt,也就是我们这次提交的三个文件。
我们再来看看757ba8这个哈希值:

1
2
3
4
5
6
7
8
9
10
11
╭─ ~/git-demo main ────────────────────────────────────────────────── 19:35:22 ─╮
╰─❯ git cat-file -p 757b                                                       ─╯
tree 1ef3e0cbf75b1f9063d5bf22a027cd35c3b34ae7
author zxsheather <zxsheather@sjtu.edu.cn> 1746876224 +0800
committer zxsheather <zxsheather@sjtu.edu.cn> 1746876224 +0800

first commit

╭─ ~/git-demo main ────────────────────────────────────────────────── 19:38:43 ─╮
╰─❯ git cat-file -t 757b                                                       ─╯
commit

可以看到,757b是一个commit对象。它包含了一个指向tree对象的指针,指向了我们刚才提交的文件。它还包含了作者的姓名,邮件等,都是配置git时填好的。所谓的1746876224是一个Unix时间戳,表示自1970年1月1日以来的秒数。+0800表示时区偏移量,这里是中国的标准时区,东八区。

本文由作者按照 CC BY 4.0 进行授权