仓库配置信息

.git/logs/.git/info/,顾名思义是一些日志和辅助信息。

.git/hooks/ 存储一些 Git 提供的钩子脚本的模板,例如(pre-push.sample),可以创建名为pre-push的钩子脚本,这个脚本在特定行为(push 之前)会自动执行,其他名称的钩子也是类似的。

.git/config 是仓库级的 Git 配置文件,优先级最高,但是配置只是局限在当前仓库中,可能包括如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
[core]
repositoryformatversion = 0
filemode = false
bare = false
logallrefupdates = true
ignorecase = true
[remote "origin"]
url = git@github.com:xxx.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
remote = origin
merge = refs/heads/main

可以发现这里的配置还包括了远程仓库的信息,以及当前仓库和远程仓库的绑定关系。

数据存储原理

.git/文件夹内部包括一个基于哈希值的简易数据库.git/object/,关于这个内部数据库,有如下要点:

  • Git对文件的保存都是基于单个文件的完整快照,而不是基于差异(这也导致 Git 不适合大文件的版本控制),每一个文件快照在git add之后(而不是提交时)都会压缩和生成哈希值,然后以哈希值为索引存储到数据库之中,这种数据项称为 blob 对象
  • 数据库中除了基本的 blob 对象(对应一个文件快照),还有体现目录结构的 tree 对象,以及代表一次提交的 commit 对象等,所有的项都以哈希值作为在数据库的索引,并且哈希值的前两个作为文件夹名称,剩下的作为实际的文件名,查找时不需要提供完整的哈希值,通常前六个数据就足够了
  • 数据库除了这些原始的零散文件,还会不定期地进行自动的整理(在推送到远程时必然会先整理数据库),此时这些对象会被打包收集到.git/object/pack/中,一些打包信息在.git/object/info/,打包是数据库内部的优化行为,对外部没有影响
  • Git 可能因为某些撤销操作,产生悬挂对象(没有任何对象指向它),包括对git add的撤销,或者对正式提交后的回滚等,Git 并不会自动删除这些悬挂对象,即使手动清理也不会删除它们。可以使用如下命令查询所有的悬挂对象
    1
    git fsck --full

index 原理

Git 的一个核心概念是 index(缓冲区,暂存区),它在逻辑上位于工作目录和 Git 版本控制之间,index 的实现是通过内部文件.git/index,它是二进制格式的文件,不可读。

index 可以被视作一个列表,列表里的每一条目包括:权限+哈希值+名称,可以通过git ls-files --stage命令查看,结果大致形如

1
2
3
100644 0c3acb5db2e121d819f1af9c0b97b8baaa12021b 0       a.txt
100644 1f482482efa7cc35d1dbab733e23a10c97a9364f 0 b.txt
100644 c830ea98d189c62bb530f061e2467e52da775b33 0 c/d.txt

查看到的结果可能是一个很大的列表,包括了所有的被 Git 追踪的项,它们的最新快照的哈希值。这个命令显示最终每一层目录下的所有文件,但仍然可以理解为 index 中存在树结构。

index 是下一次提交时用于生成顶层 tree 对象的草稿(提交生成 commit 对象指向顶层的 tree 对象),但注意它记录的内容并不是当前相比于前一次 commit 的变化量,即使刚刚提交后git status显示完全干净时,index 也不是空的!它的列表仍然记录所有的项 。

在每一次执行git add之后,对应的快照会立刻存储到数据库中,生成相应的 blob 对象(必要时生成 tree 对象),然后更新 index:

  • 对于新添加的文件,index 增加一个条目
  • 对于修改的文件,index 修改哈希值为最新版本的
  • 对于删除的文件,index 移除对应的条目

提交原理

使用git commit操作会基于 index 的内容生成本次提交所对应的顶层 tree 对象和 commit 对象,commit 对象会指向顶层 tree 对象。所有的 commit 对象会组成一个版本链表,这是 Git 版本控制的核心数据结构。版本链表包括一个特殊的第一次提交的 commit,其他的 commit 对象都包括至少一个父对象(如果执行了 merge 操作则可能有两个父对象,但是它们不等价,执行 merge 时所在的 commit 对象是第一个父对象)

可以使用git cat-file -p加上哈希值来查看具体对象的内容(-t选项查看对象的类型),例如查看 commit 对象,得到的输出为

1
2
3
4
5
6
tree 0e9f335d73965f44ea4772ce4126ab219de247fd
parent a8ff675067ba137b5e833dc9cf3e2d2c8b3fcc11
author xxx <xxx@xx.com> 1699513896 +0800
committer xxx <xxx@xx.com> 1699513896 +0800

<message>

这表明 commit 对象除了记录提交的信息(时间,作者/提交者,提交 message),还记录了顶层的 tree 对象,以及版本链表中的 commit 父对象,这就是一次提交的全部:

  • index 的作用就是下一次提交的顶层 tree 对象的草稿
  • 下一次提交的其他信息(时间,作者/提交者)会自动生成
  • 提交所需要的 message 需要手动输入

如果查看 commit 指向的顶层 tree 对象,就可以得到对应的提交版本中每一个项的快照的哈希值

1
2
3
4
5
6
100644 blob fd1240c913208228cc9e9f22d870da226ffc9b46    .gitattributes
100644 blob 1c83080acf461c2c9a4f7352fb6fbeb64347355c .gitignore
100644 blob c4f6483460f1febf62ecbf25061f4bf3a8d022d8 .proxy
100644 blob d41cb88be2e0d0526e6c4afb41194ec841847073 README.md
040000 tree 148e0955c5cdb5fc31d7277e834147a13bc5ab09 backup
...

分支原理

分支和标签功能的内部实现以文件形式存储在.git/refs/heads/.git/refs/tags/中,它们在逻辑上是指向版本链表的某个 commit 对象的指针,区别在于标签不可移动而分支可以,并且分支会随着提交(链表的延伸)自动前移到最新的 commit 对象。

例如在.git/refs/heads/通常有一个名为 main 的文件,代表主分支,它的内容只有一行,记录当前 main 分支指向的 commit 对象的哈希值,例如

1
5a0ca82eb0072623067f1d7dbd734354d72f9441

HEAD 是一个通常指向 branch 的指针(也可以直接指向标签,commit 对象或者其他,但这种游离的情况比较特殊且危险),HEAD 代表当前处于的位置,HEAD 在底层实现就是.git/HEAD,这个文本文件只有一行,记录的就是 HEAD 指向的 branch 的名称,例如

1
ref: refs/heads/main

执行 commit 操作后,会生成一个新的 commit 对象指向当前的 commit 对象,而 HEAD 指向的 branch(例如 main)则会前移到新的 commit 对象上。如果 HEAD 指向的不是一个 branch,例如指向一个标签,此时提交后并不会自动前移到新的 commit 对象,需要额外的特殊处理。

分支的合并和变基都是针对版本链表进行的操作,可以通过git log --graph --all进行检查。