对于已经上手 Git,有比较多使用经验的用户来说,想要进一步搞清楚其中细微的语义和细节并不容易。让我们以经典易混淆的 git revert、git restore 和 git reset 命令为例,对其进行辨析。假设我们找到了 Git 文档 对此的描述,简单翻译转述一下:
- git-revert(还原)命令的作用是进行新的提交,以还原另一个提交所造成的更改。
- git-restore(恢复)命令的作用是从索引或者提交中恢复文件到工作区。它不会更新分支。它也可以从提交中恢复文件到索引。
- git-reset(重置)命令的作用是更新分支,移动它的末梢来增减 commit。这个命令会改变提交历史。git-reset 也可以用来恢复索引。
读完了这个辨析,我们会对一些方面了解更加清晰,另一些方面更加一头雾水:
- 搞清楚了 git-revert 还原命令的语义
- 了解了高层次上,git-restore 恢复命令的语义:它是一个文件层面的命令,单方向从后端到前端同步文件;不改变分支、HEAD 等 “引用” 的指向、和提交历史的层面。但这个解释引入了 “提交”、”索引” 和 “工作区” 这组概念 – 为什么要从提交中恢复文件到索引?索引中有什么?
- git-reset 声称自己会更新分支、改变提交历史。但实操或者看别人实操的 working knowledge 告诉我们,运行
git reset --hard <commit-name>后,HEAD 会指向指定的提交(通常会进入 detached head state),会看到仓库中的文件树被更新到指定的提交中的状态,不会更新分支,也不会改变提交历史 – 这该如何解释?
于是我们意识到,需要深入了解 Git 基本概念、内部原理。然而,从资料的可获取性角度,资料往往强调或仅覆盖:常见命令怎么用、常见 Use-case 的工作流、常见问题的解决方法。搜索引擎把后者排在前面;项目文档也往往到了末尾才介绍后者。让我们访问 git-scm.com(Source Code Management)项目主页作为例子:
- 追踪 Reference 超链接,进入
git命令的文档,它的起始部分链接到了多个网页,包括入门用的 gittutorial,介绍常见工作流的 giteveryday,和更为深入的 Git User’s Manual。总体而言,只有 Git User’s Manual 的后半部分,即 “Git concepts”, “Low-level Git operations”, “Hacking Git” 章节触及到了我们关心的这些较为基础性的问题。 - 追踪 Learn 超链接,阅读 Pro Git Book,扫一眼目录,10 章里有 9 章侧重前者,仅第十章 “Git Internals” 解决后者。跳过前 9 章,直接阅读此章即可。
Git Internals 将 User-friendly Commands 称为 “Porcelain”,而将这一章着重讲授的 Low-level Commands 称为 “Plumbing”。该内容有如下自述:
We found that understanding this information was fundamentally important to appreciating how useful and powerful Git is, but others have argued to us that it can be confusing and unnecessarily complex for beginners.
对于新用户隐藏 Plumbing 的 complexity 和 confusion 或许是合理的。但 Porcelain 及其文档并非完美,新用户度过上手期,有一定的 working knowledge 之后,如果没能了解 Plumbing,名目繁多且与时俱进的 Porcelain 会造成更多 complexity 和 confusion。
从认知论的角度,对 Git 这一类 systems software 工具的掌握有两条线索:一是通过 Porcelain 上手使用它,获得 working knowledge,特别当你需要用它达成一些现实目标并计算你学习使用工具时间的投入产出比;二是系统性地学习它,这就离不开 Plumbing 了,这是一条需要较多前期投入来面对 complexity,但长远来看造成更少 confusion 的路。
Git 的对象存储结构
对应 Git Concepts 和 Git Internals – Git Objects 的内容
从胡乱猜测的角度,我们进入并考察 Git 项目中的 .git 文件夹,它拥有 refs/, objects/, logs/ 等文件夹,文本文件 HEAD、config 和二进制文件 index 等文件 – 单纯试图列出文件夹的内容、用文本编辑器检查文本文件,就能够对其作用以及 Git 如何工作猜出一个大概了。
让我们从对象数据库(The Object Database)开始,考察其结构。猜测对象数据库就对应 .git/objects/ 文件夹。总体而言,Git 中的对象通过其 hash 来命名/识别(具体而言,name = hash(header + object),参见 Hacking Git)。Git 定义了四种对象类型:
- blob object – blob 即 binary large object,它用来存储文件内容
- tree object – 它相当于文件系统中的 directory,是一个从文件名(字符串)到 blob object 或 tree object 的映射,也因此就用来表示文件夹及其中的(directory hierarchy)
- commit object – 它包含恰好一个 tree object,表示提交时仓库文件夹的状态,也指向一个或多个 parent commit object,形成一个有向无环图(DAG)代表提交历史
- tag object – 用来识别和签名其它 object
有诸多低层命令可以查看这些 object 的内容。最有趣的应该是 commit object:
$ git show -s --pretty=raw cb4045fc0e9dd353fc8befa8486ccc7dcdc08daf
commit cb4045fc0e9dd353fc8befa8486ccc7dcdc08daf
tree 50324102cb048e3b8ebbf45ff6aa48251b301502
parent bd1e37854601977f611f27e4c1c06173e45bce4f
author alice <email> 1783194048 -0700
committer bob <email> 1783194048 -0700
add a subdirectory
输出中有该提交对应的 tree object,以及 parent(若为此 commit 为 merge,则会有不只一个 parent)。
接着试着查看 tree:
$ git ls-tree 50324102cb048e3b8ebbf45ff6aa48251b301502
040000 tree aeb2151c096e2e4bd2fa959a29a89c2bfc3f114b subdir
100644 blob 13b681f39ffa9c11745507667a0b88b0fc167204 ux_audio_oss.c
$ git ls-tree aeb2151c096e2e4bd2fa959a29a89c2bfc3f114b
100644 blob 748a5e37a4ab0738312aea31374cafa94e78846e example.c
最后可以查看 blob,也就是打印出文件内容:
$ git show 748a5e37a4ab0738312aea31374cafa94e78846e
#include <stdio.h>
...
Git Internals – Git Objects 对此有更详尽的讲述,这里不多做转述了,从该文档页面复制一张图过来,它非常直观地展示了上述的对象存储结构:

这个架构的很自然的推论是:Git 并不存储 diff,Git 命令经常打印的 diff 是通过比较两个 commit 的文件树或者文件即时计算出来的。
a commit does not itself contain any information about what actually changed; all changes are calculated by comparing the contents of the tree referred to by this commit with the trees associated with its parents.
索引
上一章的对象存储结构好比是一个数据库或整个系统的后端。Git 在后端和用户在文件系统中实际见到的仓库文件夹(好比前端)之间设计了一个缓存(cache)或者索引(index),它在 Git 的早期版本中被称作 cache,在当下版本中被称为 index。
概念上,这个缓存大体上可以理解为对应整个项目文件夹的 tree object(不过有通过文件路径而非文件名来索引等区别),与其它代表项目顶级文件夹的 tree object 不同,它不与任何一个 commit 绑定。实际上,它对应 .git/objects/ 文件夹。
通常情况下,当你没有在仓库文件夹中手动作更改,那么 index 中的内容应该和当前 HEAD 指向的 commit 中的 tree object 内容相同:
$ git ls-files --stage
100644 748a5e37a4ab0738312aea31374cafa94e78846e 0 subdir/example.c
100644 13b681f39ffa9c11745507667a0b88b0fc167204 0 ux_audio_oss.c
可以试着手动操作一下,看看 Index 和 Object Database 的变化
索引也是引用
假设 commit 中的某个文件内容为版本0,我们编辑文件至版本1,运行 git add:
$ vim example.c # edit to v1
$ git add example.c
$ git fsck # no dangling objects
Checking object directories: 100% (256/256), done.
Checking objects: 100% (16/16), done.
$ git ls-files --stage
100644 f3d50b25e01d86e20d06567af98200446a1ca160 0 subdir/example.c
100644 13b681f39ffa9c11745507667a0b88b0fc167204 0 ux_audio_oss.c
$ git show f3d50b25e01d86e20d06567af98200446a1ca160
(content of v1)
v0 已经在 Object Database 中,被原本的 commit 引用;v1 也被添加到了 Object Database 中,因为索引要引用 v1 的文件内容。
当我们不提交,再次编辑文件至版本2,运行 git add:
$ vim example.c # edit again to v2
$ git add example.c
$ git fsck
Checking object directories: 100% (256/256), done.
Checking objects: 100% (16/16), done.
dangling blob f3d50b25e01d86e20d06567af98200446a1ca160
$ git ls-files --stage
100644 44e1caab7f667879ccea1e4c5edfa58c781cebcb 0 subdir/example.c
100644 13b681f39ffa9c11745507667a0b88b0fc167204 0 ux_audio_oss.c
$ git show 44e1caab7f667879ccea1e4c5edfa58c781cebcb
(content of v2)
我们发现索引中该文件已经不再指向版本 1 的 blob,被更新至指向版本 2 的 blob;而版本 1 的 blob object 因为没有引用而变成了 dangling blob。
由索引看 git reset 命令
至此,我们有足够的背景来看懂 git reset 的 mode 及其用例了。
写入工作区和索引:mode=hard
前面提到,运行 git reset --hard <commit-name> 之后,你的工作区文件夹会被更新;额外运行 git ls-files --stage 检查,你会发现你的索引也被更新了,匹配了目标commit 对应的仓库内容。
Undo add:不移动头,mode=mixed
$ edit (1)
$ git add frotz.c filfre.c
$ receive mail (2)
$ git reset (3)
$ git pull git://info.example.com/ nitfol (4)
这里 commit 默认为 HEAD,mode 默认为 mixed,即仅更新索引来匹配了 HEAD 中的仓库内容,但不更新工作区文件夹。
- 为什么更新索引?为了让索引和 HEAD 一致(没有 diff),可以在 HEAD 的基础上把远程分支 merge 进来。
- 为什么不更新工作区?因为我们在工作区编辑了源文件,我们不希望对它们的改动丢失。
Undo commit and redo:mode=soft
$ git commit ...
$ git reset --soft HEAD^ (1)
$ edit (2)
$ git commit -a -c ORIG_HEAD (3)
相当于 git commit --amend 的等价底层 API 实现。soft 模式的 reset 既不更新工作区,也不更新索引,只会改变 HEAD 指向的 commit。由于索引没变,所以就相当于上一次提交之前的状态,直接修改提交的内容然后提交,这里使用 -c 选项继承原来的 commit 的信息。
回看开头的问题
git-reset 声称自己会更新分支、改变提交历史,事实上并非如此 – 它的作用仅仅是设置 HEAD 或索引(Set HEAD or the index to a known state);它可以作为改变提交历史的一组命令中的一员。
我们既已知道了索引中有什么,而 “为什么要从提交中恢复文件到索引” 的问题也在 “Undo add” 的用例中得到了解释:从提交中恢复文件到索引可以让索引状态和已有提交保持一直,从而 merge;而之所以不恢复到工作区,是因为工作区中可能有我们想要保留、不被覆盖的编辑。
至此,我们可以问更本质的问题,即 Git 最初为什么要设计一个索引,如今还需不需要有索引 – 这就需要更深入的了解才能解答了。
- 为什么不把索引归并到工作区,即默认提交工作区的(全部)状态,而要在中间加入索引这个 staging area(暂存区)?
- 用户需求中,包含暂存区的功能吗?Git 内部的哪些方面依赖索引?是否需要将索引的概念作为 Git 提供的接口对外暴露?
对象存储、索引和工作区
对应 Low Level Git Operations 的内容
通过夹在中间的 “索引”,我们看清了 Git 的 对象存储 – 索引 – 工作区 这个三层结构。Low-level Git Operations 章节提供了这个结构的示意图,介绍了在相邻层次之间同步的底层 API。
+---------+ write-tree +-------+ update-index +---------+
|Object |<-[tree]------|Index |<-[filename]------|Working |
|Database | |(cache)| |Directory|
| |--read-tree-->| |--checkout-index->| |
+---------+ [tree] +-------+ [filename] +---------+
可以把 Git 对象存储结构的示意图嵌套在此三层结构示意图中:

Leave a Reply