欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Git内部原理剖析

程序员文章站 2022-07-13 16:19:54
...

Git内部原理剖析

正文共:13110字 ,预计阅读时间:33分钟

1. 导读

1.1. 为什么写这篇文章

写这篇文章的本意有二:

  1. 工作安排原因,常有同事询问我一些关于 Git 的问题,总觉得自己解释的不够透彻,因此觉得有必要深入了解一下。

  2. 目前中文的 Git 教程往往本末倒置, 一味从版本管理工具的角度去堆砌命令 ,而没有把握住Git的本质,导致读者知道的命令愈多,愈觉得 Git 复杂不友好。

本文中,笔者会通过实例演示+原理解释的方式进行剖析,并提出一些平时我们不易察觉的问题。

1.2. Git产生的背景

Git 诞生于2005年,当时 Linux 内核开发者可以免费使用 BitKeeper 作为源码管理工具,但是其作者认为部分开发者对 BitKeeper 进行****有悖原则,因而收回了使用权限,在这种危机时刻, Linus 再一次将个人英雄主义发挥到了极致,以主导设计开发了 Git 。根据这段背景我们需要意识到这么几个问题:

  1. Git 最早用来解决 Linux 内核的开发,因而其功能特点都是面向这种 参与者庞大且分散的协作开发模式设计的,企业的小团队可能体会不到 Git 的真正强大之处 。

  2. Git 的设计者和最早的使用者都是 Linux 内核开发者,比起 GUI 界面, 他们更喜欢命令行,更认同 Unix 的设计哲学 ,因而对于习惯了 GUI 界面的开发者(前端、移动端等)Git会显得十分“笨拙”。

理解这些背景对于我们认识 Git 十分重要,举一个例子,我们希望看到所有代码分支的最后提交时间和提交者,这种功能是高度定制的,如果 GUI 工具没有提供,那我们便无能为力,但是 Git 可以,通过 灵活的参数和 Linux 强大的工具集( grep 、 awk ) ,我们可以自动封装出这个命令并使用:

gs_branch_last_commit() {
    git fetch --prune
    git for-each-ref --sort='-committerdate' --format="%(refname:short) %09 %(authorname) %09 %(committerdate:relative)"  \
        | grep  --line-buffered "origin" \
        | awk '{printf "%-50s%-25s%s %s %s\n",$1,$2,$3,$4,$5}'
}

最后需要强调的是, Git本意不是做一个版本管理工具,而是文件管理系统(Git is a content-addressable filesystem) ,正如Linux在早期的邮件中所述:

Git内部原理剖析

In many ways you can just see git as a filesystem - it’s content- addressable, and it has a notion of versioning, but I really really designed it coming at the problem from the viewpoint of a filesystemperson (hey, kernels is what I do), and I actually have absolutely zero interest in creating a traditional SCM system.


—Linus Torvalds

Git内部原理剖析

在读完本文后,相信读者能更深刻地理解这段话。

Git内部原理剖析

SCM(即 Software configuration management)是一种更广义的版本管理, Linus 更愿意直接将其解释为 Source Code Management。

Git内部原理剖析


1.3. SCM的三个问题

The Architecture of Open Source Applications (Volume 2) 中提到任何一个SCM软件都需要解决三个问题, 以保证软件在开发过程中任一时间的内容都可以被追溯,并使得不同开发者可以协作开发 。这三个问题是:

  1. 存储内容(Storing content)

  2. 追踪内容的变更(Tracking changes to the content (history including merge metadata))

  3. 向其他开发者分发内容及其变更(Distributing the content and history with collaborators)

Git也不例外,接下来本文将围绕这三个问题,并结合Git自身的一些特点,进行剖析。

2. 术语

由于读者可能对于Git的内部原理不甚熟悉,所以这里把专业词汇先列出来:

  • .git Directory & Working Directory: .git 目录是 Git 存储信息和操作信息的目录, Working Directory 是我们实际操作的目录。

  • Git Object: Git 对象,我们的文件、目录和提交记录都会以 Git Object 的格式存储在 .git 目录中。

  • Git Reference: Git 引用,我们的分支、远程分支、tag的索引都是已 Git Reference 的形式存储,本质是一个包含 SHA1 值的40个字符的16进制字符串。

  • SHA1: 所有的文件的内容都会通过该算法计算出其(其实还有一个header) SHA1值作为Git 对象的文件名(其实就是数据库中的Key)。

  • plumbing & porcelain: Git的命令分类方式,前者是晦涩的底层命令,直接操作文件,后者是面向版本管理的高级命令。

3. Git 对象

3.1. .git目录

当我们在某个目录下执行 git init 命令时,该目录就会成为 Git 的一个工作目录(Working Directory),而该目录下面的 .git 目录则是 工作目录的全部历史在 Git 内的表示 。
在开始Git内部原理的探索之旅前,我们有必要认识一下 .git 目录,我们在命令行或者 GUI 界面的各种操作,本质都是操作 .git 目录下的文件。现在我们通过 git init 创建一个仓库,并通过 tree 命令查看 .git 目录的结构:

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

9 directories, 15 files

各个目录/文件的作用如下

文件

  1. HEAD : 当前分支

  2. config : 仓库级的配置信息

  3. description : 只会被Git的网络程序(如Github)使用,无需关注

  4. index : 暂存区(staging area)

目录

  1. hooks : Git Hook 的示例脚本

  2. info/exclude : 保存了一些你不想在 .gitignore 中配置的忽略文件的信息

  3. objects : 所有工作目录的内容(content)会以 Git 对象(objects)的形式存放在这个目录

  4. refs : 存放分支、tag的信息

其中 objects refs 将是本文剖析的重点,也是Git的核心。

3.2. plumbing 和 porcelain

在Git中,存在两种命令: plumbing 和 porcelain ,前者将Git作为一个文件管理系统,所有的命令都十分的底层、抽象,例如上文使用的 git for-each-ref 就是一个 plumbing 命令;对应的,后者将Git作为一个版本管理系统,所有的命令都更加抽象和直白,例如我们常见的 git add / git commit / git push 三兄弟。本文将基于常用的 porcelain 命令进行剖析,以揭示其背后的真实操作,并在某些时刻使用一些 plumbing 命令,以帮助读者从文件的角度进行更深刻的理解。

3.3. Git对象类型

前面说过,Git的本质是一个 content-addressable filesystem (基于内容寻址的文件系统)
既然是一个文件管理系统,那么文件有哪些类型呢?一般来说有 目录和文件 。对于 Git 来说,还需要记录变更,这也可以认为是一种特殊的文件类型。最后,Git还提供了一种tag类型的文件,它为某次提交提供了一个永久索引,因为对于一个SCM系统来说,记录某次重大改动(如版本发布)是十分有必要的。
在Git中,可以用一个有向无环图来表示Git对象的组织方式:

Git内部原理剖析

所以Git对象一共有四种类型:

  1. blob : 表示一个内容(注意不是文件)

  2. tree : 表示一个目录

  3. commit : 表示一次提交

  4. tag : 比较特殊,即是引用也有对象(可以人为是分支在某一时刻的快照)

3.4. Git对象操作

那么这四种文件类型具体长什么样呢?这就不得不提Git的对象模型了,在Git中,所有的对象都会通过 zlib 压缩成一个文件名为其 SHA1 值的文件,为了更具体解释这句话,我们开始实验。首先为了方便后面的表示,我设计了一个命令,借助了两个 plumbing 命令: rev-list cat-file ,用来打印所有Git对象的内容:

print_all_object() {
    for object in `git rev-list --objects --all | cut -d ' ' -f 1`; do
        echo 'SHA1: '$object
        git cat-file -p $object
        echo '^'
    done
}

第一步,初始化一个仓库并添加文件(为了模拟真实场景,这里用了多级目录):

echo "first file" > first.txt
mkdir -p second/third
echo "second file" > second/second.txt
echo "third file" > second/third/third.txt
git add first.txt
git add second
git commit -m "first commit"
echo '~~~~'
tree
print_all_object

此时工作目录如下:

.
├── first.txt
└── second
    ├── second.txt
    └── third
        └── third.txt

2 directories, 3 files

Git对象的内容如下:

SHA1: b8a7759d225d7ca4952c57c9ba785a6692a075a9 #(1)
tree 12f38251f4b5858269b7b95b8b655c88bb4185d8
author vimerzhao <aaa@qq.com> 1575091820 +0800
committer vimerzhao <aaa@qq.com> 1575091820 +0800

first commit
^
SHA1: 12f38251f4b5858269b7b95b8b655c88bb4185d8 #(2)
100644 blob 303ff981c488b812b6215f7db7920dedb3b59d9a first.txt
040000 tree be554e60137e97e1e1e8e443552e0abd17db1450 second
^
SHA1: 303ff981c488b812b6215f7db7920dedb3b59d9a #(3)
first file
^
SHA1: be554e60137e97e1e1e8e443552e0abd17db1450 #(4)
100644 blob 1c59427adc4b205a270d8f810310394962e79a8b second.txt
040000 tree d2895a749b806d7647a9622c71a03e0e3eace8dc third
^
SHA1: 1c59427adc4b205a270d8f810310394962e79a8b #(5)
second file
^
SHA1: d2895a749b806d7647a9622c71a03e0e3eace8dc #(6)
100644 blob 667bb3858a056cc96e79c0c3b1edfb60135c2359 third.txt
^
SHA1: 667bb3858a056cc96e79c0c3b1edfb60135c2359 #(7)
third file
^
  1. commit 类型的 Git 对象,记录了 commit 指向的 tree 对象,以及提交者(author/committer)的信息

  2. tree 类型的 Git 对象,该对象包含一个名为 first.txt 的文件和一个名为 second 的目录

  3. blob 类型的 Git 对象,该对象的内容为 first file

  4. 同2

  5. 同3

  6. 同2

  7. 同3

可以看出, Git 的 commit 对象可以通过 SHA1 找到 tree 对象,tree 对象可以通过 SHA1 找到其他 tree 和 blob ,在Git中, SHA1就是Git对象的指针 。此外,Git 使用这种文本化的表示方式也符合Unix 一切皆文本的哲学。为了便于理解,上面的信息可以按照上文的模型画出对应的有向无环图(暂时省略了Git引用):

Git内部原理剖析

接下来我们修改一个文件,再次提交

echo "first file modified" > first.txt
git add first.txt
git commit -m "second commit"
echo '~~~~'
print_all_object

内容如下:

SHA1: 67f9d83a9ef370c057accf103e6502d3c8a56048
tree 7b1fc0ae095fcadbd565737c2a957bdbeb9c4ee3
parent b8a7759d225d7ca4952c57c9ba785a6692a075a9
author vimerzhao <aaa@qq.com> 1575091920 +0800
committer vimerzhao <aaa@qq.com> 1575091920 +0800

second commit
^
SHA1: b8a7759d225d7ca4952c57c9ba785a6692a075a9
tree 12f38251f4b5858269b7b95b8b655c88bb4185d8
author vimerzhao <aaa@qq.com> 1575091820 +0800
committer vimerzhao <aaa@qq.com> 1575091820 +0800

first commit
^
SHA1: 7b1fc0ae095fcadbd565737c2a957bdbeb9c4ee3
100644 blob 491a7bb2dd1a1e5ba9e00440ba9f7dd25fa17336 first.txt
040000 tree be554e60137e97e1e1e8e443552e0abd17db1450 second
^
SHA1: 491a7bb2dd1a1e5ba9e00440ba9f7dd25fa17336
first file modified
^
SHA1: be554e60137e97e1e1e8e443552e0abd17db1450
100644 blob 1c59427adc4b205a270d8f810310394962e79a8b second.txt
040000 tree d2895a749b806d7647a9622c71a03e0e3eace8dc third
^
SHA1: 1c59427adc4b205a270d8f810310394962e79a8b
second file
^
SHA1: d2895a749b806d7647a9622c71a03e0e3eace8dc
100644 blob 667bb3858a056cc96e79c0c3b1edfb60135c2359 third.txt
^
SHA1: 667bb3858a056cc96e79c0c3b1edfb60135c2359
third file
^
SHA1: 12f38251f4b5858269b7b95b8b655c88bb4185d8
100644 blob 303ff981c488b812b6215f7db7920dedb3b59d9a first.txt
040000 tree be554e60137e97e1e1e8e443552e0abd17db1450 second
^
SHA1: 303ff981c488b812b6215f7db7920dedb3b59d9a
first file
^

注意commit增加了一个parent字段,此时图变成了:

Git内部原理剖析

接下来我们再删除一个文件,再次提交

rm second/second.txt
git add second
git commit -m "third commit"
git tag -a v3 -m "tag third commit"
echo '~~~~'
print_all_object

内容如下:

SHA1: 78913509ce663ff1e58e47043b015283856779dc
tree 6ac7a28caaf73725fc3383b916447e839e3c2d50
parent 67f9d83a9ef370c057accf103e6502d3c8a56048
author vimerzhao <aaa@qq.com> 1575091990 +0800
committer vimerzhao <aaa@qq.com> 1575091990 +0800

third commit
^
SHA1: 67f9d83a9ef370c057accf103e6502d3c8a56048
tree 7b1fc0ae095fcadbd565737c2a957bdbeb9c4ee3
parent b8a7759d225d7ca4952c57c9ba785a6692a075a9
author vimerzhao <aaa@qq.com> 1575091920 +0800
committer vimerzhao <aaa@qq.com> 1575091920 +0800

second commit
^
SHA1: b8a7759d225d7ca4952c57c9ba785a6692a075a9
tree 12f38251f4b5858269b7b95b8b655c88bb4185d8
author vimerzhao <aaa@qq.com> 1575091820 +0800
committer vimerzhao <aaa@qq.com> 1575091820 +0800

first commit
^
SHA1: 825133937ceb744ad49a71883f70237a4a1dfc1d
object 78913509ce663ff1e58e47043b015283856779dc
type commit
tag v3
tagger vimerzhao <aaa@qq.com> 1575091990 +0800

tag third commit
^
SHA1: 6ac7a28caaf73725fc3383b916447e839e3c2d50
100644 blob 491a7bb2dd1a1e5ba9e00440ba9f7dd25fa17336 first.txt
040000 tree d4046dced1e51bbc931b845cfea1c529fec7256c second
^
SHA1: 491a7bb2dd1a1e5ba9e00440ba9f7dd25fa17336
first file modified
^
SHA1: d4046dced1e51bbc931b845cfea1c529fec7256c
040000 tree d2895a749b806d7647a9622c71a03e0e3eace8dc third
^
SHA1: d2895a749b806d7647a9622c71a03e0e3eace8dc
100644 blob 667bb3858a056cc96e79c0c3b1edfb60135c2359 third.txt
^
SHA1: 667bb3858a056cc96e79c0c3b1edfb60135c2359
third file
^
SHA1: 7b1fc0ae095fcadbd565737c2a957bdbeb9c4ee3
100644 blob 491a7bb2dd1a1e5ba9e00440ba9f7dd25fa17336 first.txt
040000 tree be554e60137e97e1e1e8e443552e0abd17db1450 second
^
SHA1: be554e60137e97e1e1e8e443552e0abd17db1450
100644 blob 1c59427adc4b205a270d8f810310394962e79a8b second.txt
040000 tree d2895a749b806d7647a9622c71a03e0e3eace8dc third
^
SHA1: 1c59427adc4b205a270d8f810310394962e79a8b
second file
^
SHA1: 12f38251f4b5858269b7b95b8b655c88bb4185d8
100644 blob 303ff981c488b812b6215f7db7920dedb3b59d9a first.txt
040000 tree be554e60137e97e1e1e8e443552e0abd17db1450 second
^
SHA1: 303ff981c488b812b6215f7db7920dedb3b59d9a
first file
^

注意多了一个tag类型的Git对象,指向第三次提交,此时图变成了:

Git内部原理剖析

以上,我们演示了Git在进行常见的增删改的时候,背后发生的事情。

4. Git 引用

Git 创建分支的成本及其低廉。

Git内部原理剖析

Creating a branch is nothing more than just writing 40 characters to a file.

—Linus Torvalds

Git内部原理剖析

4.1. 分支

对于上面的仓库,我们此时看一下 HEAD 文件和 refs 目录下的内容,然后创建两个新的分支并提交一些内容:

cat .git/HEAD
cat .git/refs/heads/master
git branch feature1
git checkout -b feature2 # 创建两个新的分支
echo "new feature2" > feature.txt
git add feature.txt && git commit -m "add feature"
git checkout master
cat .git/refs/heads/master # 查看每个分支指向的提交
cat .git/refs/heads/feature1
cat .git/refs/heads/feature2

得到输出

78913509ce663ff1e58e47043b015283856779dc
78913509ce663ff1e58e47043b015283856779dc
a5596a09a7e83f83bf713e81e7653fa652906090

(为了节省篇幅,后面不会贴出 print_all_object 的全部执行结果)
由此可以更新一下我们的有向无环图(为了突出重点,本章节不画出 Git 对象):

Git内部原理剖析

4.2. 合并

现在master分支做一次commit,执行一次merge

echo "anothre feature" > feature.txt
git add feature.txt && git commit -m "add another feature"
git merge feature2

解冲突,然后提交

vim feature.txt
git add feature.txt && git commit -m "handle conflict"
cat feature.txt
print_all_object

部分结果如下:

anothre feature
new feature2
SHA1: 9c6eb61ba181a070e06d8c5767ea3bdca5f40558
tree da90387f4018255a3a37ff2933a8810cf3eccc1f
parent 8a1105568902f643a60165f3aa8067e8f16b9ce0
parent a5596a09a7e83f83bf713e81e7653fa652906090
author vimerzhao <aaa@qq.com> 1575092878 +0800
committer vimerzhao <aaa@qq.com> 1575092946 +0800

handle conflict
^
SHA1: 8a1105568902f643a60165f3aa8067e8f16b9ce0
tree c1ba2f6bb8f4412460347c204714a580155a1180
parent 78913509ce663ff1e58e47043b015283856779dc
author vimerzhao <aaa@qq.com> 1575092846 +0800
committer vimerzhao <aaa@qq.com> 1575092846 +0800

add another feature
^
SHA1: a5596a09a7e83f83bf713e81e7653fa652906090
tree 715230bf7c2800c4e745151ce19859127ebbb4b0
parent 78913509ce663ff1e58e47043b015283856779dc
author vimerzhao <aaa@qq.com> 1575092660 +0800
committer vimerzhao <aaa@qq.com> 1575092660 +0800

add feature
^
SHA1: 78913509ce663ff1e58e47043b015283856779dc
tree 6ac7a28caaf73725fc3383b916447e839e3c2d50
parent 67f9d83a9ef370c057accf103e6502d3c8a56048
author vimerzhao <aaa@qq.com> 1575091990 +0800
committer vimerzhao <aaa@qq.com> 1575091990 +0800

third commit
^

注意,最后一次提交有两个 parent commit 。此时,有向无环图变为:

Git内部原理剖析

4.3. 变基

现在在分支feature1做一次提交,然后使用rebase的方式同步主干:

git checkout -b feature1
echo "add feature1" > feature1.txt
git add feature1.txt && git commit -m "add feature1"
git rebase master
cat .git/refs/heads/master
cat .git/refs/heads/feature1
cat .git/refs/heads/feature2
tree
print_all_object

部分输出为:

9c6eb61ba181a070e06d8c5767ea3bdca5f40558
ce72867669c6f5ece8f2aef71476a458e502485d
a5596a09a7e83f83bf713e81e7653fa652906090
.
├── feature.txt
├── feature1.txt
├── first.txt
└── second
    └── third
        └── third.txt

2 directories, 4 files
SHA1: ce72867669c6f5ece8f2aef71476a458e502485d
tree 9a004023f848b224cc79c7aa069738d24dfc8c27
parent 9c6eb61ba181a070e06d8c5767ea3bdca5f40558
author vimerzhao <aaa@qq.com> 1575093789 +0800
committer vimerzhao <aaa@qq.com> 1575093789 +0800

add feature1
^
SHA1: 9c6eb61ba181a070e06d8c5767ea3bdca5f40558
tree da90387f4018255a3a37ff2933a8810cf3eccc1f
parent 8a1105568902f643a60165f3aa8067e8f16b9ce0
parent a5596a09a7e83f83bf713e81e7653fa652906090
author vimerzhao <aaa@qq.com> 1575092878 +0800
committer vimerzhao <aaa@qq.com> 1575092946 +0800

handle conflict
^
SHA1: 8a1105568902f643a60165f3aa8067e8f16b9ce0
tree c1ba2f6bb8f4412460347c204714a580155a1180
parent 78913509ce663ff1e58e47043b015283856779dc
author vimerzhao <aaa@qq.com> 1575092846 +0800
committer vimerzhao <aaa@qq.com> 1575092846 +0800

add another feature
^
SHA1: a5596a09a7e83f83bf713e81e7653fa652906090
tree 715230bf7c2800c4e745151ce19859127ebbb4b0
parent 78913509ce663ff1e58e47043b015283856779dc
author vimerzhao <aaa@qq.com> 1575092660 +0800
committer vimerzhao <aaa@qq.com> 1575092660 +0800

add feature
^
SHA1: 78913509ce663ff1e58e47043b015283856779dc
tree 6ac7a28caaf73725fc3383b916447e839e3c2d50
parent 67f9d83a9ef370c057accf103e6502d3c8a56048
author vimerzhao <aaa@qq.com> 1575091990 +0800
committer vimerzhao <aaa@qq.com> 1575091990 +0800

third commit
^

需要注意的是,由于执行了rebase操作,commit ce7286 的 parent commit 并不是发生提交时的 commit(789135)了,而是master分支的最新commit(9c6eb6)此时有向无环图变为:

Git内部原理剖析

5. 更多问题

以上就是本文的核心内容,但是关于Git的细节远远不止于此,比如以下问题:

  • 为什么取SHA1的前两位作为目录,后38位作为文件名?

  • Git 为什么使用blob的形式存储内容?

  • plumbing 和 porcelain 命名的含义是什么

  • Git 如何提高存储效率(比如大文件A稍作修改,其实就会有另外一个及其相似的备份,十分浪费空间)?

  • Git如何处理分布式协作开发?

  • Git 在实际项目中的最佳实践是什么?

  • ETC

欢迎 star 本博客 https://github.com/vimerzhao/vimerzhao.github.io,关注后续更新。
也可以扫码关注笔者公众号:

Git内部原理剖析