Git 内部原理--初探 .git

说到Git大家应该都非常熟悉,几乎每天都会用到它。在日常使用过程中,我们貌似并不需要关注其内部的原理,只需要记住那几个常用的命令,就可以说自己是会Git的人了。可是,事实真的是这样子的吗?今天我们就来聊聊那些不太被关注到的内部原理。

引言

首先我们要明白的一点就是,Git是一个内容寻址(content-addressable)文件系统,并在此之上提供了一个版本控制系统的用户界面。

还有一点需要明白的就是,我们日常使用的命令(checkoutcommit)对用户更加友好,因此被称为高层(porcelain)命令;还有一些早期设计的命令(hash-objectwrite-tree)被设计成能以UNIX命令行的风格连接在一起,抑或藉由脚本调用来实现功能,这些命令被称为底层(plumbing)命令。这里,我们便通过底层命令来实现几个常见的高层命令,以达到初步了解Git内部原理的效果。

Git 仓库的初始化

在我们学习Git的时候,我们最开始接触到的一定是Git仓库的配置。在这里,我们也通过Git的初始化,来看看Git的初始化的时候都干了些啥。

frends-MacBook:GitTest frendguo$ git init
Initialized empty Git repository in /Users/frendguo/SourceCode/Demo/GitTest/.git/
frends-MacBook:GitTest frendguo$ ls -al
total 0
drwxr-xr-x 3 frendguo staff 96 Jan 13 22:51 .
drwxr-xr-x 4 frendguo staff 128 Jan 13 22:51 ..
drwxr-xr-x 9 frendguo staff 288 Jan 13 22:51 .git

当我们输入git init后,Git会帮我们生成一个.git的文件夹。它的结构是这样子的:

.git/
├── config
├── description
├── HEAD
├── hooks
│ ├── applypatch-msg.sample
│ ├── commit-msg.sample
│ ├── post-update.sample
│ ├── pre-applypatch.sample
│ ├── pre-commit.sample
│ ├── prepare-commit-msg.sample
│ ├── pre-push.sample
│ ├── pre-rebase.sample
│ └── update.sample
├── info
│ └── exclude
├── objects
│ ├── info
│ └── pack
└── refs
| ├── heads
| └── tags

对于一个全新的git init版本库,这将是你看到的默认结构。description 文件仅供 GitWeb 程序使用,我们无需关心。config 文件包含项目特有的配置选项。info目录包含一个全局性排除(global exclude)文件,用以放置那些不希望被记录在 .gitignore文件中的忽略模式(ignored patterns)。hooks 目录包含客户端或服务端的钩子脚本(hook scripts)。

HEAD 文件、(尚待创建的)index 文件,和 objects 目录refs 目录。这些条目是 Git 的核心组成部分。objects 目录存储所有数据内容;refs 目录存储指向数据(分支)的提交对象的指针;HEAD 文件指示目前被检出的分支;index 文件保存暂存区信息。

既然初始化仓库只是在本地创建几个文件/文件夹,那我们是否可以通过创建文件/文件夹来初始化Git仓库呢?答案是当然可以!

frends-MacBook:GitTest frendguo$ ls -al
total 0
drwxr-xr-x 2 frendguo staff 64 Jan 13 23:20 .
drwxr-xr-x 4 frendguo staff 128 Jan 13 22:51 ..
frends-MacBook:GitTest frendguo$ git status
fatal: not a git repository (or any of the parent directories): .git

当前GitTest文件夹并不是一个Git仓库,在使用git status查询Git仓库状态时,bash会提醒这不是一个Git仓库。

于是我们在这个文件夹下创建写文件/文件夹:

frends-MacBook:GitTest frendguo$ mkdir -p .git/objects .git/refs/heads/ .git/refs/tags
frends-MacBook:GitTest frendguo$ echo "ref:refs/heads/master" >> .git/HEAD
frends-MacBook:GitTest frendguo$ git status
On branch master No commits yet nothing to commit (create/copy files and use "git add" to track)

只是简单的创建几个文件/文件夹,就可以非常简单的创建一个Git仓库。所以,如果想要在本地备份一个版本库,就可以直接拷走.git 文件夹。

git add/commit 背后的那些事

创建好Git仓库后,我们便要开始向仓库提交一些东西了。待我们在工作区的内容编辑完成后,我们就需要将内容提交到暂存区了,对于平常的使用中,我们直接git add . 就可以将当前文件夹下所有的文件/文件夹全都提交到暂存区。那这条命令背后到底干了些啥呢?接下来我们便来探索下其背后的故事。

Git 对象

前面提到过Git是一个内容寻址系统。也就是说Git的核心其实就是一个简单的键值对数据库,对于添加到Git仓库任意的对象,Git都有一个唯一的键来与之对应。这里可以通过底层命令hash-object来演示效果。

frends-MacBook:GitTest frendguo$ echo "hello git" | git hash-object -w --stdin
8d0e41234f24b6da002d962a26c2495ea16a425f

其中参数-w表示将对象写到Git的数据库当中。--stdin表示接受标准输入,这里接收到的就是hello git。对于返回值8d0e41234f24b6da002d962a26c2495ea16a425f就是前文提到的键。

那么这个键是怎么计算得来的呢?—hash。它通过将头部信息(其中包含对象类型和对象大小)和原始信息拼接起来,然后计算HASH值,得到一个40位的序列。并将前两位作为文件夹,后面38位作为文件名将文件信息保存到Git仓库中。

上文中的hello git保存到Git中如下所示:

frends-MacBook:GitTest frendguo$ find .git/objects
.git/objects
.git/objects/8d
.git/objects/8d/0e41234f24b6da002d962a26c2495ea16a425f

这里再介绍一个底层命令cat file。我们可以通过这个命令从Git中取数据,比如我们要看下8d0e41234f24b6da002d962a26c2495ea16a425f的文件类型,我们可以这么做:

frends-MacBook:GitTest frendguo$ git cat-file -t 8d0e41234f24b6da002d962a26c2495ea16a425f
blob

-t表示查看其类型。blob是Git中对象的一种类型,表示数据对象(BinaryLargeOBject)。与之对应的还有tree(树对象,下文会提到)、commit(提交对象,见下文)。

-p表示需要查看该键对应的内容。如(其他用法可以通过man git-cat-file来查看):

frends-MacBook:GitTest frendguo$ git cat-file -p 8d0e41234f24b6da002d962a26c2495ea16a425f
hello git

接下来,我们向数据库中添加本地已存在的文件:

frends-MacBook:GitTest frendguo$ echo "version 1" >> test.md
frends-MacBook:GitTest frendguo$ ls
test.md
frends-MacBook:GitTest frendguo$ git hash-object -w test.md
83baae61804e65cc73a7201a7252750c76066a30

修改test.md文件并再次保存到Git中:

frends-MacBook:GitTest frendguo$ echo "version 2" > test.md
frends-MacBook:GitTest frendguo$ git hash-object -w test.md
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a

查看objects/中的文件:

frends-MacBook:GitTest frendguo$ find .git/objects/ -type f
.git/objects//1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects//83/baae61804e65cc73a7201a7252750c76066a30
.git/objects//8d/0e41234f24b6da002d962a26c2495ea16a425f

可以看到包括之前的hello git,总共有三个文件保存到了Git仓库中。我们可以通过非常简单的方法将其恢复。

frends-MacBook:GitTest frendguo$ git cat-file -p 8d0e41234f24b6da002d962a26c2495ea16a425f > test.md
frends-MacBook:GitTest frendguo$ cat test.md
hello git

然而在这个简单的文件系统中,我们并没有保存文件名。那么Git是怎么保存文件名的呢?--答案就是树对象

树对象

接下来我们便来讨论讨论树对象。它不仅能保存文件名,还能将多个文件组织到一起。我们先来看看树对象怎么来创建吧~

通常,Git会根据某一时刻暂存区(存放在.git/index中)所表示的状态来创建并记录一个对应的树对象,如此重复便能创建一系列的树对象。因而,在创建树对象之前,我们需要先将文件加入到暂存区。这里需要用到另一个底层命令update-index,这个命令就是将文件加到暂存区中。

frends-MacBook:GitTest frendguo$ git ls-files --stag
frends-MacBook:GitTest frendguo$ git update-index --add --cacheinfo 10644 8d0e41234f24b6da002d962a26c2495ea16a425f hello
frends-MacBook:GitTest frendguo$ git ls-files --stag
100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0 hello

其中,git ls-files --stag是用来显示暂存区中的文件信息的。--add是因为hello文件不在暂存区中。--cacheinfo是因为该内容是在Git仓库中,不在当前文件夹下。10644在UNIX系统中用来表示该文件模式为普通文件。

这个时候,我们用git status看看Git仓库的状态:

frends-MacBook:GitTest frendguo$ git status
On branch master No commits yet Changes to be committed:
(use "git rm --cached <file>..." to unstage) new file: hello Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory) deleted: hello

可以看到hello文件已经加到暂存区中了。比较奇怪的是下面那个deleted: hello。这个更改是在为加到暂存区的更改,因为本地路径中不存在该文件,而暂存区中存在该文件,所以Git认为是人为的将该文件删除了。我们可以通过检出(checkout)来将文件恢复到本地。

frends-MacBook:GitTest frendguo$ git checkout hello
frends-MacBook:GitTest frendguo$ ls -l
total 8
-rw-r--r-- 1 frendguo staff 10 Jan 14 00:41 hello

这个时候的状态就像我们使用了git add hello一样。

frends-MacBook:GitTest frendguo$ git status
On branch master No commits yet Changes to be committed:
(use "git rm --cached <file>..." to unstage) new file: hello

这里也可以联想到我们在将工作区的内容恢复到暂存区的内容的时候,我们也会使用检出(checkout)命令。(这个命令的内部实现,我们以后再探讨。)

嘿,别走了,我们这就创建树对象。这里需要用到write-tree这个底层命令,它的作用就是将暂存区的内容写成一个树对象。

frends-MacBook:GitTest frendguo$ git ls-files --stag
100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0 hello
frends-MacBook:GitTest frendguo$ git write-tree
66eea8c80abea0e9836aab458e48ab9a379186e5
frends-MacBook:GitTest frendguo$ git cat-file -t 66eea8c80abea0e9836aab458e48ab9a379186e5
tree
frends-MacBook:GitTest frendguo$ git cat-file -p 66eea8c80abea0e9836aab458e48ab9a379186e5
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f hello

可以看到我们的树对象创建成功了,里面的内容就是暂存区的内容--一个hello文件。接下来我们创建一个新的树对象。

frends-MacBook:GitTest frendguo$ echo "version 1" > test.md
frends-MacBook:GitTest frendguo$ git update-index --add test.md
frends-MacBook:GitTest frendguo$ git ls-files --stag
100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0 hello
100644 83baae61804e65cc73a7201a7252750c76066a30 0 test.md
frends-MacBook:GitTest frendguo$ git write-tree
3071bf0ff03f10445bb9c43e194ae990944006f4
frends-MacBook:GitTest frendguo$ git cat-file -p 3071bf0ff03f10445bb9c43e194ae990944006f4
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f hello
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test.md

这里的树对象包含两个文件hellotest.md。现在我们将原来那个树对象添加到暂存区,可以通过read-tree将树对象的内容读取到暂存区。

frends-MacBook:GitTest frendguo$ git read-tree --prefix=FirstTree \ 66eea8c80abea0e9836aab458e48ab9a379186e5
frends-MacBook:GitTest frendguo$ git ls-files
FirstTree/hello
hello
test.md
frends-MacBook:GitTest frendguo$ git write-tree
ac3c7527fcc566453b513c8706d9f36ba138980a
frends-MacBook:GitTest frendguo$ git cat-file -p ac3c7527fcc566453b513c8706d9f36ba138980a
040000 tree 66eea8c80abea0e9836aab458e48ab9a379186e5 FirstTree
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f hello
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test.md

可以看到新的树对象包含了最开始我们创建的那个树对象和两个文件,如果我们把他们检出,是不是就能在工作目录看到根目录下有一个子目录和两个文件了。

frends-MacBook:GitTest frendguo$ ls
hello test.md
frends-MacBook:GitTest frendguo$ rm -rf hello test.md
frends-MacBook:GitTest frendguo$ ls -l
frends-MacBook:GitTest frendguo$ git checkout .
frends-MacBook:GitTest frendguo$ ls -l
total 16
drwxr-xr-x 3 frendguo staff 96 Jan 14 01:05 FirstTree
-rw-r--r-- 1 frendguo staff 10 Jan 14 01:05 hello
-rw-r--r-- 1 frendguo staff 10 Jan 14 01:05 test.md

果然是这样子的。是不是想起了我们使用的文件系统也是这种树形的结构呢。

提交对象

现在我们有三个树对象,分别表示了项目开发周期中不同时期的快照。如果我们想要重用这些快照,那么就必须记住三个树对象的SHA-1值。而且,我们并没有足够的信息来表明是谁,什么时候创建的快照以及为什么会有这个快照。因此,大佬们就想出来了一个提交对象用来保存这些信息。

我们先用commit-tree命令来创建一个提交对象,这个过程中,我们必须要指定树对象的SHA-1值,以及提交的父对象(如果存在的话)。

frends-MacBook:GitTest frendguo$ echo "first commit" | git commit-tree \ 66eea8c80abea0e9836aab458e48ab9a379186e5
1417b00016aa48b2e24518c5e1f8737b66b6a993
frends-MacBook:GitTest frendguo$ git cat-file -t 1417b00016aa48b2e24518c5e1f8737b66b6a993
commit

再来看看这个提交对象里面有些什么内容:

frends-MacBook:GitTest frendguo$ git cat-file -p 1417b00016aa48b2e24518c5e1f8737b66b6a993
tree 66eea8c80abea0e9836aab458e48ab9a379186e5
author frendguo <frendguo@live.cn> 1547399734 +0800
committer frendguo <frendguo@live.cn> 1547399734 +0800 first commit

通过查看提交对象的内容,我们可以知道,提交对象的格式:它先指定一个顶层的树对象,代表当前项目的快照;然后是作者/提交者的信息(根据user.nameuser.email来设置);留空一行,就是提交的注释了(表示为什么会有这个快照)。(这里需要提到的是提交者和作者的信息,它们大多数情况下都是一样的,但在一些情况下(比如:cherry-pick),为了保证版权,就需要保留作者的信息。)

接下来我们再根据其他两个树对象创建两个提交。

frends-MacBook:GitTest frendguo$ echo "second commit" | git commit-tree 3071bf0ff03f10445bb9c43e194ae990944006f4 -p 1417b00016aa48b2e24518c5e1f8737b66b6a993
088bb9b3d9aa4c48e28a5c27672bb75e110fba64
frends-MacBook:GitTest frendguo$ echo "third commit" | git commit-tree ac3c7527fcc566453b513c8706d9f36ba138980a -p 088bb9b3d9aa4c48e28a5c27672bb75e110fba64
8a94cd783dfb4fdf45e836f7a0ae292f49593606

这个时候我们就可以用git log --stat来查看Git的提交历史了。这里的--stat是表示查看与当前HEAD不一致的路径。

frends-MacBook:GitTest frendguo$ git log --stat 8a94cd783dfb4fdf45e836f7a0ae292f49593606
commit 8a94cd783dfb4fdf45e836f7a0ae292f49593606
Author: frendguo <frendguo@live.cn>
Date: Mon Jan 14 01:25:16 2019 +0800 third commit FirstTree/hello | 1 +
1 file changed, 1 insertion(+) commit 088bb9b3d9aa4c48e28a5c27672bb75e110fba64
Author: frendguo <frendguo@live.cn>
Date: Mon Jan 14 01:24:07 2019 +0800 second commit test.md | 1 +
1 file changed, 1 insertion(+) commit 1417b00016aa48b2e24518c5e1f8737b66b6a993
Author: frendguo <frendguo@live.cn>
Date: Mon Jan 14 01:15:34 2019 +0800 first commit hello | 1 +
1 file changed, 1 insertion(+)

截止到这里,我们算是用底层命令基本完成了git addgit commit的功能。

Git 引用

之所以说基本完成了,那是因为我们并不能像高层命令那样提交后,直接使用git log就能查看提交日志。

这里不得不提到的就是HEAD。就像前文说到的那样,HEAD表示现在被检出的分支。

frends-MacBook:GitTest frendguo$ cat .git/HEAD
ref:refs/heads/master
frends-MacBook:GitTest frendguo$ cat .git/refs/heads/master
cat: .git/refs/heads/master: No such file or directory

尝试将最新的提交对象的SHA-1值给refs/heads/master?

frends-MacBook:GitTest frendguo$ echo "8a94cd783dfb4fdf45e836f7a0ae292f49593606" > .git/refs/heads/master
frends-MacBook:GitTest frendguo$ git log
commit 8a94cd783dfb4fdf45e836f7a0ae292f49593606 (HEAD -> master)
Author: frendguo <frendguo@live.cn>
Date: Mon Jan 14 01:25:16 2019 +0800 third commit commit 088bb9b3d9aa4c48e28a5c27672bb75e110fba64
Author: frendguo <frendguo@live.cn>
Date: Mon Jan 14 01:24:07 2019 +0800 second commit commit 1417b00016aa48b2e24518c5e1f8737b66b6a993
Author: frendguo <frendguo@live.cn>
Date: Mon Jan 14 01:15:34 2019 +0800 first commit

于是我们把最后一步走完了,现在就跟高层命令一致了。

这里强烈不建议直接修改引用文件。如果想要修改,请使用update-ref命令来完成修改。

frends-MacBook:GitTest frendguo$ git update-ref refs/heads/master 8a94cd783dfb4fdf45e836f7a0ae292f49593606

通过修改master分支,可以看到分支的本质其实就是一个引用(或者说一个指针,一个提交对象的键值)。所以当我们在使用git branch命令的时候,其内部其实就是通过git update-ref来实现的。而标签引用和分支有点异曲同工之妙,本文就不再赘述了。有兴趣的可以自己探索。


总结

到这里,初探.git就结束了。本文中通过使用底层命令来实现几个简单的高层命令的功能来探索Git的内部原理。

参考

  1. Pro Git
上一篇:vue cli 3


下一篇:Git内部原理浅析