跳到正文

我所理解的 Git 最佳实践(1):从工作区到一条可审阅的提交

我所理解的 Git 最佳实践

  1. 从工作区到一条可审阅的提交(当前)
  2. 什么时候用 rebase,什么时候用 merge
  3. 利用 amend 和 rebase 保持提交简洁
  4. 在 GitHub 和 GitLab 配置 PR 的合入方式
  5. 共享测试分支与按需求部署
  6. 弄清工作树、嵌套仓库和远端的归属
  7. 没有远端时怎样用 Git 维护项目
  8. 为什么切到 orphan 分支后源码不见了
  9. 先量清仓库体积,再决定怎么迁移
  10. 用 Node.js 检查 Git 路径,提前发现 Windows 检出问题

假设在修支付回调重复入账时,顺手给同一个文件加了几行临时日志,另一个目录里还留着一半没写完的接口调整。直接提交全部改动很快,但评审者需要把这三件事重新分开。等到要回滚入账修复时,另外两件事又会被一起带走。

一条准备交给别人审阅的提交,最好只完成一项能独立解释的改动。修复和它的回归测试可以一起交付,另一项接口调整可以等下一条。同一个文件里混着两件事时,Git 也能只暂存其中一部分。

提交标题写结果,正文留理由

Angular 的提交说明规范把说明分为标题、正文和页脚。标题采用 <type>(<scope>): <short summary>,正文解释修改动机,页脚可记录破坏性变更或关联事项。Angular 对 scope 和正文有项目自己的要求,其他仓库也可以只采用其中一部分。

采用 Conventional Commits 的项目,fix 通常用于修复,feat 用于新功能,scope 可选。比如 fix: 防止支付回调重复入账,读日志就知道行为发生了什么变化。团队如果从提交说明生成 changelog 或决定发布版本,还要按项目的校验和发布配置来选 type 与破坏性变更标记。

提交前看一遍索引

磁盘上正在编辑的文件是工作树。索引也叫暂存区,决定下一条普通提交的文件内容。HEAD 则指向当前检出的提交,代表已经保存的那一版。

以普通 git commit 为例,git add 把选中的工作树内容写入索引,提交时 Git 再用索引生成一棵文件树。一个文件暂存以后又被修改,索引仍保留暂存时的版本。对同一个文件运行 git add -p,就可以只暂存修复,临时日志仍留在工作树。下图假设两处修改分别出现在不同的补丁块。

同一个文件的修复进入提交,临时日志留在工作树

git status --short
git diff -- payments/callback.ts
git add -p -- payments/callback.ts
git diff --cached
git diff --cached --check

git diff 默认对比工作树和索引。git diff --cached 对比索引和 HEAD,通常就是这次提交要带进去的补丁。最后一次检查要看完整的暂存区,尤其是工作区里已经有别人或另一项任务留下的暂存内容。新文件也要看 status,普通 diff 不显示未跟踪文件的内容。

git add -p 会逐块问要不要暂存。修复选 y,日志选 n。如果两处出现在同一块,可以先选 s 拆分。拆不开时,可以选 e 编辑这块将要暂存的补丁,再用 git diff --cached 核对。误选了几块时,git restore --staged -p -- payments/callback.ts 可以逐块撤回暂存,工作树里的修改还在。

短状态里的 AD 表示文件已暂存为新增,但随后又从工作树删除。普通提交仍会带上索引里的新增版本,提交前得先判断要留哪一份。

核对完索引后,用不带路径、也不带 -a 的 git commit 提交。这里如果改用 git commit -- payments/callback.ts,Git 会取该路径在工作树里的内容,刚排除的临时日志也可能跟进去。git diff --cached --check 检查空白错误和冲突标记,功能测试还得另外做。如果测试直接跑在包含后续改动的工作树里,结果只能说明这两批修改一起工作。要确认某条提交可以独立交付,可以在干净的临时检出中验证它。

按可以独立交付的变化拆提交

我习惯按能独立审阅的行为变化拆提交。防止重复回调的判断、给它补的回归测试、调用方为这个修复必须做的调整,可以一起提交。另一个接口的重命名即使只有两行,也值得单独审阅。行数和文件数只能提醒我检查范围。

拆分时可以问一个具体问题。假如只取走这条提交,读者能否验证它声称修好的问题?比如第一条提交引入数据库约束,第二条提交才处理约束冲突。如果第一条单独部署就会让旧代码报错,这个顺序还欠一个兼容阶段,或者两项改动需要作为一个部署单元交付。

同一项改动已经写成几条进度提交,可以用交互式 rebase 整理。手上有两项独立改动时,先暂存和提交第一项,再按同样的步骤提交第二项。

提交说明留给未来要做判断的人

提交正文值得留给单看 diff 猜不出来的历史条件。以假设的支付修复为例,标题之后可以这样写。

fix: 防止支付回调重复入账

支付渠道会在回调超时后重投同一个事件。旧版本已经入账的事件
仍可能在灰度期间重投,因此保留现有事件编号作为幂等键。

「用现有编号兼容旧版本重投」解释了为什么当时选这个键。渠道会重投、同一事件只能入账一次,则是以后改代码仍须遵守的规则,可以放到协议说明和回归测试中。如果正文只剩「新增判断、修改函数、补测试」,有一个清楚的标题就够了。把篇幅留给以后还会被问到的事,例如兼容逻辑可以在什么条件下删掉,或这次只验证了哪个环境。

维护时把提交说明读回来

遇到一个看起来多余的兼容分支,可以先定位引入它的提交,再看完整说明和当时的补丁。

git blame -- payments/callback.ts
git log -p -- payments/callback.ts
git show -s --format=%B COMMIT_SHA

COMMIT_SHA 换成前两条命令定位到的提交。blame 提供的是最后一次修改相关行的线索,格式化、移动文件或重构都可能让它指向后来的提交。拿到当时的理由后,还要回到现有协议和部署状态,确认原来的条件是否仍成立。

整理 merge、squash 或 amend 后的说明时,我会先读相关提交的完整正文,再对照最后的 diff。比如 PR 中间某个提交写了灰度期间要兼容旧事件,最终补丁仍然依赖这个前提,就应当把它留在会长期可见的 squash 提交或评审记录里。若最后一轮只重跑了回调单测,旧提交里「在测试环境重放过回调」的记录只能说明当时的版本。

刚提交完只想修最后一条的说明时,先用 git status --short --branch 和 git log -1 确认分支和最新提交。如果索引里还有下一项工作的改动,可以用不带路径的 git commit --amend --only,让新提交沿用原来的文件树。改完再检查说明和索引。即使文件树相同,改说明也会产生新的提交 ID,已经推送的分支要按共享历史的约定处理。