深入理解和运用 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 object
、blob object
、tree object
。
这些不同类型的 Git 对象在写入 Git 后,都存储于仓库(下文将简称 repo)的 repo/.git/objects
目录下,详细介绍见下文。
二进制对象:blob object
可存储任意数据结构的内容,该类型的数据结构是 <type><size><file-content>
加上 zlib 压缩,详细过程请参阅 10.2 Git 内部原理 - Git 对象 中的 对象存储
章节。
我们提交的文件内容(不包括文件名)都是存储为这个类型,举例如下:
1 | git init |
一般来说 blob object
都是由我们添加到 Git 中的文件产生的,但也可以通过其他方式添加任意数据,比如使用底层命令 git hash-object
。
树对象:tree object
在 blob object
中只存储了文件内容,并没有存储文件名、权限、所在路径等信息,这些信息都存储于 tree object
中。
树对象的主要作用就是记录目录结构,目录中的文件则是通过存储它们二进制对象的 sha1 来进行索引,而目录中的文件夹则是另一个树对象,同样是通过 sha1 来进行索引。
一个根目录的树对象其实就可以算作某个修订版本的快照,可以通过它索引到对应的所有目录和文件。
举例如下:
1 | git init |
提交对象:commit object
commit 对象存储一次提交的信息,包括所在的树对象、提交的作者、父提交(如果有的话)等。
举例如下:
1 | git init |
从上面的例子可以看出来,每个 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 | git init |
Git 结合另一个文件 .git/HEAD
来知晓当前所在的引用或直接的 commit sha1,举例如下:
1 | cat .git/HEAD |
其他常见的 Git 引用还有 tag、remote、stash 等。
这里再稍微说明一下 remote 分支的概念。
我们克隆一个远端仓库,比如 git clone git@github.com:facebook/react.git
之后,本地中出现一个文件夹 react
,这个过程其实主要是下载了远端服务器上的 .git
目录下的内容到本地的 react/.git
中。
通过克隆来创建一个本地的 git 仓库,会默认设置这个仓库的远端中的 origin 为克隆目标地址,举例如下:
1 | git clone git@github.com:facebook/react.git |
克隆完成之后我们一般就可以在本地 react/.git/refs/remote/origin
看到这个远端(remote)origin 的分支,不过由于 react 这个仓库是 gc 过的,所以得在 .git/packed-refs
中查看,举例如下:
1 | cat .git/packed-refs |
这些引用并不会自动更新,所以当别人继续往远端仓库上提交内容时(比如提交一个新的 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 | git clone git@github.com:schacon/simplegit-progit.git |
其中 refspec 相关的就是 [remote "origin"]
下的 fetch
配置项,此时我们看一下 git remote
当前的输出:
1 | git remote -v |
从上面其实可以看出来,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
分支,你可以在本地通过下面任意一种方式来访问该分支上的提交记录:
- git log origin/master
- git log remotes/origin/master
- 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 | [remote "origin"] |
假设此时仓库中有一个 PR,id 为 1,我们进行 git fetch origin
,就会得到:
1 | * [new ref] refs/pull/1/head -> origin/pr/1/head |
一个 PR 会有两个 ref,这是比较常见的情况,pull/{id}/head
这个 ref 指向 PR 原始的 commit,pull/{id}/merge
这个 ref 指向 PR 合并到目标后(预览用,不是真的合并了)的 commit,如果合并会有冲突,会导致 merge
的 ref 不产生,只有 head
。
一般情况下是不需要 merge
这个 ref 的,所以我们可以改下配置:
1 | [remote "origin"] |
此时再次 git fetch origin
,会得到一个错误:
1 | error: cannot lock ref 'refs/remotes/origin/pr/1': 'refs/remotes/origin/pr/1/head' exists; cannot create 'refs/remotes/origin/pr/1' |
正常情况下 fetch 时发现 ref 不同应该是会自动更新的,但由于我们旧的配置实际上让 pr/1
是一个目录(承载了 head 和 merge 的 ref),所以无法自动更新 pr/1
,导致报错。
我们可以删除旧的 PR refs,然后重新执行 fetch:
1 | rm -rf .git/refs/remotes/origin/pr |
此时就可以从本地方便的查看 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 | git init |
现在来简单的说明下常见的 rev 解析规则
指向单个对象的 rev 解析规则
- 先尝试当作引用名称(refname)来解析,这里说明下 refname 解析的规则和顺序
- 查找是否有路径为
.git/refname
的 Git 引用文件,这通常只对 HEAD、FETCH_HEAD、ORIG_HEAD 等起作用,我们经常使用的HEAD
就是基于这条规则工作的 - 查找
refs/refname
是否存在 - 查找
refs/tags/refname
是否存在 - 查找
refs/heads/refname
是否存在 - 查找
refs/remotes/refname
是否存在,我们经常用的git checkout master
和git checkout origin/master
就是基于上条规则和这条规则工作的 - 查找
refs/remotes/<refname>/HEAD
是否存在,这条规则让你可以使用如git checkout origin
这样的方式来切换到远端的默认分支(一般是指向 master)
- 查找是否有路径为
@
,单独使用时相当于 HEAD 的别名,比如git reset --hard HEAD~1
可以简写为git reset --hard @~1
@{<n>}
,例如@{1}
,这会解析为本地的 reflog 中的第 n 条索引对应的 sha1@{<-n>}
,例如@{-1}
,这会解析为在当前分支 / 提交
之前签出的第 n 个分支 / 提交
[<branchname>]@{upstream}
,其中 branchname 可省略,默认为当前分支,@{upstream}
可以简写为@{u}
,例如:-
master@{upstream}
、master@{u}
,这会解析为 master 分支对应的上游分支 @{u}
,这会解析为当前分支对应的上游分支
-
<rev>~[<n>]
,例如HEAD~1
,这会解析为指定修订版本的第 n 个祖先提交<rev>^[<n>]
,例如HEAD^1
,这会解析为指定修订版本的第 n 个父对象- 注意,该规则与
~
的区别是,^
不是链式的向上获取,而是取目标 commit 上的多个父对象中的某一个。 - 比较常见的具有多个父对象的 commit 是 merge commit。
- 注意,该规则与
:/regex
,例如:/^first
,这条规则会在版本库内的所有 commit 中对比是否有 commit 备注符合指定正则,如果有则取出其中修订时间最近的一个:/!-regex
,例如:/!-first
,与上条规则相反,这样会匹配到不符合正则的目标<rev>^{:/regex}
、<rev>^{:/!-regex}
,这是上面两条规则的约束版本,只会在给定的 rev 可到达的链中查找<rev>:<path>
,例如master:1.txt
,这会解析到指定修订版本中对应路径的树对象或二进制对象- 该规则还支持使用相对路径,命令会自动转换为完整路径,例如
master:./
、master:../
等
- 该规则还支持使用相对路径,命令会自动转换为完整路径,例如
<rev>^{commit}
、<rev>^{tree}
、<rev>^{object}
等
指向范围的 rev 解析规则
- 单独的
<rev>
作为范围,表明从rev
可到达的所有提交 - 提交排除,语法为
^<rev>
,例如git log HEAD ^HEAD~3
,这样就是给定了 HEAD 的前 3 个 commit 到当前 HEAD commit 的范围 - 两点语法,语法为
<rev>..<rev>
,可以认为是提交排除的语法糖,例如HEAD ^HEAD~3
可以写为HEAD~3..HEAD
,可读性更强一些- 注意两个 rev 放在前后的位置是有实际影响的,
HEAD~3..HEAD
是从HEAD~3
到达HEAD
的范围,如果反过来写HEAD..HEAD~3
,则会得到空的结果集,因为无法到达 - 两个 rev 可以有一个省略,省略后自动为 HEAD,例如
HEAD~3..
等同于HEAD~3..HEAD
- 注意两个 rev 放在前后的位置是有实际影响的,
上述规则并不是全部的,如果希望详细了解,或者没有看明白,可以参考原文 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"
…
参考文章
除了文章中提到的一些链接地址外,本文章的编写还主要参考了以下文章: