提交:一张快照,加一根指针
「提交」是 Git 里最重的一个词——你的工作、你的历史、你的责任都在里面。所以第一次看见 commit 对象的真身时,多半会有点失望:它就是几行纯文本,一共一百来个字节。但这一章会让你看到,正是这几行文本,决定了 rebase 为什么会把一切都改名。
全部内容,就这些
$ git cat-file -p HEAD
tree fe7944f85150c68ee7948bfe6f5449cb57ed591f parent 06b431f5f556d4b4d9b2f2fa4240e4d12fc6c975 author Sakura <sakura@example.com> 1700000060 +0800 committer Sakura <sakura@example.com> 1700000060 +0800 second
数一数,160 个字节。一个提交的全部家当。
逐行拆开:
| 字段 | 是什么 | 为什么需要它 |
|---|---|---|
tree | 那一刻整棵目录的完整快照 | 这是提交的内容。一行就锁定了项目的全部状态(第 3 章) |
parent | 上一个提交的哈希 | 这是提交的历史。可以有 0 个(根提交)、1 个(普通)、2 个以上(合并) |
author | 谁写的这份改动、什么时候写的 | 原始作者,rebase / cherry-pick 时保持不变 |
committer | 谁把它放进历史的、什么时候放的 | 操作者,rebase 时会被更新 |
| 空行 + 正文 | 提交信息 | 给人看的。Git 自己完全不解析它 |
一个提交对象干的事只有两件:
一、用 tree 说「此刻长这样」——这是一张完整快照,不是差量。
二、用 parent 说「上一刻是那样」——这根指针把所有提交串成一张图(卷 IV 的全部内容)。
剩下的都是给人看的元数据。Git 的历史模型,就是这两个字段撑起来的。
下面这台构造器会真的拼出这几行文本、真的算哈希。
先点「时间 +1 秒」。内容一个字节没动,看哈希会怎么样。
然后试试改提交信息里的一个字,或者加一个父提交。每一次,整个哈希都会变得面目全非。
那个「时间 +1 秒」说明了什么
你刚才应该看到了:时间戳动一秒,哈希完全变了。
这不奇怪——上一章讲过,哈希由内容决定,而时间戳就在内容里面。但这个小事有两个很实际的后果。
后果一:同样的代码,提交两次,得到两个不同的提交
做个实验:把工作区恢复到某个提交的状态,然后重新提交一次,用一模一样的提交信息。
你会得到一个全新的提交号。哪怕 tree 完全相同(内容确实一样)、作者相同、信息相同——只要时间戳不同,就是两个提交。
这也解释了 git commit --amend 的真相:它不修改原来那个提交(对象不可变,改不了),而是用新的内容造一个新提交,然后把分支贴纸挪过去。老的那个还在库里,只是没人指着它了(第 9 章会去把它找回来)。
后果二:rebase 之后哈希必然全变
这是全书最重要的连锁反应之一,值得慢慢走一遍。
假设有三个提交串在一起:
A ← B ← C
现在你 rebase,把 B 挪到别的地基上。B 的 parent 字段从 A 变成了别的东西。于是:
- B 的内容变了(
parent那一行不一样了) - 所以 B 的哈希变了 —— 现在它是 B′
- 但 C 的内容里写着
parent B,而 B 已经不是原来那个哈希了 - 所以 C 也必须重建成 C′,它的
parent指向 B′ - C 的哈希也变了……
一路连锁到分支顶端。你只想动一个提交,结果它后面所有提交的名字全变了。
「为什么 rebase 会改写历史」这个问题,答案不在 rebase 的实现里,而在「名字由内容决定」这条规则里。
只要哈希由内容算出来,而内容里包含父提交的哈希,那么改动任何一个提交,都必然导致它之后的所有提交改名。没有任何办法绕过。
换句话说:Git 里根本不存在「修改一个提交」这种操作。所有看起来像修改的命令(amend、rebase、filter-repo),实际做的都是造一批新提交,然后把贴纸挪过去。老的那些一个都没动,只是失去了引用。
第 18 章会把这件事完整演一遍,你会看到 D 和 D′ 并排躺在图上。
author 和 committer 为什么是两个人
这两行看着重复,第一次见都会觉得是冗余。但它们区分的是两件真事:
- author(作者):这份改动是谁写的、什么时候写的。
- committer(提交者):这个提交对象是谁造的、什么时候造的。
平时两者相同,所以你不会注意。它们分开的场合有三个:
- 打补丁:有人给邮件列表发了个补丁,维护者用
git am应用。作者是投稿人,提交者是维护者。Linux 内核就是这么运作的,所以那里几乎每个提交两者都不同。 - rebase:提交被重建了,作者保持原样(改动确实是他写的),提交者更新成你(这个新对象是你造的)。
- cherry-pick:同理,作者不变,提交者是你。
顺带一个日常观察:GitHub 上那个「XX committed on ⋯」的时间,用的是 committer 时间。所以 rebase 过的分支,提交在页面上的排列顺序有时候会跟你记忆里的写作顺序不一致——因为它按提交者时间排,而那个时间是 rebase 那一刻。
时间存的是 Unix 时间戳 + 时区偏移,两个独立的东西:
author Sakura <sakura@example.com> 1700000060 +0800
时间戳本身是 UTC 的,时区只是「作者当时在哪个时区」的记录。所以 +0800 改成 +0000 不会改变这个提交发生的绝对时刻,但会改变哈希(它在内容里)。
另外注意:提交时间完全由本地机器决定,可以随便伪造(GIT_AUTHOR_DATE 环境变量)。DAG 里的 parent 关系才是真正可靠的顺序——一个提交的时间戳甚至可以比它父提交还早,Git 不会报错。
所以 git log 默认按拓扑序而不是纯时间序来排,就是为了防这个。机器时钟错乱、跨时区协作、rebase 保留旧作者时间,都会让纯时间排序变得荒唐。
你可以完全绕过 git commit,手工造一个提交出来。这是理解「提交只是个对象」最好的方式:
# 1. 把当前索引写成一棵树 $ TREE=$(git write-tree) # 2. 用这棵树造一个提交对象(-p 指定父提交) $ COMMIT=$(git commit-tree $TREE -p HEAD -m "手工造的") # 3. 到这一步,提交已经在对象库里了,但没有任何分支指着它 $ git cat-file -p $COMMIT # 4. 把 main 这张贴纸挪过去 —— 现在它才算「在历史里」 $ git update-ref refs/heads/main $COMMIT
这四步就是 git commit 的全部内容。前三步造对象,第四步挪贴纸。
特别注意第 3 步之后的状态:提交对象已经存在了,但你 git log 看不到它——因为没有任何贴纸能走到它。这个状态在第 7 章(detached HEAD)和第 9 章(reflog)会反复出现。
再看一眼提交的真实字节,确认它就是纯文本:
$ git cat-file commit HEAD | xxd | head -3
00000000: 7472 6565 2066 6537 3934 3466 3835 3135 tree fe7944f8515 00000010: 3063 3638 6565 3739 3438 6266 6536 6635 0c68ee7948bfe6f5 00000020: 3434 3963 6235 3765 6435 3931 660a 7061 449cb57ed591f.pa
注意:这里的哈希是40 个 ASCII 字符(7472 6565 就是 tree),和 tree 对象里的 20 字节裸二进制不一样。commit 是纯文本格式,tree 是二进制格式——这个不一致纯属历史原因,但你在解析时会撞上。
顺手把最后一种对象讲完,它最简单,也最少用到。
Git 有两种标签,很多人分不清:
- 轻量标签(lightweight):
git tag v1.0。它根本不是对象,就是.git/refs/tags/v1.0里的一个 41 字节文件——和分支一模一样,只是放在另一个目录、而且约定俗成不再移动。 - 附注标签(annotated):
git tag -a v1.0 -m "发布"。这个会创建一个真正的 tag 对象:
object 1bada9988e4c142f884f13990c310e024c36f1ea type commit tag v1.0 tagger Sakura <sakura@example.com> 1700000200 +0800 正式发布 1.0
它有自己的哈希、自己的作者和时间、自己的信息,而且可以被 GPG 签名。发布用的标签应该用这种。
顺带一提:tag 对象的 object 可以指向任何类型的对象,包括另一个 tag(套娃)甚至一个 blob。Git 官方仓库里就有一个著名的例子——一个指向 blob 的标签,里面存着 Junio 的公钥。
「我 git commit --amend 改了个错别字,然后 push 被拒绝了,说 non-fast-forward。为什么改个错别字这么严重?」
现在你能自己解释了:--amend 没有修改那个提交,它造了一个新提交——新的时间戳(committer 时间会更新)、新的信息、于是新的哈希。
服务器上还是老的那个提交号,而你本地已经是新的了。这两个提交谁也不是谁的祖先,所以没法快进(第 16 章会给「快进」一个精确定义)。Git 拒绝,是在防止你悄无声息地把服务器上那个提交变成孤儿——别人可能已经基于它工作了。
所以规矩就是那条老话,但现在你知道它为什么了:推出去的提交,别 amend、别 rebase。要改就用 revert 往前走(第 19 章),或者确认没人拉过之后用 --force-with-lease(第 20 章会讲它比 --force 安全在哪)。
这一章的一句话
commit 对象只是几行纯文本:一个 tree(此刻的完整快照)、若干个 parent(历史)、作者与提交者、一句话。因为哈希由内容决定而 parent 就在内容里,所以改动任何一个提交,都必然让它之后的所有提交改名——Git 里根本不存在「修改提交」这种操作,只有「造新的 + 挪贴纸」。
下一章:四种对象都齐了。我们把它们放进一座真的数据库,跑两次提交,数一数到底新增了几个对象——那个数字就是「存快照不爆炸」的全部答案。