细读 Git | 进阶

配图源自 Freepik

你对 Git 的认知,只停留在 git statusgit addgit commitgit pushgit pull 层面的简单操作吗?你知道 git mergegit rebase 的区别吗?如何选择呢?

一、File Status

在 Git 的工作目录下,每一个文件都不外乎两种状态: tracked(已跟踪)和 untracked(未跟踪)。

  • tracked:表示被纳入版本控制的文件,它又细分为 unmodified(未修改)、modified(已修改)、staged(已暂存)三种状态。
  • untracked:表示除了 tracked 之外的所有文件,它既没有快照记录,也没有被放入 staging area(暂存区)。

文件的状态变化周期,如下:

在当前工作目录下,可以通过 git status 命令查看所有(已跟踪和未跟踪)文件状态。如果你希望某些文件出现在 untracked 列表,可以将其添加至 .gitignore 文件中。在 GitHub 上提供了针对数十种项目及语言的 .gitignore 模板,详看这里。

二、Git Object

从根本上来讲 Git 是一个内容寻址(content-addressable)文件系统,核心部分是一个简单的键值对数据库(key-value data store)。当你向 Git 仓库插入任意类型的内容时,它会返回一个唯一的键(SHA-1),通过该键可以在任意时刻再次取回该内容。

在初始化 Git 仓库时,它会创建一个 .git 目录,该目录下包含了几乎所有 Git 存储和操作的东西。因此,如果你想备份或者复制一个版本库,只需把这个目录拷贝至另一处即可。

$ git init temp-repo
Initialized empty Git repository in /Users/frankie/Desktop/Web/Temp/temp-repo/.git/

$ cd temp-repo

$ tree .git -L 1
.git
├── HEAD         # 指向当前分支
├── config       # 包含项目特有的配置选项
├── description  # 仅供 GitWeb 程序使用,无需关心
├── hooks        # 包含客户端或服务端的钩子脚本(hook scripts)
├── index        # 保存暂存区信息(git init 时无此文件,尚待创建)
├── info         # 包含一个全局性排除(global exclude)文件,用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns)
├── objects      # 存储所有数据内容
└── refs         # 存储指向数据(分支、远程仓库和标签等)的提交对象的指针

我们需要了解下 Git 对象(Git Object),有以下几种类型:

  • blob object(数据对象)
  • tree object(树对象)
  • commit object(提交对象)
  • tag object(标签对象)

2.1 Blob Object

通过 Git 操作的数据都存放于 objects 目录下。在仓库初始化时,objects 目录及其 packinfo 两个子目录,内容均是空的。

$ find .git/objects
.git/objects
.git/objects/pack
.git/objects/info

$ find .git/objects -type f

接下来,我们利用底层命令(plumbing command) git hash-object 来演示一下 Git 存入和取出内容。

使用 git hash-object 创建一条新数据对象,并将它手动存入你的 Git 数据库(指 .git/objects 目录)中:

$ echo 'some text...' | git hash-object -w --stdin
2c3e89d43daa5761b247cbd1ae08e08ed8cd054d

其中 -w 表示除了返回「唯一键」之外,还要将该对象写入数据库中。--stdin 表示从标准输入读取内容。

此时,我们可以看到 objects 目录下,新增了 2c/3e89d43daa5761b247cbd1ae08e08ed8cd054d 文件。

$ tree .git/objects
.git/objects
├── 2c
│   └── 3e89d43daa5761b247cbd1ae08e08ed8cd054d
├── info
└── pack

3 directories, 1 file

前面 git hash-object 操作,返回了一个长度为 40 个字符的校验和。它是一个 SHA-1 哈希值,是将待存储的数据外加一个头部信息(header)一起做 SHA-1 校验运算而得的校验和。

在 Git 中,它将校验和的「前 2 个字符」作为子目录名称,「余下 38 个字符」用作文件名。

以上就是 Git 将内容存储到对象数据库的过程。如果想要取回数据,那么可以通过 git cat-file 命令取回。

$ git cat-file -p 2c3e89d4
some text...

关于 git cat-file 更多请看这里。

$ git cat-file  
  -t  show object type
  -s  show object size
  -e  exit with zero when there's no error
  -p  pretty-print object's content
 
 

到这里,你对 Git 如何存入/取出数据应该有了初步的了解。

除此之外,我们可以将这些操作应用于文件的内容。比如,创建一个自述文件并将其内容存入数据库:

$ echo 'Some instructions...' > README.md

$ git hash-object -w README.md
9e486f6a40f2e45a8dd0835e6a0357d6f7f0db64

$ find .git/objects -type f
.git/objects/9e/486f6a40f2e45a8dd0835e6a0357d6f7f0db64
.git/objects/2c/3e89d43daa5761b247cbd1ae08e08ed8cd054d

我们可以再次修改 README.md 的内容,对象数据库中将会记录该文件的两个不同版本:

$ echo 'Some instructions...(V2)' > README.md

$ git hash-object -w README.md
e04f0c5c9d740ead52734ed920c580bf0f380ea2

$ git cat-file -p e04f0c5c
Some instructions...(V2)

$ find .git/objects -type f
.git/objects/9e/486f6a40f2e45a8dd0835e6a0357d6f7f0db64
.git/objects/e0/4f0c5c9d740ead52734ed920c580bf0f380ea2
.git/objects/2c/3e89d43daa5761b247cbd1ae08e08ed8cd054d

接着,我们模拟下「版本回退」,取回 README.md 的第一版本内容:

$ git cat-file -p 9e486f6a > README.md

$ cat README.md
Some instructions...

请注意,尽管以上操作进行了“版本回退”,但是 objects/e0/4f0c5c9d740ead52734ed920c580bf0f380ea2 文件并没有被删除。

记住文件的每一个版本所对应的 SHA-1 值并不现实,还有另外一个问题,上述操作仅在 objects 目录下保存了文件的内容,而文件名却没有被记录。通俗地将,我们通过 git cat-file -p 9e486f6a 只能获取对应的内容为 Some instructions...,但 Git 不知道它是 README.md 文件的内容。

上述类型的对象,我们称为数据对象(blob object)。可以通过 git cat-file -t 命令查到:

$ git cat-file -t 9e486f6a
blob

2.2 Tree Object

树对象(tree object),可以解决文件名保存的问题,也允许将多个文件组织到一起。

一个树对象包含了一条或多条树对象的记录(tree entry),每条记录含有一个指向数据对象(blob object)或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。

我们将上一节用到的本地 temp-repo 仓库清空,并重新初始化,另外创建三个文件,目录结构如下:

$ rm -rf .git

$ git init
Initialized empty Git repository in /Users/frankie/Desktop/Web/Temp/temp-repo/.git/

$ echo 'some instructions...(v1)' > README.md
$ echo 'node_modules' > .gitignore
$ mkdir 'src' && echo 'console.log("entry point")' > src/index.js

$ tree . -a
.
├── .git
│   └── ...
├── .gitignore
├── README.md
└── src
    └── index.js

接着,我们利用上层命令(porcelain command),将这些 unstracked 的文件,添加至暂存区并提交。

$ git add .

$ git commit -m 'first commit'
[main (root-commit) 10a28a5] first commit
 3 files changed, 3 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 README.md
 create mode 100644 src/index.js

当前对应的最新树对象是这样的:

$ git cat-file -p main^{tree}
100644 blob 3c3629e647f5ddf82548912e337bea9826b434af    .gitignore
100644 blob 6df930682ee921766a29e6504c27f79790a7bae6    README.md
040000 tree b79960a978f46909a0449add4b2ab35766883e6d    src

其中 main^{tree} 语法表示 main 分支上最新的提交所指向的树对象。 请注意,src 子目录(所对应的那条树对象记录)并不是一个数据对象,而是一个指针,其指向的是另一个树对象:

$ git cat-file -p b79960a9
100644 blob 064a5f9cffbc4df304f475a580dd961bdc2f7d38    index.js

$ git cat-file -t b79960a9
tree

此时,Git 内部存储的数据是类似这样的:

通常,Git 根据某一时刻暂存区(即 index 区域,.git/index 文件)所表示的状态创建并记录一个对应树对象,如此重复便可依次记录(某个时间段内)一系列的树对象。

接下来,我们先重置一下仓库,因为上面利用了上层命令暂存并提交了,免得干扰。下面将使用底层命令演示一下:

  1. 利用 git hash-object 来创建一个数据对象,并记录在 Git 数据库中:
$ echo 'readme v1' | git hash-object -w --stdin
504a7438c95afbd7f5280d756fb405bd85fbf19e
  1. 利用底层命令 git update-index 将这个数据对象加入暂存区,该文件为 README.md
$ git update-index --add --cacheinfo 10064 \
> 504a7438c95afbd7f5280d756fb405bd85fbf19e README.md

关于 git update-index 命令选项说明:

  • --add 是因为该文件并不在暂存区中,因此需要指定。
  • --cacheinfo 是因为添加的文件位于 Git 数据库中,而不是位于当前目录下。
  • 文件模式:10064 为文件模式,表示一个普通文件;10075 表示一个可执行文件;120000 表示一个符号链接。
  1. 通过 git write-tree 命令将暂存区内容写入一个树对象。可以使用 git cat-file -t 来验证一下它确实是树对象。
$ git write-tree
d50d689553de001d8537d94dae3cb2c89788dae1

$ git cat-file -p d50d689553de001d8537d94dae3cb2c89788dae1
100644 blob 504a7438c95afbd7f5280d756fb405bd85fbf19e    README.md

$ git cat-file -t d50d689553de001d8537d94dae3cb2c89788dae1
tree

执行 git write-tree 命令时,如果某个树对象此前并不存在的话,它会根据当前暂存区状态自动创建一个新的树对象。

  1. 接着,我们再创建一个新的树对象,它包含一个新文件 docs.md 以及 README.md 文件的第二个版本。
$ echo 'docs v1' > docs.md

$ git update-index --add docs.md

$ echo 'readme v2' | git hash-object -w --stdin
8d85786d2dc2fd2cad833d88bce5fca5d28a12fa

$ git update-index --add --cacheinfo 100644 \
> 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa README.md

到这来,目前暂存区包含了新文件 docs.mdREADME.md 的新版本。然后我们记录下这个目录数(将当前暂存区的状态记录为一个树对象),然后观察它的结构:

$ git write-tree
a3f266e268f9d5d337a4c494b234f7482a0b5840

$ git cat-file -p a3f266e268f9d5d337a4c494b234f7482a0b5840
100644 blob 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa    README.md
100644 blob e9074071f146011f8a927c2b7690df6dfe765a90    docs.md

我们发现,新的记录树对象包含两条文件记录,文件 README.md 的 SHA-1 值(8d8578)对应的内容是 readme v2,文件 docs.md 的 SHA-1 值(e90740)对应的内容是 docs v1

  1. 如果为了好玩,你还可以将第一树对象(d50d68)加入到第二个树对象(a3f266),使其成为新的树对象的一个子目录。
$ git read-tree --prefix=bak d50d689553de001d8537d94dae3cb2c89788dae1

$ git write-tree
783727c40cc4b1205242d4130b3f713ed525b23d

$ git cat-file -p 783727c40cc4b1205242d4130b3f713ed525b23d
100644 blob 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa    README.md
040000 tree d50d689553de001d8537d94dae3cb2c89788dae1    bak
100644 blob e9074071f146011f8a927c2b7690df6dfe765a90    docs.md

通过调用 git read-tree 命令,可以把树对象读入暂存区。以上指定 --prefix 选项,将一个已有的树对象作为子树读入暂存区。

此时,Git 内部存储的结构是这样的:根目录有 README.mddocs.md 两个文件,以及一个名为 bak 的子目录,它包含了 README.md 文件的第一个版本。如图:

2.3 Commit Object

做完以上操作之后,现在有了两个树对象(783727d50d68),分别代表我们想要跟踪的不同项目快照。

目前存在的问题有:如果想重用这些快照,你必须要记住所有 SHA-1 哈希值。况且,你也完全不知道谁保存了这些快照,在什么时候保存的,以及为什么保存这些快照。

接下来介绍的提交对象(commit object)就是为了保存基本信息的。通过调用底层命令 git commit-tree 可以创建一个提交对象。为此,需要指定一个树对象的 SHA-1 值,以及该提交的父提交对象(如果有的话)。

$ git commit-tree  -p 

我们使用 d50d68 树对象来创建第一个提交对象。由于是首次,因此没有父提交对象。

$ echo 'first commit' | git commit-tree d50d68
242bd136ff24d2880a68f2de9a8a3a66a0338eea

$ git cat-file -p 242bd136ff24d2880a68f2de9a8a3a66a0338eea
tree d50d689553de001d8537d94dae3cb2c89788dae1
author Frankie <[email protected]> 1647702687 +0800
committer Frankie <[email protected]> 1647702687 +0800

first commit

$ git cat-file -t 242bd136ff24d2880a68f2de9a8a3a66a0338eea
commit

由于创建时间、作者数据的不同,总会得到一个唯一的 SHA-1 值。同样地,可以通过 git cat-file 来查看这个新的提交对象。我们来看下提交对象都包含些什么信息:

tree d50d689553de001d8537d94dae3cb2c89788dae1
author Frankie <[email protected]> 1647702687 +0800
committer Frankie <[email protected]> 1647702687 +0800

first commit

提交对象的格式很简单,它先指定一个顶层的树对象,代表当前项目快照;然后是可能存在的父提交(到目前为止还不存在任何父提交);之后是作者、提交者信息,它由 user.nameuser.email 配置来设定,外加一个时间戳;留空一行,最后是提交注释。

接着,我们使用 783727 树对象创建第二个提交对象,其父提交对象是 242bd1

$ echo 'second commit' | git commit-tree 783727 -p 242bd1
086ba597542c232e267d4b9aa4c0d3d4bcf2411a

$ git cat-file -p 086ba5
tree 783727c40cc4b1205242d4130b3f713ed525b23d
parent 242bd136ff24d2880a68f2de9a8a3a66a0338eea
author Frankie <[email protected]> 1647703338 +0800
committer Frankie <[email protected]> 1647703338 +0800

second commit

现在,我们对最后提交的 SHA-1 值执行 git log 命令,你会发现,你已经有一个货真价实、可由 git log 查看的 Git 提交历史了。

$ git log --stat 086ba5
commit 086ba597542c232e267d4b9aa4c0d3d4bcf2411a
Author: Frankie <[email protected]>
Date:   Sat Mar 19 23:22:18 2022 +0800

    second commit

 README.md     | 2 +-
 bak/README.md | 1 +
 docs.md       | 1 +
 3 files changed, 3 insertions(+), 1 deletion(-)

commit 242bd136ff24d2880a68f2de9a8a3a66a0338eea
Author: Frankie <[email protected]>
Date:   Sat Mar 19 23:11:27 2022 +0800

    first commit

 README.md | 1 +
 1 file changed, 1 insertion(+)

在以上过程,我们没有借助任何上层命令,仅凭几个底层命令就完成了一个 Git 提交历史的创建。

我们使用 git addgit commit 上层命令时,Git 所做的工作实质就是:将被改写的文件保存为数据对象,更新暂存区,记录树对象,最后创建一个指明了顶层树对象和父提交对象的提交对象。

这三种 Git 对象 — 数据对象、树对象、提交对象,最初均以单独文件的形式保存在 .git/objects 目录下:

$ find .git/objects -type f
.git/objects/50/4a7438c95afbd7f5280d756fb405bd85fbf19e      # readme v1    (blob object)
.git/objects/a3/f266e268f9d5d337a4c494b234f7482a0b5840      # tree 2       (tree object)
.git/objects/08/6ba597542c232e267d4b9aa4c0d3d4bcf2411a      # commit 2     (commit object)
.git/objects/d5/0d689553de001d8537d94dae3cb2c89788dae1      # tree 1       (tree object)
.git/objects/e9/074071f146011f8a927c2b7690df6dfe765a90      # docs v1      (blob object)
.git/objects/24/2bd136ff24d2880a68f2de9a8a3a66a0338eea      # commit 1     (commit object)
.git/objects/8d/85786d2dc2fd2cad833d88bce5fca5d28a12fa      # readme v2    (blob object)
.git/objects/78/3727c40cc4b1205242d4130b3f713ed525b23d      # tree 3       (tree object)

不知道有没有同学好奇,这里面为什么有三个树对象?原因是前面我们将 tree 1(d50d68)加入到 tree 2(a3f266),形成了新的树对象 tree 3(783727)。

通过 git cat-file 查看下就清楚了:

$ git cat-file -p d50d68
100644 blob 504a7438c95afbd7f5280d756fb405bd85fbf19e    README.md

$ git cat-file -p a3f266
100644 blob 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa    README.md
100644 blob e9074071f146011f8a927c2b7690df6dfe765a90    docs.md

$ git cat-file -p 783727
100644 blob 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa    README.md
040000 tree d50d689553de001d8537d94dae3cb2c89788dae1    bak
100644 blob e9074071f146011f8a927c2b7690df6dfe765a90    docs.md

我们可以得到一个对象关系图:

2.4 Tag Object

标签对象(tag object) 非常类似于一个提交对象,它包含一个标签创建者信息、一个日期、一段注释信息,以及一个指针。 主要的区别在于,标签对象通常指向一个提交对象,而不是一个树对象。 它像是一个永不移动的分支引用——永远指向同一个提交对象,只不过给这个提交对象加上一个更友好的名字罢了。

2.5 对象存储

在介绍数据对象的时候,曾提及,在 Git 中的 SHA-1 哈希值,是由「待存储的数据」和「头部信息」一起做 SHA-1 校验运算而得的校验和。

比如,在 Git 中字符串 what is this? 返回的 SHA-1 值为:3246c91c89bbcada55565188499b8e214198fd48

$ echo -n 'what is this?' | git hash-object --stdin
ed8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25

这里使用了 echo -n 是避免在输出中添加换行。

我们使用 Ruby 脚本语言来演示下,如何生成相同的 SHA-1 值。这里通过 irb(Interactive Ruby)命令启动 Ruby 的交互模式:

$ irb

>> content = "what is this?"
=> "what is this?"

>> header = "blob #{content.length}\0"
=> "blob 13\u0000"

>> store = header + content
=> "blob 13\u0000what is this?"

>> require "digest/sha1"
=> true

>> sha1 = Digest::SHA1.hexdigest(store)
=> "ed8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25"

说明:

  1. Git 会以识别出的对象类型作为开头来构造一个「头部信息」,以上示例是一个 "blob" 字符串,接着添加一个空格,随后是数据内容的字节数,最后一个是空字节(null byte)。
  2. 将「头部信息」和「原始数据」拼接起来,并计算出这条内容的 SHA-1 校验和。计算 SHA-1 值,先通过导入 SHA-1 digest 库,然后对目标字符串调用 Digest::SHA1.hexdigest()

生成 SHA-1 值之后,还没完呢... Git 会通过 zlib 压缩这条新内容,然后将压缩后的内容存入特定的文件中。

它将会存在于 .git/objects 目录下一级,将 SHA-1 值的「前 2 位字符」作为子目录,「余下 38 位」作为该子目录下的文件名,文件内存储的是「头部信息」和「原始数据」经 zlib 压缩后的内容。

比如,SHA-1 值为 ed8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25 的话,那么最终的文件路径为 .git/objects/ed/8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25

>> require "zlib"
=> true

>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\x9CK\xCA\xC9OR04f(\xCFH,Q\xC8,V(\xC9\xC8,\xB6\a\x00J\f\x06\xEB"

>> path = ".git/objects/" + sha1[0,2] + "/" + sha1[2,38]
=> ".git/objects/ed/8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25"

>> require "fileutils"
=> true

>> FileUtils.mkdir_p(File.dirname(path))
=> [".git/objects/ed"]

>> File.open(path, "w") { |f| f.write zlib_content }
=> 29

说明:

  1. 在 Ruby 引入 zlib 库,对目标内容调用 Zlib::Deflate.deflate() 来进行压缩。
  2. 接着确定新内容存储的路径,然后再写入内容(如果目录不存在会先创建)。

然后,可以通过 git cat-file 验证一下内容是否一致哦!

$ git cat-file -p ed8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25
what is this?

到这里,你就创建了一个有效的 Git 数据对象。

所有的 Git 对象均以这种方式进行存储,区别仅在于「类型标识」,另外两种对象的「头部信息」是以 "tree""commit" 开头,而不是 "blob"

另外,虽然「数据对象」的内容几乎可以是任何东西,但「树对象」和「提交对象」的内容却有各自固定的格式。

2.6 其他

到这里,不应该再有此疑问了吧:git cat-file 是如何将 SHA-1 密文还原成明文的?

我们知道 SHA-1、MD5 这类加密算法是不可逆的,在忽略 SHA-1 被 Google 攻破的事实前提下,可以认为利用 SHA-1 值是不能被还原出原始数据的。

在 Git 中,实际被存储在 .git/objects 的所有内容,均是由「头部信息」和「原始数据」拼接后经 zlib 压缩后的结果。它是可以被还原的。而 SHA-1 哈希值则作为对应的文件路径而已,由于文件路径规则是固定的(前 2 位作为子目录名称,后 38 位作为文件名称),因此借助 SHA-1 值,我们可以找到对应的内容,可以理解为 SHA-1 在里面起指引作用。

因此,git cat-file 并不是要还原 SHA-1 所表示的原始数据,而是根据 SHA-1 哈希值在本地数据库(.git/objects 目录)找到对应的文件,然后再将这个文件还原出来。

三、Git References

如果你对仓库某一个提交(比如前面的 086ba5)开始往前的历史感兴趣,那么你可以运行 git log 086ba5 命令来显示历史:

$ git log 086ba5
commit 086ba597542c232e267d4b9aa4c0d3d4bcf2411a
Author: Frankie <[email protected]>
Date:   Sat Mar 19 23:22:18 2022 +0800

    second commit

commit 242bd136ff24d2880a68f2de9a8a3a66a0338eea
Author: Frankie <[email protected]>
Date:   Sat Mar 19 23:11:27 2022 +0800

    first commit

但这样的话,有一个明显的弊端,它要求你必须记住 SHA-1 哈希值,这显然是不合理的。假设有个一个文件来保存 SHA-1 值,而这个文件有一个简单的名字,然后用这个名字指针来代替原始的 SHA-1 值的话,会更加简单。

在 Git 中,这种简单的名称被称为「引用」(references,简写 refs)。你可以在 .git/refs 目录下找到这类含有 SHA-1 值的文件。在当前仓库中,这个目录还没包含任何文件,目录结构如下:

$ find .git/refs
.git/refs
.git/refs/heads
.git/refs/tags

$ find .git/refs -type f

如要创建一个新引用用来帮助记录最新提交所在的位置,从技术上,只需要简单地做如下操作:

$ echo 086ba597542c232e267d4b9aa4c0d3d4bcf2411a > .git/refs/heads/main

现在,你可以使用刚创建的新引用来代替 SHA-1 值了。

$ git log main --pretty=oneline
086ba597542c232e267d4b9aa4c0d3d4bcf2411a (HEAD -> main) second commit
242bd136ff24d2880a68f2de9a8a3a66a0338eea first commit

但是不提倡直接编辑引用文件,如果想要更新某个引用,Git 提供了一个更加安全的命令 git update-ref 来完成此事:

# usage: git update-ref  
$ git update-ref refs/heads/main 086ba597542c232e267d4b9aa4c0d3d4bcf2411a

这基本就是 Git 分支的本质:一个指向某一系列提价之首的指针或引用。若想在第一个提交上创建一个分支,可以这么做:

$ git update-ref refs/heads/test 242bd136ff24d2880a68f2de9a8a3a66a0338eea

$ git log test --pretty=oneline
242bd136ff24d2880a68f2de9a8a3a66a0338eea (test) first commit

这个 test 分支将只包含从第一个提交开始往前追溯的记录(但本文目前只有包含两次提交,哈哈)。此时我们运行上层命令 git branch 发现,目前已存在 maintest 两个分支了。

$ git branch
* main
  test

当运行类似于 git branch 命令时,Git 实际上会运行 git update-ref 命令,取得当前所在分支最新提交对应的 SHA-1 值,并将其加入你想要创建的任何新引用中。

此时,从 Git 数据库角度看起来像这样:

在 Git 有三种引用类型:

  • head reference(HEAD 引用)
  • tag reference(标签引用)
  • remote reference(远程引用)

3.1 HEAD 引用

现在的问题是,当你执行 git branch 时,Git 如何知道最新提交的 SHA-1 值呢?

答案就是 HEAD 文件(存在于 .git 目录下)。

HEAD 文件通常是一个「符号引用」(symbolic reference),指向目前所在的分支。所谓的符号引用,表示它是已指向其他引用的指针。

在某些罕见的情况下,HEAD 文件可能会包含一个 Git Object 的 SHA-1 值。当你 checkout 一个标签、提交或远程分支,使得你的仓库变成 的 detached HEAD 状态时,就会出现这种情况(关于分离 HEAD 另一篇文章也介绍过)。

当你查看 HEAD 文件的内容时,通常会看到类似这样的内容:

$ cat .git/HEAD
ref: refs/heads/main

然后,如果执行 git checkout test,Git 会更新 HEAD 文件:

$ git checkout test
Switched to branch 'test'

$ cat .git/HEAD
ref: refs/heads/test

当执行 git commit 时,该命令会创建一个「提交对象」,并用 HEAD 文件中那个引用所指向的 SHA-1 值设置其父提交字段。

同样地,Git 提供了一个更加安全的命令:git symbolic-ref。既可以用来查看 HEAD 引用对应的值,同样也可以设置 HEAD 引用的值。

$ git symbolic-ref HEAD
refs/heads/main

$ git symbolic-ref HEAD refs/heads/test

$ cat .git/HEAD
ref: refs/heads/test

但是,不能把符号引用设置为一个不符合引用规范的值:

$ git symbolic-ref HEAD test
fatal: Refusing to point HEAD outside of refs/

3.2 标签引用

在 Git 中,标签的作用就是给某个提交对象加上一个更友好的名字罢了,分为「轻量标签」(lightweight)和「附注标签」(annotated)两种。

可以这样创建一个轻量标签:

$ git update-ref refs/tags/v1.0.0 242bd136ff24d2880a68f2de9a8a3a66a0338eea

若要创建一个附注标签,Git 会创建一个标签对象,并记录一个引用来指向该标签对象,而不是直接指向提交对象。下面使用 git tag -a 命令来生成:

$ git tag -a v2.0.0 086ba597542c232e267d4b9aa4c0d3d4bcf2411a -m 'latest tag'

下面我们对比看下两个 Tag 的内容:

$ cat .git/refs/tags/v1.0.0
242bd136ff24d2880a68f2de9a8a3a66a0338eea

$ git cat-file -p 242bd136ff24d2880a68f2de9a8a3a66a0338eea
tree d50d689553de001d8537d94dae3cb2c89788dae1
author Frankie <[email protected]> 1647702687 +0800
committer Frankie <[email protected]> 1647702687 +0800

first commit

$ git cat-file -t 242bd136ff24d2880a68f2de9a8a3a66a0338eea
commit
$ cat .git/refs/tags/v2.0.0
980d0eab8a71de526ebd1eece1f6cbe33db0931b

$ git cat-file -p 980d0eab8a71de526ebd1eece1f6cbe33db0931b
object 086ba597542c232e267d4b9aa4c0d3d4bcf2411a
type commit
tag v2.0.0
tagger Frankie <[email protected]> 1647771598 +0800

latest tag

$ git cat-file -t 980d0eab8a71de526ebd1eece1f6cbe33db0931b
tag

我们可以发现,标签对象的 object 条目指向了我们打了标签的那个提交对象的 SHA-1 值。但是,标签对象并非必须指向某个提交对象,可以是任意类型的 Git 对象打标签。

3.3 远程引用

接着介绍第三种引用类型:远程引用(remote reference)。如果你添加了一个远程版本库并对其执行过推送操作,Git 会记录下最近一次推送时每个分支所对应的值,并保存在 .git/refs/remotes 目录下。

下面是未推送过,.git/refs 的目录结构:

$ tree .git/refs
.git/refs
├── heads
│   ├── main
│   └── test
└── tags
    ├── v1.0.0
    └── v2.0.0

2 directories, 4 files

接下来,我们将 main 分支推送至远程仓库,然后再观察下 .git/refs 目录的变化。

$ git remote add origin [email protected]:toFrankie/simple-git.git

$ git push origin main
Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Delta compression using up to 12 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (7/7), 507 bytes | 507.00 KiB/s, done.
Total 7 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:toFrankie/simple-git.git
 * [new branch]      main -> main

$ tree .git/refs
.git/refs
├── heads
│   ├── main
│   └── test
├── remotes
│   └── origin
│       └── main
└── tags
    ├── v1.0.0
    └── v2.0.0

4 directories, 5 files

$ cat .git/refs/remotes/origin/main
086ba597542c232e267d4b9aa4c0d3d4bcf2411a

我们可以看到,新增了 .git/refs/remotes/origin/main 文件,其内容 086ba597542c232e267d4b9aa4c0d3d4bcf2411a 正是最新一次的提交对象的 SHA-1 值。

远程引用(位于 refs/remotes 目录下的引用)和分支(位于 refs/heads 目录下的引用)之间最主要的区别在于,远程引用是只读的。

尽管你可以通过 git checkout 切换至某个远程引用,但是 Git 不会将 HEAD 引用指向该远程引用。你永远不能通过 commit 命令来更新远程引用。Git 将这些远程引用作为记录远程服务器上各分支最后已知位置状态的书签来管理。

未完待续...

References

  • 你们仍未掌握那天所学的 git 知识
  • Learn Git Branching
  • Git Internals - Git Objects

你可能感兴趣的:(细读 Git | 进阶)