Featured image of post git存储原理探秘

git存储原理探秘

目录结构概览

我们使用git init初始化一个全新仓库,查看其.git目录。 注:在仓库进行一些操作后,会有一些新的文件产生。

你很可能困惑这些都是啥啊?看官莫急,且听我慢慢道来,由浅入深道来。

HEAD文件: 指向checkout出的分支

1
ref: refs/heads/master

代表当前仓库的指向。当切换分支等时,这里就会变化。可以看到引用了refs/heads,这下面有本地的分支列表。

config目录:存放仓库的一些配置项

1
2
3
4
5
6
7
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
        ignorecase = true
        precomposeunicode = true

如果仓库有remote,这里还会有指向远端仓库的配置,形如:

1
2
3
4
5
6
[remote "origin"]
	url = https://github.com/lucas-clemente/quic-go.git
	fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
	remote = origin
	merge = refs/heads/master

origin对应的远程分支地址,追踪了远端分支的本地branch列表等。 还有一些如用户名等,通过git config --local key value设置的都在这个配置中体现。

description文件:别管它

GitWeb程序使用,可不关注。

hooks目录:钩子营地

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   ├── push-to-checkout.sample
│   └── update.sample

存放着一些钩子,里面有不少示例,从名称可以看出哪些时机可以有钩子。我们可以利用hook做一些事情,比如提交时检查内容是否有敏感信息等。 To enable this hook, rename this file to “commit-msg”.

info目录:

看到有exclude文件,里面类似.gitignore内容。不过一般项目根目录有.gitignore文件,但是其info/exclude里并没有同样定义,所以也不是镜像,这有啥用呢,查看官方文档说:

用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns)

不明所以。然而像一些新的机制commit-graph等也放在这个目录,未来再解析。关于commit-graph,可提前一憋:

The commit-graph file stores the commit graph structure along with some extra metadata to speed up graph walks.By listing commit OIDs in lexicographic order, we can identify an integer position for each commit and refer to the parents of a commit using those integer positions. We use binary search to find initial commits and then use the integer positions for fast lookups during the walk.

个人觉得info目录对我们理解原理影响不大,这里不深究。

objects目录:git的对象存储DB

这个本篇最重要的内容之一,理解了它对git的认知就深了一层。这个目录是git数据仓库,代码,提交信息等都在这。新创建的仓库这里啥也没有,我们随便找个有内容的仓库。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
objects
├── 07
│   └── 6c9bed872744e98fb843a501bf410474291069
├── 0d
│   └── 77c70175932f906344cc483e55beb127749103
├── 0e
│   ├── 17c016da48d4bb55c40e5946e0065ec68dca7c
│   └── 1cad78432235fc4d83629925653b9da58c59ce
├── 11
│   └── c38beb699c3992989a5161dad85909cc443984
├── 14
│   └── a990ab019c4609c2f910d5470e51fb503b0903
...
├── fd
│   ├── 0b8187dda438c79995f2185f4a0632a9c08b0d
│   └── 2d526ec9122e9895770f8ed25d30b7166f0757
├── info
└── pack
    ├── pack-c387a0c061f9a78a205bd80b09e21a613d97cabd.idx
    └── pack-c387a0c061f9a78a205bd80b09e21a613d97cabd.pack

可以看到一些2个字符的目录以及info、pack目录。其中2个字符的目录是git commit id的前2位,文件名是commit id的后38位,为什么要这样搞后面再说?(PS:这里说是commit id其实是错误叫法,后面你就懂)

info和pack,其中info为空,放啥不明。 pack里有两个文件:

1
2
pack-c387a0c061f9a78a205bd80b09e21a613d97cabd.idx
pack-c387a0c061f9a78a205bd80b09e21a613d97cabd.pack

这是什么?有什么作用,后面会讲到。

refs目录:分支和tag的指向信息

1
2
3
4
5
6
7
8
└── refs
    ├── heads
    │   └── master
    ├── remotes
    │   └── origin
    │       ├── HEAD
    │       └── master
    └── tags

基本上是一些指向commit的信息。heads/tags是本地的、remotes是远程仓库(在拉取时)所指向的信息。

index文件:staging数据

在新建的.git仓库中开始是看不到的,但常见与各开发中的代码库中。index 文件是除 objects 外最核心的部分,它是本地暂存的一些信息,可以看下图重温一下git的几个阶段。

PS:新init的仓库没有此文件能理解,但新clone的仓库为什么会有此文件呢?

logs目录:操作和提交历史

在新建的.git仓库中开始是看不到的。什么时间谁进行了什么操作,有详细记录。 并且git reflog命令以及HEAD@{1}形式的路径会用到此目录。暂时它对我们理解git原理不重要,跳过。

看清git操作背后

接下来我们搞点事再来谈原理。

准备监听.git目录

为了更好理解我们操作背后的故事,借助fswatch(在MacOS下)监听.git目录的文件变化:

1
2
3
4
5
# 安装fswatch
brew install fswatch

# 监听.git目录,将事件打印出来
fswatch -0 .git | while read -d "" event; do; echo "This file ${event} has changed.";done

git add文件

同时另开一个窗口,执行类似如下操作:

1
2
echo "hello git" > README.md
git add README.md

可以看到监听窗口同步有信息变化:

1
2
3
4
5
This file .git/objects/8d/0e41234f24b6da002d962a26c2495ea16a425f has changed.
This file .git/objects/8d has changed.
This file .git/objects/8d/tmp_obj_55Lckq has changed.
This file .git/index.lock has changed.
This file .git/index has changed.

刚才操作生成了index文件,并且objects目录也有变化:

1
2
3
4
5
.git/objects
├── 8d
│   └── 0e41234f24b6da002d962a26c2495ea16a425f
├── info
└── pack

如果你尝试看其内容,使用cat会发现乱码等,因为它是压缩过后的二进制(后面会知道怎么看它)。

git commit提交

1
git ci -m "add: readme"

这次冒出了更多的文件变化,我们看一眼:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
This file .git/objects/14/dac22917e6426c6503aeae0e32e4c1eac3a0cd has changed.
This file .git/objects/14 has changed.
This file .git/index has changed.
This file .git/objects/8e/398d4c698f6f85b00ee6264438c0c1340d9dac has changed.
This file .git/objects/8e has changed.
This file .git/logs has changed.
This file .git/logs/HEAD has changed.
This file .git/logs/refs has changed.
This file .git/logs/refs/heads has changed.
This file .git/logs/refs/heads/master has changed.

我隐去了index、logs、COMMIT_EDITMSG和不少xx.lock文件的创建/变化。我们重点关注objects下又多了啥?

1
2
3
4
5
6
7
8
9
.git/objects
├── 14
│   └── dac22917e6426c6503aeae0e32e4c1eac3a0cd
├── 8d
│   └── 0e41234f24b6da002d962a26c2495ea16a425f
├── 8e
│   └── 398d4c698f6f85b00ee6264438c0c1340d9dac
├── info
└── pack

又多了两个目录14和8d。我们仍然不知道怎么查看它,为什么有这几个文件?

objects基础概念

带着上面的困惑和看不到具体内容的着急,我们必须补点知识了。Git是一个版本控制软件这不用多说,Content Addressable File是其核心的思想,见维基说明:

Content-addressable storage (CAS), also referred to as content-addressed storage or fixed-content storage, is a way to store information so it can be retrieved based on its content, not its name or location. It has been used for high-speed storage and retrieval of fixed content, such as documents stored for compliance with government regulations. Content-addressable storage is similar to content-addressable memory.

简单理解是,不以文件名称或路径来管理内容,而是基于其内容来address(定位)。具体到实现上,关注两点:object idobject type

基于SHA-1的objectID

所谓的基于content来定位,我们不可能傻到遍历内容,而是对内容生成一个唯一摘要。在git中使用SHA-1算法基于内容计算一个SHA1值,一般叫它ObjectID(OID)。

SHA-1全称Secure Hash Algorithm,是美国国家安全局设计的一种密码散列函数。可生成160bit = 20Byte = 40个16进制字符的散列值。 它有被攻击的方法,所以在SSL中已经不被使用,但git使用冲突概率不高。

参考:https://zh.m.wikipedia.org/zh-hans/SHA-3

需要注意的是,git里计算OID不只有content来参与运算,还添加了一些额外信息,如下所示:

1
2
3
4
5
6
$ echo -n hello | git hash-object --stdin
b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0

$ printf 'blob 5\0hello' > test.txt
$ openssl sha1 test.txt
SHA1(test.txt)= b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0

展示了内容为hello通过git命令hash-object计算它OID。它等价于SHA1([object type string] [content length]\NULL content) 这样计算的,而不是直接SHA1(“hello”)。

PS: 更多信息可参考stackoverflow 或者官方描述

object的类型

上面在计算hash时提到了object type。object有四种类型

  • blob: 即文件
  • tree: 即目录,包含tree/blob
  • commit: 即commit
  • tag: 即tag。它有专门的object id,然后会指向一个commit类型object id。

Content Addressable思想下,没有了文件名和路径下,这里的类型blob与tree就相当重要,object虽然是平铺的,但是object有了类型,通过不同类型的结构表述了类似于文件系统的概念。

我们来解析上一节的示例,刚才最后git commit了一个文件。

1
2
3
4
5
6
$ git log
commit 8e398d4c698f6f85b00ee6264438c0c1340d9dac (HEAD -> master)
Author: kevin <your@gmail.com>
Date:   Sat Aug 20 12:43:33 2022

    add: readme

最后我们的objects下有三个文件:

1
2
3
4
5
6
7
8
9
.git/objects
├── 14
│   └── dac22917e6426c6503aeae0e32e4c1eac3a0cd
├── 8d
│   └── 0e41234f24b6da002d962a26c2495ea16a425f
├── 8e
│   └── 398d4c698f6f85b00ee6264438c0c1340d9dac
├── info
└── pack

可以看到有一个8e/398d4c698f6f85b00ee6264438c0c1340d9dac和我们commitid长得很像。我们可以使用git cat-file -p [OID]参数看objectid的内容。

1
2
3
4
5
6
7
8
9
$ git cat-file -p 8e398d4c698f6f85b00ee6264438c0c1340d9dac
tree 14dac22917e6426c6503aeae0e32e4c1eac3a0cd
author kevin <your@gmail.com> 1660970613 +0800
committer kevin <your@gmail.com> 1660970613 +0800

add: readme

$ git cat-file -t 8e398d4c698f6f85b00ee6264438c0c1340d9dac                                                                                
commit

可以看到原来这是一个commit对象,它包含了一个tree对象14dac22917e6426c6503aeae0e32e4c1eac3a0cd,并且有提交者信息和提交描述等。我们继续看tree 14dac22917e6426c6503aeae0e32e4c1eac3a0cd是啥呢?

1
2
3
4
5
$ git cat-file -p 14dac22917e6426c6503aeae0e32e4c1eac3a0cd                                                                                 
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f    README.md

$ git cat-file -t 14dac22917e6426c6503aeae0e32e4c1eac3a0cd
tree

这个tree对象其内容是一个blob,名称为README.md,进一步查看blob:

1
2
$ git cat-file -p 8d0e41234f24b6da002d962a26c2495ea16a425f
hello git

这个blob里就是刚才提交的实际内容。

整体回顾一下:当我们在根目录提交了一个README.md文件,其内容是hello git时,git背后objects有啥变化?

  • 在git add时,产生了一个blob对象,就是我们的README.md的内容。
  • 在git commit时,生成了一个commit对象,里面有我们的提交信息等,并且关联了一个tree。
  • 在git commit关联的tree中,描述了文件名README.md和它对应的blob。

结合上面所述,再次认识到blob是文件,tree是目录树(可以包含目录或文件),commit是一次提交,它引用了某个tree。

要是我们一次commit有多个目录下呢,是否会引用多个tree对象?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 创建如下目录dir1/dir2,并且在内写入content文件
├── dir1
│   └── content
└── dir2
    └── content
# 可看到Objects的新增    
├── objects
│   ├── 3c
│   │   └── fce6551a1477c012a248969f613e886ef762a8
│   ├── 3f
│   │   └── a573feac2ef7eda6947e507f400b0083ef67ef
│   ├── ab
│   │   └── 99df1963dde45413cefcf8dc3cb4ee76e0ad2d
│   ├── cb
│   │   └── afd3d7e1df140a39d2ea97c48665bc5154f7e3
│   ├── e8
│   │   └── 69f3e2e3864fb704c925d5cd3e752ce4578dde
│   ├── fd
│   │   └── e7b8db4226025afbe4354c836a3e429e65dba5
│   ├── info
│   └── pack
└── refs

我们先想象一下会有多少个objects?

  • 2 blob = 2个content文件
  • 1 commit = 1次提交
  • 2 tree = 2个目录

可是这里有6个objects呢,怎么回事?可以借助git rev-list命令查看objects列表:

1
2
3
4
5
6
7
$ git rev-list --objects --all
3fa573feac2ef7eda6947e507f400b0083ef67ef
3cfce6551a1477c012a248969f613e886ef762a8
fde7b8db4226025afbe4354c836a3e429e65dba5 dir1
e869f3e2e3864fb704c925d5cd3e752ce4578dde dir1/content
ab99df1963dde45413cefcf8dc3cb4ee76e0ad2d dir2
cbafd3d7e1df140a39d2ea97c48665bc5154f7e3 dir2/content

你可以自行查看内容并分析为什么还有一个object(想想哪里描述了dir1/dir2的层级关系)。

通过以上示例,或许你能感受到git的内部组织,就像实现了另一个文件系统,可能是git的创造者也是Linux之父Linus B. Torvalds信手拈来的一个方案吧:)

可视化

我们还可以借助一个可视化工具 gitviz 来更清楚的展示出objects内幕。

1
2
3
4
5
# 安装 gitviz程序
go install github.com/riezebosch/gitviz@latest

# 跳到要分支的仓库根目录
gitviz

会打开浏览器,以WebUI呈现git的内部结构,比如我们上述实践创建的git仓库就有如下呈现:

  • 红色是commit
  • 蓝色是blob
  • 绿色是tree 你可以点开各节点查看其内容。

除此之外,可以看到HEAD指向了master,master指向了一个commit。

.git目录和代码文件结构的关系

我们都知道日常在写代码时,处在git的Workspace这一层。结合上面可视化的图,或许我们已经能想象到我们所看到的源码和目录是怎么样生成的了。但想象归想象,还是实际来看一下吧?

我们找个复杂一点的项目,通过上述gitviz观察一下。

1
2
3
4
# clone一个minio仓库
git clone github.com:minio/minio.git

cd minio && gitviz

可能会有另你困惑的情况:为啥只有HEAD和几个分支名称而看不到上述的红绿蓝节点?这是因为git有一个Packing机制,从远端clone的仓库,上述几类对象会被打包为idx,pack文件。

1
2
3
4
5
├── objects
│   ├── info
│   └── pack
│       ├── pack-9b0581963dd8f5f97ec8463956b52ad1d8c9a5ca.idx
│       └── pack-9b0581963dd8f5f97ec8463956b52ad1d8c9a5ca.pack

我们可以通过如下命令解开它:

1
2
3
mv .git/objects/pack/* /tmp/
git unpack-objects < /tmp/pack-9b0581963dd8f5f97ec8463956b52ad1d8c9a5ca.pack                                                          
Unpacking objects:  37% (32037/86586), 38.11 MiB | 1.22 MiB/s

经过一段时间Unpacking后,我们熟悉的各种objects回来了。不过这个仓库太大了,上面gitviz半天没加载,我还是换个小点的仓库再看。

通过HEAD指向一层层遍历(这是一个有向无环图),我们可以获得所有的目录和文件列表,我猜是这样的吧?

后续

接下来我们可以研究一下:

  • index是如何运作的
  • packing机制
  • 基于底层命令来认知日常的git操作
  • git的几种diff算法
  • git的三路merge过程

本文开了个头,未来有空继续探究,欢迎探讨指正~

参考资料