基本概念
版本库
Git版本库(repository)只是一个简单的数据库,其中包括所有用来维护与管理项目的修订版本和历史信息。而Git版本不仅会维护项目整个生命周期的完整副本,还会提供版本库本身的副本。
之前也提到,Git在版本库中会维护一组配置值,而这些配置值并不会在clone版本库的时候进行clone,相反Git还会对每个网站,每个用户和每个版本库的配置和设置信息进行管理和检查。
在版本库中,Git会维护两个主要的数据结构:对象库(object store)和索引(index),所有这些版本库数据都存放在工作目录下的.git目录中。
- 对象库在复制操作的时候能够进行有效复制。
- 索引是暂时的信息,对版本库来说是私有的,并且可以在需要的时候按需求进行创建和修改。
Git对象类型
对象库包含版本库的原始数据文件、所有日志信息、作者信息、日期和其它用于重建项目任意版本或分支的信息。
Git对象库中的对象只有4种类型:
- 块(binary large object, blob):文件的每一个版本表示为一个块。blob用来指代某些可以包含任意数据的变量或文件,同时其内部结构会被程序忽略。一个blob被认为是一个黑盒,其中存储一个文件的数据,但不包含任何关于该文件的元数据,甚至没有文件名。
- 目录树(tree):一个目录树对象代表一层目录信息,其记录blob标识符、路径名和在一个目录里所有文件的一些元数据。其也可以递归引用其它目录树或子树对象,从而建立一个包含文件和子目录的完整层次结构。
- 提交(commit):一个提交对象保存版本库中每一次变化的元数据,包括作者、提交者、提交日期和日志消息。每一个提交对象指向一个目录树对象,该目录树对象在一张完整的快照中捕获提交时版本库的状态。除了最初的提交没有父提交,大多数提交都会有一个父提交。
- 标签(tag):一个标签对象分配一个任意的且可读的名字给一个特定对象,通常是一个提交对象。
而随着项目开发的推进,所有信息在对象库中会变化和增长,项目的编辑、添加和删除都会被跟踪和建模,而为了有效利用磁盘空间,Git会把对象压缩并存储在打包文件(pack file)中,这些文件也在对象库中。
索引
索引则是临时的,动态的二进制文件,其描述了整个版本库的目录结构。或者可以说,索引捕获项目在某个时刻的整体结构的一个版本,相当于一个快照。项目的状态可以用一个提交和一个目录树来表示,其可以来自项目历史中的任意时刻,或者是正在开发的未来状态。
比如,用户可以在索引中暂存变更,之后索引会记录和保存这些变更,直到准备提交。同时也可以删除或替换索引中的变更。
可寻址内容名称
Git对象库被组织及实现成一个内容寻址的存储系统。具体实现为,对象库中的每个对象都有一个唯一的名称,该名称是对象内容的SHA1散列值,该值能够唯一且有效地指向特定内容。
Git追踪内容
Git的内容追踪主要表现为两种形式:
- 首先Git的对象库基于其对象内容的散列值,而不是文件目录或文件名,因此可以说Git追踪的是内容。也就是说,如果两个文件的内容完全一样,无论是否处于同一目录,Git在对象库中只会保存一份blob形式的文件副本,因为根据这两个文件内容得到的散列值是一致的。
- 而当文件从一个版本演进到下一个版本时,Git的内部数据库有效地存储每个文件地每个版本,而不是它们的差异。因此Git是对文件内容的散列值作为文件名,因此其必须对每个文件的完整副本进行操作。
路径名与内容
Git维护了一个明确的文件列表来组成版本库的内容。但Git列表并不是基于文件名,实际上,Git认为文件名是区别于文件内容的数据。
系统 | 索引机制 | 数据存储 |
UNIX文件系统 | 目录(/path/to/file) | 数据块 |
Git | .git/objects/hash、树对象内容 | blob对象、树对象 |
文件名和目录名来自底层的文件系统,但Git并不关心这些名字,而只记录每个路径名,并且确保能够通过该内容精确地创建文件和目录,而这些内容都是通过散列值来索引的。
打包文件
之前提到当文件从一个版本演进到下一个版本时,Git的内部数据库有效地存储每个文件的每个版本,而不是它们的差异。那么这样的效率是否会很低。
其实Git使用了打包文件作为更加有效的存储机制。
要创建一个打包文件,Git首先定位内容非常相似的全部文件,然后为其中一个存储整个内容,然后计算相似文件之间的差异并只存储差异。比如只是更改或添加文件中的一行,Git可能会存储新版本的全部内容,然后记录有差异的一行作为差异,记录到包中。
而Git又是根据对象内容散列值进行追踪的,因此其并不关心两个文件之间的差异是否属于同一文件的不同版本,而是将版本库中任意位置取出两个文件并计算差异,只要Git认为它们足够相似来产生良好的数据压缩。而如果定位和匹配版本库中全局候选差异则是一套相当复杂的算法。
同时Git还维护打包文件中每个完整文件(包括完整内容的文件和差异构建文件)的原始blob的散列值,这也是定位包内对象的索引机制的基础。
对象库图
Git中的对象库图示如上:
- blob对象在数据结构的底层,其什么都不引用而只被树对象引用,由矩形表示
- 树对象指向若干blob对象,也可能指向其它树对象,许多不同的提交对象可能指向任何给定的树对象,由三角形表示
- 提交对象指向特定的树对象,并且该树对象是由提交对象引入版本库的,由圆形表示
- 标签最多指向一个提交对象,由菱形表示
- 分支不是基本的Git对象,但其在命名提交对象的时候起到了重要作用,由圆角矩形表示
上图表示各部分是如何协同的,该版本库表示了两个文件进行初始提交后的状态,两个文件都在顶级目录中,同时其master分支和标签V1.0都指向ID为1492的提交对象。
同时若上述两文件不变,添加一个包含一个文件的新子目录,对象库图为:
上图中,新的提交对象添加了一个关联的书对象来表示目录和文件结构的总状态。这里是ID为cafe00d的树对象。
而因为顶级目录被添加的新子目录改变了,顶级树对象的内容也跟着改变了,所有Git引入了一个新的树对象cafe00d。
而blob对象dead23和feeble在两次提交中并没有发生改变,因此可以直接被新的cafe00d树对象直接引用和共享。
而初始提交1492和提交11235也会存在一种指向关系,即初始提交1492是提交11235的父提交。
Git工作时的概念
当使用git init初始化一个空的版本库时,会在.git目录下创建如下文件:
$ find .
.
./.git
./.git/config
./.git/description
./.git/HEAD
./.git/hooks
./.git/hooks/applypatch-msg.sample
./.git/hooks/commit-msg.sample
./.git/hooks/fsmonitor-watchman.sample
./.git/hooks/post-update.sample
./.git/hooks/pre-applypatch.sample
./.git/hooks/pre-commit.sample
./.git/hooks/pre-merge-commit.sample
./.git/hooks/pre-push.sample
./.git/hooks/pre-rebase.sample
./.git/hooks/pre-receive.sample
./.git/hooks/prepare-commit-msg.sample
./.git/hooks/push-to-checkout.sample
./.git/hooks/update.sample
./.git/info
./.git/info/exclude
./.git/objects
./.git/objects/info
./.git/objects/pack
./.git/refs
./.git/refs/heads
./.git/refs/tags
可以看到,.git目录包括很多内容,这些文件是基于模板目录显示的,可以根据需要进行调整。
在一般情况下,不需要查看或者操作.git目录下的文件,而认为该目录下的文件是Git底层或者配置的一部分。
.git/objects目录用来存放所有Git对象的目录,在初始化空的版本库时,除了几个占位符外,该目录是空的:
$ find ./objects/
./objects/
./objects/info
./objects/pack
而创建如下的hello.txt对象:
$ echo "hello world" > hello.txt
$ git add hello.txt
则会在.git/objects下生成新的目录:
$ find .git/objects/
.git/objects/
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/info
.git/objects/pack
对象、散列和blob
当创建hello.txt时,也为该文件创建了对象,Git并不关心hello.txt的文件名,而只关心文件内容。Git会对该blob计算散列值,并把散列值的十六进制表示作为文件名放入对象库中。
比如此时的160位的散列值为3b18e512dba79e4c8300dd08aeb37f8e728b8dad,因此该内容另存为.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad,而Git在前两个数字后面插入一个/以提高文件系统效率。
而为了表示Git没有对文件内容进行很多操作,可以使用散列值将之从对象库中提取出来:
$ git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
hello world
该命令的作用为:
NAME
git-cat-file - Provide content or type and size information for repository objects
SYNOPSIS
git cat-file (-t [--allow-unknown-type]| -s [--allow-unknown-type]| -e | -p | <type> | --textconv | --filters ) [--path=<path>] <object>
git cat-file (--batch[=<format>] | --batch-check[=<format>]) [ --textconv | --filters ] [--follow-symlinks]
DESCRIPTION
In its first form, the command provides the content or the type of an object in the repository. The type is required unless -t or -p is used to find the object type, or -s is used to find the object size, or --textconv or --filters is used (which imply type "blob").
In the second form, a list of objects (separated by linefeeds) is provided on stdin, and the SHA-1, type, and size of each object is printed on stdout. The output format can be overridden using the optional <format> argument. If either --textconv or --filters was specified, the input is expected to list the object names followed by the path name, separated by a single whitespace, so that the appropriate drivers can be determined.
也就是说该命令可以获得版本库对象的内容或其它信息。
文件和树
既然hello.txt这一文件内容对应的blob已经在对象库中了,那么该文件的文件名的对应操作是什么。
之前提到,Git通过目录树对象来跟踪文件的路径名。当使用git add命令时,Git会给添加的每个文件的内容创建一个对象,但并不会马上为树创建一个对象,相反,索引会发生更新。
.git/index表示索引,它跟踪文件的路径名和相应的blob。每次执行命令(比如,git add、git rm或者git mv),Git会用新的路径名和blob信息来更新索引。
无论什么时候,都可以从当前索引创建一个树对象,只要通过底层的git write-tree命令来捕获索引当前信息的快照就可以了。
当前,该索引只包含一个文件,即hello.txt:
$ git ls-files -s
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0 hello.txt
该命令的含义为:
NAME
git-ls-files - Show information about files in the index and the working tree
SYNOPSIS
git ls-files [-z] [-t] [-v] [-f]
(--[cached|deleted|others|ignored|stage|unmerged|killed|modified])*
(-[c|d|o|i|s|u|k|m])*
[--eol]
[--deduplicate]
[-x <pattern>|--exclude=<pattern>]
[-X <file>|--exclude-from=<file>]
[--exclude-per-directory=<file>]
[--exclude-standard]
[--error-unmatch] [--with-tree=<tree-ish>]
[--full-name] [--recurse-submodules]
[--abbrev[=<n>]] [--] [<file>…]
DESCRIPTION
This merges the file listing in the index with the actual working directory list, and shows different combinations of the two.
One or more of the options below may be used to determine the files shown:
从上述解释来看,该命令会显示索引和工作目录中的文件信息。
之后,可以捕获索引状态并将之保存到一个树对象中:
$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60
该命令的含义为:
NAME
git-write-tree - Create a tree object from the current index
SYNOPSIS
git write-tree [--missing-ok] [--prefix=<prefix>/]
DESCRIPTION
Creates a tree object using the current index. The name of the new tree object is printed to standard output.
The index must be in a fully merged state.
Conceptually, git write-tree sync()s the current index contents into a set of tree files. In order to have that match what is actually in your directory right now, you need to have done a git update-index phase before you did the git write-tree.
生成树对象后,会将树对象保存到之前提到的.git/objects目录中:
$ find .git/objects
.git/objects
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/68
.git/objects/68/aba62e560c0ebc3396e8ae9335232cd93a3f60
.git/objects/info
.git/objects/pack
此时版本库中便存在了两个对象,3b18e5的hello.txt文件内容对象和68aba6的树对象,这跟之前是完全对应的。
这里再用git cat-file命令看一下树对象:
$ git cat-file -p 68aba62e560c0ebc3396e8ae9335232cd93a3f60
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
上边的显示内容中,100644为对象文件属性的八进制表示,散列值为hello.txt文件内容的blob对象,hello.txt则是与该blob关联的名字。
树层次结构
实际开发中,项目会包含复杂且深层嵌套的目录结构,并且会随着时间的推移而重构和移动。通过创建一个新的子目录,该目录包含hello.txt的一个副本,看下Git是如何处理的:
$ git add subdir/hello.txt
$ git write-tree
492413269336d21fac079d4a4672e55d5d2147ac
$ git cat-file -p 492413269336d21fac079d4a4672e55d5d2147ac
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
040000 tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60 subdir
在上面的打印中,添加了hello.txt的副本,然后捕获索引状态到一个树对象中,然后看下该对象的内容。从结果来看,该树对象包含了hello.txt文件内容的blob和subdir的树对象,但是subdir的散列值却是之前出现过的。
这是因为subdir的树对象只包含一个文件hello.txt,该文件和旧的hello.txt内容一致,所以subdir树和之前的树对象是一致的,也就有和之前一样的散列值。
而此时.git/objects目录也发生了改变:
$ find .git/objects/
.git/objects/
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/49
.git/objects/49/2413269336d21fac079d4a4672e55d5d2147ac
.git/objects/68
.git/objects/68/aba62e560c0ebc3396e8ae9335232cd93a3f60
.git/objects/info
.git/objects/pack
根据之前的描述,上面的三个对象分别为:
- 3b对应hello.txt文件内容的blob
- 68对应包含hello.txt的树对象
- 49对应包含hello.txt文件内容的blob和68对应的树对象
提交
既然可以通过git write-tree创建树对象,便也可以通过底层命令创建提交对象:
$ echo -n "Commit a file that says hello\n" | git commit-tree 492413269336d21fac079d4a4672e55d5d2147ac
f45c97a19d5d6ee01295c619fe24805d9888cb04
该命令的含义为:
NAME
git-commit-tree - Create a new commit object
SYNOPSIS
git commit-tree <tree> [(-p <parent>)…]
git commit-tree [(-p <parent>)…] [-S[<keyid>]] [(-m <message>)…]
[(-F <file>)…] <tree>
DESCRIPTION
This is usually not what an end user wants to run directly. See git-commit(1) instead.
Creates a new commit object based on the provided tree object and emits the new commit object id on stdout. The log message is read from the standard input, unless -m or -F options are given.
The -m and -F options can be given any number of times, in any order. The commit log message will be composed in the order in which the options are given.
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.
Normally a commit would identify a new "HEAD" state, and while Git doesn’t care where you save the note about that state, in practice we tend to just write the result to the file that is pointed at by .git/HEAD, so that we can always see what the last committed state was.
即该命令可以基于提供的树对象创建新的提交对象,并在标准输出中打印新的提交对象ID。
上面的命令针对树对象492413269336d21fac079d4a4672e55d5d2147ac创建了提交对象f45c97a19d5d6ee01295c619fe24805d9888cb04,而该提交对象又是:
$ git cat-file -p f45c97a19d5d6ee01295c619fe24805d9888cb04
tree 492413269336d21fac079d4a4672e55d5d2147ac
author wood_glb <wood_glb@git.com> 1655545440 +0800
committer wood_glb <wood_glb@git.com> 1655545440 +0800
Commit a file that says hello\n
从上面的打印可以看出,该对象是个提交对象,该类对象主要包括:
- 标识关联文件的树对象名称
- 创作新版本的作者名字和提交时间
- 提交者的名字和提交时间
- 日志消息
标签
Git只实现了一种标签对象,但是有两种基本的标签类型:
- 轻量型的(lightweight)
- 带附注的(annotated)
轻量级标签只是一个提交对象的引用,通常被版本库认为是私有的。这些标签并不在版本库中创建永久对象。
带附注的标签会创建一个对象,其包含一条提交信息,并可根据密钥进行数字签名。
Git在命名一个提交的时候对轻量级的标签和带附注的标签同等对待。不过,默认情况下,很多Git命令只对带附注的标签起作用。
可以通过git tag命令创建一个带有提交信息、带附注且未签名的标签:
$ git tag -m "Tag version 1.0" V1.0 f45c97a19d5d6ee01295c619fe24805d9888cb04
再看一下该标签对象,而该标签对象的散列值可通过如下命令获取:
$ git rev-parse V1.0
92dfd6790e2e45e9f080984cb589a89aed77f835
该命令的含义为:
NAME
git-rev-parse - Pick out and massage parameters
SYNOPSIS
git rev-parse [<options>] <args>…
DESCRIPTION
Many Git porcelainish commands take mixture of flags (i.e. parameters that begin with a dash -) and parameters meant for the underlying git rev-list command they use internally and flags and parameters for the other commands they use downstream of git rev-list. This command is used to distinguish between them.
即该命令可以通过标签值获取散列值。
既然获知了散列值,再看下该对象:
$ git cat-file -p 92dfd6790e2e45e9f080984cb589a89aed77f835
object f45c97a19d5d6ee01295c619fe24805d9888cb04
type commit
tag V1.0
tagger wood_glb <wood_glb@git.com> 1655546625 +0800
Tag version 1.0
从该显示来看,该标签指向了一个提交f45c97a19d5d6ee01295c619fe24805d9888cb04,标签名为V1.0,也打印了标签作者和时间。
Git通常给指向树对象的提交对象打标签,这个树对象包含版本库中文件和目录的整个层次结构的总状态。
Git通过某些分支来给特定的提交命名标签。