深入理解和运用 Git

深入理解和运用 Git

Git 本质上只是一个版本管理的工具,但当我们更了解这个工具的底层实现时,我们可以更好的去使用它并理解它的行为。

在工作中经常会遇到一些小伙伴对 Git 不够熟悉,比如不知道如何规避冲突,处理不可避免的冲突等。

本文适合有一定 Git 基础的读者阅读,我会在其中分享一些我对 Git 的了解和使用经验,并默认读者已经了解了基础的概念,如 add、commit、pull、push、remote、fetch 等。

Git 对象

该章节主要是简略的讲述下 10.2 Git 内部原理 - Git 对象 中的内容。

Git 的核心部分是一个简单的键值对数据库(key-value data store),你可以向 Git 仓库中插入任意类型的内容,它会返回一个唯一的键(sha1),通过该键可以在任意时刻再次取回该内容。

Git 的 commit、我们存储的文件、文件夹都是不同类型的 Git 对象,分别对应着 commit objectblob objecttree object

这些不同类型的 Git 对象在写入 Git 后,都存储于仓库(下文将简称 repo)的 repo/.git/objects 目录下,详细介绍见下文。


二进制对象:blob object

可存储任意数据结构的内容,该类型的数据结构是 <type><size><file-content> 加上 zlib 压缩,详细过程请参阅 10.2 Git 内部原理 - Git 对象 中的 对象存储 章节。

我们提交的文件内容(不包括文件名)都是存储为这个类型,举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git init
$ echo "test content" > 1.txt
$ git add 1.txt
$ git ls-files --stage
100644 d670460b4b4aece5915caf5c68d12f560a9fe3e4 0 1.txt
$ echo "下面这个命令 cat-file 是用来查看任意类型的 object 的数据的,-t 查询类型,-p 查看内容"
$ git cat-file -t d670460b4b4aece5915caf5c68d12f560a9fe3e4
blob
$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content
$ tree -F .git/objects
.git/objects
├── d6/
│ └── 70460b4b4aece5915caf5c68d12f560a9fe3e4
├── info/
└── pack/

一般来说 blob object 都是由我们添加到 Git 中的文件产生的,但也可以通过其他方式添加任意数据,比如使用底层命令 git hash-object


树对象:tree object

blob object 中只存储了文件内容,并没有存储文件名、权限、所在路径等信息,这些信息都存储于 tree object 中。

树对象的主要作用就是记录目录结构,目录中的文件则是通过存储它们二进制对象的 sha1 来进行索引,而目录中的文件夹则是另一个树对象,同样是通过 sha1 来进行索引。

一个根目录的树对象其实就可以算作某个修订版本的快照,可以通过它索引到对应的所有目录和文件。

举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git init
$ echo "111" > 1.txt
$ echo "222" > 2.txt
$ mkdir folder1
$ echo "333" > ./folder1/3.txt
$ git add .
$ echo "下面将调用一个底层命令 write-tree 来根据暂存区的内容生成一个树对象并返回它的 sha1"
$ git write-tree
14fe271f5837ca2da1a064ebfb3b21f210265d5d
$ git cat-file -t 14fe271f5837ca2da1a064ebfb3b21f210265d5d
tree
$ git cat-file -p 14fe271f5837ca2da1a064ebfb3b21f210265d5d
100644 blob 58c9bdf9d017fcd178dc8c073cbfcbb7ff240d6c 1.txt
100644 blob c200906efd24ec5e783bee7f23b5d7c941b0c12c 2.txt
040000 tree 10f33fa41b8750c0678ef94e2d1be8ac1a666b21 folder1
$ git cat-file -p 10f33fa41b8750c0678ef94e2d1be8ac1a666b21
100644 blob 55bd0ac4c42e46cd751eb7405e12a35e61425550 3.txt

提交对象:commit object

commit 对象存储一次提交的信息,包括所在的树对象、提交的作者、父提交(如果有的话)等。

举例如下:

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
$ git init
$ echo "111" > 1.txt
$ echo "222" > 2.txt
$ mkdir folder1
$ echo "333" > ./folder1/3.txt
$ git add .
$ echo "注意,即使树中的内容一致,但由于提交的时间、作者、备注等信息不同,我们会得到不同的提交对象 sha1"
$ git commit -m "first"
[master (root-commit) c461493] first
3 files changed, 3 insertions(+)
create mode 100644 1.txt
create mode 100644 2.txt
create mode 100644 folder1/3.txt
$ git cat-file -t c461493
commit
$ git cat-file -p c461493
tree 14fe271f5837ca2da1a064ebfb3b21f210265d5d
author WhiteMind <WhiteMind@qq.com> 1639826911 +0800
committer WhiteMind <WhiteMind@qq.com> 1639826911 +0800

first
$ echo "1" > 1.txt
$ git add .
$ git commit -m "second"
[master ba89643] second
1 file changed, 1 insertion(+), 1 deletion(-)
$ git cat-file -p ba89643
tree 81057fe813265e1eafa21ee283834f9d8ecf8138
parent c461493e98798a6ea74a0a02baf84c29da6d02c6
author WhiteMind <WhiteMind@qq.com> 1639827414 +0800
committer WhiteMind <WhiteMind@qq.com> 1639827414 +0800

second
$ git cat-file -p 81057fe813265e1eafa21ee283834f9d8ecf8138
100644 blob d00491fd7e5bb6fa28c517a0bb32b8b506539d4d 1.txt
100644 blob c200906efd24ec5e783bee7f23b5d7c941b0c12c 2.txt
040000 tree 10f33fa41b8750c0678ef94e2d1be8ac1a666b21 folder1
$ git cat-file -p d00491fd7e5bb6fa28c517a0bb32b8b506539d4d
1

从上面的例子可以看出来,每个 commit 都存储了一个修订版本的完整快照和信息而不是只存储了差异值,所以不依赖上一级的 commit。

而提供父级 commit 的 sha1 主要是为了让这些提交形成一个链式的结构,以此来方便理解数据变化的路径(从 a 快照变化到了 b 快照之类的)。


Git 分区与索引文件

Git 定义了三个分区:工作区(workspace)、暂存区(index)、版本库(commit history)。

这些分区对应了修订内容时的三个阶段,修订 --> 暂存 --> 提交


我们平时对文件的修订,都是在工作区进行的,工作区的内容就是 repo/ 目录下除了 .git/* 之外的所有文件,这些文件都是通过 git checkout 命令从版本库中检出得到的。

实际上我们克隆一个仓库时,也是只克隆了远端的 .git/ 目录下的文件,然后克隆完成后再检出一个默认分支(一般为 master)的文件到工作区中,可以通过 git clone 时带上 --no-checkout 参数来禁用子的自动检出。

简单举例如 git clone repo/.git repo2 这样就是把 repo/.git 当作一个远端仓库来克隆到 repo2 ,了解更多可以查看 4.2 服务器上的 Git - 在服务器上搭建 Git


我们单纯在工作区中对文件进行修订,并不会影响到 .git 中的数据,直到我们通过 git add 命令将工作区的修订放入暂存区。

暂存区并不是一个实体的文件夹,数据实际上是存储于 .git/index 索引文件中,所以也称为索引区。

索引文件中不会存储整个树对象,而是存储了提交到索引区的文件修订信息,这些信息中包括文件创建时间、修改时间、文件权限、所属用户、所属用户组、文件大小、文件内容的二进制对象 sha1、假定不变标识(用于实现 Git 的 assume-unchanged 特性)等,了解详细的数据结构可以查看 深入Git索引(一):索引文件结构篇


Git 引用

该章节主要是简略的讲述下 10.3 Git 内部原理 - Git 引用 中的内容。

现在我们知道可以通过一个 commit 不停的向前链式查找其对应的历史提交,但如果我们每次都使用 commit 的 sha1 来进行切换,会不太方便,所以有了分支。

分支就是一种常见的 Git 引用(references,或简写为 refs),它的数据存储在 .git/refs/heads 目录下。

Git 引用的数据都存储在 .git/refs 目录下,当 Git 运行 gc 之后可能会存储到 .git/packed-refs 中。

举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git init
$ echo "111" > 1.txt
$ git add .
$ git commit -m "first"
$ tree -F .git/refs
.git/refs
├── heads/
│   └── master
└── tags/
$ cat .git/refs/heads/master
a56f85af2a343980437386ccf17e2f6852bde871
$ git cat-file -p a56f85af2a343980437386ccf17e2f6852bde871
tree 58736bb5bad915b7619ddc90e0043fe3a7bc967b
author WhiteMind <WhiteMind@qq.com> 1639901755 +0800
committer WhiteMind <WhiteMind@qq.com> 1639901818 +0800

first

Git 结合另一个文件 .git/HEAD 来知晓当前所在的引用或直接的 commit sha1,举例如下:

1
2
3
4
5
6
$ cat .git/HEAD
ref: refs/heads/master
$ git checkout a56f85af2a343980437386ccf17e2f6852bde871
$ echo "注意,这里我省略了一些输出,当 checkout 的目标不是一个引用,而是一个具体的 commit sha1 时,Git 会对你做出一些提示,告知你当前是 detached HEAD(分离头)状态,并说明它与检出到引用时的区别"
$ cat .git/HEAD
a56f85af2a343980437386ccf17e2f6852bde871

其他常见的 Git 引用还有 tag、remote、stash 等。


这里再稍微说明一下 remote 分支的概念。

我们克隆一个远端仓库,比如 git clone git@github.com:facebook/react.git 之后,本地中出现一个文件夹 react ,这个过程其实主要是下载了远端服务器上的 .git 目录下的内容到本地的 react/.git 中。

通过克隆来创建一个本地的 git 仓库,会默认设置这个仓库的远端中的 origin 为克隆目标地址,举例如下:

1
2
3
4
$ git clone git@github.com:facebook/react.git
$ git remote -v
origin git@github.com:facebook/react.git (fetch)
origin git@github.com:facebook/react.git (push)

克隆完成之后我们一般就可以在本地 react/.git/refs/remote/origin 看到这个远端(remote)origin 的分支,不过由于 react 这个仓库是 gc 过的,所以得在 .git/packed-refs 中查看,举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
d5b409002b425d2242da0d9bacfad68278856400 refs/remotes/origin/0.10-stable
f6fcf385c9fd0df2ad666bc7765c127bd5e1528d refs/remotes/origin/0.11-stable
0fc0871c89963ecaeb1476b82156c2399e765e8e refs/remotes/origin/0.12-stable
76c87da026bdab63b5b109e3c073a1db74896ed6 refs/remotes/origin/0.13-stable
...
4337c1c00609ec8d7ae399c736e9d37bb159fac5 refs/tags/0.14.10
97bc4b5f87656a34139e1a8122866c8c5b432598 refs/tags/1.2.5
^a450a23e318c5a8fcba5a52c8fdc2e23584650b3
71264720050572b7bad24532ff39951f47d9296a refs/tags/15.3.1
7dfd3948a9095f0253bfba60fed52895ffbf84bb refs/tags/15.3.2
...

这些引用并不会自动更新,所以当别人继续往远端仓库上提交内容时(比如提交一个新的 commit 到分支 master 上),你本地的 .git/refs/remote/origin 中的内容并不会变化,直到你调用 git fetch

git fetch 命令会重新获取远端仓库的分支引用,更新到本地的 .git/refs/remote/origin 中。

git pull 默认情况下会自动先调用一次 git fetch,然后 git merge 当前本地分支对应的远端分支在 .git/refs/remote/origin 中的引用,所以 pull 其实相当于 fetch + merge。


Git 引用规范(refspec)

该章节主要是简略的讲述下 10.5 Git 内部原理 - 引用规范 中的内容。

fetch 远端的引用到本地,并不是只能固定分配到指定的 refs/remote/remoteName/ 路径,可以使用 git config 命令来配置,或者直接编辑 .git/config 文件。

我们先看下正常情况下,克隆后的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ git clone git@github.com:schacon/simplegit-progit.git
Cloning into 'simplegit-progit'...
remote: Enumerating objects: 13, done.
remote: Total 13 (delta 0), reused 0 (delta 0), pack-reused 13
Receiving objects: 100% (13/13), done.
Resolving deltas: 100% (3/3), done.
$ cd simplegit-progit
$ cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = git@github.com:schacon/simplegit-progit.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master

其中 refspec 相关的就是 [remote "origin"] 下的 fetch 配置项,此时我们看一下 git remote 当前的输出:

1
2
3
$ git remote -v
origin git@github.com:schacon/simplegit-progit.git (fetch)
origin git@github.com:schacon/simplegit-progit.git (push)

从上面其实可以看出来,Git 对于同一个远端的 fetch(拉取)和 push(推送)两个操作是可以配置不同的目标地址的。

目前这两个地址(fetch / push)是一样的,对应着 [remote "origin"] 下的 url 配置项(下文以 origin.url 指代)。

origin.url 其实是 fetch 的地址,push 地址的配置项则是 origin.pushurl 在为空的情况下,默认与 origin.url 一致。

上面说的两个 url 都只是从哪里拉取、往哪里推,并没有配置拉取的时候拉哪些数据、存到哪,这个就是 origin.fetch 配置项要做的事情。

origin.fetch 这个配置项的值,就是一个描述 refspec 的字符串,它的规则如下,引用自 10.5 Git 内部原理 - 引用规范

引用规范的格式由一个可选的 + 号和紧随其后的 <src>:<dst> 组成, 其中 <src> 是一个模式(pattern),代表远程版本库中的引用; <dst> 是本地跟踪的远程引用的位置。 + 号告诉 Git 即使在不能快进的情况下也要(强制)更新引用。

默认情况下,引用规范由 git remote add origin 命令自动生成, Git 获取服务器中 refs/heads/ 下面的所有引用,并将它写入到本地的 refs/remotes/origin/ 中。 所以,如果服务器上有一个 master 分支,你可以在本地通过下面任意一种方式来访问该分支上的提交记录:

  1. git log origin/master
  2. git log remotes/origin/master
  3. git log refs/remotes/origin/master

上面的三个命令作用相同,因为 Git 会把它们都扩展成 refs/remotes/origin/master。

所以我们当前例子中的 +refs/heads/*:refs/remotes/origin/* 会在我们 git fetch origin 时,将远端 origin 仓库下的 refs/heads/* 拉取到我们本地仓库下的 refs/remotes/origin/*,如 refs/remotes/origin/master,然后可以简写为 origin/master,简写的规则在下一个章节 Git 修订版本 中讲述。


实际应用

举个比较常见的 refspec 应用场景,GitHub 平台对于 PullRequest(下文简称 PR)的 ref 是存放在仓库的 refs/pull 下,我们想要拉取下来,就可以加一行:

1
2
3
4
[remote "origin"]
url = git@github.com:schacon/simplegit-progit.git
fetch = +refs/heads/*:refs/remotes/origin/*
fetch = +refs/pull/*:refs/remotes/origin/pr/*

假设此时仓库中有一个 PR,id 为 1,我们进行 git fetch origin,就会得到:

1
2
* [new ref]               refs/pull/1/head   -> origin/pr/1/head
* [new ref] refs/pull/1/merge -> origin/pr/1/merge

一个 PR 会有两个 ref,这是比较常见的情况,pull/{id}/head 这个 ref 指向 PR 原始的 commit,pull/{id}/merge 这个 ref 指向 PR 合并到目标后(预览用,不是真的合并了)的 commit,如果合并会有冲突,会导致 merge 的 ref 不产生,只有 head

一般情况下是不需要 merge 这个 ref 的,所以我们可以改下配置:

1
2
3
4
[remote "origin"]
url = git@github.com:schacon/simplegit-progit.git
fetch = +refs/heads/*:refs/remotes/origin/*
fetch = +refs/pull/*/head:refs/remotes/origin/pr/*

此时再次 git fetch origin,会得到一个错误:

1
2
error: cannot lock ref 'refs/remotes/origin/pr/1': 'refs/remotes/origin/pr/1/head' exists; cannot create 'refs/remotes/origin/pr/1'
! [new ref] refs/pull/1/head -> origin/pr/1 (unable to update local ref)

正常情况下 fetch 时发现 ref 不同应该是会自动更新的,但由于我们旧的配置实际上让 pr/1 是一个目录(承载了 head 和 merge 的 ref),所以无法自动更新 pr/1,导致报错。

我们可以删除旧的 PR refs,然后重新执行 fetch:

1
2
3
4
5
6
$ rm -rf .git/refs/remotes/origin/pr
$ rm -rf .git/logs/refs/remotes/origin/pr
$ git fetch origin
* [new ref] refs/pull/1/head -> origin/pr/1
$ git checkout origin/pr/1
Note: switching to 'origin/pr/1'.

此时就可以从本地方便的查看 PR 改动了,注意各个平台对于 PR 的 ref 存放位置可能不太一样,比如 bitbucket 就是 refs/pull-requests,并且 bitbucket 拉取后的两个 ref 名称是 from / merge,而不是 head / merge。


Git 修订版本

很多的 Git 命令的参数,都会让你指定一个修订版本(revision,或简写为 rev),可以是 commit sha1,也可以是 Git 引用,然后也有更方便的一些修订版本别名和修订版本范围选择语法,这是本章节主要说明的内容,来自于 gitrevisions

当你指定一个修订版本时,Git 会通过约定好的方式去解析出实际对应的 sha1,我们可以通过 git rev-parse 命令来显示的查看 rev 对应的 sha1,举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ git init
$ echo "111" > 1.txt
$ git add .
$ git commit -m "first"
[master (root-commit) 4af9e69] first
$ git rev-parse 4af9e69
4af9e692f66825aa517ce4c98fb06a9979f38a55
$ git rev-parse master
4af9e692f66825aa517ce4c98fb06a9979f38a55
$ git rev-parse HEAD
4af9e692f66825aa517ce4c98fb06a9979f38a55
$ echo "222" > 2.txt
$ git add .
$ git commit -m "second"
[master bc249e8] second
$ git rev-parse master
bc249e8c042db16ea7392bf207b732eacd3cfc97
$ git rev-parse HEAD
bc249e8c042db16ea7392bf207b732eacd3cfc97
$ git rev-parse HEAD~1
4af9e692f66825aa517ce4c98fb06a9979f38a55

现在来简单的说明下常见的 rev 解析规则

指向单个对象的 rev 解析规则

  1. 先尝试当作引用名称(refname)来解析,这里说明下 refname 解析的规则和顺序
    1. 查找是否有路径为 .git/refname 的 Git 引用文件,这通常只对 HEAD、FETCH_HEAD、ORIG_HEAD 等起作用,我们经常使用的 HEAD 就是基于这条规则工作的
    2. 查找 refs/refname 是否存在
    3. 查找 refs/tags/refname 是否存在
    4. 查找 refs/heads/refname 是否存在
    5. 查找 refs/remotes/refname 是否存在,我们经常用的 git checkout mastergit checkout origin/master 就是基于上条规则和这条规则工作的
    6. 查找 refs/remotes/<refname>/HEAD 是否存在,这条规则让你可以使用如 git checkout origin 这样的方式来切换到远端的默认分支(一般是指向 master)
  2. @,单独使用时相当于 HEAD 的别名,比如 git reset --hard HEAD~1 可以简写为 git reset --hard @~1
  3. @{<n>},例如 @{1},这会解析为本地的 reflog 中的第 n 条索引对应的 sha1
  4. @{<-n>},例如 @{-1},这会解析为在当前 分支 / 提交 之前签出的第 n 个 分支 / 提交
  5. [<branchname>]@{upstream},其中 branchname 可省略,默认为当前分支, @{upstream} 可以简写为 @{u} ,例如:
    1. master@{upstream}master@{u},这会解析为 master 分支对应的上游分支
    2. @{u},这会解析为当前分支对应的上游分支
  6. <rev>~[<n>],例如 HEAD~1,这会解析为指定修订版本的第 n 个祖先提交
  7. <rev>^[<n>],例如 HEAD^1,这会解析为指定修订版本的第 n 个父对象
    1. 注意,该规则与 ~ 的区别是,^ 不是链式的向上获取,而是取目标 commit 上的多个父对象中的某一个。
    2. 比较常见的具有多个父对象的 commit 是 merge commit。
  8. :/regex ,例如 :/^first,这条规则会在版本库内的所有 commit 中对比是否有 commit 备注符合指定正则,如果有则取出其中修订时间最近的一个
  9. :/!-regex,例如 :/!-first,与上条规则相反,这样会匹配到不符合正则的目标
  10. <rev>^{:/regex}<rev>^{:/!-regex},这是上面两条规则的约束版本,只会在给定的 rev 可到达的链中查找
  11. <rev>:<path>,例如 master:1.txt,这会解析到指定修订版本中对应路径的树对象或二进制对象
    1. 该规则还支持使用相对路径,命令会自动转换为完整路径,例如 master:./master:../
  12. <rev>^{commit}<rev>^{tree}<rev>^{object}

指向范围的 rev 解析规则

  1. 单独的 <rev> 作为范围,表明从 rev 可到达的所有提交
  2. 提交排除,语法为 ^<rev>,例如 git log HEAD ^HEAD~3,这样就是给定了 HEAD 的前 3 个 commit 到当前 HEAD commit 的范围
  3. 两点语法,语法为 <rev>..<rev>,可以认为是提交排除的语法糖,例如 HEAD ^HEAD~3 可以写为 HEAD~3..HEAD,可读性更强一些
    1. 注意两个 rev 放在前后的位置是有实际影响的,HEAD~3..HEAD 是从 HEAD~3 到达 HEAD 的范围,如果反过来写 HEAD..HEAD~3,则会得到空的结果集,因为无法到达
    2. 两个 rev 可以有一个省略,省略后自动为 HEAD,例如 HEAD~3.. 等同于 HEAD~3..HEAD

上述规则并不是全部的,如果希望详细了解,或者没有看明白,可以参考原文 gitrevisions


未完待续

git 底层命令

git 友好命令的底层运作过程

git 处理冲突

git worktree

git reflog、git fsck、ws / vsc localhistory and undo

git range-diff

merged commit

rebase 过程 = checkout + merge

为什么 git 默认使用 ignoreCase,有什么影响

yarn.lock 自动处理

git rebase --strategy-option="rename-threshold=10"


参考文章

除了文章中提到的一些链接地址外,本文章的编写还主要参考了以下文章:

  1. https://segmentfault.com/a/1190000024529302
  2. https://iissnan.com/progit/html/zh/ch9_7.html
作者

WhiteMind

发布于

2021-12-11

更新于

2021-12-31

许可协议

评论