Git 的无实物表演

aka: Play like Git without Git

推荐先看 The Git Parable 这篇文章,它以从0到1的思路讲解 git 的设计思想。非常推荐。

Git 是什么

引用自 Git Internals

Git is fundamentally a content-addressable filesystem with a VCS user interface written on top of it.

在这里想做的事:

  1. 演示基本的 git 命令在背后做了什么
  2. 看看 .git 文件夹的内部结构
  3. .git 内各个文件的作用

为了演示第1点,我们 不使用 任何 git 命令来作写入操作,而是用 python 来模拟 git 的行为,仅用 git 的一些读命令来验证结果。

即:Git 的无实物表演

创建一个临时文件夹,表演会在这里开始

先看看当前 git 的版本号

不同版本的 git,在一些文件结构上有区别,比方说 index file format

git init

初始化一个 git 仓库。创建最小量的文件

  1. 空的 .git 文件夹
  2. 空的 .git/objects, .git/refs/heads 文件夹
  3. 写入 config 本地配置文件
  4. 写入 HEAD 文件,让其指向默认分支 master

来看看当前的目录结构,一个最简单的 git 仓库就已经初始化好了

当前在 master 分支,暂时还没有 commit 历史,同时 working tree 也是空的

表演舞台已经准备就绪

git add

开始我们的表演

先走一小步,写一个简单的 python 文件

目录里已经有这个文件了

看看当前的仓库状态

有一个没有被 追踪 的文件 hello.py

我们想要追踪这个 hello.py 文件,即模拟 git add 的行为,该怎么做呢?

git add 做了两件事:

Git: content-addressable file system

这个文件系统是一个简单的 key-value 存储

在文件内容上应用哈希函数 sha1 得到一个哈希值,该哈希值可以作为文件内容的唯一标识,亦可作为文件名

sha 系列函数有如下特点:

换言之,没有实际可行的办法,能够找到虽然内容不同,但哈希值却一样的文件

我们再来看看 value 是怎么一回事

git 将仓库内的所有内容存在 .git/objects/ 里,称之为 object database

.git/objects/ 里的内容分为几种类型:

所有的 object 会经过一次压缩后存盘

几种 object 有一个通用的结构体:

<ascii type without space> + <space> + <ascii decimal size> + <byte\0> + <binary object data>

模拟 git add hello.py 的行为,先做第一部分:写入 blob object

看看发生了什么

首先,blob_sha 的确对应了原始的文本内容,即通过 blob object 能完全复原原始文件

.git/objects 下多了一个文件,对应 blob_sha

当前仓库的状态仍然是有 untracked file: hello.py

因为我们只做了 git add 的第一步操作,还没有更新 index (staging area)

git 将 staging area 的信息存在 .git/index 文件里

该文件的结构如下 (ref: https://git-scm.com/docs/index-format/2.25.0)

  | 0           | 4            | 8           | C              |
  |-------------|--------------|-------------|----------------|
0 | DIRC        | Version      | entry count | ctime       ...| 0
  | ctime_ns    | mtime        | mtime_ns    | device         |
2 | inode       | mode         | UID         | GID            | 2
  | file size   | blob sha     | flags | variable path name ..|
4 | ...         | NULL padding | ... another entry ...     ...| 4
  | ...         | index sha1                                  |

hello.py 加到 staging area 里,更新 .git/index

看看发生了什么

.git 下多了一个 index 文件

当前仓库的状态也发生了改变,hello.py 已经 stage 了,能够进入下一阶段:commit

git commit

分为两个部分:

当前仓库根目录下只有 hello.py 文件,以此为目录结构创建 tree object

看看有哪些变化

通过 tree_sha,我们能完整复原仓库的根目录。然后通过每个文件对应的 sha,我们就能 递归 地构建出整个仓库的目录结构

同时,.git/objects 里也多了一个对应 tree_sha 的 object 文件

看一下第一个 tree 的图示

不过仓库的状态还是有「待提交的文件 hello.py」,那是因为我们还没有做第二部分操作:写入 commit object

因为是仓库的第一个提交,所以没有 parent_commit_sha

写上 commit message, 提交当前的仓库快照

只需要 tree sha 信息就够了,通过 tree sha,找到对应的 tree object,就能完整重建整个仓库内容

通过 commit_sha 来验证一下 commit object 已经写入成功

.git/objects 里又多了一个对应 commit_sha 的 object

但是, 当前仓库的状态仍然是有「待提交的文件」

看看 git log, 居然报错了, 为什么呢?

bookmark

git 通过一个特殊的 HEAD 文件来标识当前仓库所在的版本

HEAD 文件本身的内容是指向另一个文件的,这里的 HEAD 其实是个间接引用

我们把第一条 commit 的 commit sha 写入 HEAD 指向的文件中

再来看看仓库的状态

HEAD 指向的文件已经写入 .git/refs/heads 里了

仓库的状态也总算是「nothing to commit, working tree clean」了

同时,我们有了第一条 git log !

版本控制

加上一个 README 文件,模拟日常的 git 工作流

再来看看这棵树的图示

git checkout -b

模拟切换到一个新的分支: new-idea

.git/HEAD 文件存了当前分支的名字,通过间接引用,可以知道当前版本的 commit sha

master 分支的基础上创建分支 new-idea 只需要两步:

再来通过 git 命令检查一下当前的分支,发现已经切换成功,且 HEAD 同样指向 master 分支上的最新 commit

git merge

我们在新分支 new-idea 上做一些改动,然后将这些改动合并到 master 主分支上

再来检查一下仓库状态

和之前新建一个文件后的状态不同,hello.py 是已经被 git 追踪(tracked)的文件,所以这次修改之后,hello.py 的状态是「chnages not staged for commit」

再来看看 git diff 命令,在这里 diff 比较的对象是 working directory 里的 hello.py 和 staging area 里的 hello.py

我们准备把上面做的改动提交了

先来 git add hello.py, 这一步是把 working directory 里的 hello.py 暂存到 staging area 里。依然分为两步:

staging area 里现在有两个文件了:

再来看看仓库的状态

hello.py 已经暂存到 staging area 了,能够提交了

提交 hello.py 仍然分三步:

提交后,仓库的状态是干净的

同时,git log 里也显示分支 new-idea 上有了新的提交,而原先的 master 分支停留在第二个提交上

来看看当前仓库的结构

接下来,我们把 new-idea 上的改动合并到 master 分支上

因为 new-idea 上的改动是基于 master 分支的,而且 master 分支本身没有任何提交

体现在 git log 的 commit graph 上,就是一条 线性 的提交历史

此时,git merge 采用的策略是 fast forward,也是最简单的策略,即:

master 指向的 commit 同步成 new-idea 指向的 commit

所以我们只需要简单地拷贝 .git/refs/heads/new-idea 文件到 .git/refs/heads/master 文件就行

注意到这里的操作和上面 git checkout -b 新建分支的操作相反

再来看看 git log,发现 master 分支,new-idea 分支都在最新的 commit 上了

另一方面,当 git log 的 commit graph 出现分叉时,即要 merge 的两个分支有各自独立的提交时,采用的策略是 recursive.

按下不表了