一个陌生人拿到 git log --oneline,能不能说出最近五次改动各自做了什么、为什么?
答「否」,说明提交信息只在描述动作。要改的是模板与检查,不是让作者下次写得详细一点。
从 main 拉出短分支,合回 main 之后删除。不设长期存在的 develop——课程项目只有一条发布线,多一条长期分支就多一套需要手工同步的差异。分支名写成「类型/做了什么」,类型与提交信息的类型用同一套。
| 类型 | 用在哪 | 示例 |
|---|---|---|
feat/ | 新功能 | feat/load-note |
fix/ | 缺陷修复 | fix/scope-check |
docs/ | 只改文档 | docs/readme-start |
refactor/ | 不改行为的重写 | refactor/note-store |
test/ | 只改测试 | test/scope-cases |
chore/ | 构建、依赖、配置 | chore/bump-ruff |
用全小写、词与词用连字符、总长不超过 40 个字符。名称要能看出改了什么:fix/123 和拼音分支看不出。main 与 release 是例外,它们不套这个格式。
git switch -c feat/load-note # 推送前看当前分支名,并用同一台正则自检 git branch --show-current | grep -Eq '^(feat|fix|docs|refactor|test|chore)/[a-z0-9]+(-[a-z0-9]+)*$' # 合回之后删除远端分支 git push origin --delete feat/load-note
模板采用 Conventional Commits 的三段结构。第一行说做了什么和影响范围;正文说为什么;脚注放关联与破坏性变更。「做了什么」在改动差异里已经有了,所以正文不要再逐条复述,写选这个做法而没选另一个的原因。
| 段 | 类型 | 用在哪 |
|---|---|---|
| 第一行 | <类型>(<范围>): <描述> | 描述 ≤ 50 字符、动词开头、不以句号结尾。范围可省略 |
| 空行 | 必须有 | 没有空行时 git log 会把两段拼成一行 |
| 正文 | 自由文本 | 写为什么、有什么取舍。每行 ≤ 100 字符 |
| 脚注 | Refs: #12、BREAKING CHANGE: … | 关联与不兼容变更 |
| 类型 | 含义 | 对版本号的影响(见发布) |
|---|---|---|
feat | 新功能 | 次版本号 +1 |
fix | 缺陷修复 | 修订号 +1 |
perf | 性能改动,行为不变 | 修订号 +1 |
refactor | 不改行为的重写 | 不变 |
docs | 只改文档 | 不变 |
test | 只改测试 | 不变 |
chore | 构建、依赖、配置 | 不变 |
feat(notes): 按会话里的所属范围读取笔记 请求体里的 scope_id 不再采用,避免读到其他范围。 读路径统一走 internal/notes.Visible,列表与详情共用一处判断。 Refs: #12
update fix bug 修改了一下
阈值写进配置,红了的改动由写代码的自己改,不进评审。subject-case 关掉,因为中文没有大小写。范围不在清单里时只警告,用来发现新出现的模块名。
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"header-max-length": [2, "always", 72],
"subject-empty": [2, "never"],
"subject-case": [0],
"type-enum": [
2,
"always",
["feat", "fix", "docs", "refactor", "test", "chore", "perf"],
],
"scope-enum": [
1,
"always",
["notes", "http", "env", "db", "ui", "docs", "deps", "ci"],
],
"body-max-line-length": [2, "always", 100],
},
};
prepare-commit-msg 给编辑器预填模板,commit-msg 做检查。两个文件都要给执行权限:chmod +x .githooks/*。
git config core.hooksPath .githooks
#!/bin/sh npx --no-install commitlint --edit "$1"
纯 Go 或纯 Python 仓库不想为一条检查引入 Node 时,用这条 grep。它只查类型与长度,查不了正文。
#!/bin/sh
msg=$(head -n1 "$1")
echo "$msg" | grep -Eq '^(feat|fix|docs|refactor|test|chore|perf)(\([a-z0-9-]+\))?: .{1,50}$' \
|| { echo "提交信息不符合模板: $msg"; exit 1; }
钩子不是唯一防线:钩子只在本地生效,git commit --no-verify 能绕过。所以在持续集成里再跑一次同一条检查,命令写在质量门禁的 make check 里。把钩子当成唯一防线是常见误判。
助手一次能产出几百行,评审一次能认真看完的是一百多行。这两个数字之间的差距用上限来兜住,不靠「这次先这样」。
package-lock.json、go.sum、生成文件。超了就拆成多次合并请求,按「先数据结构、再实现」的顺序。git add -p 拆开。第二条用来排除生成文件,否则一次依赖升级就会让行数失控。第三条按提交逐个统计,用来找「一条提交做了两件事」。
git diff --shortstat main...HEAD
git diff --name-only main...HEAD | wc -l
git diff --numstat main...HEAD |
grep -vE '(package-lock\.json|go\.sum|\.min\.)' |
awk '{a+=$1; d+=$2} END {print a+d" 行"}'
git log --oneline --no-merges main..HEAD
唯一的例外:第一次建立项目骨架的那次改动会超过这些上限(一次要建目录、配置、依赖),此时把它当成一次例外,条件是改动说明里写明「骨架」两个字,且不含业务逻辑。生成文件与依赖锁文件的改动不计入行数,但仍计入文件数——一次引入 30 个新依赖要单独说明。
类型用 feat 还是 fix、范围写 notes 还是 note,这类已经由检查处理。评审要看的是下面六类——它们都指向「这次改动不该以现在这个形态合并」。
| 现象 | 说明什么 | 处理 |
|---|---|---|
| 提交信息是「update」「修改」 | 无法用 git log 定位改动,发布说明也写不出来 | 要求改写:最后一条用 git commit --amend,多条用 git rebase -i。改写前确认分支只有自己用 |
| 一次合并请求里既有功能又有排版 | 真实改动被格式化差异盖住 | 拆成两次:先只跑 make fmt 提交,再提交功能 |
分支名看不出改了什么,例如 fix/123 | 分支列表里找不到相关分支 | git branch -m fix/scope-check 后重新推送 |
| 改动说明里出现「顺便」 | 夹带了不在本次范围内的改动 | 拆开或删掉。顺便做的事下次单独提 |
| 改了 12 个文件但没有一条测试改动 | 功能改动没有验证 | 要求补测试,见单元测试 |
| 同一条提交里既有功能又有阈值或工作流改动 | 判据被夹带修改 | 按质量门禁的「不许改判据」处理,挡回并拆开 |
改写历史的边界:改写提交历史只在分支还没被别人拉过时安全。分支已经有人基于它工作时,改写会造成对方强制同步;这种情况下改成追加一条 docs 或 chore 提交来修正说明,不动历史。
$ git log --oneline -5 3f1a2c9 update a8b0d11 fix 7c2e4f3 修改 9d1a0b5 wip e5c7d22 update code
认它的办法:一个陌生人看 git log --oneline,能不能说出最近五次各自做了什么。说不出,出事要回滚时就只能猜哪一次是坏的那次。助手写提交信息倾向于概括动作而不是交代原因——「update」在写它的那一刻是准确的,半年后是零信息。
git branch -r 里除 main 与当前功能分支外没有长期存在的分支。违反后果:两条长期分支要手工同步,差异越大越不敢合。npx --no-install commitlint --edit 或那条 grep 退出码为 0,且持续集成里也跑一次。违反后果:半年后要查「所属范围那次改动」时无从下手。git diff --numstat main...HEAD 统计 ≤ 400 行、文件 ≤ 20 个。违反后果:评审只能抽查,回滚会连带撤掉不相关的改动。main 上开启「禁止直接推送」和「合并前必须通过检查」两项;git log --first-parent --merges main | head 能看到合并记录。违反后果:检查跑绿只是建议,任何人都能绕过它直接推。一个人也要做的一条:第四条在只有一个人的仓库里也照样要做——它的作用不是防别人,是防自己在赶时间的时候绕过检查。第一次建立仓库、还没有功能分支时,可以直接推 main,从第二次改动开始启用保护。