
核心认知:Git 是键值数据库,不是差异存储
多数人对 Git 的直觉是「存每个文件的修改差异」——错。Git 的底层是一个内容寻址的键值数据库:每份文件内容(快照)作为一个独立对象存进 .git/objects,键是内容的 SHA-1 哈希。提交不做 diff、不存 delta(早期版本的部分打包压缩除外),每次提交都是整个项目文件的完整快照。这个设计带来三个后果:切换分支极快(只是换指针)、任何提交可独立校验完整性(哈希即内容指纹)、未变更的文件在新旧快照间共享同一个 blob(零成本复用)。
用 git cat-file 可以亲手解剖:echo "hello" | git hash-object --stdin 会得到一个哈希——这就是这个内容在 Git 世界里的「身份证号」:内容变,哈希变;内容同,哈希同。Git 的一切都建立在这个映射上。
三种对象:blob、tree 与 commit
blob(文件内容):一个 blob 就是一个文件的完整内容,不含文件名、不含路径——文件名与位置由 tree 记录。两个内容相同的文件(哪怕名字不同)共享同一个 blob。tree(目录快照):一个 tree 记录一个目录的状态——一组「文件名 → blob 哈希」与「子目录名 → 子 tree 哈希」的条目。目录嵌套就是 tree 嵌套。commit(提交):指向一个根 tree、指向父提交(可多个——合并提交)、附带作者/时间/提交信息。一次提交 = 一棵从根 tree 出发的完整文件树 + 一条指向历史的指针。
flowchart LR
C2["commit #2<br>tree: a1b2<br>parent: C1"] --> C1["commit #1<br>tree: c3d4"]
C2 --> T2["tree(项目根)"]
C1 --> T1["tree(项目根)"]
T2 --> B1["blob: app.py v2"]
T2 --> B2["blob: README.md(与 #1 共享)"]
T2 --> B3["blob: util.py(新增)"]
T1 --> B4["blob: app.py v1"]
T1 --> B2
图里 README.md 未修改,两个 tree 指向同一个 blob——「快照」听起来贵,实际增量是零。一次提交的真实成本只与新改动成正比。
引用:分支与 HEAD 的指针本质
分支不是「一条提交链」,是一个 41 字节的文本文件:.git/refs/heads/main 里就写着一个 commit 哈希——「分支指向最新提交」是字面意义的指向。创建分支 = 写一个 41 字节的文件,微秒级——这就是「Git 建分支便宜到随便建」的底层原因。HEAD 是另一个文件(.git/HEAD),内容是「当前在哪个分支」(ref: refs/heads/main)或「直接挂在某个提交上」(detached HEAD)。提交动作的底层:把新 commit 对象写入数据库 + 把当前分支文件的内容改成新哈希——两步写完,一次提交完成。
远端引用与标签同理:origin/main 是本地记录的「上次看到远端在哪」的缓存(git fetch 更新的就是这些缓存文件——这就是为什么 fetch 后要 merge/rebase 才是「拉取」),tag 是指向某个 commit 的命名引用(annotated tag 本身也是个对象,可以带签名)。
rebase 与 merge 的对象层真相
「merge 还是 rebase」的争论,在对象层面一目了然。merge 创建一个新的 commit,它有两个父提交——两条历史线在此汇合,历史保留原貌(图状)。rebase 不创建合并提交,而是把你的提交「复制」成新对象(内容相同但父指针变了,所以哈希全变)接到目标分支末端,再把分支指针挪过去——历史变成一条直线,但原来的提交对象仍在数据库里(只是没有任何引用可达,最终被 gc 清理)。rebase 的「改写历史」在底层就是生成一批新对象、把引用挪过去、旧对象弃养——这也解释了 rebase 的黄金法则:只 rebase 没人基于它工作的提交(引用挪走后,别人的工作基于旧对象,会出现「历史分叉」的混乱)。
冲突的本质也在此显形:merge 时两个提交都改了同一个文件的同一区域,Git 无法自动决定 tree 该指向哪个 blob,停下要求人类裁决——冲突标记的双方就是两个 blob 的内容。
实用推论:三个日常问题的底层答案
为什么大文件是 Git 的天敌?blob 存完整内容,二进制大文件每次修改产生一个新 blob(无 delta 压缩收益),仓库体积按修改次数线性膨胀——Git LFS 的解法是把 blob 换成指针文件、内容存到专门的服务器。为什么 git pull 后有时出现「已经在最新」但代码不对?远端引用缓存与工作区状态的混淆——底层看,git pull = fetch(更新远端引用)+ merge(合并到本地分支),两步各自的失败形态不同,分开执行(fetch + status 看清 + merge)永远比一把梭清晰。为什么 commit 哈希能当完整性校验?commit 哈希由树哈希 + 父哈希 + 元数据算出,树哈希由所有 blob 哈希算出——篡改任何一个文件的内容都会连锁改变整条链的哈希,历史不可篡改是哈希链的数学性质,与区块链同源。
Git 的日常疑难:从底层理解高频问题
对象模型落到日常,高频疑难问题有了底层答案。疑难一:git commit --amend 到底做了什么——它生成一个「新对象」(父指针相同的兄弟 commit)、把分支引用挪过来、旧 commit 弃养——「修改上次提交」的本质是「用新提交替换」,理解了就不会问「amend 之后原来的提交去哪了」(还在对象库里,直到 gc)。疑难二:为什么 rebase 后 push 被拒——远端的历史引用还指向旧对象,你的 rebase 产生了新对象链,远端认为你在「丢弃别人的提交」——--force-with-lease 的语义(远端还是你上次看到的样子才允许强推)比裸 force 安全一层。疑难三:stash 的本质——stash 也是 commit(存在 refs/stash,两个 parent:工作区与暂存区)——「暂存的改动去哪了」的答案:它是一个特殊的提交对象,pop 时恢复并删除引用。疑难四:cherry-pick 与 rebase 的关系——两者都是「复制提交对象」(生成新哈希的对象接到别处),区别只在批量与自动化程度。疑难五:merge 冲突后不想合了怎么办——git merge --abort 恢复到合并前(HEAD 指回原 commit,工作区重置)——「abort」能工作的原因:原来的对象都还在,只是把引用和工作区改回去。这一节的方法论:Git 的所有疑难,都值得「先看对象与引用的位置」(git log --graph、git reflog——reflog 是「你所有 HEAD 移动的历史」,几乎一切「丢失」的提交都能在 reflog 里找回)——底层模型是 Git 最好的排障工具。
reflog 与找回:Git 里几乎没有真正的丢失
「我 commit 错了」「我 rebase 丢了提交」「我 reset --hard 了」——Git 社区的一句老话:只要提交过,几乎不会真丢。机制:Git 的对象库会保留「不再被任何引用指向」的对象至少 30 天(gc 的宽限),而 reflog(引用日志)记录了你本地 HEAD 的每一次移动——reset、rebase、merge、checkout 的每一步都有日志。找回的流程:git reflog 找到误操作前的位置(每条记录带 HEAD@{n} 与描述)→ git reset --hard HEAD@{2}(把分支引用挪回去)或 git branch rescue HEAD@{2}(先救到一个新分支更稳)。reflog 的边界:它只记本地操作(别的机器丢的管不了)、未提交的改动(没进对象库的)真救不了——「commit 早做,commit 勤做」的工程意义:进了对象库的才有保险。另一个进阶工具:git fsck --lost-found 扫描悬空对象(没有任何引用指向的 commit)——reflog 清掉之后的最后手段。这一节的元结论:Git 的「危险命令」大多只是「挪引用」——引用能挪回来,历史就能找回来;真正不可逆的只有「从未提交过的内容」。把这条刻进认知,对 Git 的恐惧就卸掉了一半。
Git 的工作流选型:从单人到大团队的分支策略
Git 的对象模型(正文)之上,分支策略是团队级的选型——主流方案与适用规模值得一张地图。方案一:主流干式(trunk-based)。所有人向 main(主干)高频合入,短命分支(1–2 天)+ 特性开关保护未完成功能——持续部署团队的选择(合入即发布的前提是测试与门禁完善)。方案二:GitHub Flow。main + feature 分支 + Pull Request 评审——中小团队与开源项目的通用解(简单、评审内建)。方案三:Git Flow。main/develop/feature/release/hotfix 五类分支——发布周期长、多版本并存的传统软件(桌面软件、版本化交付)——它的复杂度在持续部署时代成了负担(「Git Flow 已死」的争论实质是交付模式的变迁)。选型的三个判断变量:发布节奏(持续部署→trunk、版本发布→flow 类)、团队规模与评审文化(PR 评审是否内建)、以及对历史可读性的要求(squash merge 让主干线性干净 vs merge commit 保留完整上下文——两种主流口味)。底层视角的统一解释:所有分支策略都是「引用的组织方式」——对象库不变,变的是「谁、何时、把引用挪到哪」的社交约定——Git 工作流没有对错,只有与交付节奏的匹配。
Git 的日常疑难:从底层理解高频问题
对象模型落到日常,高频疑难问题有了底层答案。疑难一:git commit --amend 到底做了什么——它生成一个「新对象」(父指针相同的兄弟 commit)、把分支引用挪过来、旧 commit 弃养——「修改上次提交」的本质是「用新提交替换」,理解了就不会问「amend 之后原来的提交去哪了」(还在对象库里,直到 gc)。疑难二:为什么 rebase 后 push 被拒——远端的历史引用还指向旧对象,你的 rebase 产生了新对象链,远端认为你在「丢弃别人的提交」——--force-with-lease 的语义(远端还是你上次看到的样子才允许强推)比裸 force 安全一层。疑难三:stash 的本质——stash 也是 commit(存在 refs/stash,两个 parent:工作区与暂存区)——「暂存的改动去哪了」的答案:它是一个特殊的提交对象,pop 时恢复并删除引用。疑难四:cherry-pick 与 rebase 的关系——两者都是「复制提交对象」(生成新哈希的对象接到别处),区别只在批量与自动化程度。疑难五:merge 冲突后不想合了怎么办——git merge --abort 恢复到合并前(HEAD 指回原 commit,工作区重置)——「abort」能工作的原因:原来的对象都还在,只是把引用和工作区改回去。这一节的方法论:Git 的所有疑难,都值得「先看对象与引用的位置」(git log --graph、git reflog——reflog 是「你所有 HEAD 移动的历史」,几乎一切「丢失」的提交都能在 reflog 里找回)——底层模型是 Git 最好的排障工具。
小结
Git 的底层是内容寻址键值数据库:blob 存内容、tree 存目录快照、commit 串起树与历史;分支与 HEAD 只是 41 字节的引用文件;提交即快照(未变文件共享 blob)、分支即指针(创建微秒级)。rebase 是「复制对象 + 挪引用」,merge 是「双亲合流」,冲突是两个 blob 争夺 tree 的一个槽位。理解对象模型,Git 的一切行为——从轻量分支到历史改写、从完整性校验到 LFS 的存在理由——都从咒语变成了推论。
读者留言
COMMENTS 暂无还没有留言,来说第一句?