资料库 · Git 数据模型与内部原理
第 ① 卷,条目 1–20。来源是 jvns.ca(Julia Evans)和 github.blog 的 Git 团队。
这一卷的排法是有讲究的:前 12 条建模型,后 8 条讲实现。前面那批读完你会知道 Git 在磁盘上是什么样、命令在动哪个指针;后面那批是 Derrick Stolee 写的"把 Git 当数据库看"系列,讲的是索引、查询、同步这些工程层面的东西。
如果你只有一小时,读第 1、2、3、5、11 条。这五条是模型的骨架。
磁盘上的 Git
1. Inside .git
来源:Julia Evans,jvns.ca,2024 年 1 月。
读它解决什么:.git 目录里那些文件夹分别是什么,为什么删掉 .git 就等于删掉了整个历史。
要点:
- 逐个走
.git下的目录:objects、refs、HEAD、index、config、logs - 这是作者 Git 系列的入口篇,后面几篇都建立在这一篇的基础上
我的补充:这一篇值得边读边开一个终端跟着敲。建一个空目录,git init,然后 find .git -type f 看初始状态有几个文件(很少,大概 10 个左右,全是模板和配置)。然后 echo hi > a.txt && git add a.txt,再看一次——多出来的那个 objects/xx/yyyy... 就是 blob 对象。git cat-file -p <hash> 能把它打出来,内容就是 hi。
这个实验做一遍,你对"Git 是内容寻址的键值库"这句话的理解就从背诵变成了知道。之后遇到任何诡异状态,你的第一反应会变成"去 .git 里看看现在到底是什么状态",而不是在网上搜命令。
顺带说个实用的:.git/index 是二进制的暂存区,git status 慢的时候八成是它太大。git ls-files --stage | wc -l 能看条目数。
2. In a git repository, where do your files live?
来源:Julia Evans,jvns.ca,2023 年 9 月。
读它解决什么:你的文件在 Git 里到底存了几份、存在哪。
要点:
- 追踪同一个文件在工作区、暂存区(index)、对象数据库三处的形态
- 解释 Git 的存储是"内容 + 指向内容的名字"两层,而不是按路径存
我的补充:这一篇解决的是一个很多人从没想过但天天受它影响的问题:Git 不存"文件",它存"内容",再用树对象把内容挂到路径上。
一个直接的推论:改文件名对 Git 来说不是一个操作。你 git mv a.txt b.txt,Git 记录的是"树对象里 a.txt 这一项没了,多了一项 b.txt 指向同一个 blob"。git log --follow 之所以需要这个额外的参数,就是因为重命名要靠事后启发式推断(内容相似度超过阈值就认为是重命名),而不是记录下来的事实。
这也解释了为什么大文件必须走 LFS:每次改动一个 100MB 的二进制文件,对象库里就多一个完整的 100MB blob(二进制压缩不动,delta 也算不出来),仓库体积线性膨胀,而且永远删不掉(除非重写历史)。
3. A data model for Git (and other docs updates)
来源:Julia Evans,jvns.ca,2026 年 1 月。
读它解决什么:想要一份把 Git 数据模型讲清楚的正式说明,而不是零散的技巧。
要点:
- 作者尝试为 Git 写一份数据模型层面的文档,同时提到其他文档改进
- 2026 年 1 月发表,是本卷收录范围内这个方向最新的一篇
我的补充:这一篇的存在本身就说明了一件事:Git 官方文档最大的缺口不是命令说明,而是模型说明。git help rebase 会告诉你所有参数,但不会告诉你 rebase 在对象库里实际做了什么(答案:为每个提交造一个新提交,旧提交变成不可达对象,等 gc 回收)。
我自己带新人的经验:先讲模型再讲命令,学习曲线能砍掉一半以上。具体讲四件事就够了——(1) 提交是快照不是差异;(2) 分支是一个 41 字节的文件,里面就是一个 hash;(3) HEAD 是指向分支的指针,或者直接指向提交(detached);(4) 所有"危险"操作只是在动指针,对象本身还在,git reflog 能找回来。
第四条尤其重要。新人怕 Git 是因为觉得操作不可逆,知道 reflog 之后那种恐惧会消失,之后才敢练。
4. Do we think of git commits as diffs, snapshots, and/or histories?
来源:Julia Evans,jvns.ca,2024 年 1 月。
读它解决什么:为什么同一个提交,有时候该当快照理解,有时候该当差异理解。
要点:
- 讨论三种心智模型:差异、快照、历史
- 指出不同 Git 命令实际上假设了不同的模型
我的补充:这一篇点到了 Git 最大的认知陷阱。存储上提交是快照,但一大半命令的行为是按差异定义的:
git show、git log -p、git diff—— 显示的是差异(快照相减算出来的)cherry-pick、revert、rebase、am—— 语义是"把这个提交的差异应用到别处"git merge、git checkout—— 按快照工作
搞混这一点会导致具体的错误判断。举个真实场景:你 cherry-pick 一个提交到另一个分支,结果冲突了。如果你以为提交是快照,这事没法解释(快照哪来的冲突)。理解成"取差异再应用"就清楚了:目标分支的上下文和源分支不一样,patch 打不上。
我的做法是:看历史时想快照,做操作时想差异。这两个模型都要留着,不要试图统一。
5. Commits are snapshots, not diffs
来源:Derrick Stolee,GitHub Blog,2020 年 12 月。
读它解决什么:从 Git 实现者的角度确认提交的本质,以及这个认知怎么影响你读 git log。
要点:
- GitHub Git 团队的核心贡献者写的入门级说明
- 明确"提交存的是完整树快照",差异是展示时算出来的
我的补充:这一篇和上一条(第 4 条)配着读,一个是从用户困惑出发,一个是从实现出发,结论互相印证。
Stolee 这一篇里最实用的是它解释了 git log 的路径过滤为什么会"漏"提交。当你 git log -- some/file.txt,Git 默认开了历史简化:如果一个合并提交的差异对这个文件来说是空的(两边改动一样),这个提交就被跳过。所以 git log -- file 的输出不是"所有碰过这个文件的提交"。
想要完整的:git log --full-history -- file。想看合并提交里对这个文件做了什么:加 -m。我排查"这一行是谁改的、什么时候改的"卡住过好几次,最后都是因为默认的历史简化把关键的合并提交藏了。
6. Some miscellaneous git facts
来源:Julia Evans,jvns.ca,2023 年 10 月。
读它解决什么:一批零散但会改变你判断的 Git 事实。
要点:
- 杂项合集,不成体系但每条都是作者研究 Git 内部时确认过的细节
- 属于"知道了会少踩坑"那类内容
我的补充:这类"杂项"文章的价值容易被低估。Git 里有一批只有知道才不会踩的事实,没有任何逻辑能推出来。我自己踩过的几个:
.gitignore对已跟踪的文件完全无效。 加了规则文件还在变更列表里,得先git rm --cached。git add .和git add -A在 Git 2.0 之后行为一样了,但在旧版里不一样(前者不处理删除)。CI 里跑老版本 Git 的机器上会有区别。- 空目录 Git 不跟踪。 需要占位就放
.gitkeep,这不是 Git 的特性,只是个约定。 - 文件模式只记录一位(可执行与否),其他权限不记。所以
chmod 600提交不上去,部署脚本别指望 Git 帮你管权限。
指针:分支、HEAD、引用
7. How HEAD works in git
来源:Julia Evans,jvns.ca,2024 年 3 月。
读它解决什么:HEAD 到底是什么,detached HEAD 状态为什么会出现、怎么安全脱离。
要点:
- HEAD 通常是指向当前分支的符号引用,也可以直接指向一个提交
- 解释"分离头指针"状态的成因与含义
我的补充:HEAD 只是 .git/HEAD 这个文本文件。cat .git/HEAD 正常情况下输出 ref: refs/heads/main——一行字符串。分离状态下它直接是一个 40 位 hash。
detached HEAD 唯一真正的风险是:在这个状态下的提交不被任何分支引用,切走之后就成了不可达对象,30 天后被 gc 掉。 不是"不能提交",是"提交了容易丢"。
所以补救办法很简单:真的在分离状态下写了东西,git switch -c 临时分支名 就地建个分支把它接住。已经切走才发现的话,git reflog 里找那个 hash,同样能救。
CI 里 git checkout <sha> 是常态(就该是分离的,CI 不需要分支),所以看到 detached 提示别慌,先分清是不是自己手滑。
8. The "current branch" in git
来源:Julia Evans,jvns.ca,2024 年 3 月。
读它解决什么:"当前分支"这个概念在不同命令里含义不同,导致的一类混淆。
要点:
- 讨论"当前分支"在 Git 中的多重含义
- 与 HEAD 那一篇配套,同月发表
我的补充:这一篇指出的问题在脚本里最要命。你想在 CI 里拿"当前分支名",会发现有好几个不等价的办法:
git rev-parse --abbrev-ref HEAD—— 分离状态下返回字面量HEAD,不是错误,是个会骗过你的字符串git symbolic-ref --short HEAD—— 分离状态下直接报错退出(脚本里这个更好,失败是显式的)git branch --show-current—— Git 2.22+ 才有,分离状态返回空字符串
我的建议:脚本里用 git symbolic-ref --short HEAD,让它在分离状态下失败。用第一种的最大风险是你会拿到字符串 "HEAD" 然后当分支名往下传,最后在某个 git push origin HEAD:HEAD 之类的地方炸掉,而且报错信息完全指不到根因。
CI 环境里更稳的做法是直接用平台注入的变量(GITHUB_REF_NAME、CI_COMMIT_REF_NAME),别自己算。
9. git branches: intuition & reality
来源:Julia Evans,jvns.ca,2023 年 11 月。
读它解决什么:你直觉里的"分支"和 Git 实现的"分支"差在哪。
要点:
- 直觉上分支是"一串提交",实现上分支是"一个指向单个提交的指针"
- 这个落差解释了一批常见困惑
我的补充:这个落差是大量 Git 困惑的单一根因,值得记死:分支就是 .git/refs/heads/<名字> 这个文件,里面 41 个字节(40 位 hash 加换行)。仅此而已。
从这一条能直接推出好几件事:
- 建分支是 O(1) 的,不管仓库多大——就是写个 41 字节的文件。所以别吝啬建分支。
- 删分支不删提交,只删那个文件。提交还在对象库里,reflog 里能找回。
- "这个提交在哪个分支上"是个反向查询,Git 得从所有分支往回走才能答(
git branch --contains <sha>),所以它比正向操作慢。 - 两个分支"分叉"了,其实是两个指针从共同祖先各走了一段。这个图景一建立起来,rebase 和 merge 的区别就是自明的:merge 造一个有两个父提交的新提交,rebase 把一侧的提交重放到另一侧顶上。
10. Dealing with diverged git branches
来源:Julia Evans,jvns.ca,2024 年 2 月。
读它解决什么:git pull 报"分支已分叉"时,几种处理方式分别会发生什么。
要点:
- 系统梳理分叉状态下的选项:merge、rebase、force push、reset
- 属于作者 Git 系列里最偏实操的一篇
我的补充:分叉本身不是错误状态,是两边都有对方没有的提交这个客观事实。Git 只是不知道你想要哪种结果,所以停下来问。
我自己的决策规则,按"这个分支有没有别人在用"分:
只有我在用(feature 分支) → git pull --rebase,历史干净,review 好读。把 git config --global pull.rebase true 设上,省得每次想。
别人也在用(main、共享分支) → 一律 merge,绝不 rebase。rebase 共享分支等于给所有协作者制造一次"你的本地和远端分叉了",而他们多半会用 force push 或者乱来的 merge 去解决,最后历史彻底乱掉。
已经 force push 过、把别人搞坏了 → 让对方 git reset --hard origin/<分支> 之前,先 git branch backup-$(date +%s) 存一份。这个习惯救过我不止一次。
另外 Git 2.27 之后,不配置 pull.rebase 就直接 git pull 会打一大段警告。别忽略它,去把配置设上——那个警告的意思是"我不猜,你自己定"。
合并、rebase 与三方合并
11. How git cherry-pick and revert use 3-way merge
来源:Julia Evans,jvns.ca,2023 年 11 月。
读它解决什么:cherry-pick 和 revert 为什么会冲突——它们看起来只是"应用一个补丁"。
要点:
- cherry-pick 与 revert 内部走的是三方合并,不是简单打补丁
- 解释三方合并的三个输入分别是什么
我的补充:这一篇是本卷我最推荐的一条,因为它讲清了 Git 里唯一真正的算法。理解三方合并之后,merge、rebase、cherry-pick、revert、git stash pop、git checkout -m 全部变成同一件事的不同包装。
三方合并的三个输入:base(共同祖先版本)、ours(当前版本)、theirs(要合进来的版本)。规则很简单:base 到 ours 变了、base 到 theirs 没变,取 ours;反过来取 theirs;两边都变且变得不一样,冲突。
cherry-pick 的巧妙之处在于它人为构造了这三个输入:base 是被 pick 提交的父提交,theirs 是被 pick 的提交,ours 是当前 HEAD。所以冲突的含义很具体:你当前分支上这块代码,和被 pick 提交的父提交那时候相比已经变了,Git 没法确定该保留谁的改动。
一个实用推论:冲突标记里 <<<<<<< HEAD 和 >>>>>>> 之间的 ======= 只显示了两边,base 被藏了。开 git config --global merge.conflictStyle zdiff3(Git 2.35+),冲突块里会多显示 base 那一段。解冲突的效率差别很大——能看到共同祖先,你才知道每一边"改了什么",而不是只看到两个结果去猜。
12. git rebase: what can go wrong?
来源:Julia Evans,jvns.ca,2023 年 11 月。
读它解决什么:rebase 具体会在哪些地方出问题,以及这些问题的成因。
要点:
- 系统列举 rebase 的失败模式,而不是笼统地说"rebase 危险"
- 与三方合并那一篇同月发表,共用一套模型
我的补充:"rebase 危险"这句话流传太广,但它把两类完全不同的风险混在了一起:
风险一:重复解同一个冲突。 rebase 是逐个提交重放的,如果你有 10 个提交都碰了同一个文件,你可能要解 10 次几乎一样的冲突。这个用 git rerere 能解决(git config --global rerere.enabled true),它记住你的解法并自动重放。开了这个之后 rebase 长链的体验完全不一样。
风险二:改写了别人依赖的历史。 这个才是真正的危险,而且 rerere 帮不上。判断标准只有一条:这些提交推到远端并且别人拉过了吗?没有就随便 rebase,有就别动。
还有一类容易被忽略的:rebase 中途放弃了但没清理干净。git rebase --abort 能回到原状,但如果你在冲突状态下直接 git checkout 别的分支,会留在一个半途状态里。判断办法:.git/rebase-merge/ 或 .git/rebase-apply/ 目录存在就说明还在 rebase 中。git status 也会提示,但很多人跳过不读。
13. Confusing git terminology
来源:Julia Evans,jvns.ca,2023 年 11 月。
读它解决什么:Git 的术语为什么这么难——不是你的问题,是命名的问题。
要点:
- 逐个拆解容易误解的 Git 术语
- 作者做过读者调查,收集的是真实的困惑点
我的补充:这一篇有实际的治疗作用:读完你会知道卡住不是因为笨,是因为这些词确实起得不好。几个最典型的:
- "index"、"staging area"、"cache" 是同一个东西的三个名字。 命令参数里三个都能见到(
--cached、git ls-files --stage、.git/index)。 git checkout干两件不相关的事:切分支、恢复文件。Git 2.23 拆成了git switch和git restore——用新的这两个,别再用 checkout。语义清楚,而且不容易手滑(git checkout .会静默丢掉所有未提交改动,这是我见过最常见的数据丢失方式)。git reset干三件事,取决于--soft/--mixed/--hard,而默认是--mixed。- "remote tracking branch" 不是分支,是远端状态的本地缓存快照,
git fetch时才更新。所以origin/main显示的是你上次 fetch 时远端的样子,不是现在的样子。这个误解会导致"我明明看到 origin/main 是这样"然后 push 被拒。 - "origin" 不是关键字,只是默认远端名,可以改。
14. Notes on git's error messages
来源:Julia Evans,jvns.ca,2024 年 4 月。
读它解决什么:Git 的报错到底在说什么,以及为什么它们这么难读。
要点:
- 逐条分析常见 Git 错误信息的实际含义
- 指出报错措辞与实际问题之间的落差
我的补充:Git 报错的通病是它描述内部状态,而不是描述你该做什么。几个高频的翻译:
fatal: refusing to merge unrelated histories—— 两个仓库没有共同祖先。通常是你在已有内容的目录里git init之后又加了远端。加--allow-unrelated-histories能强推过去,但先想清楚是不是搞错了仓库。error: failed to push some refs+hint: Updates were rejected—— 远端有你没有的提交。先git pull,别直接-f。fatal: not a valid object name: 'main'—— 通常是空仓库(还没有任何提交),或者分支名其实是master。You are in 'detached HEAD' state—— 见第 7 条。Your branch and 'origin/x' have diverged—— 见第 10 条。
顺带说:Git 的 hint: 行常常是整段输出里唯一有用的部分,但它排在最后,而且很多人只看第一行 error: 就去搜索了。养成读到底的习惯,能省很多次搜索。
15. Popular git config options
来源:Julia Evans,jvns.ca,2024 年 2 月。
读它解决什么:有哪些配置值得开,而默认没开。
要点:
- 作者向读者征集后整理的常用配置清单
- 覆盖 diff、merge、pull、push、别名等方向
我的补充:Git 的默认配置背着极重的向后兼容包袱,很多明显更好的行为因为怕破坏老脚本而没有变成默认。我自己每台新机器必设的一批:
# 分叉时不猜,强制你明确指定
git config --global pull.rebase true
# 只推当前分支到同名远端分支
git config --global push.default simple
# 新分支第一次 push 自动建上游
git config --global push.autoSetupRemote true
# 冲突块里显示共同祖先(见第 11 条)
git config --global merge.conflictStyle zdiff3
# 自动记住冲突解法并重放(见第 12 条)
git config --global rerere.enabled true
# 更聪明的重命名/移动检测,diff 可读性提升明显
git config --global diff.algorithm histogram
# 分支列表按最近提交排序,而不是字母序
git config --global branch.sort -committerdate
# fetch 时清掉远端已删的分支引用
git config --global fetch.prune true其中 push.autoSetupRemote(Git 2.37+)和 zdiff3(2.35+)是最近几年加的,很多人不知道。前者省掉了每次新分支都要打一遍 --set-upstream,后者直接改善解冲突体验。
Windows 上额外加一条 core.autocrlf 的判断——但别盲目设 true,更好的做法是仓库里放 .gitattributes 明确指定每类文件的换行,这样不依赖每个人的本地配置。
16. Some Git poll results
来源:Julia Evans,jvns.ca,2024 年 3 月。
读它解决什么:想知道别人实际上怎么用 Git、卡在哪,而不是"应该"怎么用。
要点:
- 作者面向读者的 Git 使用调查结果汇总
- 数据来源是真实用户,反映实际使用分布
我的补充:这类调查数据的用处是校准你的技术判断。写文档、做内部培训、定团队规范的时候,你对"大家会不会"的估计往往严重偏高。
从我自己带团队的观察,比例大致是:能熟练用 rebase -i 的不到三分之一;知道 reflog 存在的大概一半;用过 bisect 的很少,但每个用过的人都说它救过命;worktree 几乎没人用(见第 47 条,其实很值得用)。
这个分布直接影响规范怎么定。如果你的团队规范要求"提交前 squash 成一个干净的提交",而一半人不会用交互式 rebase,那这条规范的实际效果是产生一批乱来的强推。要么先培训,要么改成用平台的 squash merge(在 PR 界面点一下,不需要任何人会 rebase)。我倾向后者:把技能要求从流程里移除,比要求所有人学会更可靠。
17. New zine: How Git Works!
来源:Julia Evans,jvns.ca,2024 年(URL 路径为 4 月,站点列表显示 6 月)。
读它解决什么:想要一份把上面那些零散文章系统串起来的成品。
要点:
- 作者把 Git 系列整理成的付费 zine 的发布公告
- 公告本身免费,说明了内容组织方式
我的补充:把这一条收进来不是为了推销,是因为这篇公告本身就是一份好的目录:它说明了作者认为 Git 知识该按什么顺序组织,而那个顺序和本卷 1–16 条的排法基本一致(先模型、再指针、再操作)。
zine 这个形式值得说一句:它的约束(篇幅极短、必须配图)反过来逼出了很高的信息密度。技术写作里最难的不是把话说全,是决定不说什么。作者的 zine 系列每一本都是这个取舍练习的成品,从"怎么写技术文档"的角度读也有收获。
把 Git 当数据库看(GitHub Git 团队系列)
18. Git's database internals I: packed object store
来源:Derrick Stolee,GitHub Blog,2022 年 8 月 29 日。
读它解决什么:对象库是怎么从"一个文件一个对象"压成 packfile 的,为什么这决定了仓库大小和克隆速度。
要点:
- 五篇系列的第一篇,讲 packed object store
- 作者是 Git 核心贡献者,也是 GitHub 侧 Git 性能工作的主要负责人
我的补充:这个系列的切入角度非常好:把 Git 当成一个专用数据库来分析,然后逐个讨论数据库该有的东西——存储格式、查询、索引、复制、扩展性。这个视角一旦建立,Git 的性能问题就不再神秘。
第一篇的关键概念是 loose object 与 packfile 的区别。你新提交的对象是松散的(一个文件一个对象,zlib 压缩),git gc 会把它们打包成 packfile,包内用 delta 编码(只存与相似对象的差异)。这就是为什么一个几万提交的仓库 .git 只有几十 MB。
实际影响:git gc 没跑过的仓库,git status 和 git log 会明显变慢,因为要遍历成千上万个小文件。Git 2.31 之后有 git maintenance start 能注册后台任务自动做这事(见第二卷)。碰到"某个仓库莫名很慢",先 git count-objects -v 看 count(松散对象数)——上万就该 git gc 了。
19. Git's database internals II: commit history queries
来源:Derrick Stolee,GitHub Blog,2022 年 8 月 30 日。
读它解决什么:git log 这类历史查询在底层是什么操作,为什么大仓库上会慢。
要点:
- 系列第二篇,讲提交历史查询
- 涉及提交图(commit-graph)这类加速结构
我的补充:这一篇解释了 commit-graph 文件为什么能让 git log --graph 从几十秒降到瞬间。核心问题是:判断"提交 A 是不是 B 的祖先",朴素做法要在提交图上做遍历,而每读一个提交对象都要解压。commit-graph 把提交的元信息(父提交、时间、generation number)预先扁平化存成一个二进制文件,遍历时不用碰对象库。
generation number 是里面最漂亮的一招:给每个提交标一个"到根的最大距离",然后祖先判断可以先用它剪枝——如果 A 的 generation 比 B 大,A 不可能是 B 的祖先,直接返回,不用遍历。
实操上:git commit-graph write --reachable 手动生成一次,或者靠 git maintenance。大仓库里这个文件对 git log、git merge-base、git branch --contains 的加速都很明显。这也是为什么同一个仓库在 GitHub 网页上翻历史很快而你本地很慢——服务端一直维护着这些索引。
20. Git's database internals III: file history queries
来源:Derrick Stolee,GitHub Blog,2022 年 8 月 31 日。
读它解决什么:按路径查历史(git log -- path)为什么比按提交查历史贵得多。
要点:
- 系列第三篇,讲文件级历史查询
- 涉及 Bloom filter 这类用于路径过滤的加速结构
我的补充:这一篇是对第 5 条那个"路径过滤会漏提交"问题的实现侧解释。按路径查历史的代价高,是因为 Git 没有"路径 → 修改过它的提交"这个索引;它只能沿着提交往回走,每一步比较两棵树看这个路径有没有变。
加速手段是 changed-path Bloom filter:给每个提交存一个 Bloom filter,记录"这个提交改过哪些路径"。查询时先问 filter——"肯定没改过"就直接跳过,"可能改过"才去比较树。Bloom filter 只有假阳性没有假阴性,所以这个剪枝是安全的。
这个结构随 commit-graph 一起写:git commit-graph write --reachable --changed-paths。在大仓库里 git log -- 某个深层文件 的提速能到一个数量级。值得一说的是这也解释了 git blame 为什么慢——它本质上是"文件历史查询 + 逐行归属",两个贵操作叠在一起。
本卷小结:第 1–6 条建立"Git 是内容寻址库"的模型;第 7–10 条把"分支/HEAD 只是指针"这件事讲透;第 11–17 条覆盖操作层面的行为与配置;第 18–20 条是实现侧的三篇内部原理。系列的第四、第五篇(分布式同步与扩展性)在第三卷。
上一页: 专题资料库总览 · 下一卷: ② Git 版本演进 →
