Git 深度解读:从对象模型到分支指针,把版本控制的内核讲透
摘要 :Git 不是一个"存档工具",而是一个内容寻址的对象数据库 加上一层引用系统 。本文不做命令罗列,而是从
.git目录里真实存在的文件出发,用本机实测的输出讲清四件事:提交到底存了什么、分支为什么廉价、合并与变基在数据结构上做了什么、出错后还能不能救回来。文中所有命令与数字均在本机 Git 2.53.0 上实测,可直接复现。
一、三代版本控制,与 Git 定下的几条设计原则
先看清 Git 站在什么位置。
第一代是本地版本控制,代表是 RCS。它的做法是在本地保存文件的历次修订,配合"锁文件"避免两人同时改。它解决了单人场景的备份与回溯,但天然不支持多人协作。
第二代是集中式版本控制,代表是 CVS 与后来的 SVN。历史集中保存在一台服务器上,每个人从服务器取出最新版本,改完提交回去。它解决了协作,但把"能不能干活"绑在了网络上;分支与合并的代价也高,团队往往只敢在主干上直接修改。
第三代是分布式版本控制,Git 是其中的胜出者。它的诞生有明确的现实动因:据 Git 官方文档《Git 简史》记载,Linux 内核项目在 1991 至 2002 年间靠提交补丁和保存归档维护代码,2002 年起改用专有的 BitKeeper;2005 年 BitKeeper 的商业公司与 Linux 社区的合作结束、收回了免费授权,社区只能自己造一套。Linus Torvalds 为新系统定下的目标包括:速度、设计简单、对非线性开发模式的强力支持(允许大量并行分支)、完全分布式,以及能扛住 Linux 内核级的超大规模项目。这些目标决定了 Git 的技术形状------为了"快",它把几乎所有操作都放在本地;为了"支持海量分支",它把分支做成了极轻量的指针;为了"完全分布式",它让每个克隆都是一份完整仓库。
理解 Git 的最佳角度不是命令,而是它作为数据库的三个设计决定:
#mermaid-svg-7iZsoKpk4iAW27PU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7iZsoKpk4iAW27PU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7iZsoKpk4iAW27PU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7iZsoKpk4iAW27PU .error-icon{fill:#552222;}#mermaid-svg-7iZsoKpk4iAW27PU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7iZsoKpk4iAW27PU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7iZsoKpk4iAW27PU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7iZsoKpk4iAW27PU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7iZsoKpk4iAW27PU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7iZsoKpk4iAW27PU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7iZsoKpk4iAW27PU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7iZsoKpk4iAW27PU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7iZsoKpk4iAW27PU .marker.cross{stroke:#333333;}#mermaid-svg-7iZsoKpk4iAW27PU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7iZsoKpk4iAW27PU p{margin:0;}#mermaid-svg-7iZsoKpk4iAW27PU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7iZsoKpk4iAW27PU .cluster-label text{fill:#333;}#mermaid-svg-7iZsoKpk4iAW27PU .cluster-label span{color:#333;}#mermaid-svg-7iZsoKpk4iAW27PU .cluster-label span p{background-color:transparent;}#mermaid-svg-7iZsoKpk4iAW27PU .label text,#mermaid-svg-7iZsoKpk4iAW27PU span{fill:#333;color:#333;}#mermaid-svg-7iZsoKpk4iAW27PU .node rect,#mermaid-svg-7iZsoKpk4iAW27PU .node circle,#mermaid-svg-7iZsoKpk4iAW27PU .node ellipse,#mermaid-svg-7iZsoKpk4iAW27PU .node polygon,#mermaid-svg-7iZsoKpk4iAW27PU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7iZsoKpk4iAW27PU .rough-node .label text,#mermaid-svg-7iZsoKpk4iAW27PU .node .label text,#mermaid-svg-7iZsoKpk4iAW27PU .image-shape .label,#mermaid-svg-7iZsoKpk4iAW27PU .icon-shape .label{text-anchor:middle;}#mermaid-svg-7iZsoKpk4iAW27PU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7iZsoKpk4iAW27PU .rough-node .label,#mermaid-svg-7iZsoKpk4iAW27PU .node .label,#mermaid-svg-7iZsoKpk4iAW27PU .image-shape .label,#mermaid-svg-7iZsoKpk4iAW27PU .icon-shape .label{text-align:center;}#mermaid-svg-7iZsoKpk4iAW27PU .node.clickable{cursor:pointer;}#mermaid-svg-7iZsoKpk4iAW27PU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7iZsoKpk4iAW27PU .arrowheadPath{fill:#333333;}#mermaid-svg-7iZsoKpk4iAW27PU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7iZsoKpk4iAW27PU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7iZsoKpk4iAW27PU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7iZsoKpk4iAW27PU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7iZsoKpk4iAW27PU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7iZsoKpk4iAW27PU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7iZsoKpk4iAW27PU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7iZsoKpk4iAW27PU .cluster text{fill:#333;}#mermaid-svg-7iZsoKpk4iAW27PU .cluster span{color:#333;}#mermaid-svg-7iZsoKpk4iAW27PU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7iZsoKpk4iAW27PU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7iZsoKpk4iAW27PU rect.text{fill:none;stroke-width:0;}#mermaid-svg-7iZsoKpk4iAW27PU .icon-shape,#mermaid-svg-7iZsoKpk4iAW27PU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7iZsoKpk4iAW27PU .icon-shape p,#mermaid-svg-7iZsoKpk4iAW27PU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7iZsoKpk4iAW27PU .icon-shape .label rect,#mermaid-svg-7iZsoKpk4iAW27PU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7iZsoKpk4iAW27PU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7iZsoKpk4iAW27PU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7iZsoKpk4iAW27PU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 决定一:内容寻址对象以内容哈希命名
历史难以被悄悄篡改相同内容自动去重
决定二:快照式提交每次提交记录整棵目录树
回退与比较不需要回放差异未改动的文件复用同一对象
决定三:本地优先远端只是另一个仓库
绝大多数操作离线可用分支与实验的成本极低
顺带记录两条改变使用习惯的版本演进,这两句都来自 Git 官方发布说明(RelNotes):Git 2.23 引入了 git switch 与 git restore,把长期身兼数职的 git checkout 拆成"切分支"和"还原文件"两件事;Git 2.34 起,默认合并策略由 recursive 换成 ort------本机合并时打印的也正是 Merge made by the 'ort' strategy.。
二、先看 .git 目录:一个仓库的真实结构
一切从磁盘上的文件开始。在一个新建仓库里列出 .git 的内容,得到的是这些东西:
text
COMMIT_EDITMSG HEAD ORIG_HEAD config description
hooks index info logs objects refs
逐个对号入座:objects 是对象库,存放全部文件内容、目录树、提交与标签;refs 存分支、标签等引用;HEAD 记录"当前在哪";index 是暂存区的二进制快照;logs 是引用变更日志,也就是 reflog 的数据来源;config 是这个仓库的配置;hooks 是本地钩子脚本;info 放排除规则、提交图索引等辅助文件;ORIG_HEAD 则是若干危险操作(merge、rebase、reset)前留下的"上一个位置",属于临时救援线索。
refs 与 objects 这两个目录,几乎解释了 Git 的全部行为。
三、对象模型:四种对象与内容寻址
3.1 四种对象各管什么
Git 对象库里只有四种对象,职责分明:
| 对象类型 | 存什么 | 说明 |
|---|---|---|
| blob | 文件内容 | 只存内容与长度,不含文件名 |
| tree | 目录结构 | 记录文件名、权限,以及指向 blob / 子 tree 的哈希 |
| commit | 一次提交 | 指向一棵 tree、一个或多个父提交、作者与提交者信息、提交说明 |
| tag | 附注标签 | 指向某个对象,附带标签名、打标签者与说明 |
文件名放在 tree 里而不是 blob 里,这一点初学者容易忽略,但它带来一个直接结果:同一份内容在仓库中只会保存一次,无论它出现在多少个文件名下、多少个版本里。
3.2 内容寻址:名字就是内容的指纹
Git 用对象的哈希值命名对象。把同一段内容算两次,得到的哈希完全一致;差一个字符,哈希全变:
bash
$ printf 'hello git\n' | git hash-object --stdin
8d0e41234f24b6da002d962a26c2495ea16a425f
$ printf 'hello git\n' | git hash-object --stdin
8d0e41234f24b6da002d962a26c2495ea16a425f # 同一个内容,同一个哈希
$ git hash-object c.txt # 内容改成 "hello svn"
de7ba8918834a480db4473c0844b81f495bba516
这串 40 个十六进制字符就是对象在磁盘上的"地址",前两位是目录名,后 38 位是文件名:
text
.git/objects/8d/0e41234f24b6da002d962a26c2495ea16a425f (26 字节的松散对象文件)
内容寻址带来两个重要性质。第一是自动去重 :向仓库提交两个内容完全相同的文件,磁盘上只会出现一份数据------实测中整个仓库只产生了 3 个对象(一份内容、一棵树、一次提交),两个文件在树里指向同一个 blob,仓库总共占用十几 KB。第二是可校验:对象名由其内容决定,改动内容必然导致名字变化,所以"静默篡改历史"的难度极高。
3.3 一次提交里到底有什么
提交一个文件后,用 cat-file 把 commit 对象摊开看,内容是高度结构化的:
bash
$ git cat-file -p HEAD
tree c5723c0d95315a8739ca137d83ed1ec4ebbdd1de
author Demo <demo@example.com> 1790854883 +0800
committer Demo <demo@example.com> 1790854883 +0800
第一次提交
$ git cat-file -p HEAD^{tree}
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f a.txt
一次提交对象只有 162 字节,一棵只含一个文件的树只有 33 字节。"Git 每次提交都保存快照"这句话的正确理解是:提交指向一棵完整的目录树,逻辑上是那一刻项目的全貌;但未改动的文件复用的是同一个对象,物理上不会重复存储。至于磁盘占用如何被进一步压缩,第五节会用实测数据说明。
附注标签也是对象,因此可以像查看提交一样查看它:
bash
$ git tag -a v1.0 -m "1.0 发布版" && git cat-file -p v1.0
object 038d440f1a6bc70f24b909d37f341099d38556a2
type commit
tag v1.0
tagger Demo <d@e.com> 1790855218 +0800
1.0 发布版
3.4 关于哈希算法:SHA-1、碰撞与 SHA-256
默认情况下 Git 用 SHA-1,对象名是 40 个十六进制字符(160 位)。SHA-1 已被证明可以人为构造碰撞------2017 年的 SHAttered 攻击给出了两个内容不同、SHA-1 相同的 PDF 文件。因此 Git 也支持以 SHA-256 命名的仓库格式,初始化时指定即可,此时对象名变成 64 个十六进制字符:
bash
$ git init --object-format=sha256 repo && git -C repo rev-parse --show-object-format
sha256
$ git -C repo rev-parse HEAD | wc -c # 提交哈希长度
65 # 64 个字符 + 换行
需要客观说明的是:出于兼容性与迁移成本,默认格式至今仍是 SHA-1。对绝大多数团队来说,SHA-1 的碰撞风险远小于"误提交密钥""历史被 rebase 改写"这类现实问题,优先级不必排在最前。
四、引用系统:分支、HEAD 与远端引用
4.1 一个分支就是一个 41 字节的文件
这是 Git 最容易被低估的设计。创建一个分支后去量它的文件大小:
bash
$ git branch feature
$ wc -c .git/refs/heads/master .git/refs/heads/feature
41 .git/refs/heads/master
41 .git/refs/heads/feature # 40 个十六进制字符 + 换行
$ cat .git/refs/heads/master
9cb99b9b6498fd86391c4dd2791a335eb49d72f0
分支的内容就是它指向的那个提交的哈希。所谓"创建分支""切换分支",本质是新建或改写一个小文件;所谓"提交",本质是写一批新对象,再把当前分支这个文件改成新提交的哈希。开销如此之小,才是"一个功能一个分支"这种工作方式可行的前提。
引用多了以后,Git 会把它们折叠进 .git/packed-refs 以减少文件数量,执行 git gc 之后可以看到单个 ref 文件消失、内容进入这个文件:
text
# pack-refs with: peeled fully-peeled sorted
9cb99b9b6498fd86391c4dd2791a335eb49d72f0 refs/heads/feature
42f9db8b6fc82589b98bead45283e7574332128d refs/heads/master
4.2 HEAD:当前在哪
HEAD 通常不是哈希,而是一行指向某个引用的"软链接":
bash
$ cat .git/HEAD
ref: refs/heads/master
如果直接检出一个历史提交而不是分支,HEAD 里就变成裸哈希,这就是"游离 HEAD"(detached HEAD):
bash
$ git switch --detach HEAD~2 && cat .git/HEAD
c72691ea4c0ff339bf578eb793a52cf2003d967a # 不再是引用,而是具体提交
游离状态下新提交的代码不属于任何分支,一旦切走就有被回收的风险。要保住它,把它固化成一个分支即可:git switch -c rescue-from-detach。
4.3 远端引用:远端分支也是本地文件
Git 里没有"远端分支"这种特殊实体,只有"记录远端仓库状态的本地引用"。本地建一个裸仓库当远端,推送后观察:
bash
$ git remote add origin /path/to/remote.git && git push origin main feature
$ git branch -r
origin/feature
origin/main
$ git config --get remote.origin.fetch
+refs/heads/*:refs/remotes/origin/*
$ wc -c < .git/refs/remotes/origin/main
41
这条 refspec 的含义是:把远端 refs/heads/* 映射到本地 refs/remotes/origin/*。所以 git fetch 只是"同步这些引用并下载缺失对象",git pull 等于 fetch 加一次合并或变基。远端引用同样是一个 41 字节的文件,origin/main 与本地 main 各指各的提交------这正是"分布式"在文件层面的体现。
4.4 reflog:误操作的物理保险
logs 目录记录着引用如何一步步变化。它的价值在于:被 reset 掉、被 rebase 替换掉、被 commit --amend 覆盖掉的提交,对象并没有立刻消失,而 reflog 里有找回它们的线索 。手册中规定的默认保留期是 90 天(配置项 gc.reflogExpire 默认值),足够覆盖绝大多数"手抖"事故。第五节会演示一次完整救援。
五、三个区域与一次提交的旅程
5.1 三个区域
工作区是你编辑的目录;暂存区(index)是一份"下次提交要包含什么"的清单;本地仓库是 objects 与 refs 组成的对象库。三者关系可以这样理解:git add 把工作区的现状写进索引,git commit 把索引固化成一棵 tree 并生成 commit 对象,git push 把本地新提交送到远端。
索引本身是一个二进制文件,实测包含两个文件的仓库,.git/index 只有 209 字节;用 git ls-files --stage 可以看到它记录的正是"路径 → blob 哈希"映射:
bash
$ git ls-files --stage
100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0 a.txt
100644 42fccb44db8531bb0e334142312616888ab24469 0 big.txt
5.2 为什么非要一个暂存区
这是初学者最常提出的疑问。答案是:一次连续修改通常包含好几件不相干的事 。你可能顺手修了个错别字、改了一处逻辑、又调了一个配置项。暂存区让你把这些内容切成几次独立提交,让历史里每一次提交都对应一件完整的事。日后用 git log -S 或 git blame 考古时,这个习惯的价值会立刻体现出来。配合 git add -p 逐块挑选改动,甚至可以在一次编辑中挑出不同主题的修改。
文件在多区域之间的状态,用 git status --short 的两个字母就能看清:?? 未跟踪、A 已暂存的新增、M 已修改,字母分别在左右两列表示"暂存区状态"和"工作区状态";出现 UU 意味着合并冲突,双方都改了同一文件。
5.3 一次提交的完整命令流
bash
git status # 我现在改了什么
git add -p # 挑出属于同一件事的改动(可选)
git diff --staged # 提交前审视即将进入仓库的内容
git commit -m "做了什么,为什么"
git log --oneline -3 # 确认历史是否符合预期
git push origin main # 同步到远端
六、提交图与分支模型
6.1 提交图是有向无环图,不是链表
每个提交通过 parent 指向父提交,于是历史构成一张 DAG(有向无环图)。两条分支的共同源头叫 merge base,Git 的合并算法就从它出发:
bash
$ git log --graph --oneline --all --decorate
* 5998506 (feature) F2 分支再改一次
* 1a8fbf8 F1 分支新增文件
| * 93b791f (HEAD -> main) M2 主干改说明
|/
* 3fca433 M1 初始化
$ git merge-base main feature
3fca433...
6.2 快进合并:只移动指针,不产生新提交
当目标分支自上次分叉后没有任何新提交时,合并无需计算,只是把指针向前移动:
bash
$ git merge --ff-only ff
更新 3ee9055..cd2e5dc
Fast-forward
a.txt | 1 +
1 file changed, 1 insertion(+)
$ git rev-parse main ff # 合并后两者指向同一提交
cd2e5dc... cd2e5dc...
注意这里的"不产生新提交"指的是没有生成合并提交;提交总数增加,是因为把分支上已有的提交纳入了当前分支。
6.3 三方合并:产生有两个父提交的提交
两条线都前进过,就必须做真正的合并:以 merge base 为基准,把两侧相对基准的改动叠加起来。成功时生成一个有两个 parent 的合并提交:
bash
$ git merge --no-ff -m "M3 合并 feature 分支" feature
Merge made by the 'ort' strategy.
$ git cat-file -p HEAD
tree f2915f08858e85ac97b92e3078bb1b1c1699c6e2
parent 93b791f63e0740804fba462775212927f4f17a50
parent 599850618e8f75ceae758d4c853e8ac8fa651fb6
...
$ git show --no-patch --format='%h -> 父提交: %p' HEAD
038d440 -> 父提交: 93b791f 5998506
提交图从此有了一个汇聚点。这对理解 --first-parent 很有用:只看主干视角时,合并进来的整条分支会被压缩成一次提交,这是审阅主干历史时最常用的姿势。
bash
$ git log --oneline --first-parent
038d440 M3 合并 feature 分支
93b791f M2 主干改说明
3fca433 M1 初始化
6.4 冲突:索引里同时保留三个版本
两边改了同一处,Git 不猜,而是把选择权交回给人。此时索引里同时保存三个版本 ,编号对应"共同祖先 / 本方 / 对方"。冲突文件的形态是:本方内容、一行 7 个等号组成的分隔线、对方内容,分别用 <<<<<<<、>>>>>>> 包起来:
bash
$ git merge ca
自动合并 shared.txt
冲突(内容):合并冲突于 shared.txt
$ echo $?
1
$ cat shared.txt
<<<<<<< HEAD
共享行 = 主干的改法
〔分隔线:一行 7 个等号〕 ← 此处用文字代替,避免被博客排版工具误处理
共享行 = 分支 A 的改法
>>>>>>> ca
$ git status --short
UU shared.txt
$ git ls-files -u
100644 ba54b8485f24614c47eea96e7dc814fa77fcd967 1 shared.txt # 共同祖先
100644 66017457b3c69ebc7e45b0e1d25d6e1c4137d0ed 2 shared.txt # 本方
100644 6514f597c9f8a3928c4cad43d17dc4aadf838a01 3 shared.txt # 对方
理解"三个阶段"就能理解 git checkout --ours/--theirs、git mergetool 这些工具在做什么:它们操作的就是这三个版本。想放弃这次合并,git merge --abort 会回到合并前的干净状态(实测:提交数与合并前一致,工作区无残留改动)。解决完成后先 git diff --check 确认没有把冲突标记提交进仓库,再 git add 并提交。
6.5 变基:换一条基准线,代价是哈希全变
变基把当前分支上的提交"搬到"新基准上逐一重放,换来一条直线。它的代价可以从哈希上直接看到:
bash
$ git log --oneline -2 rb # 变基前
905df64 R2 分支第二个提交
2b380f8 R1 分支第一个提交
$ git rebase main
$ git log --oneline -2 rb # 变基后
18701ba R2 分支第二个提交
323afe2 R1 分支第一个提交
提交说明没变,但哈希全变了------因为提交对象里含有父提交的哈希,换了父提交,它自己就成了一个新对象。这条性质直接推出工程上最硬的规矩:不要对已经推送给别人的分支做变基。别人基于旧提交做的工作会失去共同祖先,后续合并会反复产生莫名其妙的冲突。变基只适合整理尚未共享的本地提交。
七、撤销与救援:把"后悔药"分成三类
7.1 先分清改的是"历史"还是"现状"
| 我想做什么 | 命令 | 影响范围 | 安全性 |
|---|---|---|---|
| 撤销某次已推送的改动 | git revert <提交> |
生成反向的新提交,不改历史 | 安全,团队首选 |
| 回退尚未推送的本地提交 | git reset --soft/--mixed |
移动分支指针,改动保留 | 安全,本地操作 |
| 彻底丢弃最近的本地提交与改动 | git reset --hard <提交> |
指针与工作区一起回退 | 危险,可被 reflog 救回 |
| 只还原某个文件 | git restore <文件> |
只改工作区,不动历史 | 安全 |
| 分支被误删、提交被 reset 掉 | git reflog + reset --hard |
找回对象 | 救援手段 |
7.2 reset 的三种模式,差别只在"改动留在哪一层"
bash
$ git reset --soft HEAD~1 # 指针后退,改动留在暂存区
--soft:HEAD=7621d2f|暂存区里待提交的改动=a.txt
$ git reset HEAD~1 # 默认 --mixed:指针后退,改动退回工作区
--mixed:HEAD=c72691e|暂存区待提交=0 条|工作区改动 1 条
$ git reset --hard HEAD # 指针与工作区一起回退
--hard:工作区改动 0 条
记忆法:--soft 撤提交不撤改动,--mixed 再撤暂存,--hard 连工作区一起撤。
7.3 reflog 救援实测
--hard 丢掉东西之后,被丢掉的提交对象仍然在对象库里,reflog 里留着它的哈希:
bash
$ git reflog | head -5
c72691e HEAD@{0}: reset: moving to HEAD
c72691e HEAD@{1}: reset: moving to HEAD~1
7621d2f HEAD@{2}: reset: moving to HEAD~1
003b8d4 HEAD@{3}: commit: C4 # 被丢掉的那次提交
$ git reset --hard 003b8d4 # 一句话救回来
恢复后 HEAD=003b8d4 提交数=4
需要提醒两点。第一,reflog 是本地 的,推不到远端,也没有远端版本可以依赖。第二,未 gc 前对象一定还在;对象的清理默认以两周为界(git gc --prune 的默认值),reflog 条目默认保留 90 天(gc.reflogExpire 默认值)。所以事故之后的第一件事是停止一切操作,先把 reflog 看一遍,而不是急着重新写代码。
7.4 revert 与 reset 的分水岭
git revert 生成一次新提交来抵消历史中某次改动,历史被追加而不是改写:
bash
$ git revert --no-edit HEAD
[feature 3570e16] Revert "准备被回滚的提交"
1 file changed, 1 deletion(-)
$ git log --oneline -3
3570e16 Revert "准备被回滚的提交"
4edc70b 准备被回滚的提交
5998506 F2 功能分支再改一次
判断标准很简单:这段历史别人有没有拿到 。拿到了就用 revert,没拿到才考虑 reset。
八、存储性能:打包、增量压缩,以及大文件的代价
8.1 松散对象与 packfile
每次提交都会在 .git/objects 下留下若干松散对象文件,一个几百次提交的仓库会积累成千上万个小文件。git gc 会把它们打包成一个 pack 文件,并对相似对象做增量压缩。实测一个 31 次提交、每次修改同一份 400 行文本的仓库:
| 指标 | gc 之前 | gc 之后 |
|---|---|---|
| 松散对象 | 93 个(372 KB) | 0 个 |
| 包内对象 | 0 | 93 个(pack 13 KB) |
.git 总体积 |
872 KB | 192 KB |
体积缩到约五分之一,靠的就是增量压缩:把对象间的差异压成"相对某个基准对象的补丁",链式存储。实测该仓库的增量链分布为------链长 1 的对象 11 个、链长 2 的 10 个、链长 3 的 7 个。此外,git commit-graph write 会把提交图预处理成一份索引文件(实测:该仓库生成 2972 字节的 commit-graph),让 git log --graph、git branch --contains 这类需要遍历祖先关系的操作不必再解压对象。
8.2 同一套机制下,文本与二进制的差距是数量级的
把"改同一份大文件四次"分别在文本和随机二进制上做一遍,结果差异极端:
| 实验对象 | 每次改动 | 版本数 | .git 最终体积 |
是否发生增量压缩 |
|---|---|---|---|---|
| 9.6 MB 文本(12 万行) | 改其中一行 | 5 | 488 KB | 是,verify-pack 显示后续版本在包里只有几百字节、depth=1 |
| 5 MB 随机二进制(图片/视频的近似物) | 改 1 个字节 | 5 | 5292 KB | 否,verify-pack 显示每个 blob 都是 5242880 字节,且"压缩"后 5244490 字节------不缩反胀 |
原因不难理解。文本的改动是局部且可预测的,增量算法能把它压成极小补丁,整份 9.6 MB 的文件在包里只占 323 KB(约 30:1);而随机二进制没有可利用的冗余,也无法被 zlib 压缩,每一次改动都几乎等于把整份文件再存一遍。
这张表就是"不要把大文件提交进 Git"的量化理由:仓库体积会随改动次数 线性增长。做法是把大文件放到外部存储(对象存储、制品库、云盘),仓库里只用 Git LFS 保留一个文本指针。一旦已经提交进去了,清理不只是删文件那么简单------历史里仍然存在,需要 git filter-repo 或 BFG 重写历史,并让所有协作者重新克隆。
8.3 只要一部分历史:浅克隆、稀疏检出与并行工作区
仓库大到一定程度,就需要"按需取用"的手段。以下三种都在本机实测过:
bash
# 浅克隆:只要最近一次提交的历史
$ git clone --depth 1 file:///path/to/repo shallow
浅克隆提交数: 1 完整仓库提交数: 13
浅克隆 .git 体积: 188 KB 完整 .git 体积: 640 KB
# 稀疏检出:只要一部分目录,其余目录不检出到工作区
$ git sparse-checkout init --cone && git sparse-checkout set src
set src 之后,根目录: src # assets、docs 不再出现在工作区
$ git sparse-checkout list
src
# 并行工作区:同一个仓库挂多个工作目录,互不干扰
$ git worktree add -b wt-branch ../repo-wt
$ cat ../repo-wt/.git
gitdir: /path/to/repo/.git/worktrees/repo-wt # 只是一行指针文件
worktree 解决的是很实际的问题:改到一半需要紧急修线上 bug,不必 stash 或复制整个仓库,直接在另一个工作目录里开工即可。浅克隆的代价是历史不完整(无法在其中追溯久远的问题),需要时再用 git fetch --unshallow 补齐。
8.4 二分定位:把"哪个提交引入的问题"从猜变成算
历史越干净,二分定位越好用。造一个第 13 次提交引入问题的场景,交给 Git 自动二分:
bash
$ git bisect start && git bisect bad HEAD && git bisect good <第一个提交>
$ git bisect run sh check.sh
正在执行 'sh' 'check.sh'
二分查找中:在此之后,还剩 4 个版本待测试 (大概 2 步)
二分查找中:在此之后,还剩 2 个版本待测试 (大概 1 步)
7f75abb05b78a0a6cd7518379daa1e088f9d6410 is the first bad commit
step 13
22 个提交里定位到目标只用了 4 步。前提是每个提交都能构建或运行------这正是"每次提交都保持可用"这条纪律的实际回报。与之配套的考古工具还有:git log -S '字符串' 找出某段内容是哪次提交引入或删除的;git log -G '正则' 按增删行匹配;git blame 逐行标注最后一次修改它的提交;git log --diff-filter=A --name-only -- 路径 只看某个文件是何时被加入的。这些命令在实测中都能精确命中目标提交。
九、工程实践:把内核知识换成日常纪律
理解了数据结构,很多"规矩"就不再是教条。
9.1 三种主流工作流,选一种并保持一致
| 工作流 | 分支结构 | 适合场景 |
|---|---|---|
| Git Flow | main / develop / feature / release / hotfix | 有明确发布周期、需要长期维护多个版本的产品 |
| GitHub Flow | main + 短生命周期 feature 分支 + PR 合并 | 持续交付的 Web 服务与多数团队 |
| 主干开发 | 大家都在 main 上,小步快跑 + 特性开关 | 自动化测试与 CI 成熟、需要快速迭代的团队 |
选择的关键不在"哪个高级",而在与发布节奏、测试能力是否匹配。分支模型是团队约定的产物,混用两套模型才是混乱的来源。
9.2 十条经过验证的硬规矩
- 一次提交只做一件事,提交说明写清"做了什么、为什么"------这是给未来的自己(和
git blame)留线索。 - 动手前先同步(
fetch+ 合并或变基),切分支前先提交或stash,"代码不见了"的事故多数源于这两步没做。 - 已推送的公共分支不做变基,需要回滚就用
revert。 - 强制推送用
--force-with-lease而不是--force,前者在远端被别人更新过时会拒绝执行。 .gitignore只对未跟踪文件生效;已误提交的文件要git rm --cached后才会被忽略。- 密钥一旦提交进历史,删除文件不等于安全 ------必须立刻轮换密钥,再考虑用
filter-repo清理历史。 - 大的二进制、数据集、模型权重不进 Git,走 LFS 或外部存储。
- 跨平台协作统一行尾策略(
core.autocrlf或.gitattributes),避免整文件"假改动"淹没真实 diff。 - 提交前用
git diff --staged审视内容,合并冲突解决后用git diff --check确认没有残留标记。 - 重要的分支与标签,远端要有;本地只有一份的仓库,本身就构成单点风险。
十、结语:把 Git 当成一个数据库
回到最初的问题:Git 为什么能成为默认选择?
因为它用极少的抽象解决了极多的问题:对象库 (内容寻址、自动去重、可校验)负责"存什么";引用系统 (分支、HEAD、远端引用、reflog)负责"当前在哪、怎么变化";索引 负责"下一次提交包含什么";而所有命令不过是这四个数据结构之上的操作界面。理解了这层结构,merge 与 rebase 的区别、reset 三种模式的差异、分支为什么廉价、误删为什么能救回,都是同一套模型的自然推论,而不是需要背下来的咒语。
对刚上手的人,建议的路径是:先把工作区、暂存区、仓库三个区域和一次提交的旅程跑通;再把"分支是指针、提交图是 DAG"这两个图像建立起来;最后才去碰 reset、rebase 这类会改写历史的命令------那时你已经知道它们在改什么,就不会再怕它们。
参考来源:
- Pro Git(Git 官方书):1.2 Git 简史、10. Git 内部原理
- git-scm.com 官方手册 :git-config(
gc.reflogExpire、gc.pruneExpire默认值)、git-gc、git-merge、git-verify-pack- Git 官方发布说明 :RelNotes 2.23.0(引入
git switch/git restore)、RelNotes 2.34.0(ort成为默认合并策略)- SHAttered:SHA-1 碰撞攻击演示(2017)
- 本文所有命令输出与体积数据,均为本机 Git 2.53.0 实测(Linux)
关于作者:喵本喵叁肆,独立技术博主,长期关注数据工程与隐私计算方向的技术原理与工程实践。本文另有面向非技术读者的科普版(《Git:给代码装上一台时间机器》),可按需查阅。