Skip to content
Published on

用 Git 内部结构理解命令 — 用对象、引用、索引重读 reset 和 rebase

分享
Authors

引言 — 背命令的人和懂模型的人

搜 Git 用法,出来的大多是命令清单:这种情况用这条,那种情况用那条。问题是清单上没有的情况一定会出现。到那时候,背命令的人再去搜,懂数据模型的人则去推。

好在要记的模型非常小。四种对象、引用、索引。这三样就是全部。本文先把这三样直接打开看看,然后再逐一回过头去看 reset、rebase、cherry-pick 和分离 HEAD 为什么会那样工作。如果好奇的是数据本身怎么存的,Git 到底是怎么存储数据的一篇讲得更细。本文专注在用那个模型重读命令。

四种对象 — 以及相同内容就是相同哈希

Git 保存的对象只有四种。

blob 只装文件内容,里面既没有文件名,也没有权限和位置。tree 表示一个目录,装着模式、类型、哈希和名字的列表。commit 装着一个 tree、若干父提交、作者和提交者,以及提交信息。tag 对象在创建带注释的标签时产生,装着目标对象、标签名和签名。

而所有这些对象的名字,都是对内容做哈希得到的值。所以内容相同,名字就相同。验证起来很容易。

$ printf 'hello\n' > hello.txt
$ cp hello.txt copy.txt
$ git add hello.txt copy.txt

$ git ls-files --stage
100644 ce013625030ba8dba906f756967f9e9ca394464a 0	copy.txt
100644 ce013625030ba8dba906f756967f9e9ca394464a 0	hello.txt

文件名不一样,哈希却一样。存下来的对象也只有一个。只要内容相同,在仓库里的哪个位置、重复提交多少次,对象都不会增加。

由此派生出一个事实,它在实务中经常把人搞糊涂。因为 blob 不知道自己的名字,Git 并不记录文件改名。名字由 tree 持有,只改了名字的变更不过是一个 tree 不同的新提交而已。日志和 diff 里看到的改名标记不是被保存下来的事实,而是 Git 当场按内容相似度推测出来的结果。那些"跨越改名继续追历史"的选项偶尔表现得很怪,原因就在这里。

亲手打开看看 — hash-object 与 cat-file

哈希是怎么算出来的,两条命令就能确认。

$ printf 'hello\n' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a

$ printf 'blob 6\0hello\n' | shasum
ce013625030ba8dba906f756967f9e9ca394464a  -

第二行才是 Git 实际做的事。它在内容前面拼上由类型名、字节数和一个空字节组成的头部,再对整体做哈希。正因为有这个头部,同样的字节序列作为 blob 和作为 tree 会得到不同的哈希。

提交也不过是一段没什么特别的文本。

$ git commit -q -m "첫 커밋"
$ git cat-file -p HEAD
tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
author Demo <demo@example.com> 1785081963 +0900
committer Demo <demo@example.com> 1785081963 +0900

첫 커밋

没有 parent 那一行,因为这是最初的提交。接着顺着 tree 再往里走一层。

$ git cat-file -t aaa96ce
tree

$ git cat-file -p aaa96ce
100644 blob ce013625030ba8dba906f756967f9e9ca394464a	hello.txt

提交指向 tree,tree 把名字和 blob 配成对拿着。这个结构只是在整个仓库里递归地重复而已。提交对象里没有一个字节的文件内容,连文件名都没有

想一次看清有多少对象、都是什么类型,可以用批量查询。

$ git cat-file --batch-check --batch-all-objects | head -4
ad50a5790e96b8f448d121aa76cee2cc3f05afce commit 178
aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7 tree 37
ce013625030ba8dba906f756967f9e9ca394464a blob 6

引用 — 分支为什么只是一个 41 字节的文件

分支不是什么数据结构,它就是一个装着一个提交哈希的文本文件。

$ cat .git/HEAD
ref: refs/heads/main

$ cat .git/refs/heads/main
951f4a64d6435393fdedb7eb6aadd53939826b4c

$ wc -c .git/refs/heads/main
      41 .git/refs/heads/main

40 个十六进制字符加一个换行。所以创建分支就是写一个文件的事,仓库再大成本也一样。把别的版本控制系统里"分支是重活"那个年代的感觉原样搬到 Git 上,人就会舍不得开分支,可完全没有舍不得的理由。

HEAD 还多了一层。它不直接指向提交,而是指向分支名的符号引用。所以做提交时,被更新的是 HEAD 所指分支的那个文件。

$ git symbolic-ref HEAD
refs/heads/main

$ git update-ref refs/heads/experiment 951f4a6
$ git rev-parse experiment
951f4a64d6435393fdedb7eb6aadd53939826b4c

这里有个坑。引用并不总是以文件形式存在。整理作业跑过之后,引用会被打包进一个文件,单独的文件就没了。

$ git pack-refs --all
$ ls .git/refs/heads/
$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
951f4a64d6435393fdedb7eb6aadd53939826b4c refs/heads/main

目录空了不等于分支没了。与其直接读文件,不如永远去问 Git

$ git rev-parse main
951f4a64d6435393fdedb7eb6aadd53939826b4c
$ git show-ref --heads

针对引用数量涨到数万个的大型仓库,还准备了新的存储格式。目前还处于实验阶段,但在引用查询本身成为瓶颈的环境里值得考虑。

$ git init --ref-format=reftable myrepo

索引 — 暂存区的真实面貌

暂存区不是一个概念,而是一个文件。它是仓库里的一个二进制文件,装着路径、blob 哈希和文件状态信息的列表。

$ git ls-files --stage
100644 ce013625030ba8dba906f756967f9e9ca394464a 0	hello.txt
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0	note.txt

这里要读出两件事。

第一,把文件放进暂存区的那一刻,它的内容就已经被存成了对象。暂存不是预约,而是记录。所以才会出现下面的结果。

$ echo v1 > note.txt
$ git add note.txt
$ echo v2 > note.txt
$ git commit -q -m "메모 추가"

$ git show HEAD:note.txt
v1
$ cat note.txt
v2

暂存之后又改了文件的话,被提交的是暂存那一刻的内容。这不是 bug,而是"索引是什么"这个定义本身推出来的行为。同样的道理,只暂存、没提交的内容在出事之后也能恢复,因为对象早就存好了。

第二,列表里的数字 0 是阶段号。平时只有 0 一个,但在冲突期间同一条路径会扩成三行。

$ git ls-files --unmerged
100644 a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 1	src/checkout/retry.ts
100644 b2c3d4e5f60718293a4b5c6d7e8f90123456789a 2	src/checkout/retry.ts
100644 c3d4e5f60718293a4b5c6d7e8f90123456789ab1 3	src/checkout/retry.ts

1 号是共同祖先,2 号是当前分支,3 号是并进来的一侧。所谓冲突状态,就是索引里对同一条路径存着三个候选的状态。解决冲突并暂存之后,三行会合并成 0 号那一行,到那时才终于可以提交。解决过程中也能把每个版本单独取出来看。

$ git show :1:src/checkout/retry.ts > base.ts
$ git show :2:src/checkout/retry.ts > ours.ts
$ git show :3:src/checkout/retry.ts > theirs.ts

索引里还一并装着文件大小和修改时间之类的状态信息。这既是状态检查不打开文件也能判断有无变化的原因,也是文件涨到几十万个之后这道检查本身变成瓶颈的原因。那个点上的处方整理在仓库变慢的时候一篇里。

模型能解释的那些命令

现在再来重读这些命令。判断标准只有三条:会不会创建新对象,会不会移动引用,会不会覆盖工作树。

命令在数据模型里做的事新对象是否覆盖工作树
git branch写一个装着一行提交哈希的引用没有
git switch改变 HEAD 指向的对象,并对齐索引和工作树没有
git commit创建 tree 和 commit 对象,并移动当前分支引用创建
git reset --soft只移动分支引用没有
git reset --hard移动引用,并把索引和工作树对齐到目标提交没有
git rebase每个提交都新建一个提交对象,并移动引用创建
git cherry-pick以目标提交的父为基准做三方合并,然后新建提交创建
git tag -a创建 tag 对象并写一个引用创建

读完这张表,常见的那些问题就自己解开了。

reset 被称为危险命令,原因并不是它移动引用。移动引用不过是重写一个 41 字节的文件,旧值还留在 reflog 里。危险的只有那个会覆盖工作树的选项。表格最后一列就是危险度

rebase 为什么不搬走提交而是新建提交,理由同样明确。提交对象里装着父提交的哈希,而提交的名字是对这份内容整体做的哈希,所以父提交变了,名字就不可能不变。重写这个词不是比喻,而是字面意义上的描述。这个事实带来的团队规则,在merge 与 rebase一篇里讲过。

cherry-pick 为什么会冲突,答案也出自这里。cherry-pick 不是把存下来的 diff 剪下来贴上去。Git 压根不存 diff,只存快照。所以 cherry-pick 是把目标提交的父 tree 当作共同祖先来做三方合并。要摘取的那个提交的父状态离当前分支的状态越远,冲突就越多。如果同一处改动要反复搬到好几个分支上,那就不如把冲突解决的复用打开。

分离 HEAD(detached HEAD)也不是什么特殊状态,只是 HEAD 文件里装的不是分支名而是提交哈希而已。

$ git switch --detach 951f4a6
HEAD is now at 951f4a6 메모 추가

$ cat .git/HEAD
951f4a64d6435393fdedb7eb6aadd53939826b4c

在这个状态下做提交,被更新去指向新提交的是 HEAD 本身,可没有分支引用可更新。所以一旦切到别处,就再没有任何东西指着那个提交了。Git 之所以好心提醒你,原因就在这里。

$ git switch -
Warning: you are leaving 1 commit behind, not connected to
any of your branches:

  d66381f 임시 실험

If you want to keep it by creating a new branch, this may be a good time
to do so with:

 git branch <new-branch-name> d66381f

Switched to branch 'main'

detached HEAD 不是坏了,只是没有名牌的状态。提交对象好好地存着,给它挂个名字就原样活过来了。

从 SHA-1 到 SHA-256 — 碰撞攻击与 Git 的应对

用哈希给对象命名的设计很优雅,但它依附于哈希函数的寿命。Git 在 2005 年选的是 SHA-1,之后针对它的攻击一直在推进。

2017 年,CWI 阿姆斯特丹和谷歌的研究者公开了两个 SHA-1 哈希相同、内容却不同的 PDF。2020 年,勒朗和佩兰实现了选择前缀碰撞,把成本压到了几万美元的量级。选择前缀碰撞意味着攻击者可以指定前面那一段,这就离伪造真实文档近得多了。

Git 的哈希首要用途是完整性校验和标识,而不是签名。但签名的提交和标签最终签的正是这个哈希,所以一旦碰撞成为可能,签名的意义就会动摇。

应对分两条线推进。第一条是能立刻用上的防御。从 Git 2.13 起,带碰撞检测的 SHA-1 实现成了默认。当它被要求对带有已知攻击手法痕迹的数据做哈希时,会拒绝而不是把计算做完。

$ git hash-object --stdin < shattered-1.pdf
fatal: SHA-1 collision detected on 38762cf7f55934b34d179ae6a4c80cadccbb7f0a

第二条是根本解决。从 Git 2.29 起,可以实验性地使用 SHA-256 对象格式。

$ git init --object-format=sha256 sha256demo
$ cd sha256demo

$ git rev-parse --show-object-format
sha256

$ printf 'hello\n' | git hash-object --stdin
2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4

内容一样,名字却完全不同,长度是 64 个字符。在仓库内部,所有命令都照常工作。不过现在还不是实务上该迁移的对象,因为 SHA-1 仓库和 SHA-256 仓库之间的互操作还没完成,主流托管服务也不接受。

不过眼下有件事可以先做。假定哈希长度是 40 的脚本和正则表达式,早晚全都会坏。如果你在做工具,与其把长度写死,不如去问仓库更安全。

$ git rev-parse --show-object-format
sha1

结语 — 用三个问题代替命令表

Git 的命令不必全部背下来。碰到陌生命令时只要问三件事:这条命令会不会创建新对象,会不会移动引用,会不会覆盖工作树。

会创建新对象就意味着哈希会变,在共享历史上要小心。只移动引用的话,reflog 会帮你倒回来。会覆盖工作树的话,未提交的改动就消失了,而这一样是任何工具都救不回来的。Git 里真正危险的命令不是改历史的命令,而是删除尚未成为对象的东西的命令

用同一个模型梳理撤销命令的文章,在按情况回退一篇里。