多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

深入理解Git提交对象:从对象模型到reset、rebase底层原理

深入理解Git提交对象:从对象模型到reset、rebase底层原理 很多人用 Git 几年commit敲了无数次git log看得滚瓜烂熟但你要问他提交对象到底是什么能讲清楚的不多。这很正常因为日常使用根本不需要碰这一层。可一旦你遇到那种诡异问题——比如 amend 之后原提交去哪了、rebase 到一半想反悔、误删的分支怎么找回——光靠背命令是解决不了的。这时候就该回到 Git 对象模型把提交对象这件事彻底弄明白。这篇文章我会从对象模型讲起然后把一个 commit 拆开给你看再聊聊分支、HEAD、rebase、reset 这些日常操作在底层到底发生了什么。适合所有想从会用 Git进阶到懂得 Git的人。1. 提交对象不是快照的堆叠先搞懂 Git 到底存了什么1.1 三兄弟blob、tree、commitGit 内部的对象一共就四种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 zhangsanexample.com 1718762345 0800 committer zhangsan zhangsanexample.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 路上性价比最高的一次投入。
返回列表