大多数人学 Git 是在背命令:
commit、push、rebase、reset --hard......命令越背越多,心里越来越虚,因为不知道背后发生了什么。这篇文章想反着来一次:先弄清 Git 的数据模型,你会发现那些"吓人"的命令突然全都讲得通了。
目录
- [写在前面:为什么 Git 难学](#写在前面:为什么 Git 难学 "#1-%E5%86%99%E5%9C%A8%E5%89%8D%E9%9D%A2%E4%B8%BA%E4%BB%80%E4%B9%88-git-%E9%9A%BE%E5%AD%A6")
- [核心洞察:Git 存的是快照,不是差异](#核心洞察:Git 存的是快照,不是差异 "#2-%E6%A0%B8%E5%BF%83%E6%B4%9E%E5%AF%9Fgit-%E5%AD%98%E7%9A%84%E6%98%AF%E5%BF%AB%E7%85%A7%E4%B8%8D%E6%98%AF%E5%B7%AE%E5%BC%82")
- [内容寻址:Git 的对象数据库](#内容寻址:Git 的对象数据库 "#3-%E5%86%85%E5%AE%B9%E5%AF%BB%E5%9D%80git-%E7%9A%84%E5%AF%B9%E8%B1%A1%E6%95%B0%E6%8D%AE%E5%BA%93")
- 手搓一次提交:只用底层命令
- [commit 对象的解剖报告](#commit 对象的解剖报告 "#5-commit-%E5%AF%B9%E8%B1%A1%E7%9A%84%E8%A7%A3%E5%89%96%E6%8A%A5%E5%91%8A")
- 分支:一个文件而已
- [HEAD 的本质与 detached 状态](#HEAD 的本质与 detached 状态 "#7-head-%E7%9A%84%E6%9C%AC%E8%B4%A8%E4%B8%8E-detached-%E7%8A%B6%E6%80%81")
- 标签的两种形态
- 暂存区到底暂存了什么
- [打包与压缩:Git 为什么不占空间](#打包与压缩:Git 为什么不占空间 "#10-%E6%89%93%E5%8C%85%E4%B8%8E%E5%8E%8B%E7%BC%A9git-%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%8D%E5%8D%A0%E7%A9%BA%E9%97%B4")
- 用对象模型重新解释你天天在用的命令
- [挽救现场:reflog 是你的后悔药](#挽救现场:reflog 是你的后悔药 "#12-%E6%8C%BD%E6%95%91%E7%8E%B0%E5%9C%BAreflog-%E6%98%AF%E4%BD%A0%E7%9A%84%E5%90%8E%E6%82%94%E8%8D%AF")
- [附:50 行 Python 造一个 mini-git](#附:50 行 Python 造一个 mini-git "#13-%E9%99%8450-%E8%A1%8C-python-%E9%80%A0%E4%B8%80%E4%B8%AA-mini-git")
- 思考题
- 参考资料
1. 写在前面:为什么 Git 难学
Git 大概是被抱怨最多的主流工具。rebase 和 merge 的区别、reset 三种模式、checkout 和 switch 的关系、冲突到底怎么解------每个问题单独看都像是一条孤立的规定,只能死记硬背。
但如果你打开任何一个 Git 仓库下的 .git 目录看一眼,会发现 Git 其实是一个设计极其简单、甚至可以说优雅的系统:
- 它本质上是一个内容寻址的键值数据库(content-addressable storage);
- 外面套了一层薄薄的"引用系统",用来给数据库里的对象起名字;
- 你天天敲的所有命令,都只是对这套数据库的读写操作。
换句话说,Git 的命令是易变的,但数据模型是永恒的。Linus 在 2005 年写第一版 Git 时,最初的定位甚至不是"版本控制系统",而是"文件系统"------版本控制只是架在它上面的一个应用。理解了这一点,剩下的全是细节。
本文会大量使用 Git 的"底层命令"(plumbing commands),它们是给程序和高级用户用的,日常你几乎不会敲,但它们是看穿 Git 的 X 光机。对应的"高层命令"(porcelain commands)就是我们平时用的 git add、git commit 那些。
2. 核心洞察:Git 存的是快照,不是差异
这是理解 Git 最重要的一个心智转变。
很多人(包括很多教程)把 Git 想象成这样:每次提交,Git 记录"哪些文件改了什么",然后一条 diff 链从头串到尾。想看第 N 版某个文件?从第 1 版开始一路打补丁打过去。
Subversion 确实是这么想的。但 Git 不是。
Git 的模型是:每次提交,都是对整个项目目录树的一次完整快照。你改了一个文件,提交,Git 做的事情是:
- 把没变的文件"存一个指向旧数据的指针";
- 把变了的新文件作为新对象完整存一份;
- 把整个目录树重新描述一遍。
所以 Git 里"历史"长这样(概念图):
text
提交1 提交2 提交3
┌─────┐ ┌─────┐ ┌─────┐
│快照1│ ←──── │快照2│ ←──── │快照3│
└─────┘ └─────┘ └─────┘
每个提交都是"当时的全貌",而不是"和上一版比改了啥"
你可能会立刻反驳:那我改一个 100MB 大文件里的一个字,难道每次提交都存 100MB?问得好------这个担心完全合理,答案在[第 10 节](#第 10 节 "#10-%E6%89%93%E5%8C%85%E4%B8%8E%E5%8E%8B%E7%BC%A9git-%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%8D%E5%8D%A0%E7%A9%BA%E9%97%B4"):Git 在打包时会做增量压缩(delta compression) ,但那是存储层的优化,不属于概念模型。概念模型上,每次提交就是全量快照;存储上,Git 会自动找出相似对象并只存差异。
这个"概念全量、存储增量"的分层设计,是 Git 既简单又高效的根源。diff 只是一个派生品:想比较两个快照?Git 现场给你算 diff,而不是把 diff 存起来。
3. 内容寻址:Git 的对象数据库
3.1 动手看看 .git
新建一个仓库看看里面有什么:
bash
$ mkdir git-lab && cd git-lab
$ git init
$ ls -a
. .. .git
$ find .git -type f | sort
.git/config
.git/description
.git/HEAD
.git/hooks/... # 一堆示例钩子,暂时忽略
.git/info/exclude
.git/objects/ # ← 对象数据库,空的(连目录都没有)
.git/refs/ # ← 引用目录,空的
忽略掉钩子和模板,真正干活的就三样东西:
objects/:对象数据库,Git 的一切数据都在这里;refs/:引用,一堆"名字 → 对象哈希"的指针;HEAD:一个特殊的引用,指向"你现在在哪"。
剩下的一切------分支、标签、暂存区、提交历史、远程仓库------全都是这三样东西的不同玩法。
3.2 blob:最小的存储单元
Git 有四种对象:blob、tree、commit、tag 。先从最简单的 blob 开始。blob(binary large object)存的就是文件的内容,仅此而已------不带文件名、不带权限、不带任何元数据。
bash
$ echo "hello, git" > hello.txt
$ git hash-object -w hello.txt
3b18e512dba79e4c8300dd08aeb37f8e728b8dad...
hash-object -w 的意思是:把这个文件的内容作为一个 blob 写入对象数据库,返回它的哈希。看看刚才还空空如也的 objects/:
bash
$ find .git/objects -type f
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
规律很明显:哈希的前两位做目录名,剩下的 38 位做文件名。这是为了防止单目录文件太多导致文件系统性能下降(FAT 文件系统的历史教训)。
文件里面是什么?是被 zlib 压缩过的:
bash
$ cat .git/objects/3b/18e5...dad # 一堆乱码
$ python3 -c "import zlib; print(zlib.decompress(open('.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad','rb').read()))"
b'blob 11\x00hello, git\n'
解压出来,格式是:<对象类型> <内容字节数>\0<内容>。就这么多。
用 Git 自带的工具看更方便:
bash
$ git cat-file -t 3b18e512 # 看类型
blob
$ git cat-file -p 3b18e512 # 看内容(pretty print)
hello, git
(Git 允许哈希只写前几位,只要不产生歧义。)
3.3 SHA-1:既是校验和,也是地址
blob 的哈希是这样算出来的:
text
SHA1( "blob " + 字节数 + "\x00" + 文件内容 )
注意两点:
- 哈希覆盖了类型和长度,所以一个 blob 冒充不了 tree;
- 哈希只由内容决定,和文件名、路径、时间、作者全都无关。
这带来一个极好的性质------相同的内容在同一个仓库里永远只存一份。你把同一个 3MB 的依赖文件复制到十个目录,Git 只存一个 blob,十处各放一个指针而已。
哈希同时充当了三重身份:
- 校验和:内容被篡改一个字节,哈希立刻对不上,损坏无处遁形;
- 地址:数据库的主键,凭内容就能定位数据(这就是"内容寻址"的含义);
- 身份:后面会看到,commit 的哈希还间接锁定了它父提交的哈希,一环扣一环,让整个历史不可篡改------想伪造某个历史提交,就得连带伪造它之后所有提交的哈希。
顺带一提:SHA-1 在 2017 年被谷歌证明了理论碰撞攻击(SHAttered 攻击,针对的是 PDF 场景)。Git 社区的应对是推进 SHA-256 支持,现在 git init --object-format=sha256 就能用新算法初始化仓库。对日常使用来说 SHA-1 的 Git 实现仍有防护,但这是历史上真实发生过的一次"底座更换"。
3.4 tree:目录的化身
blob 只管内容,那"文件叫什么名字、在哪个目录下"由谁管?答案是 tree 对象------它对应目录,内容是一张条目列表:
text
<模式> <类型> <哈希>\t<名字>
动手造一个看看。先用 update-index 把文件塞进暂存区(下一节详讲),再 write-tree 生成 tree:
bash
$ git update-index --add hello.txt
$ git write-tree
b5bb9d8014a0f9b1d61e21e796d78dccdf1352f2...
$ git cat-file -p b5bb9d80
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
一行条目,含义一目了然:
| 字段 | 值 | 说明 |
|---|---|---|
| 模式 | 100644 |
普通文件;100755 可执行;120000 符号链接;040000 子目录;160000 子模块(gitlink) |
| 类型 | blob |
指向 blob 对象 |
| 哿希 | 3b18... |
就是我们刚写入的那个 |
| 名字 | hello.txt |
文件名在这里! |
关键点:tree 里面还可以嵌 tree。有子目录时,子目录就是一个嵌套的 tree 对象:
text
tree (根目录)
├── blob README.md
├── blob main.py
└── tree src/
├── blob util.py
└── tree core/
└── blob engine.py
于是"任意时刻的完整项目快照" = 一个根 tree 对象。它递归地描述了所有文件的名字、路径、内容、权限。这就是第 2 节说的"快照"在数据层的真实形态。
3.5 四种对象总览
| 对象 | 对应概念 | 内容 |
|---|---|---|
| blob | 文件内容 | 纯内容,无元数据 |
| tree | 目录 | 条目列表:模式 + 名字 + 指向 blob/子 tree 的哈希 |
| commit | 一次提交 | 指向一个根 tree + 父提交们 + 作者/提交者 + 信息 |
| tag | 附注标签 | 指向任意对象(通常是 commit)+ 标签名 + 说明 |
四种对象存进 objects/ 的格式完全一样:类型 大小\0内容,zlib 压缩,SHA-1 寻址。一个数据库,四种记录,Git 的全部家当。
4. 手搓一次提交:只用底层命令
现在我们完全绕开 git add 和 git commit,用四条底层命令手动制造一次提交。做完这一次,你对 Git 的理解会跨过一个台阶。
bash
# 第 0 步:准备仓库和文件
$ git init hand-made && cd hand-made
$ echo "v1" > a.txt
# 第 1 步:把 a.txt 的内容存为 blob(等价于 git add 的存储部分)
$ git update-index --add a.txt
# 第 2 步:把暂存区写成 tree 对象(一张完整快照)
$ TREE=$(git write-tree)
$ echo $TREE
# 第 3 步:把 tree 包装成 commit 对象(手动指定父提交为空、写提交信息)
$ COMMIT=$(echo "我的第一次手工提交" | git commit-tree $TREE)
$ echo $COMMIT
# 第 4 步:让 master 分支指向这个 commit(这一步之后提交才"存在"于分支上)
$ git update-ref refs/heads/master $COMMIT
# 验收:高层命令完全认识我们手工拼出来的东西
$ git log
$ git status
git log 会一本正经地显示出这条提交历史,git status 会报告工作区是干净的------高层和底层看到的是同一个数据库,没有半点魔法。
把这四步和日常命令对应起来:
| 我们敲的底层命令 | 对应的高层行为 |
|---|---|
git hash-object -w + git update-index --add |
git add(写 blob + 更新暂存区) |
git write-tree |
(git commit 内部的一步:快照 → tree) |
git commit-tree |
git commit(tree + 父提交 + 信息 → commit) |
git update-ref refs/heads/master |
git commit 的最后一步:把分支挪到新提交上 |
所以一次 git commit 的本质是:**把工作区变化凝固成一个新 tree,再把它连在旧提交后面成为新 commit,最后把当前分支名挪过来指向它。**三步,没了。
5. commit 对象的解剖报告
用 cat-file -p 看一个真实提交的全文:
text
tree 29fab7adaa4b46a7e5b7a7be5fca29b0e4c4a2ec
parent a1b2c3d4e5f6...
author Zhang San <zhangsan@example.com> 1717142400 +0800
committer Zhang San <zhangsan@example.com> 1717142400 +0800
修复登录页在 Safari 下白屏的问题
原因:Optional chaining 编译目标过低,详见 JIRA-1024
逐行解读:
tree:这次提交的完整快照。commit 对象真正"拥有"的只有这一个字段------提交的内容全在这个 tree 里。parent:父提交,可以有 0 个(初始提交)、1 个(普通提交)、2 个(合并提交)。整条历史就是靠这个字段串成的单向链表。author:代码作者。committer:实际执行提交的人。平时相同,但打补丁、cherry-pick、rebase 之后两者会不同------git log --format=fuller能看到两者的区别。- 时间戳 :Unix 时间 + 时区。注意
git commit --date改的是 author 时间,committer 时间照常是当下。 - 空行之后:提交信息,爱写多长写多长。
再强调一次那个反直觉的事实:commit 里没有 diff 。想看"这次提交改了什么",Git 是拿这次提交的 tree 和父提交的 tree 现场对比算出来的。git show <commit> 的本质就是 git diff <commit>^ <commit> 加了个漂亮外壳。
还有一个冷知识:提交是可以有任意多提交信息的 ------git commit-tree 允许你不写 -m,信息为空也能提交(虽然 git commit 默认会拒绝空信息)。同样的,parent 数量上不封顶,理论上你能造一个八亲代的"章鱼合并"提交,Git 不拦你,只是你的同事会想打你。
6. 分支:一个文件而已
现在揭晓标题里的悬念。创建并查看一个分支:
bash
$ git branch feature-login
$ cat .git/refs/heads/feature-login
3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a
一个分支就是一个 41 字节的文本文件(40 位哈希 + 一个换行符),内容是它指向的 commit 的哈希。没了。
所以我们能瞬间回答一堆"背了很久"的问题:
- 为什么 Git 建分支快到无感? 因为
git branch只是写一个 41 字节的文件。对比 SVN 要复制整个目录树。 - 为什么 Git 鼓励频繁建分支? 因为分支没有成本------它不包含任何提交数据,只是一个指针。
git branch -d删分支会丢提交吗? 删的只是这个 41 字节的指针文件。提交对象本体还在数据库里,只是变得"不可达"(reachable),要等 GC 才回收------这也是误删分支能救回来的原因(见[第 12 节](#第 12 节 "#12-%E6%8C%BD%E6%95%91%E7%8E%B0%E5%9C%BAreflog-%E6%98%AF%E4%BD%A0%E7%9A%84%E5%90%8E%E6%82%94%E8%8D%AF"))。- "分支合并"合的是什么? 是两个指针指向的 commit 各自的 tree。分支本身从不"包含"提交,它只是历史链上的一个游标。
分支多了之后,Git 会把分散的引用文件打包进一个 .git/packed-refs 文件做优化,此时 cat .git/refs/heads/xxx 可能找不到文件,别慌,用 git show-ref 看全部引用更可靠。
除了分支(refs/heads/),引用还有几个常用命名空间:
text
refs/heads/* 本地分支
refs/remotes/* 远程分支的本地缓存(origin/main 等)
refs/tags/* 标签
refs/stash git stash 的最新一条
refs/notes/* 附注
以及一个不在 refs/ 里的特殊引用------HEAD,下一节单独讲它。
7. HEAD 的本质与 detached 状态
bash
$ cat .git/HEAD
ref: refs/heads/master
HEAD 通常不直接指向提交,而是指向一个分支 ------这种写法叫符号引用(symbolic ref)。于是链路是:
text
HEAD → refs/heads/master → commit 3f2a1b9 → tree(快照)
你在这 当前分支 最新提交 项目全貌
理解了 HEAD,几个日常疑惑立刻消解:
"提交之后分支为什么会自动前移?" 因为 git commit 的最后一步就是"让 HEAD 当前指向的那个引用改指向新提交"。HEAD 挂在哪个分支上,哪个分支就走。
detached HEAD(游离头指针)是怎么回事? 当你 git checkout <某提交哈希> 时,HEAD 里写的是一个裸哈希而不是 ref: ...,这就叫 detached。此时你在提交历史上"站着",但不在任何分支"上"------接下来提交的话,新提交没有分支接管,你一 checkout 走开,它们就成了没人指的孤儿。所以 Git 会在你切换时大字报警。想留下这些提交?在走之前 git switch -c new-branch 建个分支接管即可。
HEAD~2 和 HEAD^2 完全不是一回事,这是高频翻车点:
HEAD~n:沿第一父提交 向上走 n 代,~是"往前数几辈";HEAD^n:第 n 个父提交 (只在合并提交上有意义),^是"选哪个爹"。
text
M ── 合并提交(parents: B 和 C)
/ \
B C
│
A
M~2 == A (沿第一父辈往上两代:M → B → A)
M^2 == C (第二个爹是 C)
M~~ == M~2 == M^~ 都能写到 A,但语义路径不同
8. 标签的两种形态
标签分两种,区别就在对象模型层面:
轻量标签(lightweight) :就是一个 41 字节的指针文件,和分支没有本质区别,只是放在 refs/tags/ 下、且不随提交移动。git tag v0.1 默认创建的就是它。
附注标签(annotated) :是一个真正的 tag 对象,存在对象数据库里:
bash
$ git tag -a v1.0 -m "第一个正式版"
$ git cat-file -p v1.0
object 3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a
type commit
tag v1.0
tagger Zhang San <zhangsan@example.com> 1717142400 +0800
第一个正式版
tag 对象有自己的作者、日期、说明,支持 GPG 签名(git tag -s)。注意 refs/tags/v1.0 文件里存的是 tag 对象的哈希,而不是 commit 的。
经验法则:临时标记用轻量标签,对外发布用附注标签。发布版本需要"谁打的、什么时候、说明什么、签名是否可信"这些信息,轻量标签一样都提供不了。
9. 暂存区到底暂存了什么
暂存区(staging area / index / cache,三个名字指同一个东西)是 Git 新手最大的迷雾区。看穿它只需要一句话:
暂存区就是"下一次提交的 tree 的草稿"。
git status 的三段式输出,就是在对比三份东西:
text
git status 的输出其实是在做两次 diff:
工作区 vs 暂存区 → "Changes not staged for commit"(改了没 add)
暂存区 vs HEAD → "Changes to be committed"(add 了没提交)
暂存区本体是 .git/index,一个二进制文件,用 git ls-files --stage 可以看到它的内容:
bash
$ git ls-files --stage
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0 hello.txt
100644 8d0e41234fabc15de6cbc3a2b7d6a4b6b1f9a0c2 0 src/main.py
每行 = 模式 + blob 哈希 + stage 编号 + 文件名。它就是一个扁平化的 tree 草稿 :git add 把文件内容存成 blob、再把"文件名 → blob 哈希"的映射写进 index;git commit 时 write-tree 把这张表原样翻译成 tree 对象。
stage 编号平时都是 0,但发生合并冲突时会出现 1、2、3 三个版本同名字段并存:
text
stage 1 = 共同祖先(base)版本
stage 2 = 当前分支(ours)版本
stage 3 = 对方分支(theirs)版本
三方合并和冲突标记(<<<<<<< / ======= / >>>>>>>)就是拿这三个版本算出来的。你解决冲突后 git add,三个 stage 又合并回一个 stage 0。
另一个实用推论:git add 存的是文件当时的内容快照,不是"把这个文件登记为待提交" 。add 之后再改文件,暂存区里仍是旧版本------这就是为什么改完还要再 add 一次,也是 git add -p 能逐块挑选内容的原理:它是在直接编辑 index 这张草稿表。
10. 打包与压缩:Git 为什么不占空间
前面说 blob 是逐个 zlib 压缩存放的,这种形态叫松散对象(loose object)。但它有个问题:每次修改都生成一个全新的完整 blob,改一百次同一个大文件就存一百份。这不科学。
Git 的解决分两层:
第一层:内容寻址天然去重。 内容相同的 blob 永远只有一个(3.3 节讲过)。这已经干掉了大部分冗余。
第二层:打包 + 增量压缩(delta compression)。 当松散对象积累到一定数量,或你执行 git gc、git push 时,Git 会把它们打包成 packfile(.git/objects/pack/*.pack),并且在包内寻找相似对象,只存差异:
bash
$ git count-objects -v
count: 0 # 松散对象数量
size: 0
in-pack: 87 # 包内对象数量
packs: 1
size-pack: 12 # 打包后的总大小(KB)
增量压缩的方向常常出乎意料:Git 有时会存"最新版本完整 + 旧版本是差异",因为你更常访问最新版本,从它出发取旧版本只需反向打补丁一次。挑哪个做基底是 Git 按访问成本权衡的。
pack 旁边配有 .idx 索引文件(哈希 → 包内偏移量),所以从包里找对象是 O(1) 定位 + 补丁回放。
这套机制解释了一个经典疑问:"每次提交都是全量快照,仓库为什么不大得离谱?" 因为概念模型是全量,存储层是增量。两个分层各干各的,互不拖累。这也正是"Git 的模型简单"的体现------它宁可把优化做成后台可重做的缓存(packfile 完全可以删掉重新生成),也不把优化混进核心模型里。
顺带一提:.git 目录本身就是整个仓库。拷走这一个文件夹(或者一个裸仓库 git clone --bare 的产物),你就带走了全部历史、全部分支和标签。
11. 用对象模型重新解释你天天在用的命令
有了前面的模型,这一节我们快问快答,把高频命令全部翻译成"数据库操作":
git merge ------ 找出两分支的最近公共祖先(merge-base),对 base / ours / theirs 三个快照做三方合并,生成一个有两个 parent 的新 commit(快照层面是新 tree,不是"补丁串联")。能快进(fast-forward)时则不造新提交,只是把分支指针往前挪------本质就是 update-ref。
git rebase ------ 把一系列提交逐一复制 成新 commit(新 tree 内容相同,但 parent 链和 committer 信息变了,所以哈希全变),接到目标提交后面,最后把分支指针挪到复制链的末端。为什么 rebase 后要 force push? 因为原链上的提交成了孤儿,远端还指着旧链,两边历史已经不同了。黄金法则:不要 rebase 别人可能已经拉取的共享分支------你等于告诉所有人"历史换了一套"。
git reset ------ 三种模式,一句话记住"动到哪一层":
| 模式 | HEAD/分支指针 | 暂存区 | 工作区 |
|---|---|---|---|
--soft |
✅ 移动 | 保留 | 保留 |
--mixed(默认) |
✅ 移动 | ✅ 重置 | 保留 |
--hard |
✅ 移动 | ✅ 重置 | ✅ 重置 |
注意 git reset 本质是移动当前分支的引用,所以叫"撤销提交",实际是"让分支不再指向那些提交"------提交本体仍在,reflog 能救。
git revert ------ 不移动任何指针,而是生成一个反向提交把旧改动抵消掉。历史只增不改,所以是唯一适用于共享分支的"撤销"。
git cherry-pick ------ 把某个 commit 的 tree 与其父 tree 的 diff,应用到当前分支顶端,制造一个内容等价但哈希不同的新 commit。本质:复制粘贴一个提交。
git checkout / git switch ------ 老命令 checkout 身兼三职,模型视角下看得一清二楚:切分支(改 HEAD 的符号指向)、切提交(HEAD 写裸哈希 → detached)、恢复文件(从指定 commit 的 tree 里拷 blob 出来覆盖工作区)。因为歧义太多,Git 2.23 才拆出 switch(管前两个)和 restore(管最后一个)。
git clone ------ 把对方的对象数据库整个抄一份,再把 refs/remotes/origin/* 指到对方分支的当前位置,最后 checkout 出一个工作副本。本地和远端从此是两个平等独立的数据库,push/pull 只是在两个数据库之间搬运对象 + 更新对方引用的协议操作。
git push 被拒(non-fast-forward) ------ 远端分支的提交不在你本地历史的祖先链上,你若推进去,远端会丢提交,Git 拒绝执行。--force 是你说"我确定要丢"。
看到没有------没有一个命令需要背,它们全是"读 tree、写对象、挪引用"这三板斧的不同组合。
12. 挽救现场:reflog 是你的后悔药
先建立一个大前提:只要你没有执行 git gc 清理,任何曾经存在于你本地仓库的 commit 对象都还在 。被 reset --hard、被 branch -D、被 rebase 抛弃的提交,都只是"不可达",不是"不存在"。
问题是:不可达的提交没有名字,怎么找到它?答案就是 reflog------Git 给你的 HEAD 和分支引用自动记的移动日志:
bash
$ git reflog
3f2a1b9 HEAD@{0}: reset: moving to HEAD~1
8c7d6e5 HEAD@{1}: commit: 写错的那个提交
3f2a1b9 HEAD@{2}: commit (initial): 第一次提交
每一行都是"这个引用曾经指向哪里"。注意 reflog 记录的是引用的移动史,它只存在于你的本地仓库(不随 push 传输)------这正是它的价值:它是专属于你的、别人看不到的后悔药。
经典救援现场:
bash
# 场景:刚 reset --hard 丢掉了还在写的代码
$ git reset --hard HEAD~1
HEAD is now at 3f2a1b9 上一个提交
# 冷静。查日志,找到丢提交前的位置:
$ git reflog
8c7d6e5 HEAD@{0}: commit: 写了一半的功能 ← 要救的就是它
3f2a1b9 HEAD@{1}: reset: moving to HEAD~1
# 把分支指回去:
$ git reset --hard 8c7d6e5
# 或者不清工作区,只把丢的提交"捡"回来:
$ git cherry-pick 8c7d6e5
同理可救:误删的分支(git reflog 找到它最后的提交,git branch feature-login <hash> 重建)、失败的 rebase(git reflog 找 rebase 前的位置 reset 回去)、git stash drop 掉的暂存(git fsck --unreachable 能列出所有不可达提交,比 reflog 更彻底)。
默认情况下,HEAD 的 reflog 记录保留 90 天,不可达提交的记录保留 30 天,之后才轮到 GC 清理对象本体。所以后悔药的有效期是"天"级别的------出事后第一时间别乱动,先 git reflog。
13. 附:50 行 Python 造一个 mini-git
最后来点好玩的:只用 Python 标准库,实现"写对象 + 读对象",直观感受 Git 的存储格式有多简单。
python
#!/usr/bin/env python3
"""mini-git:不到 50 行,实现 hash-object 与 cat-file"""
import hashlib, os, sys, zlib
GIT_DIR = ".minigit"
def object_path(sha: str) -> str:
return os.path.join(GIT_DIR, "objects", sha[:2], sha[2:])
def hash_object(data: bytes, write: bool = True) -> str:
"""和 git 一样的格式:'类型 大小\\0内容',zlib 压缩后按哈希落盘"""
full = b"blob %d\x00" % len(data) + data
sha = hashlib.sha1(full).hexdigest()
if write:
os.makedirs(os.path.dirname(object_path(sha)), exist_ok=True)
with open(object_path(sha), "wb") as f:
f.write(zlib.compress(full))
return sha
def cat_file(sha: str) -> bytes:
with open(object_path(sha), "rb") as f:
full = zlib.decompress(f.read())
header, _, body = full.partition(b"\x00")
return body
if __name__ == "__main__":
cmd, arg = sys.argv[1], sys.argv[2]
if cmd == "hash-object": # python3 minigit.py hash-object a.txt
with open(arg, "rb") as f:
print(hash_object(f.read()))
elif cmd == "cat-file": # python3 minigit.py cat-file <哈希>
sys.stdout.buffer.write(cat_file(arg))
试一下:
bash
$ echo "hello, git" > a.txt
$ python3 minigit.py hash-object a.txt
3b18e512dba79e4c8300dd08aeb37f8e728b8dad... ← 和真 Git 算出来的一模一样!
$ python3 minigit.py cat-file 3b18e512...
hello, git
自己写的玩具和 Git 官方实现,对同样的内容算出同样的哈希 ------因为这不是什么私有格式,就是"加个头、算 SHA-1、zlib 压缩、按哈希存文件"而已。版本控制史上最重要的工具之一,它的地基你十分钟就能亲手复现。有兴趣的话可以继续扩展:加一个 index 文件实现 add,加 write-tree 实现快照,再实现 commit-tree------你会发现每一步都不难,你正在重走 Git 的诞生之路。
14. 思考题
- 两个不同仓库里,同一个文件内容的 blob 哈希相同吗?commit 哈希呢?(提示:commit 里有时间戳和作者。)
git commit --amend之后,原提交去哪了?还能找回吗?- 为什么
git pull --rebase之后不需要 force push,而 rebase 共享分支需要? - 一个合并提交被
git revert时为什么要指定-m 1或-m 2?(提示:revert 一个双亲提交时,"沿第一父辈"的语义需要你来指定。) .git/objects里出现了一个既不是 4 种对象类型、也解不开的文件,最可能是什么?(提示:想想 packfile。)- 如果把
.git/HEAD手动改成一行裸哈希(不带ref:前缀),仓库会发生什么?
参考答案(点开前先自己想)
- blob 相同(只由内容决定);commit 不同(时间戳、committer 时间、所在历史链都不同,甚至父提交链不同)。
- 原提交成为不可达对象,仍在数据库中,
git reflog里 HEAD@{1} 就指向它,可随时找回。 pull --rebase是把你的未推送的本地提交 搬到远端最新提交之后,远端历史没有被改写;而 rebase 共享分支改写的是别人已经拉取过的历史,远端需要 force 才能移动。- 合并提交有两个 parent,revert 需要知道"撤销掉哪一边带来的改动",
-m 1表示保留第一父辈(通常是主分支)一侧、反转另一侧。 - packfile 及其索引(
.pack/.idx文件),它们不走"两位前缀目录"的松散对象存放规则。 - HEAD 变成 detached 状态。Git 不会报错,但后续提交不会被任何分支接管。
15. 参考资料
- 《Pro Git》(Scott Chacon & Ben Straub)第 10 章 Git Internals ------ 本文的主干蓝本,官方免费在线阅读:git-scm.com/book/zh/v2
- Git from the Bottom Up(John Wiegley)------ 更短的经典小册子,从文件系统视角讲 Git
- Linus Torvalds 2005 年的 Git 邮件列表原帖 ------ 看 Git 诞生的第一周发生了什么
git help hash-object/git help cat-file------ 官方文档里被低估的 plumbing 说明书(另有git help gitcore-tutorial和git help gitglossary,官方手把手底层教程与术语表)- SHAttered 攻击论文(2017):shattered.io ------ SHA-1 碰撞攻击的官方页面
写在最后:Git 最迷人的地方不是功能多,而是它的复杂度全部集中在"数据模型 + 一堆小工具"上 ,而不是散落在无数特例和隐藏状态里。下次再被 Git 的某个行为惊到,别翻 Stack Overflow 了------
cat-file一下,问问数据库本库。