Git 底层原理:分支为什么只是一个 41 字节的文件

大多数人学 Git 是在背命令:commitpushrebasereset --hard......命令越背越多,心里越来越虚,因为不知道背后发生了什么。这篇文章想反着来一次:先弄清 Git 的数据模型,你会发现那些"吓人"的命令突然全都讲得通了。


目录

  1. [写在前面:为什么 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")
  2. [核心洞察: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")
  3. [内容寻址: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")
  4. 手搓一次提交:只用底层命令
  5. [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")
  6. 分支:一个文件而已
  7. [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")
  8. 标签的两种形态
  9. 暂存区到底暂存了什么
  10. [打包与压缩: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")
  11. 用对象模型重新解释你天天在用的命令
  12. [挽救现场: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")
  13. [附: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")
  14. 思考题
  15. 参考资料

1. 写在前面:为什么 Git 难学

Git 大概是被抱怨最多的主流工具。rebasemerge 的区别、reset 三种模式、checkoutswitch 的关系、冲突到底怎么解------每个问题单独看都像是一条孤立的规定,只能死记硬背。

但如果你打开任何一个 Git 仓库下的 .git 目录看一眼,会发现 Git 其实是一个设计极其简单、甚至可以说优雅的系统:

  • 它本质上是一个内容寻址的键值数据库(content-addressable storage);
  • 外面套了一层薄薄的"引用系统",用来给数据库里的对象起名字;
  • 你天天敲的所有命令,都只是对这套数据库的读写操作。

换句话说,Git 的命令是易变的,但数据模型是永恒的。Linus 在 2005 年写第一版 Git 时,最初的定位甚至不是"版本控制系统",而是"文件系统"------版本控制只是架在它上面的一个应用。理解了这一点,剩下的全是细节。

本文会大量使用 Git 的"底层命令"(plumbing commands),它们是给程序和高级用户用的,日常你几乎不会敲,但它们是看穿 Git 的 X 光机。对应的"高层命令"(porcelain commands)就是我们平时用的 git addgit commit 那些。


2. 核心洞察:Git 存的是快照,不是差异

这是理解 Git 最重要的一个心智转变。

很多人(包括很多教程)把 Git 想象成这样:每次提交,Git 记录"哪些文件改了什么",然后一条 diff 链从头串到尾。想看第 N 版某个文件?从第 1 版开始一路打补丁打过去。

Subversion 确实是这么想的。但 Git 不是。

Git 的模型是:每次提交,都是对整个项目目录树的一次完整快照。你改了一个文件,提交,Git 做的事情是:

  1. 把没变的文件"存一个指向旧数据的指针";
  2. 把变了的新文件作为新对象完整存一份;
  3. 把整个目录树重新描述一遍。

所以 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" + 文件内容 )

注意两点:

  1. 哈希覆盖了类型和长度,所以一个 blob 冒充不了 tree;
  2. 哈希只由内容决定,和文件名、路径、时间、作者全都无关。

这带来一个极好的性质------相同的内容在同一个仓库里永远只存一份。你把同一个 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 addgit 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~2HEAD^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 commitwrite-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 gcgit 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. 思考题

  1. 两个不同仓库里,同一个文件内容的 blob 哈希相同吗?commit 哈希呢?(提示:commit 里有时间戳和作者。)
  2. git commit --amend 之后,原提交去哪了?还能找回吗?
  3. 为什么 git pull --rebase 之后不需要 force push,而 rebase 共享分支需要?
  4. 一个合并提交被 git revert 时为什么要指定 -m 1-m 2?(提示:revert 一个双亲提交时,"沿第一父辈"的语义需要你来指定。)
  5. .git/objects 里出现了一个既不是 4 种对象类型、也解不开的文件,最可能是什么?(提示:想想 packfile。)
  6. 如果把 .git/HEAD 手动改成一行裸哈希(不带 ref: 前缀),仓库会发生什么?

参考答案(点开前先自己想)

  1. blob 相同(只由内容决定);commit 不同(时间戳、committer 时间、所在历史链都不同,甚至父提交链不同)。
  2. 原提交成为不可达对象,仍在数据库中,git reflog 里 HEAD@{1} 就指向它,可随时找回。
  3. pull --rebase 是把你的未推送的本地提交 搬到远端最新提交之后,远端历史没有被改写;而 rebase 共享分支改写的是别人已经拉取过的历史,远端需要 force 才能移动。
  4. 合并提交有两个 parent,revert 需要知道"撤销掉哪一边带来的改动",-m 1 表示保留第一父辈(通常是主分支)一侧、反转另一侧。
  5. packfile 及其索引(.pack/.idx 文件),它们不走"两位前缀目录"的松散对象存放规则。
  6. 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-tutorialgit help gitglossary,官方手把手底层教程与术语表)
  • SHAttered 攻击论文(2017):shattered.io ------ SHA-1 碰撞攻击的官方页面

写在最后:Git 最迷人的地方不是功能多,而是它的复杂度全部集中在"数据模型 + 一堆小工具"上 ,而不是散落在无数特例和隐藏状态里。下次再被 Git 的某个行为惊到,别翻 Stack Overflow 了------cat-file 一下,问问数据库本库。

相关推荐
demon75520032 小时前
Git Worktree详解介绍
git·worktree
胖大和尚3 小时前
当前仓库推送到同一台机器上的另一个文件夹
git
轮到我狗叫了1 天前
git - 版本控制工具 - 对应的常见命令 - 以及无需后续每次手动source conda
git
changxiang1 天前
GIT 备忘
git
DogDaoDao2 天前
Windows 开发提效工具全景指南:60+ 工具的工程化分层配置
windows·git·程序员·开发工具·powershell·everything·msys2
wdfk_prog2 天前
GitHub push 失败:如何扫描并清理 Git 历史中的大文件
git·elasticsearch·github
大明二代2 天前
使用 Traefik、cert-manager 和 DNS-01 为内网 Kubernetes 服务配置 HTTPS
git·kubernetes·flux
小芒果_012 天前
git常用命令速查
大数据·git·elasticsearch
2501_930472442 天前
腾讯云助手规范化Git提交记录
git·elasticsearch·腾讯云