对象与哈希
你可以用 Git 好多年,却不知道它是怎样保存你的工作的。但看一眼内部,就能解释你学过的许多东西:为什么提交有哈希,为什么分支不花任何成本,为什么改写一次提交会改变它之后的每一次提交,以及为什么分离的 HEAD 并不可怕。本模块打开 .git 文件夹。其中的一切对你来说都是只读的:你只看,不改。
从本质上说,Git 是一个由对象(object)组成的小型数据库。每个对象都以根据其内容计算出的名字存储,这个名字就是它的哈希,一个 40 个字符的十六进制数,例如 8bf3c2d58c2d8d9c541c166d6e80435faab5756a。有三种对象保存着你的历史:
- 数据对象(blob)保存一个文件的内容:只有字节,没有文件名。
- 树对象(tree)保存一个文件夹的内容:一份名字列表,每个名字都带有一个数据对象(对应文件)或另一个树对象(对应子文件夹)的哈希。
- 提交对象(commit)保存整个项目的树对象的哈希、父提交的哈希(合并提交有两个,第一次提交没有)、作者、提交者、日期和说明。
所以,一次提交就是一个快照:它的树对象描述了那一刻项目中的每个文件。第四种对象,即附注标签(annotated tag),会在第 10 模块中出现。
食谱仓库中的两次提交。每次提交都指向自己的树对象,较新的那次还指向它的父提交。两个树对象都指向同一个 README 数据对象,因为 README 在两次提交之间没有变化。
查看一次提交的内部
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 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这一行:正是这一点使它成为第一次提交。
作者和提交者通常是同一个人。当有人应用了别人的工作时,两者就会不同,例如变基重放同事的提交时:作者写出了这项改动,提交者则为它生成了这次提交。
哈希来自内容
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
pancakes.md 的哈希是 871d830f…,与第 2 节中树对象列出的数据对象哈希相同。完全相同的副本得到相同的哈希,所以 Git 只会以这个名字存储它一次。加上一个句点之后,得到的哈希与原来的毫无共同之处。
这解释了你见过的几件事:
- 没有改动的文件不占额外空间。 对于每个没有改动的文件,新提交的树对象引用的仍是原来的数据对象。只有改动过的文件才会增加新的数据对象。
- 历史无法被悄悄改动。 一次提交包含它的树对象的哈希和父提交的哈希。改动一次旧提交中的任何内容,哪怕只是说明里的一个错字,它的哈希就会改变;下一次提交指向的父提交就不同了,所以它的哈希也会改变,如此一直延续到最新的提交。这就是为什么修补和变基会生成新的提交(第 3 和第 8 模块),也是为什么共享的历史一旦被改写,别人就会察觉。
- 副本彼此一致。 两个克隆如果都有某个哈希相同的提交,那么直到这次提交为止,它们的历史完全相同。
Git 的哈希用 SHA-1 计算,采用的是能检测已知攻击的加固版本。较新版本的 Git 也可以创建使用更强的 SHA-256 的仓库(git init --object-format=sha256),其哈希长 64 个字符。托管服务目前大多还不支持这种仓库,所以暂时请使用默认设置。
对象存放在哪里
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 中的各项,按 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 那样编辑它。
分支与 HEAD
分支不是对象。它是一个引用(ref):一个指向某次提交的名字,以小文件的形式存放在 .git/refs/heads/ 中,文件内容是那次提交的哈希。HEAD 也是一个引用,通常是符号引用(symbolic ref):它不保存哈希,而是写着你所在分支的名字。
在这个动手环节中,你读取 HEAD 和 main,创建一个分支并切换过去,然后观察这些文件的变化。要跟着操作,请运行同样的命令;你的哈希会有所不同,ls .git/refs/heads 也会列出你所有的分支。做完后运行 git switch main。
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/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 等命令读取引用,而不要直接打开文件。
练习
哪种对象? 下面每一项分别保存在数据对象、树对象还是提交对象中:pancakes.md 的文字;名字 pancakes.md;提交说明;父提交的哈希;pancakes.md 是普通文件而不是可执行文件这一事实。
查看解答
文字:数据对象。名字:树对象。提交说明:提交对象。父提交的哈希:提交对象。文件模式(100644):树对象,与名字写在一起。
第一次提交的树。 只用 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
第一个树对象中只列出了 README.md。它的数据对象 2ecc1f2 与第 2 节中较新的树对象里的相同:README 没有变化。
一个字母。 你在交互式变基中用 reword 修正了一次提交的说明中的错字,而这次提交之后还有三次提交。为什么只改了一条说明,四次提交却都得到了新的哈希?
查看解答
被改动的那次提交内容变了,所以它的哈希也变了。下一次提交包含其父提交的哈希,而这个哈希现在不同了,所以它的内容和哈希也随之改变,之后的每次提交都是如此。每次提交的哈希都涵盖了它之前的全部历史。
磁盘上的分离 HEAD。 让 HEAD 分离到最新提交之前的那次提交上,然后查看 .git/HEAD。接着切换回 main,再看一次。
查看解答
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/HEAD 中是一个提交的哈希;在分支上时,其中是 ref: refs/heads/main。在分离状态下做的提交只会移动 HEAD,没有任何分支文件记录它,所以这样的提交很容易丢失(第 3 模块)。
数一数对象。 一个新仓库有两次提交。第一次加入 a.txt 和 b.txt。第二次只改动了 b.txt。这个仓库中有多少个数据对象、树对象和提交对象?
查看解答
三个数据对象(a.txt、第一版 b.txt、第二版 b.txt),两个树对象(每次提交一个,都指向同一个 a.txt 数据对象),以及两个提交对象:共七个对象。
廉价的分支。 从存储的角度解释:为什么创建分支不花时间,以及为什么删除一个已合并的分支不会丢失任何东西。
查看解答
分支就是一个保存某次提交哈希的小文件;创建分支只是写下这个文件,不复制任何对象。删除已合并的分支只是删除这个文件。提交是对象,而它们被合并进去的那个分支,仍然能通过自己的提交的父提交找到它们。
自测
.git/refs/heads/main 中通常是什么?main 上时,.git/HEAD 中是什么?延伸阅读
- Scott Chacon 和 Ben Straub,《Pro Git》,第 10.2 节:Git 对象和第 10.3 节:Git 引用。
- git cat-file、git hash-object 和 git rev-parse 的参考页面。
- gitrepository-layout,其中描述了
.git文件夹中的每个文件。