chore(V5.7): 增加中文提交信息校验门禁
This commit is contained in:
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
REPO_ROOT=$(git -C "$HOOK_DIR" rev-parse --show-toplevel 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$REPO_ROOT" ]; then
|
||||||
|
echo "FAIL: 无法确定 Git 仓库根目录,中文提交门禁未执行。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo "FAIL: 未找到 Node.js,无法执行中文提交门禁。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec node "$REPO_ROOT/scripts/check-commit-message.mjs" --file "$1"
|
||||||
@@ -108,6 +108,22 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
|
|||||||
|
|
||||||
## 持续开发规则
|
## 持续开发规则
|
||||||
|
|
||||||
|
### 一次性启用 Git 中文提交门禁
|
||||||
|
|
||||||
|
每次新克隆仓库后必须执行一次安装脚本;Git hook 不会仅因克隆仓库自动启用。Windows 在仓库内执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\scripts\setup-git-hooks.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
WSL/Ubuntu 在仓库内执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh scripts/setup-git-hooks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会把当前克隆的 `core.hooksPath` 配置为 `.githooks`。之后每次提交都会校验首行符合 `<type>(<scope>): 中文摘要`;可单独运行 `node scripts/tests/check-commit-message.test.mjs` 验证门禁。
|
||||||
|
|
||||||
- 唯一模块顺序:`M00 → M01 → M02 → … → M10`,模块内按 `A → B → C → …`。
|
- 唯一模块顺序:`M00 → M01 → M02 → … → M10`,模块内按 `A → B → C → …`。
|
||||||
- `docs/module-status.md` 顶部的 `execution_cursor` 是唯一续接游标;Codex 不重新规划、不从 M00 重来。
|
- `docs/module-status.md` 顶部的 `execution_cursor` 是唯一续接游标;Codex 不重新规划、不从 M00 重来。
|
||||||
- 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。
|
- 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { statSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export const DEFAULT_ALLOWED_TYPES = Object.freeze([
|
||||||
|
'feat',
|
||||||
|
'fix',
|
||||||
|
'refactor',
|
||||||
|
'test',
|
||||||
|
'build',
|
||||||
|
'chore',
|
||||||
|
'docs',
|
||||||
|
'perf',
|
||||||
|
'ci',
|
||||||
|
'style',
|
||||||
|
'revert',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SUBJECT_PATTERN = /^([a-z][a-z0-9-]*)\(([A-Za-z0-9][A-Za-z0-9._/-]{0,31})\): (.*)$/u;
|
||||||
|
const CJK_UNIFIED_IDEOGRAPH_PATTERN = /\p{Unified_Ideograph}/u;
|
||||||
|
const GENERATED_SUBJECT_PATTERN = /^(?:Merge(?:\s|$)|Revert(?:\s|$))/u;
|
||||||
|
|
||||||
|
export class CommitMessageError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CommitMessageError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAllowedTypes(value) {
|
||||||
|
const types = String(value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((type) => type.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (types.length === 0 || types.some((type) => !/^[a-z][a-z0-9-]*$/u.test(type))) {
|
||||||
|
throw new CommitMessageError('允许的提交类型必须是逗号分隔的小写 Conventional Commits 类型。');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(types)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCommitMessage(message, options = {}) {
|
||||||
|
if (typeof message !== 'string') {
|
||||||
|
throw new CommitMessageError('提交消息必须是字符串。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = (message.split(/\r?\n/u, 1)[0] ?? '').replace(/^\uFEFF/u, '');
|
||||||
|
if (!subject.trim()) {
|
||||||
|
throw new CommitMessageError('提交标题不能为空。');
|
||||||
|
}
|
||||||
|
if (GENERATED_SUBJECT_PATTERN.test(subject)) {
|
||||||
|
throw new CommitMessageError('Git 自动生成的 Merge/Revert 英文标题不可提交,请改写为“<type>(<scope>): 中文摘要”。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = SUBJECT_PATTERN.exec(subject);
|
||||||
|
if (!match) {
|
||||||
|
throw new CommitMessageError('提交标题格式错误,应为“<type>(<scope>): 中文摘要”,且 scope 不得为空或包含空格。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, type, scope, summary] = match;
|
||||||
|
const allowedTypes = options.allowedTypes ?? DEFAULT_ALLOWED_TYPES;
|
||||||
|
if (!allowedTypes.includes(type)) {
|
||||||
|
throw new CommitMessageError(`不允许的提交类型“${type}”;允许类型:${allowedTypes.join(', ')}。`);
|
||||||
|
}
|
||||||
|
if (!summary.trim()) {
|
||||||
|
throw new CommitMessageError('冒号后的中文摘要不能为空。');
|
||||||
|
}
|
||||||
|
if (!CJK_UNIFIED_IDEOGRAPH_PATTERN.test(summary)) {
|
||||||
|
throw new CommitMessageError('冒号后的摘要必须至少包含一个 CJK 统一表意文字,禁止全英文摘要。');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subject, type, scope, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
return [
|
||||||
|
'用法:',
|
||||||
|
' node scripts/check-commit-message.mjs <提交消息文件>',
|
||||||
|
' node scripts/check-commit-message.mjs --file <提交消息文件>',
|
||||||
|
' node scripts/check-commit-message.mjs --message "<提交消息>"',
|
||||||
|
'可选:--types feat,fix,docs(覆盖默认允许类型)',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCliInput(argv) {
|
||||||
|
let filePath;
|
||||||
|
let literalMessage;
|
||||||
|
let allowedTypes = DEFAULT_ALLOWED_TYPES;
|
||||||
|
const positional = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const argument = argv[index];
|
||||||
|
if (argument === '--help' || argument === '-h') {
|
||||||
|
return { help: true };
|
||||||
|
}
|
||||||
|
if (argument === '--file' || argument === '-f') {
|
||||||
|
filePath = argv[++index];
|
||||||
|
if (!filePath) throw new CommitMessageError('--file 后必须提供提交消息文件路径。');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (argument === '--message' || argument === '-m') {
|
||||||
|
literalMessage = argv[++index];
|
||||||
|
if (literalMessage === undefined) throw new CommitMessageError('--message 后必须提供提交消息字符串。');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (argument === '--types') {
|
||||||
|
const value = argv[++index];
|
||||||
|
if (value === undefined) throw new CommitMessageError('--types 后必须提供允许类型。');
|
||||||
|
allowedTypes = parseAllowedTypes(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
positional.push(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePath !== undefined && literalMessage !== undefined) {
|
||||||
|
throw new CommitMessageError('--file 与 --message 不能同时使用。');
|
||||||
|
}
|
||||||
|
if (positional.length > 1 || ((filePath !== undefined || literalMessage !== undefined) && positional.length > 0)) {
|
||||||
|
throw new CommitMessageError(`参数不合法。\n${usage()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePath === undefined && literalMessage === undefined && positional.length === 1) {
|
||||||
|
const candidate = positional[0];
|
||||||
|
try {
|
||||||
|
if (statSync(candidate).isFile()) filePath = candidate;
|
||||||
|
else literalMessage = candidate;
|
||||||
|
} catch {
|
||||||
|
literalMessage = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filePath === undefined && literalMessage === undefined) {
|
||||||
|
throw new CommitMessageError(`缺少提交消息文件或字符串。\n${usage()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = literalMessage;
|
||||||
|
if (filePath !== undefined) {
|
||||||
|
try {
|
||||||
|
message = await readFile(resolve(filePath), 'utf8');
|
||||||
|
} catch {
|
||||||
|
throw new CommitMessageError(`无法读取提交消息文件:${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { message, allowedTypes, help: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const input = await readCliInput(process.argv.slice(2));
|
||||||
|
if (input.help) {
|
||||||
|
process.stdout.write(`${usage()}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = checkCommitMessage(input.message, { allowedTypes: input.allowedTypes });
|
||||||
|
process.stdout.write(`PASS: 提交标题符合中文门禁:${result.subject}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
process.stderr.write(`FAIL: ${message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : '';
|
||||||
|
if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ Assert-NativeSuccess "check-workspace"
|
|||||||
Assert-NativeSuccess "check-reference"
|
Assert-NativeSuccess "check-reference"
|
||||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-line-endings.ps1
|
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-line-endings.ps1
|
||||||
Assert-NativeSuccess "check-line-endings"
|
Assert-NativeSuccess "check-line-endings"
|
||||||
|
node scripts/tests/check-commit-message.test.mjs
|
||||||
|
Assert-NativeSuccess "check-commit-message"
|
||||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-large-files.ps1
|
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-large-files.ps1
|
||||||
Assert-NativeSuccess "check-large-files"
|
Assert-NativeSuccess "check-large-files"
|
||||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-repo-completeness.ps1
|
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-repo-completeness.ps1
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function ConvertFrom-Utf8Base64([string]$Value) {
|
||||||
|
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Git([string[]]$Arguments) {
|
||||||
|
$Output = & git @Arguments
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
$Prefix = ConvertFrom-Utf8Base64 "R2l0IOWRveS7pOaJp+ihjOWksei0pe+8mg=="
|
||||||
|
throw "$Prefix git $($Arguments -join ' ')"
|
||||||
|
}
|
||||||
|
return $Output
|
||||||
|
}
|
||||||
|
|
||||||
|
$RepoRoot = (Invoke-Git @("rev-parse", "--show-toplevel") | Select-Object -First 1).Trim()
|
||||||
|
$HookFile = Join-Path $RepoRoot ".githooks\commit-msg"
|
||||||
|
if (-not (Test-Path -LiteralPath $HookFile -PathType Leaf)) {
|
||||||
|
$Prefix = ConvertFrom-Utf8Base64 "5pyq5om+5Yiw5o+Q5Lqk6Zeo56aB77ya"
|
||||||
|
throw "$Prefix$HookFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Git @("-C", $RepoRoot, "config", "--local", "core.hooksPath", ".githooks") | Out-Null
|
||||||
|
$ConfiguredPath = (Invoke-Git @("-C", $RepoRoot, "config", "--local", "--get", "core.hooksPath") | Select-Object -First 1).Trim()
|
||||||
|
if ($ConfiguredPath -ne ".githooks") {
|
||||||
|
$Prefix = ConvertFrom-Utf8Base64 "R2l0IGhvb2tzUGF0aCDphY3nva7moKHpqozlpLHotKXvvIzlvZPliY3lgLzvvJo="
|
||||||
|
throw "$Prefix$ConfiguredPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host (ConvertFrom-Utf8Base64 "UEFTUzog5bey5Li65b2T5YmN5YWL6ZqG5ZCv55SoIEdpdCDkuK3mlofmj5DkuqTpl6jnpoHvvIhjb3JlLmhvb2tzUGF0aD0uZ2l0aG9va3PvvInjgII=")
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||||
|
echo "FAIL: 当前目录不在 Git 仓库中。" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
hook_file="$repo_root/.githooks/commit-msg"
|
||||||
|
|
||||||
|
if [ ! -f "$hook_file" ]; then
|
||||||
|
echo "FAIL: 未找到提交门禁:$hook_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$hook_file"
|
||||||
|
git -C "$repo_root" config --local core.hooksPath .githooks
|
||||||
|
configured_path=$(git -C "$repo_root" config --local --get core.hooksPath)
|
||||||
|
|
||||||
|
if [ "$configured_path" != ".githooks" ]; then
|
||||||
|
echo "FAIL: Git hooksPath 配置校验失败,当前值:$configured_path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PASS: 已为当前克隆启用 Git 中文提交门禁(core.hooksPath=.githooks)。"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CommitMessageError,
|
||||||
|
checkCommitMessage,
|
||||||
|
parseAllowedTypes,
|
||||||
|
} from '../check-commit-message.mjs';
|
||||||
|
|
||||||
|
const checkerPath = resolve('scripts/check-commit-message.mjs');
|
||||||
|
|
||||||
|
test('接受符合格式的中文摘要', () => {
|
||||||
|
const result = checkCommitMessage('feat(M09-D1): 建立商品与库存流水底座');
|
||||||
|
assert.equal(result.type, 'feat');
|
||||||
|
assert.equal(result.scope, 'M09-D1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('接受技术英文与中文混合摘要', () => {
|
||||||
|
checkCommitMessage('fix(M09-D2): 修复 MySQL CAS 并发扣减竞态');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只校验首行并允许多行正文', () => {
|
||||||
|
checkCommitMessage('docs(spec): 固化 V5.7 中文提交规则\n\n补充风险、验证结果与回滚说明。');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('拒绝全英文摘要', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => checkCommitMessage('feat(M09-D1): add product inventory foundation'),
|
||||||
|
(error) => error instanceof CommitMessageError && /CJK/u.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('拒绝空摘要', () => {
|
||||||
|
assert.throws(() => checkCommitMessage('feat(M09-D1): '), /摘要不能为空/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('拒绝 Git 默认 Merge 与 Revert 标题', () => {
|
||||||
|
assert.throws(() => checkCommitMessage("Merge branch 'main'"), /Merge\/Revert/u);
|
||||||
|
assert.throws(() => checkCommitMessage('Revert "feat: add inventory"'), /Merge\/Revert/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('接受改写为规范格式的中文回退标题', () => {
|
||||||
|
checkCommitMessage('revert(M09-D1): 回退库存流水迁移');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('拒绝缺少 scope 或不允许的类型', () => {
|
||||||
|
assert.throws(() => checkCommitMessage('chore: 更新文档'), /格式错误/u);
|
||||||
|
assert.throws(() => checkCommitMessage('release(M09-D1): 发布库存底座'), /不允许的提交类型/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('可以覆盖允许的 Conventional Commits 类型', () => {
|
||||||
|
const allowedTypes = parseAllowedTypes('feat,release');
|
||||||
|
checkCommitMessage('release(M09-D1): 发布库存底座', { allowedTypes });
|
||||||
|
assert.throws(() => checkCommitMessage('fix(M09-D1): 修复库存', { allowedTypes }), /不允许的提交类型/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('命令行支持提交消息文件与传入字符串', async () => {
|
||||||
|
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'qipai-commit-message-'));
|
||||||
|
const messageFile = join(temporaryDirectory, 'COMMIT_EDITMSG');
|
||||||
|
try {
|
||||||
|
await writeFile(messageFile, 'test(M09-D1): 补充库存并发测试\n\n验证锁定与释放。\n', 'utf8');
|
||||||
|
|
||||||
|
const fileResult = spawnSync(process.execPath, [checkerPath, '--file', messageFile], { encoding: 'utf8' });
|
||||||
|
assert.equal(fileResult.status, 0, fileResult.stderr);
|
||||||
|
assert.match(fileResult.stdout, /PASS/u);
|
||||||
|
|
||||||
|
const stringResult = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[checkerPath, '--message', 'build(V5.7): 接入 Git 中文提交门禁'],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(stringResult.status, 0, stringResult.stderr);
|
||||||
|
|
||||||
|
const rejectedResult = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[checkerPath, '--message', 'build(V5.7): wire commit gate'],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(rejectedResult.status, 1);
|
||||||
|
assert.match(rejectedResult.stderr, /FAIL/u);
|
||||||
|
|
||||||
|
const missingFileResult = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[checkerPath, '--file', join(temporaryDirectory, 'missing-message')],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(missingFileResult.status, 1);
|
||||||
|
assert.match(missingFileResult.stderr, /无法读取提交消息文件/u);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user