Ran Wei/Git 系列
English
Git 与版本控制 — Ran Wei

Git 的内部原理

看看 .git 文件夹的内部:保存历史的数据对象、树对象和提交对象,为它们命名的哈希,以及构成分支和 HEAD 的那些小文件。

2 小时2 个时段4 个动手环节Git 2.50.1 (Apple Git-155)

完成后你能够

  • 说出 Git 的三种主要对象:数据对象、树对象和提交对象,以及各自保存的内容。
  • 用 git cat-file 查看任意对象的内部。
  • 解释哈希如何由内容计算得出,以及为什么改动一次提交会改变之后所有提交的哈希。
  • 在 .git 文件夹中找到对象、分支和 HEAD 的存放位置。
  • 用这些概念解释为什么分支很廉价,以及什么是分离的 HEAD。

开始之前

第 2 至第 8 模块。日常工作并不需要这些内容,但它们能解释 Git 为什么会这样运作。

目录

学习计划

2 小时

时间为估计值,包含输入命令的时间。进度保存在当前浏览器,中英文版本共享。

时段 160 分钟
对象
时段 260 分钟
存储、引用与练习
1

对象与哈希

你可以用 Git 好多年,却不知道它是怎样保存你的工作的。但看一眼内部,就能解释你学过的许多东西:为什么提交有哈希,为什么分支不花任何成本,为什么改写一次提交会改变它之后的每一次提交,以及为什么分离的 HEAD 并不可怕。本模块打开 .git 文件夹。其中的一切对你来说都是只读的:你只看,不改。

从本质上说,Git 是一个由对象(object)组成的小型数据库。每个对象都以根据其内容计算出的名字存储,这个名字就是它的哈希,一个 40 个字符的十六进制数,例如 8bf3c2d58c2d8d9c541c166d6e80435faab5756a。有三种对象保存着你的历史:

  • 数据对象(blob)保存一个文件的内容:只有字节,没有文件名。
  • 树对象(tree)保存一个文件夹的内容:一份名字列表,每个名字都带有一个数据对象(对应文件)或另一个树对象(对应子文件夹)的哈希。
  • 提交对象(commit)保存整个项目的树对象的哈希、父提交的哈希(合并提交有两个,第一次提交没有)、作者、提交者、日期和说明。

所以,一次提交就是一个快照:它的树对象描述了那一刻项目中的每个文件。第四种对象,即附注标签(annotated tag),会在第 10 模块中出现。

两次提交及其树对象和数据对象 提交Add a pancake recipe提交Add a README父提交树树数据对象README.md 的内容数据对象pancakes.md 的内容提交指向一棵树和它的父提交;树指向数据对象。没有改动的文件只存储一次。
图 9.1

食谱仓库中的两次提交。每次提交都指向自己的树对象,较新的那次还指向它的父提交。两个树对象都指向同一个 README 数据对象,因为 README 在两次提交之间没有变化。

2

查看一次提交的内部

git cat-file 可以显示任意对象:-t 打印它的类型,-p(pretty-print,美观打印)打印它的内容。你可以用完整的哈希指定对象,也可以只用开头的几个字符,只要没有别的对象以它们开头就行;通常七个字符就够了。

在这个动手环节中,你从食谱仓库最新的提交出发,一路追到一个文件。要在你自己的仓库中跟着操作,请从 git log --oneline 打印的某个哈希开始,然后把你看到的每个哈希复制到下一条命令中。

动手试试查看一次提交的内部
git log --oneline
输出
8bf3c2d Add a pancake recipe
907a979 Add a README
git cat-file -t 8bf3c2d
输出
commit
git cat-file -p 8bf3c2d
输出
tree 063e1373abab86d50db53df21ded863f36cfc794
parent 907a97958867987ca6ef7be665cd4b3f86bb08d6
author Alex Smith <alex@example.com> 1767604020 +0000
committer Alex Smith <alex@example.com> 1767604020 +0000

Add a pancake recipe
git cat-file -p 063e137
输出
100644 blob 2ecc1f2d65cf3b272e009b477014267d15171467	README.md
100644 blob 871d830f4cec82e60e46ad67ec042c33f215a834	pancakes.md
git cat-file -p 871d830
输出
# Pancakes

- 200 g flour
- 2 eggs
- 300 ml milk
git cat-file -p 907a979
输出
tree 38a015c41d763382717c898da156ce660f860bea
author Alex Smith <alex@example.com> 1767603900 +0000
committer Alex Smith <alex@example.com> 1767603900 +0000

Add a README
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。
  • git cat-file -t 8bf3c2d 说明 8bf3c2d 是一个 commit。
  • -p 显示整个提交对象,它很短。tree 给出项目树对象的哈希,parent 给出前一次提交的哈希 907a979。author 和 committer 给出姓名和邮箱,然后是日期,即自 1970 年 1 月 1 日(UTC)以来的秒数,再后面是时区 +0000。空一行之后是说明。一次提交就只有这些内容。
  • git cat-file -p 063e137 显示那个树对象。每一行都有一个模式(mode)(100644 表示普通文件;100755 表示可执行文件,040000 表示子文件夹,也就是另一个树对象)、一个类型、一个哈希和一个名字。
  • git cat-file -p 871d830 显示 pancakes.md 的数据对象:正好是文件的内容,没有名字,也没有日期。名字保存在树对象里。
  • 第一次提交 907a979 有 tree,但没有 parent 这一行:正是这一点使它成为第一次提交。

作者和提交者通常是同一个人。当有人应用了别人的工作时,两者就会不同,例如变基重放同事的提交时:作者写出了这项改动,提交者则为它生成了这次提交。

3

哈希来自内容

Git 根据对象的内容计算它的哈希,内容前面还会加上一个简短的头部,写明对象的类型和大小。相同的内容总是得到相同的哈希,在任何一台电脑上都一样;任何改动,无论多么微小,都会得到一个完全不同的哈希。git hash-object <file> 计算一个文件作为数据对象时的哈希,但不存储任何东西。

在这个动手环节中,你把 pancakes.md 的哈希与一份完全相同的副本的哈希进行比较,然后再与改动了一个字符(一个句点)之后的副本比较。要跟着操作,请使用你仓库中的任意文件。

动手试试内容相同,哈希相同
git hash-object pancakes.md
输出
871d830f4cec82e60e46ad67ec042c33f215a834
创建或编辑 pancakes-copy.md,使其内容为:
# Pancakes

- 200 g flour
- 2 eggs
- 300 ml milk
git hash-object pancakes-copy.md
输出
871d830f4cec82e60e46ad67ec042c33f215a834
创建或编辑 pancakes-copy.md,使其内容为:
# Pancakes

- 200 g flour
- 2 eggs
- 300 ml milk.
git hash-object pancakes-copy.md
输出
797b0e6c8204d8a9fce80e8b1c8254eb2f060d10
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

pancakes.md 的哈希是 871d830f…,与第 2 节中树对象列出的数据对象哈希相同。完全相同的副本得到相同的哈希,所以 Git 只会以这个名字存储它一次。加上一个句点之后,得到的哈希与原来的毫无共同之处。

这解释了你见过的几件事:

  • 没有改动的文件不占额外空间。 对于每个没有改动的文件,新提交的树对象引用的仍是原来的数据对象。只有改动过的文件才会增加新的数据对象。
  • 历史无法被悄悄改动。 一次提交包含它的树对象的哈希和父提交的哈希。改动一次旧提交中的任何内容,哪怕只是说明里的一个错字,它的哈希就会改变;下一次提交指向的父提交就不同了,所以它的哈希也会改变,如此一直延续到最新的提交。这就是为什么修补和变基会生成新的提交(第 3 和第 8 模块),也是为什么共享的历史一旦被改写,别人就会察觉。
  • 副本彼此一致。 两个克隆如果都有某个哈希相同的提交,那么直到这次提交为止,它们的历史完全相同。
注意 — SHA-1 与 SHA-256

Git 的哈希用 SHA-1 计算,采用的是能检测已知攻击的加固版本。较新版本的 Git 也可以创建使用更强的 SHA-256 的仓库(git init --object-format=sha256),其哈希长 64 个字符。托管服务目前大多还不支持这种仓库,所以暂时请使用默认设置。

4

对象存放在哪里

Git 关于一个仓库所知道的一切都在它的 .git 文件夹中。在这个动手环节中,你列出它的内容,找到对象,并按哈希挑出其中一个。要跟着操作,请在你自己的仓库中运行同样的命令;你在 objects 下看到的文件夹名会有所不同。

动手试试对象存放在哪里
ls .git
输出
COMMIT_EDITMSG  HEAD  config  description  hooks  index  info  logs  objects  refs
ls .git/objects
输出
06  2e  38  87  8b  90  info  pack
ls .git/objects/87
输出
1d830f4cec82e60e46ad67ec042c33f215a834
git cat-file -t 871d830f4cec82e60e46ad67ec042c33f215a834
输出
blob
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

.git 中的各项,按 ls 列出的顺序:

  • COMMIT_EDITMSG 保存你最近一次提交的说明。
  • HEAD 记录你所在的位置(第 5 节),config 保存仓库的设置,包括它的远程仓库。
  • description 只供一个老式的网页查看器使用,hooks 保存示例脚本,Git 可以在某些时刻运行这些脚本。
  • index 就是第 2 模块中的暂存区:一份下一次提交将包含的文件及其数据对象的列表。
  • info 中有 exclude,这是只对这个克隆生效的私有 .gitignore。
  • logs 保存第 8 模块中的 reflog。
  • objects 和 refs 保存对象和分支。

在 objects 下,每个对象都是一个以其哈希命名的文件:前两个字符是文件夹名,其余 38 个字符是文件名。所以数据对象 871d830f… 就是文件夹 87 中的文件 1d830f4cec…。这六个文件夹对应两次提交的六个对象:两个提交对象、两个树对象和两个数据对象。README 的数据对象由两个树对象共用,如第 1 节的图所示。

对象文件是压缩过的,所以你无法用 cat 阅读它们;请使用 git cat-file。随着仓库增长,Git 会把许多对象打包到 objects/pack 下的少数几个文件中,并把相似的对象存储为彼此之间的差异。这就是为什么历史很长的仓库占用的空间通常比你预想的少。

陷阱

永远不要手动编辑或删除 .git 中的文件。损坏的对象或引用可能导致提交无法读取。本模块展示的每一件事,都有能安全完成它的 Git 命令。唯一的例外是 info/exclude,你可以像编辑 .gitignore 那样编辑它。

5

分支与 HEAD

分支不是对象。它是一个引用(ref):一个指向某次提交的名字,以小文件的形式存放在 .git/refs/heads/ 中,文件内容是那次提交的哈希。HEAD 也是一个引用,通常是符号引用(symbolic ref):它不保存哈希,而是写着你所在分支的名字。

在这个动手环节中,你读取 HEAD 和 main,创建一个分支并切换过去,然后观察这些文件的变化。要跟着操作,请运行同样的命令;你的哈希会有所不同,ls .git/refs/heads 也会列出你所有的分支。做完后运行 git switch main。

动手试试分支和 HEAD 都是小文件
cat .git/HEAD
输出
ref: refs/heads/main
ls .git/refs/heads
输出
main
cat .git/refs/heads/main
输出
8bf3c2d58c2d8d9c541c166d6e80435faab5756a
git branch starters
ls .git/refs/heads
输出
main  starters
cat .git/refs/heads/starters
输出
8bf3c2d58c2d8d9c541c166d6e80435faab5756a
git switch starters
输出
Switched to branch 'starters'
cat .git/HEAD
输出
ref: refs/heads/starters
git rev-parse HEAD
输出
8bf3c2d58c2d8d9c541c166d6e80435faab5756a
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。
  • .git/HEAD 中写着 ref: refs/heads/main:你在 main 上。.git/refs/heads/main 只有一行,即最新提交的哈希 8bf3c2d…。
  • git branch starters 创建了 .git/refs/heads/starters,其中是同一个哈希。这个小文件就是整个分支,所以创建分支能瞬间完成,而且不复制任何东西(第 4 模块)。
  • git switch starters 把 .git/HEAD 改成了 ref: refs/heads/starters。你提交时,Git 会把新提交的哈希写进 HEAD 所指分支的文件中,分支标签就是这样向前移动的。
  • git rev-parse HEAD 把任何名字转换成它所代表的哈希。在脚本中读取引用时,这是安全的做法。

其他引用也遵循同样的模式。origin/main 这样的远程跟踪分支位于 refs/remotes/ 下,标签位于 refs/tags/ 下。分离的 HEAD(第 3 模块)不过是 .git/HEAD 中直接写着某次提交的哈希,而不是分支名,练习 4 会展示这一点。

Git 并不总是为每个引用保存一个文件。它可以把许多引用收集到一个文件 .git/packed-refs 中,较新的版本还提供了一种完全不同的存储格式。所以请用 git rev-parse、git branch 等命令读取引用,而不要直接打开文件。

6

练习

练习 1★★★概念3 分钟

哪种对象? 下面每一项分别保存在数据对象、树对象还是提交对象中:pancakes.md 的文字;名字 pancakes.md;提交说明;父提交的哈希;pancakes.md 是普通文件而不是可执行文件这一事实。

查看解答

文字:数据对象。名字:树对象。提交说明:提交对象。父提交的哈希:提交对象。文件模式(100644):树对象,与名字写在一起。

练习 2★★★编程4 分钟

第一次提交的树。 只用 git log 和 git cat-file,列出你的食谱仓库中第一次提交的树对象里的文件。

查看解答

找到第一次提交,读出它的树对象的哈希,再显示这个树对象:

参考解答第一次提交的树
git log --oneline
输出
8bf3c2d Add a pancake recipe
907a979 Add a README
git cat-file -p 907a979
输出
tree 38a015c41d763382717c898da156ce660f860bea
author Alex Smith <alex@example.com> 1767603900 +0000
committer Alex Smith <alex@example.com> 1767603900 +0000

Add a README
git cat-file -p 38a015c
输出
100644 blob 2ecc1f2d65cf3b272e009b477014267d15171467	README.md
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

第一个树对象中只列出了 README.md。它的数据对象 2ecc1f2 与第 2 节中较新的树对象里的相同:README 没有变化。

练习 3★★★概念3 分钟

一个字母。 你在交互式变基中用 reword 修正了一次提交的说明中的错字,而这次提交之后还有三次提交。为什么只改了一条说明,四次提交却都得到了新的哈希?

查看解答

被改动的那次提交内容变了,所以它的哈希也变了。下一次提交包含其父提交的哈希,而这个哈希现在不同了,所以它的内容和哈希也随之改变,之后的每次提交都是如此。每次提交的哈希都涵盖了它之前的全部历史。

练习 4★★★编程4 分钟

磁盘上的分离 HEAD。 让 HEAD 分离到最新提交之前的那次提交上,然后查看 .git/HEAD。接着切换回 main,再看一次。

查看解答
参考解答HEAD 分离时保存的内容
git switch --detach HEAD~1
输出
HEAD is now at 907a979 Add a README
cat .git/HEAD
输出
907a97958867987ca6ef7be665cd4b3f86bb08d6
git switch main
输出
Previous HEAD position was 907a979 Add a README
Switched to branch 'main'
cat .git/HEAD
输出
ref: refs/heads/main
上面的输出来自 git version 2.50.1 (Apple Git-155),在构建本页时实际运行得到。其他版本的 Git 提示措辞可能略有不同。

分离时,.git/HEAD 中是一个提交的哈希;在分支上时,其中是 ref: refs/heads/main。在分离状态下做的提交只会移动 HEAD,没有任何分支文件记录它,所以这样的提交很容易丢失(第 3 模块)。

练习 5★★★概念3 分钟

数一数对象。 一个新仓库有两次提交。第一次加入 a.txt 和 b.txt。第二次只改动了 b.txt。这个仓库中有多少个数据对象、树对象和提交对象?

查看解答

三个数据对象(a.txt、第一版 b.txt、第二版 b.txt),两个树对象(每次提交一个,都指向同一个 a.txt 数据对象),以及两个提交对象:共七个对象。

练习 6★★★概念3 分钟

廉价的分支。 从存储的角度解释:为什么创建分支不花时间,以及为什么删除一个已合并的分支不会丢失任何东西。

查看解答

分支就是一个保存某次提交哈希的小文件;创建分支只是写下这个文件,不复制任何对象。删除已合并的分支只是删除这个文件。提交是对象,而它们被合并进去的那个分支,仍然能通过自己的提交的父提交找到它们。

7

自测

1
数据对象中保存着什么?
2
提交对象中保存着什么?
3
不同文件夹中的两个文件内容完全相同。Git 为它们存储几个数据对象?
4
为什么改动一次旧提交会改变之后所有提交的哈希?
5
.git/refs/heads/main 中通常是什么?
6
你在 main 上时,.git/HEAD 中是什么?
7
哪条命令能安全地显示任意对象的内容?
8

延伸阅读