原子化提交
原子化提交只包含一个逻辑变更。其价值并非为了整洁本身——而是团队在提交混杂时会失去的三种具体能力:
- 审查:审阅者可以在脑海中只关注一个概念并对其进行判断。
- 回退:可以撤销一个糟糕的变更,而不带走好的变更。
- 二分查找:
git bisect可以定位引入 bug 的提交,但这要求每个提交只包含一个变更并且每个提交都能独立构建。
最后一条是最常被忽略的。原子化并不意味着“尽可能小”——而是“一个完整的变更”。将一个函数与其所需的调用点分开的提交虽然更小,但并非原子化,因为它会使代码树处于损坏状态。
工作流程
1. 在暂存任何内容之前先审视全局
在决定如何划分变更之前,先了解整个工作树:
git status --porcelain # 每条路径,包括未跟踪文件
git diff # 未暂存的变更
git diff --cached # 已暂存的任何内容
git log --oneline -15 # HEAD 的位置以及本地风格
不要将 git add -A 和 git commit -a 作为第一步操作。它们是将混合工作树变成混合提交的方式,而一旦提交,拆分它要比之前拆分困难得多。同时检查是否有根本不应提交的变更——构建输出、.env 文件、调试打印语句、无关的编辑器配置。
2. 将变更分组为逻辑单元
最可靠的检验标准是语言层面:起草提交主题,如果它需要一个“and”才能准确描述,那么你实际上需要多个提交。“修复登录重定向并升级 lodash”就是两个提交。审阅者的问题则是第二个检验标准:他们是否必须理解变更 X 才能判断变更 Y?如果不是,那么这些变更是独立的,应该分开。
分开的情况:
| 保持分离 | 原因 |
|---|---|
| 格式/空白 vs. 逻辑 | 格式化会使得那三行真正的代码被埋没在400行噪音中。这是最有价值的拆分。 |
| 重命名 vs. 行为变更 | 同样原因——重命名会触及所有地方并掩盖真正重要的一行。 |
| 依赖升级 vs. 代码 | 每个都需要各自的回退故事。 |
| 无关的错误修复 | 独立的回退,独立的二分查找。 |
| 重构 vs. 使用重构的特性 | 让审阅者能够确认重构是保持行为不变的。 |
放在一起的情况:
| 保持在一个提交中 | 原因 |
|---|---|
| 变更及其测试 | 它们是一个逻辑单元;为未发布的代码编写测试,或为没有测试的代码编写测试,都只是变更的一半。 |
| 重命名及其所有调用点 | 拆分会使代码树损坏——与原子化相反。 |
| 签名变更及其调用者 | 同上。 |
| 变更及其描述性文档/更新日志 | 它们描述同一个决策,应该一起回退。 |
3. 对所有提交进行排序,确保每个都能构建
顺序很重要,因为每个提交都应该使工作树保持正常工作状态。将基础部分放在使用它们的内容之前:在调用代码之前添加辅助函数,在基于重构构建特性之前进行重构,在编写查询之前确定模式。如果两个组相互依赖,这说明它们实际上应该是一个提交。
4. 暂存每个分组
使用最简洁的可行技术。只有在更简单的方法无法表达拆分意图时才升级:
级别 1 - 暂存整个文件。 这涵盖了绝大多数实际的拆分场景,因为逻辑变更通常位于不同的文件中:
git add src/auth.py tests/test_auth.py
git commit -m "..."
级别 2 - 暂时搁置整个文件,当工作树中某些内容可能被意外提交时使用:
git stash push -- requirements.txt # 暂时搁置
# ... 提交其余部分 ...
git stash pop # 取回
级别 3 - 拆分单个文件内的变更。 仅当单个文件确实包含两个逻辑变更时才需要。具体操作比较繁琐且容易出错,因此在尝试之前请先阅读 references/splitting-one-file.md。
git add -p在非交互式环境中无法工作,并且会静默失败。 它会打印第一个代码块,在 stdin 上遇到 EOF,什么也不暂存,并以退出码 0 结束。这里的成功退出码意味着什么事都没发生。git add -i、git commit -p和git checkout -p也是如此。因为失败看起来像成功,通常的后果是得到一个空提交或缺少预期变更的提交——所以请改用参考文件中的技术,并在提交前使用git diff --cached验证索引状态。
5. 为每一条信息使用仓库自己的语言风格
匹配周围的历史记录,而不是引入一种新约定。在一个全是纯文本主题的日志中插入一个 feat: 前缀就是噪音,它会让历史看起来像是两个项目。请先阅读本地风格:
git log --no-merges --format='%s' -n 30 # 主题风格
git log --no-merges -n 10 --format='%n---%n%s%n%b' # 是否使用正文
查找:type: 或 type(scope): 前缀(约定式提交)、类似 [PROJ-123] 的工单前缀、大小写、祈使句 vs. 过去时(“Add” vs. “Added”)、典型主题长度,以及正文是否正常使用还是主题单独存在。采用你找到的风格。约定式提交是一个好的默认选择,仅当日志中已经使用了它,或者仓库还没有历史记录且用户没有偏好。
无论本地风格如何,主题应该说明更改了什么,而正文——当历史记录使用正文时——应该说明为什么,因为差异本身已经展示了更改了什么。如果仓库有 CONTRIBUTING.md 或 commit-msg 钩子,它们优先于从日志中推断出的规则。
匹配约定绝不意味着凭空捏造它所携带的数据。 如果本地风格嵌入了工单或问题引用——[PROJ-123]、Fixes #456——而你没有获得真实的标识符,请不要猜测一个,也不要继续你从日志中看到的编号。一个看似合理的 [PROJ-124] 比根本没有前缀更糟糕:它是永久性的,看起来像是真正的可追溯性,而且它可能指向别人无关的工单。省略引用,匹配风格的其余部分,并在你的回复中说明缺少了 ID,以便用户可以补充——提交尚未推送,修改成本很低。如果用户在线,可以向他询问编号。
6. 在交还给用户之前进行验证
git log --oneline -n <计数> # 你构建的提交结构
git status --porcelain # 是否还有剩余内容
确认每个提交只包含其自身的变更(git show --stat <sha>),并且没有任何内容被遗漏。如果某些变更出于设计原因而未提交,请明确说明——用户认为工作树是干净的而实际上不是,这是最糟糕的结果,因为他们稍后才会发现,并且不知道这是否是有意为之。
护栏
- 指导如何提交,而不是决定是否提交。 如果用户没有要求提交,本技能不会使创建提交变得合适,也从不暗示推送。
- 切勿为了简化拆分而丢弃工作。
git checkout -- <file>、git reset --hard和git stash drop会销毁尚未提交且没有被任何提交保存的工作——这是不可恢复的。如果某项技术需要这些命令之一,请先备份内容并在运行前确认备份存在。 - 不要为了整理而重写已发布的历史记录。拆分已经推送的提交意味着强制推送,这会重写其他人可能已经拉取的历史;请先询问。
- 不要为了看起来全面而过度拆分。 三个每个都破坏构建的提交比一个能工作的提交更糟糕。如有疑问,优先选择能保持每个提交功能完整的分组。
- 如果工作树实际上只包含一个逻辑变更,就将其作为一个提交。 并非每个工作树都需要拆分,而这样说本身就是一个不错的结果。