第 9 章 · 版本控制与协作

git 是现代软件工程的地基,但多数人的用法停留在 add/commit/push 三连,遇到冲突就慌、历史混乱、改坏东西找不回。本章从 git 的数据模型讲起(懂了模型,一切命令都是自然推论),再讲团队协作的分支策略与 review 流程。数据库课第 8 章「日志是真相之源」的思想,在版本控制里是字面意义的实现:git 就是一个内容寻址的日志系统。

9.1 心智模型:快照与有向无环图

git 的三个区域(工作区、暂存区、仓库)是操作层面的说法;数据层面,git 是一张有向无环图:

  • commit:一次完整快照(指向树的指针 + 父提交指针 + 元信息),不是 diff;
  • branch:指向某个 commit 的可移动标签(40 字节,branch 不是目录不是容器);
  • HEAD:你当前站在哪个 commit/分支上。
main:     A ── B ── E ── F
                    \
feat:                C ── D

有了这个模型,git 命令全是图的操作:checkout/switch = 移动 HEAD,merge = 造一个双父提交(汇合两条线),rebase = 把一串提交摘下来重放到另一条线上(改写历史)。「git 难」的本质是「图形结构看不见」,补上心智模型后难的是记命令,而是能推断命令。

9.2 日常纪律:提交的艺术

好提交的三个标准:小(一个提交一件事,回滚和 review 的粒度)、完整(每个提交都能编译、测试过,历史里每个点都是可用状态,git bisect 靠它)、有信息(标题讲「为什么」,正文讲细节;「fix bug」式的标题 = 浪费一次考古线索)。

fix: 账单导出在时区偏移下多算一天

cellForDate 用的是 UTC 日历,导出列用的是本地时区,
跨月月底会偏一天。统一改用 Asia/Shanghai 格式化。
见 #342

暂存区的价值在此兑现:git add -p(逐块暂存)把「顺手改了两个不相关的东西」拆成两个干净的提交,review 者会感谢你。.gitignore 的清单管理(node_modules、构建产物、密钥,第 9 章前端课提过)是仓库卫生的第一课。

9.3 分支策略:从 flow 到 trunk-based

团队协作的分支策略演化:

  • Git Flow(2010 年代主流):master(生产)+ develop(集成)+ feature/release/hotfix 多级分支。结构清晰但仪式重,适合「有发布周期」的软件;
  • GitHub Flow(简单派):main 保护 + 每个功能一个短命分支 → Pull Request → 合并。现代 Web 服务的默认答案;
  • Trunk-Based Development(激进派):所有人直接小步提交到主干(或短命分支小于 1 天),配合特性开关(feature flag)控制可见性。高频部署(第 2 章 DevOps)的标配。

选择的判据是发布节奏:按版本发布(客户端、嵌入式)用 Git Flow 变体;持续部署(互联网服务)用 GitHub Flow 或 TBD。共同铁律:分支寿命越短冲突越小(feature 分支活一个月,合并时就是灾难片)。

9.4 Pull Request 与 code review

PR 是协作流程的枢纽,它同时完成三件事:代码审查(质量闸门)、知识流动(团队知道你在改什么)、CI 门禁(第 10 章,全绿才能合)。

review 的姿势(被审者与审者各有一半责任):PR 要小(少于 400 行 diff,review 质量随大小暴跌)、描述写清「为什么」;审者盯三样——正确性、可读性(第 7 章)、测试,风格问题交给工具(第 9 章前端课的 Prettier),人只管人该管的事。评论语气是工程文化的一部分:对事不对人(「这里可能越界」而非「你怎么连边界都不检查」),「nit:」前缀标记纯风格意见。

9.5 危机处理:后悔药清单

git 的安全网极厚,常见事故的急救:

git commit --amend                # 刚提交想改信息/补文件(未 push 时)
git reset --soft HEAD~1           # 撤销提交,改动回到暂存区
git restore file                  # 丢弃工作区某文件的改动(危险,不可恢复)
git reflog                        # 全部 HEAD 移动记录:后悔药的终极大法
git reset --hard <reflog里的hash> # 回到任意历史点(包括"丢失"的提交)
git revert <commit>               # 生成一个"反向提交"撤销某提交(已 push 的安全选择)
git stash                         # 手头的活先存起来,切分支救火
git bisect start                  # 二分查找引入 bug 的提交(数据结构课第 7 章在 git 里)

revert 与 reset 的分界:改写公共历史(reset 已 push 的提交)会毁掉协作者的世界,已 push 的一律 revert(向前撤销);reset 只对未 push 的本地历史安全。这条纪律救过无数团队的命。

常见错误

  • master 直接开写。绕过 PR 等于绕过 review 和 CI;哪怕单人项目,分支 + PR 的自审也值得。
  • 提交巨石。三千行一个 commit 标「update」;回滚与考古全部失效。
  • 解决冲突全靠「accept theirs/mine」。冲突是两个意图的碰撞,逐行理解再合;盲选等于随机丢代码。
  • 把密钥提交进历史。.env 上了 GitHub;删除文件没用(历史里还在),要么轮换密钥,要么 git filter-repo 清史 + 当它已泄露处理。

练习

  1. 动手:对任意仓库跑 git log --graph --oneline --all,把输出画成 DAG 图;再 git reset --soft 制造一个「改写历史」,用 reflog 找回(安全环境练习)。
  2. 动手:双人模拟冲突:两人改同一函数的不同部分与相同部分,各走一遍 merge,体会「非冲突的并行」与「真冲突」的区别;然后用 rebase 方式再做一遍,对比历史线性度。
  3. 手算:团队 8 人,GitHub Flow,人均每天 3 个 PR。若 review 平均延迟 4 小时,估算并行存在的分支数;当分支存活上限设为 1 天时,冲突概率与分支寿命的关系。
  4. 思考:rebase 改写历史与「不可变审计」的价值观冲突。对比第 8 章(数据库 WAL 不可改)和 git rebase 的哲学,给「什么时候允许改写历史」画一条线。