这是 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

⚠️ 这是破坏性重写历史

  1. 操作前全量备份(cp -r 或推一个备份 remote)。
  2. 所有协作者必须重新克隆,旧本地仓库作废。
  3. 推送用 git push --force 全分支全标签。
  4. 如果只是为了去掉误提交的密钥,改完历史后密钥照样要换——它可能已经泄露。

查一下仓库里最占空间的历史对象:

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 规范。