这是 Git 手册系列的第三篇。前两篇讲"怎么用",本篇讲"为什么":理解了 Git 的内部模型,所有命令都会变成推理题而非背诵题;再配上一批工程化工具,解决"什么时候出的 bug"、“仓库太大”、“多仓库依赖"这类真实难题。
目录
- Git 对象模型:blob、tree、commit、tag
- 引用:分支、HEAD、tag 都只是指针
- 彻底理解 merge / rebase / cherry-pick
- bisect:二分查找"哪次提交引入了 bug”
- worktree:一个仓库多个工作区
- submodule 与 subtree:仓库套仓库
- hooks:在关键时刻自动执行脚本
- Git LFS 与大文件
- 仓库瘦身:清理历史中的大文件
- 高级调试与诊断
Git 对象模型:blob、tree、commit、tag
Git 本质是一个内容寻址的文件系统 + 一个版本控制外壳。.git/objects/ 下只有四种对象:
| 对象 | 内容 | 类比 |
|---|---|---|
| blob | 文件的内容(不含文件名) | 文件快照 |
| tree | 一个目录:文件名 → blob/tree 的指针列表 | 文件夹快照 |
| commit | 指向一个 tree + 父 commit 指针 + 作者/时间/信息 | 一次版本 |
| tag(附注) | 指向一个 commit + 标签信息 | 里程碑 |
所有对象用内容算 SHA-1(新版支持 SHA-256)哈希作为 id:内容相同 → id 相同,这就是 Git 去重和完整性校验的原理。
一次提交的结构示意:
commit a1b2c3d
├── tree e4f5g6h ← 项目根目录快照
│ ├── src/ → tree ...
│ ├── README.md → blob ...
│ └── package.json→ blob ...
├── parent 9i0j1k2 ← 上一次提交
├── author heyaohua <...> 1700000000 +0800
└── message 修复登录页按钮错位
自己动手看一眼(plumbing 命令):
git cat-file -t a1b2c3d # 对象类型:commit
git cat-file -p a1b2c3d # 打印内容
git cat-file -p HEAD^{tree} # 当前提交的根 tree
关键推论:
- “快照"其实很省空间:没改的文件,新 tree 直接复用旧 blob 指针。
- commit 是不可变的:任何修改历史(amend/rebase)都是生成新对象,旧的靠 reflog 留着。
引用:分支、HEAD、tag 都只是指针
.git/refs/ 和 .git/HEAD 揭开了分支的全部秘密:
- 分支:一个文件,内容就是某个 commit 的哈希。
git switch -c foo就是写了一个 41 字节的文件——所以 Git 开分支快到零成本。 - HEAD:一个文件,内容是"当前指向哪个分支”(如
ref: refs/heads/main)。 - detached HEAD:HEAD 直接指向 commit 而不是分支(如
git switch a1b2c3d)。此时提交的改动没有分支名挂着,切走后会变成"孤儿",解法是立刻git switch -c rescue给它安个分支名。 - 远程跟踪分支
origin/main:也是本地的一个指针,记录"上次和远程同步时远程在哪",只有fetch/pull/push时才更新。
合并提交 = 有两个 parent 的 commit。fast-forward 合并 = 只是把分支指针往前挪,不产生新 commit。
git log --oneline --graph --all # 现在你应该能完全读懂这张图了
git rev-parse HEAD main origin/main # 看各引用实际指向的哈希
彻底理解 merge / rebase / cherry-pick
有了对象模型,三者的区别一句话说清:
- merge:新建一个双 parent 的 commit,tree 取三方合并(base、我方、对方)的结果。
- rebase:把每条提交的 diff 依次"重放"到新 base 上,生成一串新 commit(旧提交还在,靠 reflog 可见)。
- cherry-pick:等价于对单条提交做一次 rebase——算出它的 diff,重放到当前 HEAD。
所以"rebase 会改历史"的准确说法是:commit id(哈希)会变,因为 parent 变了 → 内容变了 → 哈希变了。这也是为什么不能 rebase 公共分支:别人手里还是旧 id,你们的对象图从此分叉。
bisect:二分查找"哪次提交引入了 bug"
场景:线上报 bug,上次发版还是好的,中间隔了 200 条提交,不知道哪次改坏了。人肉翻要半天,bisect 用二分查找只要约 8 步(log₂200 ≈ 7.6)。
git bisect start
git bisect bad # 当前版本是坏的
git bisect good v1.2.0 # 上次发版是好的
# Git 自动 checkout 到中间的提交,你测试后回答:
git bisect good # 或 git bisect bad
# …重复几轮…
git bisect reset # 结束后回到原分支
最后 Git 会告诉你:a1b2c3d is the first bad commit。
自动化 bisect——有现成测试脚本时全程不用动手:
git bisect start HEAD v1.2.0
git bisect run npm test # 退出码非 0 即 bad
git bisect reset
worktree:一个仓库多个工作区
场景:正在 feature 分支开发到一半(一堆未提交改动),需要紧急在 main 上验证一个问题。stash 来回倒腾很烦,worktree 直接给仓库开第二个目录:
git worktree add ../my-project-hotfix main # 在旁边目录开一个 main 的工作区
cd ../my-project-hotfix # 独立目录,独立工作区,共享 .git
# …修 bug、提交、push…
cd ../my-project
git worktree remove ../my-project-hotfix # 用完删掉
git worktree list
注意:同一分支不能同时被两个 worktree 检出。这也是 Code Review 时快速跑别人 PR 的利器。
submodule 与 subtree:仓库套仓库
submodule:嵌一个独立仓库的固定版本
git submodule add https://github.com/org/shared-lib.git vendor/shared-lib
git commit -m "引入 shared-lib 子模块"
主仓库存的只是"子模块的仓库地址 + commit id"。克隆含子模块的项目:
git clone --recurse-submodules <url>
# 或事后补:
git submodule update --init --recursive
更新子模块:
cd vendor/shared-lib && git pull origin main && cd -
git add vendor/shared-lib && git commit -m "升级 shared-lib"
痛点:每个协作者都要记得 --recurse-submodules,子模块内部的修改提交流程也绕。适合:外部依赖、发布节奏完全独立的组件。
subtree:把另一个仓库的内容直接并入
git subtree add --prefix vendor/shared-lib https://github.com/org/shared-lib.git main --squash
git subtree pull --prefix vendor/shared-lib https://github.com/org/shared-lib.git main --squash
对协作者完全透明(就是一个普通目录),但拉取更新稍慢、命令冗长。适合:想 vendoring 一份代码且偶尔同步上游。
选择建议:依赖关系强、需要锁版本 → submodule;只想拷一份进来偶尔同步 → subtree;能走包管理器(npm/pip/go mod)的一律别用这两个。
hooks:在关键时刻自动执行脚本
.git/hooks/ 里的可执行脚本会在对应时机触发。常用:
| hook | 时机 | 典型用途 |
|---|---|---|
pre-commit | 提交前 | lint、格式化、跑单测 |
commit-msg | 提交信息生成后 | 校验 message 格式(conventional commits) |
pre-push | 推送前 | 跑完整测试套件 |
post-merge | 合并后 | 检测 package.json 变了就提醒装依赖 |
示例 pre-commit:
#!/bin/sh
npm run lint-staged || exit 1
实践中不要手写裸 hook,用 husky(Node 项目)或 pre-commit 框架(Python/多语言)管理,它们把 hooks 纳入版本库、团队共享:
pnpm add -D husky lint-staged
npx husky init
echo "pnpm lint-staged" > .husky/pre-commit
Git LFS 与大文件
Git 对二进制的差量存储很差,一个 100MB 的 PSD 改 10 次,仓库就胖 1GB。Git LFS(Large File Storage)把大文件内容存到专门服务器,仓库里只留指针:
git lfs install
git lfs track "*.psd" "*.zip" "assets/videos/**"
git add .gitattributes # 必须提交,团队才会共享规则
git add design.psd && git commit -m "添加设计稿"
注意:LFS 只对新提交生效;历史里已有的大家伙要用下节的方法清理。另外 LFS 服务器(GitHub 等)有流量配额,重度使用前先算账。
仓库瘦身:清理历史中的大文件
场景:早期误提交了构建产物 / 视频,仓库 clone 要 10 分钟。目标是改写全部历史,把大文件从每个 commit 里抠掉。
推荐工具 git-filter-repo(官方推荐的第三方工具,替代已废弃的 filter-branch):
pip install git-filter-repo
# 按路径删除(历史上所有提交)
git filter-repo --path dist/ --invert-paths
# 按大小:干掉所有超过 50M 的 blob
git filter-repo --strip-blobs-bigger-than 50M
⚠️ 这是破坏性重写历史:
- 操作前全量备份(
cp -r或推一个备份 remote)。 - 所有协作者必须重新克隆,旧本地仓库作废。
- 推送用
git push --force全分支全标签。 - 如果只是为了去掉误提交的密钥,改完历史后密钥照样要换——它可能已经泄露。
查一下仓库里最占空间的历史对象:
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '/^blob/ {print substr($0,6)}' | sort -k2 -n | tail -20
高级调试与诊断
# 仓库统计与磁盘占用
git count-objects -vH
# 某行代码的演化史:谁在什么时候为什么改的
git log -L 10,20:src/app.js # 跟踪文件第 10-20 行的变更历史
git log -S "functionName" # 找"某段字符串被增删"的提交(pickaxe)
git log -G "regex" # 按正则找 diff 内容变化的提交
# 分支拓扑
git log --graph --oneline --all --decorate
git branch --contains <commit> # 哪些分支包含这个提交(修没修进发布版?)
git branch --no-merged main # 还没合并进 main 的分支
# 比较
git diff main...feature/xxx # 三点:feature 从 main 分叉后引入的改动
git range-diff main..old main..new # 比较 rebase 前后两个提交序列的差异
# 数据完整性
git fsck # 检查对象库损坏 / 悬空对象
-S(pickaxe)是排查"这行代码什么时候没的"的终极武器,配合 --reverse 还能看一段逻辑从出生到现在的完整演化。
本篇速查表
| 目的 | 命令 |
|---|---|
| 看对象内容 | git cat-file -p <id> |
| 看引用指向 | git rev-parse <ref> |
| 二分排错 | git bisect start/bad/good/run |
| 多工作区 | git worktree add <path> <branch> |
| 子模块 | git submodule update --init --recursive |
| 并入外部仓库 | git subtree pull --prefix=... --squash |
| 提交前自动化 | husky / pre-commit 框架 |
| 大文件 | git lfs track "*.bin" |
| 历史瘦身 | git filter-repo --invert-paths --path ... |
| 找代码何时被删 | git log -S "..." |
| 检查仓库健康 | git fsck / git count-objects -vH |
到这里,Git 对你已经没有黑盒了。下一篇《终极篇》转向工程之巅:monorepo 策略、安全合规(签名提交、密钥扫描)、CI/CD 中的 Git 技巧、灾难恢复 SOP,以及一套可以直接落地的团队 Git 规范。