很多人用 Git 几年,commit敲了无数次,git log看得滚瓜烂熟,但你要问他"提交对象到底是什么",能讲清楚的不多。这很正常,因为日常使用根本不需要碰这一层。可一旦你遇到那种诡异问题——比如 amend 之后原提交去哪了、rebase 到一半想反悔、误删的分支怎么找回——光靠背命令是解决不了的。这时候就该回到 Git 对象模型,把提交对象这件事彻底弄明白。这篇文章我会从对象模型讲起,然后把一个 commit 拆开给你看,再聊聊分支、HEAD、rebase、reset 这些日常操作在底层到底发生了什么。适合所有想从"会用 Git"进阶到"懂得 Git"的人。
1. 提交对象不是快照的堆叠:先搞懂 Git 到底存了什么
1.1 三兄弟:blob、tree、commit
Git 内部的对象一共就四种:blob(数据块)、tree(目录树)、commit(提交)、tag(标签)。提交对象只是其中之一,但它是把另外几个串起来的关键。
先说blob。你在仓库里新建一个文件,写了几行内容,Git 会对这份内容做 SHA-1 哈希,得到一个 40 位的十六进制串,然后把这个串作为文件名、文件内容作为文件体存进.git/objects目录。这个对象就是 blob。注意:blob 只存内容,不存文件名,不存路径。同样的内容不管放在哪个目录、叫什么名字,算出来的 blob 哈希一模一样。Git 之所以能瞬间判断哪些文件没改动,靠的就是这个特性。
接着说tree。tree 对应一个目录。它记录的是:这个目录下有哪些子目录、哪些文件,每个子目录对应哪个 tree 对象,每个文件对应哪个 blob 对象,以及文件的权限和名字。换句话讲,tree 把"文件名 + 路径 + 内容"这三者绑定在了一起,还原出一个完整的目录快照。
最后就是commit。commit 本身不直接存代码,它存的是一组元数据:指向某个 tree 对象(代表这一次提交时整个仓库的根目录快照)、指向父提交(parent)、作者和提交者信息、提交时间、提交信息。
有读者可能会问:为什么不直接让 commit 指向一堆 blob?原因很简单——如果文件有几百个,commit 里列几百条记录太笨重;而且只要任意一个文件内容变了,整个提交对象就全乱了。用 tree 做中间层,一个 commit 只需要记一个根 tree 的哈希,然后 tree 逐层下钻,目录结构自然就还原出来了。这个设计非常优雅,相当于用一棵"内容寻址"的树来描述整个项目的状态。
1.2 为什么 Git 存的是快照而不是差异
用过 SVN 或者其他集中式版本管理工具的人,刚开始会有个思维定式:版本库存的应该是"每一版相对上一版改了什么"。Git 不是这样。Git 每一次提交,都是对整个仓库目录树的一张完整快照。
你会觉得这很浪费空间?其实不会。关键在于 blob 的内容寻址:如果两次提交之间只有一个文件改动,那么大部分 blob 对象因为内容没变、哈希没变,会被两个 tree 直接复用。新增的只是一个新 blob 和新 tree,还有新的 commit 对象。所以 Git 存快照是"逻辑上存快照,物理上自动去重",空间开销远小于你想象。
理解了这一点,后面很多事情就顺理成章了:比如切换分支为什么快、为什么同一份内容在仓库里挪了位置也不会产生重复存储、为什么git log能轻松对比任意两次提交的差异——因为它把两个 tree 递归对比一下就行了,跟"从起点开始重放"完全是两码事。
2. 把一个 commit 拆开看:cat-file 里的真相
2.1 实操:解剖一个真实的提交
光讲概念抽象,最好的学习方式是自己动手拆一个 commit。新建一个临时仓库,做两次提交,然后用底层命令看:
$ mkdir demo && cd demo $ git init $ echo "hello git" > a.txt $ git add a.txt $ git commit -m "first commit" $ echo "hello again" >> a.txt $ git add a.txt $ git commit -m "second commit" $ git cat-file -p HEAD tree b429c96d9a7a2f7f3af1a4b9d8f4e2d6c1a0b3e9 parent 8b3c51a9d08f0d6c7b3e9f4a2c1d8e6f0a5b7c2a author zhangsan <zhangsan@example.com> 1718762345 +0800 committer zhangsan <zhangsan@example.com> 1718762345 +0800 second commit看到没有,一个提交对象的内容就这五行元数据加一段提交信息。tree指向第二个提交时的根目录快照,parent指向前一个提交的哈希,author 和 committer 记录了作者和提交者,后面是提交时间戳和时区。
这里有个很多人忽略的细节:author 和 committer 是分开存的。正常情况下两者一致,但如果你用git cherry-pick、git rebase或者git am应用补丁,原始作者会保留在 author 里,而执行操作的人会变成 committer。所以你偶尔会在git log里看到某次提交的作者是别人、提交者是你自己。了解这俩字段,以后查代码归属的时候不会懵。
2.2 tree 对象里藏了整个目录结构
接着看刚才那个 tree 对象。橡树是树,tree 是目录,这个命名倒是挺形象。用 cat-file 继续挖:
$ git cat-file -p b429c96 100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b5dad a.txt这一行就说明:仓库根目录下有一个a.txt文件,权限是100644(普通文件),内容对应哈希3b18e51...的 blob。如果目录结构更复杂,tree 里会有多条记录,子目录对应的行是40000 tree xxxx 子目录名。你可以用git ls-tree -r HEAD一次性把整棵树递归展开:
$ git ls-tree -r HEAD 100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b5dad a.txt这样整个仓库的每个文件、每个目录、每段内容的哈希,就全摆在你面前了。Git 判断两个提交改了哪些文件,本质上就是拿两个 tree 做递归 diff。你可以试试git diff HEAD~1 HEAD,想想它底层在干什么,会发现一切都对得上。
2.3 哈希的不可变性意味着什么
commit 对象里存了 tree 的哈希、parent 的哈希、作者信息、时间戳、提交信息。这带来一个关键结论:任何一处改动,哪怕只是提交信息里的一个标点符号,都会导致整个 commit 对象的哈希完全改变。
这也是 Git 历史"无法在不留痕迹的情况下修改"的根本原因。所谓"改写历史",本质是用新的提交对象替换旧的提交对象,然后让分支指针指向新的;旧对象虽然不再被引用,但在对象库里还躺着一段时间,用git fsck还能捞回来。这个特性既是安全的底线,也是很多命令(rebase、amend、reset)在底层运作的地基。记住这句话:在 Git 里,没有真正的删除,只有引用的移动。
3. parent 指针与提交链:历史、分支和 HEAD 的底层关系
3.1 一次提交就是链表里的一个节点
每个 commit 对象都有一个或多个 parent。普通提交有一个 parent,根提交没有 parent,合并提交有两个甚至更多 parent。把所有提交通过 parent 连起来,就形成了一张有向无环图(不展开术语了,可以理解成一条可以分叉、可以合并的链)。
你平时在git log --oneline里看到一条从新到旧的线性列表,背后就是这么一条链。git log做的事很简单:从 HEAD 指向的提交出发,沿着 parent 一直往回走,一边走一边打印。
如果提交发生过合并,链会在某个节点分成多条线,git log --graph画出来的那些支线,反映的就是这种结构。正因为结构如此简单,Git 做历史遍历时非常快,而且各种HEAD~3、HEAD^^的表达方式,本质上都是在这条链上数节点。
3.2 分支只是一个贴纸,HEAD 才是你的位置
接下来是改变很多人世界观的一句话:在 Git 里,分支不是一个"目录",不是一个"集合",它只是指向某个提交的引用。git branch创建分支,是在.git/refs/heads/下多了一个文件,文件内容是一个 40 位哈希。删除分支,就是删除这个文件。仅此而已。
在 Git 内部,提交对象之间通过 parent 连接,分支不过是一个个"便利贴",贴在某次提交上,方便你快速回到那里。你觉得"我在哪个分支上"的"在哪里",其实是 HEAD 决定的。HEAD 是一个特殊的引用,它要么指向某个分支(默认情况),要么直接指向某个提交(detached HEAD,分离头指针状态)。
你可以直接读文件确认:
$ cat .git/HEAD ref: refs/heads/main $ cat .git/refs/heads/main 3b18e512dba79e4c8300dd08aeb37f8e728b5dad看到没有,HEAD 文件里写的是"我指向 main 分支",main 分支文件里写的是"我指向 3b18e51..."。而你当前工作区的内容,就是 HEAD 指向的这个提交对应的 tree 展开出来的样子。git checkout、git switch切换分支,本质就是:把 HEAD 指向另一个分支,然后把那个分支对应 tree 的内容覆盖到工作区。
3.3 提交链和分支引用配合出的历史
这套设计带来的能力超乎想象。分支创建成本极低,因为它只是贴一张便利贴;分支合并成本可控,因为合并不过是在两个分支的"分叉点"之后,把两边的改动重新整合,生成一个新的合并提交。一切操作都发生在对象和引用两个层面,安全性很高——你随时可以用git reflog找回旧引用,用git fsck找回游离对象。
理解了这个模型,再回头看"Git 分支合并"这类高频操作,思路会清晰很多:git merge是找一个共同祖先(merge base),然后把两边的差异合并出新的提交;git rebase是把当前分支的提交一个个摘下来,换到另一个基点重新生成。这些操作的成败,全取决于你是否能随时掌控"提交链"这条主线索。
4. 从对象视角重新理解日常操作:amend、reset、rebase 在做什么
4.1 commit --amend:那不是修改,是换一个新提交
很多人以为git commit --amend是"修改上一次提交"。字面上没错,但底层真相完全不同:Git 会基于当前暂存区的内容,生成一个全新的提交对象,它的 parent 指向原提交的 parent,然后让分支引用指向这个新提交。原提交呢?原地消失(从引用视角看),但对象库中依然存在,直到被垃圾回收。
这意味着三件事:
- amend 之后,提交的哈希一定会变,所以千万不要 amend 已经推到远程共享分支的提交,否则会搞得所有协作者都出现分叉。
- 如果你只是想改提交信息,不想动文件内容,
git commit --amend -m "新信息"就够用。 - 如果你 amend 之后后悔了,在
git reflog里还能找到原提交的哈希,git reset --hard 原哈希就能回来。所以 amend 并不是"不可逆"的,只是你平时不知道去哪找退路。
4.2 reset 的三个层级:移动引用、重置索引、清空工作区
git reset常常被粗暴地理解为"撤销"。它其实是在做三件事的组合,按照涉及的范围从小到大:
| 命令 | 移动分支引用 | 重置暂存区(索引) | 重置工作区 |
|---|---|---|---|
git reset --soft <target> | 是 | 否 | 否 |
git reset --mixed <target>(默认) | 是 | 是 | 否 |
git reset --hard <target> | 是 | 是 | 是 |
--soft只动"贴纸"位置,你仍然保持着所有文件的暂存状态,常用于"把最近几次提交重新压成一个提交"的场景。具体做法是:git reset --soft HEAD~3,把分支引用倒退三次提交,然后一条git commit把这些改动整体打包成一个新提交。
--mixed是默认行为,移动引用并清空暂存区,但工作区内容不动。这样所有改动会变成"未暂存"状态,你可以重新选择性地 add 和 commit,适合用来把一个大提交拆成多个。
--hard最危险,引用、暂存区、工作区全部重置。执行后工作区的改动直接丢失。但注意:工作区里从未提交过的内容,丢了就是真的丢了,Git 对象库里从来没见过它,想靠 reflog 也找不回来。所以reset --hard这种操作,我个人的习惯是在执行前先git stash或者直接复制一份关键文件,防患于未然。已提交过的内容丢了还可以从 reflog 里捞,未提交的内容才是彻底无助。
4.3 rebase、cherry-pick 与 merge:都是提交对象的搬运和再生
这三个命令是日常协作里最绕的,但从提交对象视角看,逻辑会异常简洁。
git rebase做的事情:从当前分支与目标分支分叉的地方开始,把当前分支独有的每一个提交摘出来,按顺序一个个"重放"到目标分支的顶端。重放意味着每个提交都会在新基底上重新生成,内容 diff 可能一样,但 parent 变了,哈希全部变化。正因为如此,rebase 之后的分支历史是一条干净的线性链,但"改写"的痕迹从哈希上都能看出来。
git cherry-pick则是摘取某一次提交的改动,在当前分支上生成一个新提交。原理上,它读取指定提交的 tree 与其 parent 的 tree,算出 diff,再把 diff 应用到当前分支上,最后创建新提交。
git merge则是找到两条链的共同祖先,把两边相对共同祖先的改动合并,生成一个新的合并提交,这个提交会有两个 parent。如果你遇到冲突,解决冲突后形成的提交同样是个标准 commit,只是它的 parent 列表里有两个分支的最新提交。
这三个命令的共性是:它们不会"修改"任何已有提交,而是制造新提交。无数次操作积攒下来,对象库里会堆积大量"曾经存在过但不再被引用"的提交,它们不会被立刻清理,而是留待git gc在超时之后统一回收。这也是为什么 Git 仓库总会在某些操作后变大——那些是历史里被你改写掉的旧对象,还在库里躺着。
5. 提交对象思维解决过的三个真实问题
5.1 amend 之后后悔了,怎么找回原来的提交
我有一回在一个项目上git commit --amend改了提交信息,结果改完发现把该保留的署名搞没了,想回到 amend 之前的状态。当时心里一沉,感觉是不是没救了。后来冷静下来一想,commit 对象没被真正删除,只是没人引用了。于是:
$ git reflog 8b3c51a HEAD@{0}: commit (amend): 修正后的信息 3b18e51 HEAD@{1}: commit: 原始提交reflog记录了 HEAD 引用每次移动的历史。HEAD@{1}就是 amend 之前指向的提交,也就是原始提交的哈希。接下来:
$ git reset --hard 3b18e51一切恢复原样。整个操作没超过十秒钟。从这个案例得到的经验是:凡是已经进入提交对象库的东西,绝大部分都丢不了,关键是你要知道去哪里找。git reflog就是那份"引用移动档案",建议把它当成救命稻草记住。
5.2 误删的分支,凭什么能百分之百找回
另一个常见事故:分支删了,发现上面还有没合并的成果。当时第一反应是"完了,白干了"。同样,稳住。删除分支只是删除了.git/refs/heads/下的那个引用文件,分支所指的提交对象还完整地存在对象库里。找回方式:
$ git reflog --all $ git fsck --lost-foundgit reflog --all可以看到所有引用的历史,包括已被删除的分支曾经的指向。如果 reflog 里找不到(比如过了很久,或者被 gc 清理了一部分),就上git fsck --lost-found,它会把对象库里所有没有被任何引用指向的提交对象全部列出来。找到那个提交哈希后,一条git branch 新分支名 哈希就能把分支原封不动地重建出来。
这里要提个醒:reflog 有保质期,默认gc.reflogExpire是 90 天(还没被 gc 的话或许更长)。如果你删分支后大半年才想起来,那回收的难度会大不少。所以发现误删后,第一时间先别乱动,优先执行 fsck 把哈希打印出来存好,再慢慢恢复也不迟。
5.3 rebase 到一半心态爆炸,如何安全放弃
rebase 出冲突是家常便饭,但很多人一看到冲突标记就开始慌,然后一通乱改,结果越改越乱,最后只想"放弃 rebase"。正确的姿势其实非常简单,因为在开始 rebase 时,Git 会把你原来的 HEAD 保存在 reflog 里。你只需:
$ git rebase --abort如果 rebase 卡到一半连 abort 都执行不下去(极少数情况),或者你想要回到 rebase 开始之前的状态,用 reflog:
$ git reflog a2b3c4d HEAD@{1}: rebase (start): checkout ... $ git reset --hard a2b3c4d核心要点是理解:rebase 过程中生成的一堆临时提交对象,都是中间产物,只要你知道原来的提交哈希,随时可以跳回去。提交对象模型给了你一个巨大的安全网——你可以大胆尝试各种操作,因为大多数情况下反悔的路径都是存在的。
6. 提交对象模型让我养成的几个习惯
6.1 每次提交前想清楚:这次提交到底改了哪一件事
提交对象的不可变特性,决定了"提交信息写错"这件事的代价是改写历史,而不是擦掉重填。所以我在实践中越来越在意"一次提交只做一件事"。这个习惯叫原子提交(atomic commit)。具体标准:一次提交里包含的所有改动,应该能用一个逻辑主题说清楚,比如"修复登录页白屏"、"增加用户导出功能"、"更新依赖版本"。
如果提交里既有新功能又夹带了两处格式调整,将来想单独回退某个改动,就会发现因为提交粒度太粗而无从下手。配合git add -p分块暂存,可以把一处改动拆成多个提交,这个能力是 Git 的强项,也是入门后值得专门练一练的操作。
6.2 提交信息不是日记,是给未来排查问题的人看的说明书
当你理解了 commit 对象里 msg 字段的作用,就会明白提交信息不是写给自己看的日记,而是给"几个月后的自己"和"协作同事"看的说明书。我的固定模板是这样的:
第一行不超过 50 个字符,说明这个提交做了什么;空一行;然后详细说明为什么要这么做、做法是什么、有没有副作用。例如:
fix: 修复登录页在 Safari 下白屏 原因:Safari 对 flex 布局中的 height: 100% 解析不一致, 导致容器高度塌陷。 方案:改用 min-height: 100vh 替代 height: 100%。 影响:仅在登录页生效,其他页面无影响。这类信息在将来做版本回溯、定位问题时,价值极大。git blame定位到某一行代码后,能看到作者留下的上下文,往往比注释还靠谱。毕竟代码注释会过时,但提交信息是跟着提交走的。
6.3 大胆操作,但永远留一条退路
提交对象模型给人最大的安全感,是它把所有状态都变成了"可寻址"的对象。只要你不执行git gc加上一些激进参数清理对象库,绝大多数误操作都有挽回余地。所以我现在的工作流可以比较大胆:想试 rebase 就试,想合并提交就合并,想改历史就改。但前提是我严格遵守两条约定:
- 从不改写已经推到共享远程分支的历史。
- 每次做危险操作前,记一下当前分支的哈希,或者干脆先建一个备份分支。
备份分支的成本低到几乎可以忽略——一条命令的事。但当你需要它的时候,它会让你免于在凌晨三点对着git fsck的输出流冷汗。
坦白讲,Git 的命令多到记不完,我也有很多不常用的参数要靠查文档。但自从把提交对象、tree、blob、parent 这些底层概念弄明白之后,再接触任何新的 Git 命令,我都会先问自己一句:它底层是在移动引用、新建对象,还是在修改工作区?这么一想,大部分命令的运作方式就猜得八九不离十了。如果你正在经历"命令背了就忘、操作全靠搜索"的阶段,我建议你别急着背更多命令,花一个下午把提交对象拆开看一看。磨刀不误砍柴工,这可能是你学习 Git 路上性价比最高的一次投入。