卷 I · 物CH 04深度 4/24

提交:一张快照,加一根指针

「提交」是 Git 里最重的一个词——你的工作、你的历史、你的责任都在里面。所以第一次看见 commit 对象的真身时,多半会有点失望:它就是几行纯文本,一共一百来个字节。但这一章会让你看到,正是这几行文本,决定了 rebase 为什么会把一切都改名。

commit 对象parent 指针不可变性作者 vs 提交者

全部内容,就这些

$ 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 的历史模型,就是这两个字段撑起来的。

下面这台构造器会真的拼出这几行文本、真的算哈希。

▶ 动手 · commit 构造器

先点「时间 +1 秒」。内容一个字节没动,看哈希会怎么样。

然后试试改提交信息里的一个字,或者加一个父提交。每一次,整个哈希都会变得面目全非。

那个「时间 +1 秒」说明了什么

你刚才应该看到了:时间戳动一秒,哈希完全变了。

这不奇怪——上一章讲过,哈希由内容决定,而时间戳就在内容里面。但这个小事有两个很实际的后果。

后果一:同样的代码,提交两次,得到两个不同的提交

做个实验:把工作区恢复到某个提交的状态,然后重新提交一次,用一模一样的提交信息。

你会得到一个全新的提交号。哪怕 tree 完全相同(内容确实一样)、作者相同、信息相同——只要时间戳不同,就是两个提交。

这也解释了 git commit --amend 的真相:它不修改原来那个提交(对象不可变,改不了),而是用新的内容造一个新提交,然后把分支贴纸挪过去。老的那个还在库里,只是没人指着它了(第 9 章会去把它找回来)。

后果二:rebase 之后哈希必然全变

这是全书最重要的连锁反应之一,值得慢慢走一遍。

假设有三个提交串在一起:

A ← B ← C

现在你 rebase,把 B 挪到别的地基上。B 的 parent 字段从 A 变成了别的东西。于是:

  1. B 的内容变了(parent 那一行不一样了)
  2. 所以 B 的哈希变了 —— 现在它是 B′
  3. 但 C 的内容里写着 parent B,而 B 已经不是原来那个哈希了
  4. 所以 C 也必须重建成 C′,它的 parent 指向 B′
  5. C 的哈希也变了……

一路连锁到分支顶端。你只想动一个提交,结果它后面所有提交的名字全变了。

◆ 这不是 Git 的设计选择,是数学后果

「为什么 rebase 会改写历史」这个问题,答案不在 rebase 的实现里,而在「名字由内容决定」这条规则里

只要哈希由内容算出来,而内容里包含父提交的哈希,那么改动任何一个提交,都必然导致它之后的所有提交改名。没有任何办法绕过。

换句话说:Git 里根本不存在「修改一个提交」这种操作。所有看起来像修改的命令(amendrebasefilter-repo),实际做的都是造一批新提交,然后把贴纸挪过去。老的那些一个都没动,只是失去了引用。

第 18 章会把这件事完整演一遍,你会看到 D 和 D′ 并排躺在图上。

author 和 committer 为什么是两个人

这两行看着重复,第一次见都会觉得是冗余。但它们区分的是两件真事:

  • author(作者):这份改动是谁写的、什么时候写的。
  • committer(提交者):这个提交对象是谁造的、什么时候造的。

平时两者相同,所以你不会注意。它们分开的场合有三个:

  1. 打补丁:有人给邮件列表发了个补丁,维护者用 git am 应用。作者是投稿人,提交者是维护者。Linux 内核就是这么运作的,所以那里几乎每个提交两者都不同。
  2. rebase:提交被重建了,作者保持原样(改动确实是他写的),提交者更新成你(这个新对象是你造的)。
  3. 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 看一眼

你可以完全绕过 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 是二进制格式——这个不一致纯属历史原因,但你在解析时会撞上。

✎ 第四种对象:tag

顺手把最后一种对象讲完,它最简单,也最少用到。

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 里根本不存在「修改提交」这种操作,只有「造新的 + 挪贴纸」。

下一章:四种对象都齐了。我们把它们放进一座真的数据库,跑两次提交,数一数到底新增了几个对象——那个数字就是「存快照不爆炸」的全部答案。