• 本文适合人群:具备 Git 基本使用经验的软件开发工程师、测试工程师、运维工程师等
  • 本文学习目的:理解 Git 中最基本原理部分,以求触类旁通,在遇到突发问题时具备自行分析能力
  • 建议阅读时间:30 分钟

一、基本构成

Git 由 对象库(Object store) 和 索引(index) 构成。

1.1 对象库

主要包含 4 种类型原子对象 :块(blob)、目录树(tree)、提交(commit)、标签(tag)。

块:文件的每一个版本表示为一个块,保存文件数据,但不包含任何关于这个文件的元数据,连文件名都没有

blob: ”binary large object” 二进制大对象

目录树:代表一层目录信息,记录 blob 标识符 / 路径名 / 和目录中所有文件的一些元数据,也可以递归引用其他目录树和子树对象,本质上为树形数据结构存储

提交:保存版本库中每一次变化的元数据,包括作者 / 提交者 / 提交日期和日志信息.
每个提交对象指向一个目录树对象,这个目录树对象在一张完整的快照中捕获提交时版本库的状态

标签:分配一个任意的且人类刻度的名字给一个特定对象

为有效地利用磁盘空间和网络带宽,Git 把对象压缩并存储在打包文件 (pack file) 里,这些文件也在对象库里。

1.2 索引

索引是一个临时的、动态的二进制文件,它描述整个版本库的目录结构。更具体地说,索引捕获项目在某个时刻的整体结构的一个版本。项目的状态可以用一个提交和一棵目录树表示,它可以来自项目历史中的任意时刻,或者它可以是你正在开发的未来状态。

Git 的关键特色之一就是它允许你用有条理的、定义好的步骤来改变索引的内容。索引使得开发的推进与提交的变更之间能够分离开来。

Git 通过目录树(tree)的对象来跟踪文件的路径名。当使用 git add 命令时,Git 会给添加的每个文件的内容创建一个对象,但它并不会马上为树创建一个对象。相反,只更新了索引。

索引位于.git/index 中,跟踪文件的路径名和相应的 blob
每次执行 git add /git rm /git mv 的时候,会使用新的路径名和 blob 信息来更新索引

查看当前索引中文件与 blob 关联的命令
git ls-files -s
捕获当前索引状态并保存到一个树对象的命令
git write-tree

1.3 可寻址内容名称

对象库中的每一个对象都有一个唯一的名称,由对象的内容应用 SHA1 得到的散列值。

SHA1 的值是一个 160 位的数,表示一个 40 位的十六进制数
在不同目录甚至不同机器中,对相同内容始终产生同样的 ID
Git 中 SHA1 别称:散列码、对象 ID
SHA1 是 “安全散列加密” 算法,直到现在没有任何已知方法可以刻意碰撞。
对于 160 位数,你有 2^160 或者大约 10^48 种可能,一万亿人每秒产生一万亿个新的唯一 blob 对象,持续一万亿年,也只有 10^43 个 blob 对象

1.4 Git 追踪内容

关键概念 1:Git 追踪的是内容,而不是文件次相关的文件名或者目录名。

  1. 如果两个文件内容完全一样只保存一份 blob 形式的内容副本

  2. 如果其中一个发生变化,会为它计算新的 SHA1 值并加到对象库中

关键概念 2:Git 内部数据库有效地存储每个文件的每个版本,而不是他们的差异。

因为 Git 使用一个文件的全部内容的散列值作为文件名,所以它必须对每个文件的完整副本进行操作
Git 不能将工作或者对象库条目建立在文件内容的一部分或者文件的两个版本之间差异上

问题:存储每个文件每个版本的完整内容是否太低效?

1.5 打包文件 (pack file)

定位内容相似全部文件,为他们之一存储整个内容,之后计算相似文件之间的差异并且只存储差异。
Git 还维护打包文件表示中每个完整文件(包括完整内容的文件和通过差异重建出来的文件)的原始 blob 的 SHA1 值,这给定位包内对象的索引机制提供了基础。

使用散列值把 blob / 目录树从对象库里提取出来的命令:
git cat-file -p xxx(SHA1)
通过对象唯一前缀来查找对象的散列值:
Git rev-parse xxxx (短前缀)

二、演示图示

2.1 创建仓库

shell
1
2
3
4
5
6
7

mkdir temp

cd temp

git init

2.2 创建一个新文件

shell
1
2
3
4
5
6
7

touch test.txt

echo "hello" > test.txt

cat test.txt // 输出hello

2.3 将新文件添加到索引

shell
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

git add test.txt

// 查看当前索引中文件与blob关联

git ls-files -s // 输出 100644 ce013625030ba8dba906f756967f9e9ca394464a 0 test.txt

// 查看此时git object目录

find .git/objects

/* 输出以下内容

.git/objects

.git/objects/pack

.git/objects/info

.git/objects/ce

.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a

*/

2.4 查看 blob 对象内容

shell
1
2
3

git cat-file -p ce013625030ba8dba906f756967f9e9ca394464a // 输出 hello

2.5 创建树对象

shell
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

git write-tree // 输出 920512d27e4df0c79ca4a929bc5d4254b3d05c4c

// 查看此时git object目录

find .git/objects

/* 输出以下内容

.git/objects

.git/objects/92

.git/objects/92/0512d27e4df0c79ca4a929bc5d4254b3d05c4c

.git/objects/pack

.git/objects/info

.git/objects/ce

.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a

*/

// 查看树对象内容

git cat-file -p 920512d27e4df0c79ca4a929bc5d4254b3d05c4c // 输出 100644 blob ce013625030ba8dba906f756967f9e9ca394464a test.txt

2.6 手动创建提交对象

shell
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45

echo -n "first commit" | git commit-tree 920512d27e4df0c79ca4a929bc5d4254b3d05c4c

// 输出 5d1b1de556bdd019f138052cc791de11027c37a2

// 查看提交对象内容

git cat-file -p 5d1b1de556bdd019f138052cc791de11027c37a2

/* 输出以下内容

tree 920512d27e4df0c79ca4a929bc5d4254b3d05c4c

author fordxiao <fordxiao@futunn.com> 1620303459 +0800

committer fordxiao <fordxiao@futunn.com> 1620303459 +0800

first commit

*/

// 查看此时git object目录

/* 输出以下内容

.git/objects

.git/objects/92

.git/objects/92/0512d27e4df0c79ca4a929bc5d4254b3d05c4c

.git/objects/pack

.git/objects/5d

.git/objects/5d/1b1de556bdd019f138052cc791de11027c37a2

.git/objects/info

.git/objects/ce

.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a

*/

2.7 将 master 分支 head 指向刚刚的 commit

shell
1
2
3
4
5
6
7
8
9
10
11
12
13

// 没有log生成,显示的错误原因是HEAD没有指定有效的版本

git log

// 更新master分支的head的值,指向我们的commit对象

git update-ref refs/heads/master 5d1b1de556bdd019f138052cc791de11027c37a2

// 此时就有log了

git log

综上整个过程,其实就是从 修改内容 -> 执行 git add -> 执行 git commit 的底层分解步骤,对象结构如下图:

三、日常实践常见问题(持续更新)

Q1 如何回滚单个或多个连续的 commit

shell
1
2
3
4
5
6
7
8
9

git revert <old commit>..<new commit>

// 举个栗子

git revert dfca3e328f

git revert c93eb7f88..dfca3e328f

Q2: feature 分支合并入主版本分支后,想回滚应该怎么操作

shell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

// 假设feature分支名为A,主版本分支(合并分支)名为master

// 首先保证当前处在主版本分支

git checkout master

/* 执行回滚

-m 之后可以是1或者2

1代表保留合并分支内容,即master分支

2代表保留被合并分支内容,即A分支

commit_id为合并产生的merge commit

下面操作将保留master分支的修改,而撤销A分支合并过来的修改

*/

git revert -m 1 <commit_id>

Q3: feature 分支想借助 ReviewBoard 发 CodeReview,应该注意哪些问题

ReviewBoard 脚本 postreview.py 中明确指出:

shell
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

postreview.py -s -r <review Id> {[<earlier commit Id>-]<commit Id>}|{<commit Id>,<commit Id>,...}

-s 表示把staged文件提交review

-r 表示要提交到一个已经publish的review里边来更新该review

<commit Id>可以是长Id也可以是短Id

postreview.py <commit Id>:

把<commit Id>对应的commit提交去review

例如: postreview b400c4e

postreview.py <earlier commit Id>-<commit Id>:

把从<earlier commit Id>到<commit Id>所对应的范围内的(连续的)所有commit都提交去review

例如:postreview.py b400c4e-91970bf

postreview.py <commit Id>,<commit Id>,...

把分立的多个commit提交到同一个review里边(这commitID的顺序需要注意下,最早的提交应该在最前)

例如 postreview.py 83d452d,03d6025,6e69a6a

commit id 必须是连续的,那么会发生常见无法正常发 CR 的情况:

  • 拉取 feature 分支后,提交若干 commit,反合入其他分支内容,继续提交若干个 commit,导致最终需要 CR 的 commit 不连续,无法正常使用 ReviewBoard

所以在需求开发过程中,我们应该尽量做到:

  1. 拉取 feature 分支后,尽量不再合入其他分支内容,保持提交树干净

  2. 力求做到每个 commit 原子性,忌讳 “一个 commit 中完成多项工作” 或 “一项工作分了多个 commit 进行”

针对特殊情况下如依赖因素导致的反合其他分支内容非抗力因素,建议在将 feature 分支合入 master/version 分支时,发起 MergeRequest, 这样在 gitlab 上依旧能生成对应的 diff content

Q4: 开发过程中被紧急问题突发打断,如何快速切换现场

一种常见的做法就是准备多个 group, 通过物理隔离的方法来做到多分支维护;然而有些时候可能在小规模变更产生之后,需要切换到另一条分支或者回到当前分支远程 Head 指针状态下进行其他操作,此时就需要暂存这些现场。
一般可能会执行 git commit 来暂存变更,但这样做有一个坏处,即 “破坏了 commit 原子性和完整性” 的原则,这个 commit 的内容是临时的,这对于后续的提交树维护是存在极大风险的。为此,更推荐采用 git stash 来进行缓存。

git stash 可以理解为一个栈,负责将当前索引区的变更产生一个临时 pack file,缓存到栈中,随后可以随时取出,遵循 FILO 原则。

shell
1
2
3
4
5
6
7
8
9
10
11
12
13

// 将当前索引区内容存入栈中

git stash push

// 获取当前栈列表

git stash list

// 将栈顶的内容还原到当前索引区

git stash pop

Q5: git pull 执行不顺畅时,如何快速清理现场

shell
1
2
3
4
5
6
7
8
9
10
11
12
13

// 清理索引区中所有的untracked files和目录(注意,此时未commit的新增文件将会被全部清理)

git clean -fd

// 放弃索引区中所有的tracked文件变更(注意,此时未commit的变更内容将会被全部清理)

git checkout .

// 重新拉取

git pull

Q6: 如何放弃本地所有内容,直接对齐对应远程分支内容

shell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

// 清理索引区中所有的untracked files和目录(注意,此时未commit的新增文件将会被全部清理)

git clean -fd

// 放弃索引区中所有的tracked文件变更(注意,此时未commit的变更内容将会被全部清理)

git checkout .

// 获取远程最新commit

git fetch

// 将本地分支head设置为远程head

git reset --hard origin/分支名