diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..b7abbef --- /dev/null +++ b/.githooks/commit-msg @@ -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" diff --git a/README.md b/README.md index 610ee9b..7e05296 100644 --- a/README.md +++ b/README.md @@ -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`。之后每次提交都会校验首行符合 `(): 中文摘要`;可单独运行 `node scripts/tests/check-commit-message.test.mjs` 验证门禁。 + - 唯一模块顺序:`M00 → M01 → M02 → … → M10`,模块内按 `A → B → C → …`。 - `docs/module-status.md` 顶部的 `execution_cursor` 是唯一续接游标;Codex 不重新规划、不从 M00 重来。 - 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。 diff --git a/scripts/check-commit-message.mjs b/scripts/check-commit-message.mjs new file mode 100644 index 0000000..1451394 --- /dev/null +++ b/scripts/check-commit-message.mjs @@ -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 英文标题不可提交,请改写为“(): 中文摘要”。'); + } + + const match = SUBJECT_PATTERN.exec(subject); + if (!match) { + throw new CommitMessageError('提交标题格式错误,应为“(): 中文摘要”,且 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(); +} diff --git a/scripts/dev/windows/test-all.ps1 b/scripts/dev/windows/test-all.ps1 index c8ab4e7..97e385d 100644 --- a/scripts/dev/windows/test-all.ps1 +++ b/scripts/dev/windows/test-all.ps1 @@ -12,6 +12,8 @@ Assert-NativeSuccess "check-workspace" Assert-NativeSuccess "check-reference" & powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-line-endings.ps1 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 Assert-NativeSuccess "check-large-files" & powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-repo-completeness.ps1 diff --git a/scripts/setup-git-hooks.ps1 b/scripts/setup-git-hooks.ps1 new file mode 100644 index 0000000..5f78987 --- /dev/null +++ b/scripts/setup-git-hooks.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=") diff --git a/scripts/setup-git-hooks.sh b/scripts/setup-git-hooks.sh new file mode 100755 index 0000000..ec8a97c --- /dev/null +++ b/scripts/setup-git-hooks.sh @@ -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)。" diff --git a/scripts/tests/check-commit-message.test.mjs b/scripts/tests/check-commit-message.test.mjs new file mode 100644 index 0000000..26add31 --- /dev/null +++ b/scripts/tests/check-commit-message.test.mjs @@ -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 }); + } +});