本层判断标准

一个陌生人拿到 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

要发布的那次提交,在 main 上打 v 开头的标签。main 在代码托管上设为受保护分支,合入走合并与审查。回滚用上一版标签,见发布。

什么时候放宽:这条正则只约束功能分支。接受校外贡献的仓库通常会允许 patch-1 这类由网页生成的默认名,此时把检查放宽成「警告」而不是阻断——课程项目里没有这个来源,保持阻断。

提交信息模板

模板采用 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
修改了一下

commitlint.config.mjs

阈值写进配置,红了的改动由写代码的自己改,不进评审。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],
  },
};

装上钩子(git 自带的 hooksPath,不依赖前端工具链)

prepare-commit-msg 给编辑器预填模板,commit-msg 做检查。两个文件都要给执行权限:chmod +x .githooks/*。

git config core.hooksPath .githooks

.githooks/commit-msg

#!/bin/sh
npx --no-install commitlint --edit "$1"

仓库里没有 Node 时的替代检查

纯 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 里。把钩子当成唯一防线是常见误判。

单次合并请求的体量上限

助手一次能产出几百行,评审一次能认真看完的是一百多行。这两个数字之间的差距用上限来兜住,不靠「这次先这样」。

单次合并请求改动行数
≤ 400 行
不含 package-lock.json、go.sum、生成文件。超了就拆成多次合并请求,按「先数据结构、再实现」的顺序。
单次合并请求涉及文件
≤ 20 个
超过说明这次改动在做两件不相关的事。按目录拆。
单条提交改动行数
≤ 300 行
超过说明这一条提交里有两件事,用 git add -p 拆开。
合并请求包含的事情数
1 件
功能 + 它的测试 + 它的文档算一件。排版、升级依赖、调整阈值都不算同一件。
合并请求存活时间
≤ 3 天
超期就拆小或关掉重开。长期挂着的分支会积累冲突,评审也会失去上下文。

自检命令

第二条用来排除生成文件,否则一次依赖升级就会让行数失控。第三条按提交逐个统计,用来找「一条提交做了两件事」。

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」在写它的那一刻是准确的,半年后是零信息。

必须死守的四条

从 main 拉、合回 main、删除,不设长期分支判据:git branch -r 里除 main 与当前功能分支外没有长期存在的分支。违反后果:两条长期分支要手工同步,差异越大越不敢合。
每条提交信息符合模板判据:npx --no-install commitlint --edit 或那条 grep 退出码为 0,且持续集成里也跑一次。违反后果:半年后要查「所属范围那次改动」时无从下手。
一次合并请求只做一件事,行数与文件数在上限内判据:git diff --numstat main...HEAD 统计 ≤ 400 行、文件 ≤ 20 个。违反后果:评审只能抽查,回滚会连带撤掉不相关的改动。
合并走合并请求,main 受保护判据:main 上开启「禁止直接推送」和「合并前必须通过检查」两项;git log --first-parent --merges main | head 能看到合并记录。违反后果:检查跑绿只是建议,任何人都能绕过它直接推。

一个人也要做的一条:第四条在只有一个人的仓库里也照样要做——它的作用不是防别人,是防自己在赶时间的时候绕过检查。第一次建立仓库、还没有功能分支时,可以直接推 main,从第二次改动开始启用保护。