本文参考自 Pro Git,整理相关内容,补充了细节,并按照更容易理解的方式编写出来,期望能帮助你理解 Git 的相关原理,拨开平常使用过程中的迷雾。可以放心食用。
基础回顾
我们已经很熟练地来使用 Git 完成完成日常开发工作了,但是有些基本的概念这里还得需要再回顾一下,里面说不定还有我们曾经不解的地方。
Git 与其他版本控制系统的差异
Git 与其他版本控制系统最大的差别在于 Git 对待数据的方法。其他系统保存的信息是初始文件,以及随着时间积累各文件之间产生的差异。
Git 直接记录快照,而非差异比较。 每次提交时,Git 会将文件的快照保存起来并制作这个快照的索引。通过该索引,我们可以找到这个快照。
怎么理解快照?
A snapshot of a file system is an image of a file system at a particular point in time. A snapshot of a file system may be used to restore the file system to its state at the time of creation of the snapshot in。
文件系统的快照是文件系统在特定时间点的映像。文件系统的快照可以用于将文件系统恢复到创建快照时的状态。
这里仅需要对"快照"有一个模糊的概念。通过后面的学习,我们将知道 Git 是如何制作和保存快照的。
.git 目录概览
在开始之前,我们还需要回顾一下 .git
目录中的内容。
当在一个新目录或者已有目录执行 git init
时,Git 会创建一个 .git
目录。这个目录包含了几乎所有 Git 存储和操作的对象。 如若想备份或复制一个版本库,只需把这个目录拷贝至另一处即可。
该目录默认的内容结构如下:
文件或目录 | 说明 |
---|---|
HEAD | 指示目前被检出的分支 |
config | 包含项目特有的配置 |
description | 仅供 GitWeb 程序使用,无需关心 |
hooks/ | 目录包含客户端或服务端的钩子脚本(hook scripts) |
info/ | 包含一个全局性排除文件 |
objects/ | 存储所有数据内容 |
refs/ | 存储指向数据(分支)的提交对象的指针 |
index | 保存暂存区信息 |
上述目录结构中 index
文件暂时还没有创建,但它很重要,所以笔者将它列在这里。
该目录下可能还会包含其它文件,不过对于一个全新的 git init
版本库,这是我们看到的默认结构。
文件状态与三个区域
项目中的文件有两种类型,未追踪文件和已追踪文件。
未追踪是指 Git 不会去关心它的变化。例如这个例子中 Git 并不关心 test.txt
文件:
$ git init test #初始化一个 git 仓库,目录名称为 test
Initialized empty Git repository in /Users/cj/Documents/test/.git/
$ cd test
$ echo 'an untracked file' > test.txt #创建 test.txt 文件
$ git status
On branch master
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
test.txt
nothing added to commit but untracked files present (use "git add" to track)
执行 git add test.txt
就可以将它添加到暂存区,并让 Git 追踪它。
已追踪的文件有三种状态,我们的文件可能处于其中之一:已修改、已暂存、已提交。
文件状态 | 描述 |
---|---|
已修改(modified) | 已经修改了文件,但还没有暂存 |
已暂存(staged) | 表示对一个已修改文件的当前版本做了标记 |
已提交(commited) | 数据已经安全的保存在本地数据库中 |
由此也引出了 Git 项目三个区域的概念:
区域 | 描述 |
---|---|
工作目录 | 也就是我们肉眼能够看到的文件。<br />它是从项目的某个版本独立提取出来的内容,放在磁盘上供我们使用或修改。<br />使用 git add 命令就会将工作目录中的文件快照放到暂存区。 |
暂存区(也叫 Index 区) | 暂存区就是 .git 中的 index 文件。保存了下次将提交的文件列表信息。<br />使用 git commit 相关命令之后,就会把 stage 中的内容保存到 Git 仓库。 |
Git 仓库(也叫 commit history) | Git 仓库目录是 Git 用来保存项目的元数据和对象数据库的地方。<br /><br />任何提交只要进入 commit history ,基本可以认为永远不会丢失了。 |
基本的 Git 工作流程如下:
- 在工作目录中修改文件。
- 暂存文件,将文件的快照放入暂存区域。
- 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 仓库目录。
查看工作目录与暂存区的状态,使用 git status
命令。
查看 Git 仓库的状态,使用 git log
命令。
如果你对上述内容完全理解,那么我们就进入下面的内容。
三个对象
简介
blob 对象
用于保存我们在文件中编辑的数据内容。
tree 对象:
首先,它可以保存 blob 对象,以及 blob 对象对应的文件名。
再者,它还可以保存 子tree 对象,以及 子tree 对应的目录名。
所以,项目中的所有内容都可以用一些 blob 对象和一些 tree 对象来描述。
如果用这些 blob 对象和 tree 对象,再生成一棵 tree 对象。那么这棵生成的 tree 对象,就是当前项目的快照,它记录了当前时刻项目的状态,保存了所有内容!
一系列的 tree 对象,就是一系列的快照。
commit 对象
保存快照的 SHA-1 值,以及提交者、提交时间等相关信息。
我们可以通过 commit 对象,找到任意"一张"快照,来查看某一时刻项目的状态。
详细介绍
下面来亲眼看一看这三种对象。
blob 对象
Git 的核心部分是一个键值对数据库(key-value data store)。向数据库中插入一个对象,它会返回一个 key
。使用这个 key
,我们可以在任意时刻再次检索到这个对象。
这里不要着急,有时间的话跟着笔者一起敲下面的命令。
向 Git 数据库中存入文本数据
首先,我们新创建一个 git 仓库。
#创建一个 git 仓库,目录名为 test
$ git init test
Initialized empty Git repository in /Users/cj/Documents/test/.git/
$ cd test
#查找 .git/objects 下的所有目录和文件
$ find .git/objects
.git/objects
.git/objects/pack
.git/objects/info
可以看到,我们新创建一个 Git 仓库时,Git 对 .git/objects
目录进行了初始化,创建了 pack
和 info
子目录,但均为空。
接着,我们通过 hash-object
指令直接往 Git
数据库存入一些文本:
$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4
上述指令你可能会困惑,我在这里列出解释:
指令、参数、其它 | 含义 |
---|---|
| | 表示将上条指令的输出作为下一条指令的参数 |
hash-object | 将任意数据保存到 .git 仓库,并返回相应的 key |
-w | 指示 hash-object 命令存储数据对象<br />若不指定此选项,则该命令仅返回对应的 key |
--stdin | 指示 git hash-object 命令从标准输入读取内容<br />若不指定此选项,则须在命令尾部给出一个文件路径,Git 将存储该文件的内容 |
d670460b4b4aece5915caf5c68d12f560a9fe3e4 | SHA-1 哈希值,长度为 40 个字符的校验和 |
现在,我们已经往 Git 数据库中写入了文本。让我们再来查看一下 .git/objects
:
$ find .git/objects
.git/objects
.git/objects/d6 #多了一个 d6 目录
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 多了一个文件
.git/objects/pack
.git/objects/info
可以看到,.git/objects
文件夹下面多了一个子目录,以及子目录下多了一个文件。
这就是 Git 存储内容的方式,一个文件对应一条内容,以该内容加上特定头部信息一起的 SHA-1 校验和为文件命名。 校验和的前两个字符用于命名子目录,余下的 38 个字符则用作文件名。
SHA-1 校验和
Git 内容 + 头部 信息拼接起来,计算出这条新内容的 SHA-1 校验和,这就是我们平时看到的 SHA-1 校验和。
为什么要加一个头部信息呢?
头部信息主要是为了指明这条 SHA-1 校验和指代的对象的类型,比如 blob、tree 或者 commit。
我们将上面做的事情整理一下:
1. 我们向 Git 中存储了一个对象,它是文本 `test content`
2. Git 将对象以文件的形式存储到 `.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4`
3. Git 返回给我们了一个 key: `d670460b4b4aece5915caf5c68d12f560a9fe3e4`
请确保理解了我们之前做的事情,再接着进行。
通过 key 从 Git 中读取数据
我们可以通过 key 来查看对象的类型,以及取出对象,使用 cat-file
就可以查看 Git 对象:
$ git cat-file -t d670460b4b4aece5915caf5c68d12f560a9fe3e4
blob
$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content
参数 | 作用 |
---|---|
-t | 查看对象的类型 |
-p | 参数表示自动判断对象的类型,并为我们显示格式友好的内容 |
至此,我们已经可以向 Git 中存入文本,并且通过 key 来读取它!Git 就是这样的一个数据库。
向 Git 数据库中存入文件数据
我们还可以对文件进行同样的操作。
首先,创建一个新文件,并将其存入数据库:
$ echo 'version 1' > test.txt # 创建 test.txt 文件,里面的文本内容是 'version 1'
$ git hash-object -w test.txt # 将文件存入 Git 数据库
83baae61804e65cc73a7201a7252750c76066a30 # Git 返回 key
然后,我们向文件里写入新内容,并再次将其存入数据库:
$ echo 'version 2' > test.txt # 将 test.txt 中文本的内容覆盖为 'version 2'
$ git hash-object -w test.txt # 再次将文件存入 Git 数据库
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a # Git 返回 key
先想一想,Git 数据库中现在有几条数据?
三条。我们查看 .git/objects
:
$ find .git/objects -type f # -type f 表示指查找文件(不找目录)
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 #这是我们第一次存的 'test content'
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a #这是我们第三次存的 'version 2'
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 #这是我们第二次存的 'version 1'
它保存了三条数据,它们是以文件的形式保存的。
使用 Git 数据库中的内容来恢复工作区的内容
现在,工作区中 test.txt
文件的内容是 version 2
:
$ cat test.txt
version 2
我们可以把内容恢复到 version 1
或者 version 2
:
$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1
$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
$ cat test.txt
version 2
引出 blob 概念
上述类型的对象,我们称之为数据对象(blob object)。
利用 cat-file -t
命令,可以让 Git 告诉我们其内部存储的任何对象类型,只要给定该对象的 SHA-1 值:
$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
blob
树对象
理解了 blob
对象后,我们再来看一下 tree
对象。
在上面的操作中,虽然 blob
对象已经能够保存文件的内容了,但它并没有保存下来文件名。这显然还不能够完整描述我们项目当前的状态。
树对象(tree object)能解决文件名保存的问题,也允许我们将多个文件组织到一起。Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化。 所有内容均以树对象和数据对象的形式存储,其中树对象对应了 UNIX 中的目录项,数据对象则大致上对应了文件内容。 一个树对象包含了一条或多条树对象记录,每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。
首先,tree 对象可以保存 blob 对象,以及 blob 对象对应的文件名。
再者,tree 对象还可以保存 子tree 对象,以及 子tree 对应的目录名。
所以,项目中的所有内容都可以用一些 blob 对象和一些 tree 对象来描述。
如果用这些 blob 对象和 tree 对象,再生成一棵 tree 对象。那么这棵生成的 tree 对象,就是当前项目的快照,它记录了当前时刻项目的状态,保存了所有内容!
一系列的 tree 对象,就是一系列的快照。
Git 会根据某一时刻暂存区所表示的状态创建并记录一个对应的树对象。
我们先整理一下我们之前向 Git 中存入的 blob 对象:
key | 类型 | value | 说明 |
---|---|---|---|
d670460b4b4aece5915caf5c68d12f560a9fe3e4 | blob | test content | 我们第一次存入的文本 |
83baae61804e65cc73a7201a7252750c76066a30 | blob | version 1 | test.txt 的第一个版本 |
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a | blob | version 2 | test.txt 的第二个版本 |
生成快照
我现在想创建一个快照。为此,我需要先暂存一些文件。
首先,使用 update-index
把 test.txt
文件的第一个版本 人为地加入到暂存区:
$ git update-index --add --cacheinfo 100644 83baae61804e65cc73a7201a7252750c76066a30 test.txt
item | 含义 |
---|---|
update-index | 可以为一个单独文件创建一个暂存区 |
--add | 因为此前该文件并不在暂存区中,所以必须指定该选项 |
--cacheinfo | 表示要注册地点文件位于数据库中,而不是当前目录下 |
100644 | 文件的模式,表示这是一个普通文件。<br />还有其它模式,不过现在我们并不关心。 |
然后,使用 write-tree
命令来创建一个树对象。它会根据当前暂存区状态自动创建一个新的树对象:
Creates a tree object using the current index. The name of the new tree
object is printed to standard output.
$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579
这棵树 d8329fc1cc938780ffdd9f94e0d364e0ea74f579,就是我们当前暂存区的快照。
快照的存储
看一下 .git/objects
中的内容,可以看到新增了一个对象:
$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 #新增对象
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
我们再来查看一下它的类型和内容:
$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
tree #说明它是一个树对象
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test.txt #它里面有一个 blob 对象
这说明快照也是被存储在 .git/objects 目录中,它也是一个 tree 对象。
再创建一个快照
接着我们来创建一个新的树对象,它包括 test.txt 文件的第二个版本,以及一个新的文件:
$ echo 'new file' > new.txt
$ git update-index --cacheinfo 100644 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
$ git update-index test.txt
$ git update-index --add new.txt
暂存区现在包含了 test.txt 文件的新版本,和一个新文件:new.txt。
根据暂存区的内容创建一棵树(快照):
$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341
查看这棵树(快照)的结构:
$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
我们注意到,新的树对象包含两条文件记录,同时 test.txt 的 SHA-1 值(1f7a7a
)是先前值的第二个版本。
再来创建第三棵树
我们使用 read-tree
将第一个树对象读到暂存区,使其成为第三棵树的一个子目录。
再通过调用 write-tree
命令,生成第三棵树。
DESCRIPTION
Reads the tree information given by <tree-ish> into the index, but does
not actually update any of the files it "caches".
...
OPTIONS
--prefix=<prefix>
Keep the current index contents, and read the contents of the named
tree-ish under the directory at <prefix>. The command will refuse to
overwrite entries that already existed in the original index file.
$ git read-tree --prefix=newDir d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git write-tree
87681ae53c6777ab971a1ca06848bf46306aa8a2
这里的 --prefix=newDir
表示子目录的名称是 newDir
。
我们来查看一下第三棵树:
$ git cat-file -p 87681ae53c6777ab971a1ca06848bf46306aa8a2
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 newDir # 注意这个 tree
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
至此,我们创建了三棵树(三个快照),它们都被保存在 Git 数据库中。请确保理解了上面的内容,再进行下面的阅读。
Commit 对象
为什么要有 commit 对象
现在有三个树对象,分别代表了三个时刻暂存区域的快照。
三棵树 | SHA-1 |
---|---|
第一棵树 | d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
第二棵树 | 0155eb4229851634a0f03eb265b69f5a2d56f341 |
第三棵树 | 87681ae53c6777ab971a1ca06848bf46306aa8a2 |
然而还有一个问题:若想重用这些快照,我们必须记住所有三个 SHA-1 哈希值。 并且,我们也完全不知道是谁保存了这些快照,在什么时刻保存的,以及为什么保存这些快照。
而以上这些,正是提交对象(commit object)能为你保存的基本信息。
创建一个提交对象
可以通过调用 commit-tree
命令创建一个提交对象,为此需要指定一个树对象的 SHA-1 值,以及该提交的父提交对象(如果有的话)。
Creates a new commit object based on the provided tree object and emits
the new commit object id on stdout.
A commit object may have any number of parents. With exactly one parent,
it is an ordinary commit. Having more than one parent makes the commit a
merge between several lines of history. Initial (root) commits have no
parents.
While a tree represents a particular directory state of a working
directory, a commit represents that state in "time", and explains how to
get there.
我们从之前创建的第一个树对象开始:
$ echo 'first commit' | git commit-tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
92f113cd3745d1d60ee3432d728139741ad34ebe
我们查看一下这个新的对象:
$ git cat-file -t 92f113cd3745d1d60ee3432d728139741ad34ebe
commit #这是一个 commit 对象
$ git cat-file -p 92f113cd3745d1d60ee3432d728139741ad34ebe
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 #当前项目的快照!
author chenjun <304114852@qq.com> 1591452459 +0800
committer chenjun <304114852@qq.com> 1591452459 +0800
first commit
再创建两个提交对象
接着,我们将创建另两个提交对象,它们分别引用各自的上一个提交(作为其父提交对象):
$ echo 'second commit' | git commit-tree 0155eb -p 92f113
520814962b564d8f57107785842a404bc530fa25
$ echo 'third commit' | git commit-tree 87681a -p 520814
d3eeae3ba52790d231ca37aadc3d0f8d3763e161
这三个提交对象分别指向之前创建的三个树对象快照中的一个。
神奇的事情
现在,神奇的事情发生了。我们对最后一个提交的 SHA-1 值运行 git log
命令,会出乎意料的发现,我们已有一个货真价实的、可由 git log
查看的 Git 提交历史了:
$ git log --stat d3eeae3ba52790d231ca37aadc3d0f8d3763e161
commit d3eeae3ba52790d231ca37aadc3d0f8d3763e161
Author: chenjun <304114852@qq.com>
Date: Sat Jun 6 22:17:20 2020 +0800
third commit
newDir/test.txt | 1 +
1 file changed, 1 insertion(+)
commit 520814962b564d8f57107785842a404bc530fa25
Author: chenjun <304114852@qq.com>
Date: Sat Jun 6 22:16:54 2020 +0800
second commit
new.txt | 1 +
test.txt | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
commit 92f113cd3745d1d60ee3432d728139741ad34ebe
Author: chenjun <304114852@qq.com>
Date: Sat Jun 6 22:13:20 2020 +0800
first commit
test.txt | 1 +
1 file changed, 1 insertion(+)
我们自始至终都没有执行过 git add
和 git commit
命令
但这就是每次我们运行 git add
和 git commit
命令时, Git 所做的实质工作。
Git 将被改写的文件保存为数据对象,更新暂存区,记录树对象,最后创建一个指明了顶层树对象和父提交的提交对象。
这三种主要的 Git 对象——数据对象、树对象、提交对象——最初均以单独文件的形式保存在 .git/objects
目录下。
下面列出了目前示例目录内的所有对象,辅以各自所保存内容的注释:
$ find .git/objects -type f
.git/objects/92/f113cd3745d1d60ee3432d728139741ad34ebe # first commit
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt version 2
.git/objects/87/681ae53c6777ab971a1ca06848bf46306aa8a2 # tree 3
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/52/0814962b564d8f57107785842a404bc530fa25 # second commit
.git/objects/d3/eeae3ba52790d231ca37aadc3d0f8d3763e161 # third commit
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt version 1
如果跟踪所有的内部指针,将得到一个类似下面的对象关系图(请忽略 commit id 不对,因为这张图是从 Git Pro 中截取的,但这不影响我们所表述的信息):