构建高质量软件:持续集成与持续交付系统实践
上QQ阅读APP看书,第一时间看更新

3.2 文件状态生命周期及Git中的对象

简单了解Git的基本操作之后,本节将深入讲解Git如何管理数据文件,以及Git中不同对象之间的关联关系。

3.2.1 文件状态生命周期

如果想要进一步深入了解Git的内部原理,就必须清晰地掌握文件在Git中的状态生命周期,本节将通过若干个示例共同探讨文件状态生命周期的相关内容。

通过git init命令创建并初始化一个空的本地仓库之后,该本地仓库所在的目录就称为工作目录,工作目录中存在一个与Git相关的隐藏目录,名为“.git”,整个工作目录都可以移动至其他任意路径中,且不用担心有版本信息丢失的风险,这也是Git相较于SVN的优点之一。

在工作目录中首次创建一个文件时,该文件的状态为“Untracked”,该状态的意思是:Git知道有一个新的文件加入了工作目录,但是并不会对该文件进行任何管理,原因是Git之前没有“见过”它。如果想要让Git仓库管理“Untracked”状态的文件,则必须使用add命令将该文件纳入版本控制中。一旦对新创建的文件执行add命令,该文件的状态就会变为“Unmodified”,此状态的意思是:该文件是全新的,并且处于待提交状态(由于Git之前从未“见过”该文件,因此Git无法得知它是否被修改过),对文件执行add命令之后,文件就会进入索引目录或暂存(staging)目录,我们就可以进一步将该文件提交至本地仓库了。

当修改一个已经提交至本地仓库的文件时,执行git status命令就会发现它的状态为“Modified”,图3-2详细描述了文件状态的整个生命周期和不同状态之间的转换关系。

066-01

图3-2 文件状态生命周期

本地工作目录很好理解,它其实就是一个磁盘路径。本地仓库或已提交目录(committed目录)也很好理解,它代表着Git的本地仓库。而暂存目录(staging目录)理解起来相对来说就有些难度了,主要原因是该目录是一个虚拟的目录,它是在文件由工作目录提交至提交目录之间的一个过渡目录,所有新建或修改的文件,在进入提交目录之前都必须先经过暂存目录,也就是前文中介绍的git add命令,示例代码如下。

#在工作目录或工作区中创建一个新的文件。
> echo "the new file named b.txt" >>b.txt
# 试图将文件直接提交至本地仓库或已提交区。
> git commit --message "add the new file b.txt"
On branch master
Untracked files:
        b.txt
# 提示无法提交至已提交区,必须先将其加入暂存目录或暂存区中。
nothing added to commit but untracked files present
# 将b.txt文件加入暂存区中。
> git add b.txt
# 再次提交。
> git commit --message "add the new file b.txt"
# 文件成功进入已提交区。
[master f30ebbc] add the new file b.txt
 1 file changed, 1 insertion(+)
 create mode 100644 b.txt

如果误将文件加入至暂存目录,想要让其回退至Untracked状态,就要使用reset命令,示例代码如下。

# 创建一个空的文件,并将其加入暂存区。
> touch c.txt && git add c.txt
> git status
On branch master
Changes to be committed:
    (use "git reset HEAD <file>..." to unstage)
        new file:   c.txt
> git reset HEAD c.txt
> git status
On branch master
Untracked files:
    (use "git add <file>..." to include in what will be committed)
        c.txt
nothing added to commit but untracked files present (use "git add" to track)

另外,还有一种命令是git rm,可用于将暂存区文件回退至Untracked状态,示例代码如下。

# 创建一个空的文件,并将其加入暂存区。
> touch c.txt && git add c.txt
> git status
On branch master
Changes to be committed:
    (use "git reset HEAD <file>..." to unstage)
        new file:   c.txt
> git rm  --cached c.txt
rm 'c.txt'
> git status
On branch master
Untracked files:
    (use "git add <file>..." to include in what will be committed)
        c.txt
nothing added to commit but untracked files present (use "git add" to track)

加入暂存区的文件在提交之后就会进入已提交区,实际上,Git的仓库正是由若干个提交动作构成的,换言之,Git仓库管理了一系列的提交动作,如图3-3所示。

067-01

图3-3 Git仓库中包含了一系列的提交动作

在Git的历史提交记录中,始终存在一个指针HEAD指向最近(最后)一次的提交(如图3-4所示),HEAD指针可用于将文件回溯到以前的某次提交。

068-01

图3-4 HEAD指针始终指向当前分支的最后一次提交

# git log命令可用于列出历史提交记录。
> git log
commit f30ebbc1fc5507e176653329801044a0e090a4bc
Author: Alex Wang <alex@wangwenjun.com>
Date:   Wed Feb 17 23:34:58 2021 -0800

    add the new file b.txt

commit f7c6a653954df76767e5a85f7d2472cbab5010f4
Author: Alex Wang <alex@wangwenjun.com>
Date:   Wed Feb 17 21:39:02 2021 -0800

first commit
#最近一次的提交编号为:f30ebbc1fc5507e176653329801044a0e090a4bc。
# cat HEAD文件。
> cat .git/HEAD
# HEAD文件指向当前的分支(master)文件。
ref: refs/heads/master
# cat当前的分支文件,可以发现它保存的就是最后一次文件的提交编号。
> cat .git/refs/heads/master
f30ebbc1fc5507e176653329801044a0e090a4bc

3.2.2 Git中的对象

数据对象在Git中的存储与传统的版本控制系统不同,以SVN为例,SVN将首次提交至仓库的数据文件存储为初始版本,之后对于该文件的任何修改,都会记录补丁文件(delta)的变化。如图3-5所示,获取最新一次版本(Version 6)的某个文件(A)时,它实际上是原始文件A与三次不同的补丁文件(delta1、delta2、delta3)合并的结果。

068-02

图3-5 传统VCS版本控制系统对不同版本的文件存储

Git不会在不同的版本中存储每个文件的补丁文件列表,而是会记录下每个文件的快照,以及它们相对于本地仓库的相对路径,即在Git中以文件树的形式跟踪仓库中文件的变化,Git中的每次提交都会记录在文件树中(后文将会通过示例演示文件的不同版本的Git文件树之间的关系)。Git版本控制系统在不同版本中对文件的存储示意图如图3-6所示。

069-01

图3-6 Git版本控制系统在不同版本中对文件的存储

大致了解了Git本地仓库不同版本中文件的存储形式之后,下面就来通过一个示例进行深入分析。首先,创建并初始化一个新的本地仓库,然后分别提交三个文件至本地仓库,每一次提交都会使本地仓库产生一个最新的版本(HEAD指针始终指向当前分支的最近或最后一次提交)。

# 创建一个新的目录。
> mkdir -p lesson02
# 进入该目录。
> cd lesson02
# 创建一个空的本地仓库并且初始化。
> git init
Initialized empty Git repository in /home/wangwenjun/git/lesson02/.git/
# 创建一个文本文件并将其提交至暂存区。
> echo "test">test.txt && git add test.txt
# 提交test.txt文件至本地仓库(已提交区)。
> git commit --message "first commit"
# 每次提交都会产生一个SHA-1算法生成的唯一数字b9d95ff,代表当前最新的版本。
[master (root-commit) b9d95ff] first commit
 1 file changed, 1 insertion(+)
 create mode 100644 test.txt

待文件提交至本地仓库后,文件会以blob的形式存储于文件树(tree)结构中,除了blob之外,Git还支持文件目录(它是Git仓库文件树的子树)的存储。

# 使用cat-file命令读取最后一次提交的HEAD。
> git cat-file -p HEAD
# 当前文件树的SHA-1值。
tree 2b297e643c551e76cfa1f93810c50811382f9117
author Alex Wang <alex@wangwenjun.com> 1613655644 -0800
committer Alex Wang <alex@wangwenjun.com> 1613655644 -0800

first commit
# 查看当前文件树的文件列表。
> git cat-file -p 2b297e643c551e76cfa1f93810c50811382f9117
# 只有一个blob类型的文件。
100644 blob 9daeafb9864cf43055ae93beb0afd6c7d144bfa4    test.txt

那么,最后一个SHA-1值代表什么意思呢?看了下面的命令示例之后就会明白,它是Git通过zlib库对test.txt文本内容进行压缩后产生的一个SHA-1值。

> echo "test" | git hash-object --stdin
# 输出test的SHA-1值。
9daeafb9864cf43055ae93beb0afd6c7d144bfa4
> git cat-file -p 9daeafb9864cf43055ae93beb0afd6c7d144bfa4
# 输出内容test。
test

接下来再分别创建两个新的文件git.txt和hub.txt并提交(为了节约篇幅,这里省略了具体的操作过程)。

# 使用cat-file命令读取最后一次提交的HEAD。
> git cat-file -p HEAD
tree aefbe4a0061f550a60f6acb234b56ec08537bfd8
parent 4434483509292b7b19f7e7447cfb8d7a1749f54b
author Alex Wang <alex@wangwenjun.com> 1613657492 -0800
committer Alex Wang <alex@wangwenjun.com> 1613657492 -0800

third commit
-------当前文件树的SHA-1值。
> git cat-file -p aefbe4a0061f550a60f6acb234b56ec08537bfd8
# 有三个文件在已提交区(本地仓库)。
100644 blob 5664e303b5dc2e9ef8e14a0845d9486ec1920afd    git.txt
100644 blob 122a5d7890d39b2c3fcd75ece90f11a6eff4ca63    hub.txt
100644 blob 9daeafb9864cf43055ae93beb0afd6c7d144bfa4    test.txt
----------分割线
> git cat-file -p HEAD^
tree 9207512fb747c40eb6b9eaff4c774dfec2090c29
parent b9d95ff8cae0a149c9850ab3a70d8cf829148790
author Alex Wang <alex@wangwenjun.com> 1613657472 -0800
committer Alex Wang <alex@wangwenjun.com> 1613657472 -0800

second commit
---------前一次文件树的SHA-1值。
> git cat-file -p 9207512fb747c40eb6b9eaff4c774dfec2090c29
# 有两个文件在已提交区(本地仓库)。
100644 blob 5664e303b5dc2e9ef8e14a0845d9486ec1920afd    git.txt
100644 blob 9daeafb9864cf43055ae93beb0afd6c7d144bfa4    test.txt
-----------分割线
> git cat-file -p HEAD^^
tree 2b297e643c551e76cfa1f93810c50811382f9117
# 请注意,这里已经没有parent了,因为first commit是首次提交。
author Alex Wang <alex@wangwenjun.com> 1613655644 -0800
committer Alex Wang <alex@wangwenjun.com> 1613655644 -0800

first commit
---------第一次文件树的SHA-1值。
> git cat-file -p 2b297e643c551e76cfa1f93810c50811382f9117
100644 blob 9daeafb9864cf43055ae93beb0afd6c7d144bfa4    test.txt

根据上面的操作再结合图3-6,我们可以更进一步地细化出三个不同版本下文件树的结构和进化过程(如图3-7所示)。

071-01

图3-7 Git数据对象在不同版本中的关系图

需要注意的是,默认情况下,SHA-1会生成40位的哈希值,但是在大多数情况下,数值的前6位已经足以区分出不同的对象了。

了解了blob数据对象在不同版本下的关系图和文件树对象之后,接下来我们再进一步探究这些数据对象在Git本地仓库中是如何存储的。

# 通过cd命令进入“.git”路径下的objects目录。
> cd .git/objects
# 最后一次提交hub.txt,SHA-1的值为:122a5d7890d39b2c3fcd75ece90f11a6eff4ca63
# 再次通过cd命令进入“.git/objects”目录下名为12的文件夹。
> cd 12
# 在该目录下我们会看到文件2a5d7890d39b2c3fcd75ece90f11a6eff4ca63,其中存储了hub.txt的内容。

如果修改了hub.txt之后再次提交,那么它前一个版本的数据内容是会被覆盖,还是会针对新版本的hub.txt创建一个新的数据对象呢?答案当然是创建一个新的数据对象了,否则怎么进行版本控制呢,但是不同版本的数据对象相对于本地仓库的相对路径却是完全一样的。

# 修改hub.txt文件。
> echo "new line" >>hub.txt
# 提交对hub.txt文件的修改。
> git commit -am "append new line in hub.txt"
# 执行cat-file命令。
> git cat-file -p 7a64db4
tree 409dabd9f8d6daa1b02b0d91a98d5d7ee0db8495
...
> git cat-file -p 409dabd9f8d6daa1b02b0d91a98d5d7ee0db8495
...
100644 blob 210a49ce8d5f5e498b85758e83aa773452155b5b    hub.txt
...
> cd .git/objects/
> ls
12  21  2b  39  40  44  4b  56  7a  92  9d  ae  b9  e1  e9  fe  info  pack
#其中,12和21目录下存储了hub.txt文件在两个不同版本下的数据对象文件。

每个文件树(tree)都有一个唯一的SHA-1值,每次提交都有一个唯一的SHA-1值,版本仓库中的不同分支也会有与之对应的SHA-1值,数据文件本身也具有唯一的SHA-1值,可见SHA-1哈希值在Git中的重要性。

文件树、提交、分支、数据文件,在Git中统称为对象,除此之外,Git中还包含了其他类型的对象,比如,ref、reflog、index等。这些不同的对象都有唯一能确定它们的SHA-1值,Git也是借助对SHA-1的管理,进而索引和管理不同的对象。图3-8展示了当前Git本地仓库中所有对象之间的关系。

072-01

图3-8 本地仓库下所有对象的关系图