🔧 chore(hooks): 新增提交与推送质量门禁

This commit is contained in:
Abner
2026-04-06 18:13:44 +08:00
parent 1db402a372
commit 01df53a9df
11 changed files with 621 additions and 6 deletions

3
.githooks/commit-msg Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
node scripts/commit-msg-lint.js "$1"

3
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
node scripts/pre-commit-check.js

3
.githooks/pre-push Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
node scripts/pre-push-check.js --mode=fast

3
.githooks/prepare-commit-msg Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
node scripts/prepare-commit-msg.js "$1" "$2" "$3"

View File

@@ -112,10 +112,10 @@
```bash
# Windows
start_simyo_server.bat
# macOS/Linux
./start_simyo_server.sh
# 或手动启动
npm start
```
@@ -269,7 +269,7 @@ open tests/test_simyo_esim.html
### 参与方式
1. 🍴 Fork项目仓库
2. 🌿 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 💾 提交更改 (`git commit -m 'Add AmazingFeature'`)
3. 💾 按规范提交 (`git commit -m '✨ feat(core): 新增二维码缓存逻辑'`)
4. 📤 推送分支 (`git push origin feature/AmazingFeature`)
5. 🔃 创建Pull Request
@@ -278,6 +278,29 @@ open tests/test_simyo_esim.html
- 添加必要的测试用例
- 更新相关文档
### 提交信息规范emoji + 中文)
1. 启用仓库钩子:`npm run hooks:install`
2. 提交格式:`<emoji> <type>(optional-scope): <中文描述>`
3. type 可选值:`feat`、`fix`、`docs`、`style`、`refactor`、`perf`、`test`、`build`、`ci`、`chore`、`revert`
4. emoji 与 type 需要匹配(例如 `` 对应 `feat``🐛` 对应 `fix`
示例:
- `✨ feat(auth): 新增登录态自动续期`
- `🐛 fix(simyo): 修复验证码重试逻辑`
- `📝 docs(readme): 更新安装说明`
### 当前 Git 钩子
- `pre-commit`:提交前格式化 staged 文件,并执行 JS/JSON 语法检查与智能引号拦截
- `pre-push`:默认快速模式(`pre-commit` + 相关测试),无差异文件时回退全量测试
- `prepare-commit-msg`:提交信息为空时自动填充模板
- `commit-msg`校验提交信息格式emoji + type + 中文描述 + 映射关系)
可用命令:
- 查看钩子列表:`npm run hooks:list`
- 手动执行提交前检查:`npm run precommit:check`
- 手动执行推送前全量检查:`npm run prepush:check`
- 手动执行推送前快速检查:`npm run prepush:check:fast`
## 📞 支持与反馈
遇到问题或有建议?

View File

@@ -100,10 +100,10 @@ Modern eSIM management toolkit designed for Giffgaff and Simyo users, supporting
```bash
# Windows
start_simyo_server.bat
# macOS/Linux
./start_simyo_server.sh
# Or manually start
npm start
```
@@ -257,7 +257,7 @@ Contributions and suggestions welcome!
### How to Participate
1. 🍴 Fork project repository
2. 🌿 Create feature branch (`git checkout -b feature/AmazingFeature`)
3. 💾 Commit changes (`git commit -m 'Add AmazingFeature'`)
3. 💾 Commit with format (`git commit -m '✨ feat(core): 新增二维码缓存逻辑'`)
4. 📤 Push branch (`git push origin feature/AmazingFeature`)
5. 🔃 Create Pull Request
@@ -266,6 +266,29 @@ Contributions and suggestions welcome!
- Add necessary test cases
- Update relevant documentation
### Commit Message Rules (emoji + Chinese summary)
1. Enable repo hooks: `npm run hooks:install`
2. Format: `<emoji> <type>(optional-scope): <中文描述>`
3. Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
4. Emoji must match type (for example, `` for `feat`, `🐛` for `fix`)
Examples:
- `✨ feat(auth): 新增登录态自动续期`
- `🐛 fix(simyo): 修复验证码重试逻辑`
- `📝 docs(readme): 更新安装说明`
### Current Git Hooks
- `pre-commit`: formats staged files and runs JS/JSON syntax + smart quotes checks
- `pre-push`: defaults to fast mode (`pre-commit` + related tests), falls back to full tests when no diff files are detected
- `prepare-commit-msg`: auto-fills a template when commit message is empty
- `commit-msg`: validates message format (emoji + type + Chinese summary + mapping)
Useful commands:
- List hooks: `npm run hooks:list`
- Run pre-commit checks manually: `npm run precommit:check`
- Run full pre-push checks manually: `npm run prepush:check`
- Run fast pre-push checks manually: `npm run prepush:check:fast`
## 📞 Support and Feedback
Having issues or suggestions?

View File

@@ -4,6 +4,12 @@
"description": "专为Giffgaff和Simyo用户设计的eSIM管理工具集",
"main": "index.js",
"scripts": {
"hooks:install": "git config core.hooksPath .githooks",
"hooks:list": "ls -1 .githooks",
"precommit:check": "node scripts/pre-commit-check.js",
"prepush:check": "node scripts/pre-push-check.js --mode=full",
"prepush:check:fast": "node scripts/pre-push-check.js --mode=fast",
"commitmsg:lint": "node scripts/commit-msg-lint.js",
"prestart": "npm run build",
"predev": "npm run build",
"prenetlify-dev": "npm run build",

165
scripts/commit-msg-lint.js Normal file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env node
/**
* commit-msg 校验:
* 1. 首段必须为 emoji或 :shortcode:
* 2. 后续遵循 Conventional Commits 头部格式;
* 3. emoji 与 type 必须匹配;
* 4. 描述必须包含中文字符。
*
* 格式:<emoji> <type>(optional-scope): <中文描述>
* 示例:✨ feat(auth): 新增登录态自动续期
*/
const fs = require('fs');
const COMMIT_TYPES = [
'feat',
'fix',
'docs',
'style',
'refactor',
'perf',
'test',
'build',
'ci',
'chore',
'revert'
];
const TYPE_TO_EMOJI = {
feat: ['✨', ':sparkles:'],
fix: ['🐛', ':bug:'],
docs: ['📝', ':memo:'],
style: ['💄', ':lipstick:'],
refactor: ['♻️', '♻', ':recycle:'],
perf: ['⚡️', '⚡', ':zap:'],
test: ['✅', ':white_check_mark:'],
build: ['📦', ':package:'],
ci: ['👷', ':construction_worker:'],
chore: ['🔧', '🧹', ':wrench:', ':broom:'],
revert: ['⏪️', '⏪', ':rewind:']
};
const TOKEN_TO_TYPES = new Map();
for (const [type, tokens] of Object.entries(TYPE_TO_EMOJI)) {
for (const token of tokens) {
const key = token.startsWith(':')
? token.toLowerCase()
: token.replace(/\uFE0F/g, '');
if (!TOKEN_TO_TYPES.has(key)) {
TOKEN_TO_TYPES.set(key, []);
}
TOKEN_TO_TYPES.get(key).push(type);
}
}
const HEADER_PATTERN = new RegExp(
`^(${COMMIT_TYPES.join('|')})(\\([a-z0-9._/-]+\\))?:\\s(.+)$`,
'u'
);
const ALLOWED_PAIRS_HELP = Object.entries(TYPE_TO_EMOJI)
.map(([type, tokens]) => `${type}: ${tokens.join(' / ')}`)
.join('\n ');
function fail(message) {
console.error('\n❌ 提交信息格式不符合规范:');
console.error(` ${message}\n`);
console.error('期望格式:');
console.error(' <emoji> <type>(optional-scope): <中文描述>');
console.error('\nemoji 与 type 对应关系:');
console.error(` ${ALLOWED_PAIRS_HELP}`);
console.error('\n示例');
console.error(' ✨ feat(auth): 新增登录态自动续期');
console.error(' 🐛 fix(simyo): 修复验证码重试逻辑');
console.error(' 📝 docs(readme): 更新安装说明\n');
process.exit(1);
}
function getFirstMeaningfulLine(content) {
const lines = content.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
return trimmed;
}
return '';
}
function isEmojiToken(token) {
const key = token.startsWith(':')
? token.toLowerCase()
: token.replace(/\uFE0F/g, '');
return TOKEN_TO_TYPES.has(key);
}
function validateEmojiTypePair(emojiToken, type) {
const key = emojiToken.startsWith(':')
? emojiToken.toLowerCase()
: emojiToken.replace(/\uFE0F/g, '');
const allowedTypes = TOKEN_TO_TYPES.get(key);
if (!allowedTypes || allowedTypes.length === 0) {
fail(`不支持的 emoji${emojiToken}`);
}
if (!allowedTypes.includes(type)) {
fail(`emoji ${emojiToken} 与 type ${type} 不匹配。`);
}
}
function validateCommitHeader(header) {
if (!header) {
fail('提交标题为空。');
}
// 允许 git 自动生成的合并/回滚信息
if (header.startsWith('Merge ') || header.startsWith('Revert "')) {
return;
}
const firstSpaceIndex = header.indexOf(' ');
if (firstSpaceIndex <= 0) {
fail('缺少 emoji 前缀,或未使用空格分隔。');
}
const emojiToken = header.slice(0, firstSpaceIndex).trim();
const conventionalHeader = header.slice(firstSpaceIndex + 1).trim();
if (!isEmojiToken(emojiToken)) {
fail('提交标题需要以受支持的 emoji或 :shortcode:)开头。');
}
const matched = conventionalHeader.match(HEADER_PATTERN);
if (!matched) {
fail(
`类型段不合法。请使用 ${COMMIT_TYPES.join(
', '
)} 之一,并遵循 type(scope): subject 格式。`
);
}
const type = matched[1];
validateEmojiTypePair(emojiToken, type);
const subject = matched[3].trim();
if (!/[\u3400-\u9FFF]/u.test(subject)) {
fail('提交描述必须包含中文字符。');
}
}
function main() {
const messageFilePath = process.argv[2];
if (!messageFilePath) {
fail('未接收到 commit message 文件路径。');
}
const content = fs.readFileSync(messageFilePath, 'utf8');
const header = getFirstMeaningfulLine(content);
validateCommitHeader(header);
}
main();

189
scripts/pre-commit-check.js Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* pre-commit 检查:
* 1) 仅处理 staged 文件安全格式化LF、行尾空白、文件尾换行
* 2) 检查 staged JS 语法;
* 3) 检查 staged JSON 语法;
* 4) 拦截代码文件中的智能引号,避免引号边界问题。
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
let esbuild = null;
try {
esbuild = require('esbuild');
} catch (_) {
esbuild = null;
}
const TEXT_EXTENSIONS = new Set([
'.js',
'.mjs',
'.cjs',
'.json',
'.md',
'.css',
'.html',
'.yml',
'.yaml',
'.sh'
]);
const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
const SMART_QUOTES_RE = /[\u2018\u2019\u201C\u201D]/u;
function git(args, options = {}) {
return execFileSync('git', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
...options
});
}
function getStagedFiles() {
const output = git([
'diff',
'--cached',
'--name-only',
'--diff-filter=ACMR',
'-z'
]);
return output.split('\0').filter(Boolean);
}
function shouldFormat(relPath) {
const ext = path.extname(relPath).toLowerCase();
if (TEXT_EXTENSIONS.has(ext)) {
return true;
}
const baseName = path.basename(relPath);
return baseName === '.gitignore' || baseName === '.gitattributes';
}
function normalizeText(content) {
let next = content.replace(/\r\n/g, '\n');
next = next.replace(/[ \t]+$/gm, '');
if (next.length > 0 && !next.endsWith('\n')) {
next += '\n';
}
return next;
}
function formatStagedFiles(stagedFiles) {
const updated = [];
for (const relPath of stagedFiles) {
if (!shouldFormat(relPath)) {
continue;
}
const absPath = path.resolve(relPath);
if (!fs.existsSync(absPath)) {
continue;
}
const original = fs.readFileSync(absPath, 'utf8');
const normalized = normalizeText(original);
if (normalized !== original) {
fs.writeFileSync(absPath, normalized, 'utf8');
git(['add', '--', relPath]);
updated.push(relPath);
}
}
return updated;
}
function readStagedContent(relPath) {
return git(['show', `:${relPath}`]);
}
function fallbackNodeSyntaxCheck(relPath, content) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'esim-precommit-'));
const ext = path.extname(relPath).toLowerCase() || '.js';
const tempFile = path.join(tempDir, `check${ext}`);
try {
fs.writeFileSync(tempFile, content, 'utf8');
const result = spawnSync(process.execPath, ['--check', tempFile], {
encoding: 'utf8'
});
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || '').trim());
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function checkJavaScriptSyntax(relPath, content) {
if (esbuild) {
esbuild.transformSync(content, {
loader: 'js',
sourcefile: relPath,
charset: 'utf8'
});
return;
}
fallbackNodeSyntaxCheck(relPath, content);
}
function runChecks(stagedFiles) {
const failures = [];
for (const relPath of stagedFiles) {
const ext = path.extname(relPath).toLowerCase();
if (!JS_EXTENSIONS.has(ext) && ext !== '.json') {
continue;
}
const content = readStagedContent(relPath);
if (SMART_QUOTES_RE.test(content)) {
failures.push(
`${relPath}: 检测到智能引号U+201C/U+201D/U+2018/U+2019请改为标准半角引号。`
);
continue;
}
try {
if (JS_EXTENSIONS.has(ext)) {
checkJavaScriptSyntax(relPath, content);
} else if (ext === '.json') {
JSON.parse(content);
}
} catch (error) {
failures.push(`${relPath}: ${error.message}`);
}
}
return failures;
}
function main() {
const stagedFiles = getStagedFiles();
if (stagedFiles.length === 0) {
process.exit(0);
}
const formatted = formatStagedFiles(stagedFiles);
if (formatted.length > 0) {
console.log(`✅ 已格式化并重新暂存 ${formatted.length} 个文件`);
}
const failures = runChecks(stagedFiles);
if (failures.length > 0) {
console.error('\n❌ pre-commit 检查失败:');
for (const failure of failures) {
console.error(` - ${failure}`);
}
process.exit(1);
}
console.log('✅ pre-commit 检查通过');
}
main();

143
scripts/pre-push-check.js Normal file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* pre-push 检查:
* 1) 先执行 pre-commit 级别检查,保证待推送内容格式与基础语法正确;
* 2) 快速模式仅执行相关测试,全量模式执行完整测试。
*/
const { execFileSync, spawnSync } = require('child_process');
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const RELATED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.json']);
function runGit(args) {
return execFileSync('git', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
}
function resolveMode() {
const argMode = process.argv
.find(arg => arg.startsWith('--mode='))
?.split('=')[1];
const envMode = process.env.PREPUSH_MODE;
const mode = (argMode || envMode || 'full').toLowerCase();
return mode === 'fast' ? 'fast' : 'full';
}
function getDiffRange() {
try {
const upstream = runGit([
'rev-parse',
'--abbrev-ref',
'--symbolic-full-name',
'@{upstream}'
]).trim();
if (upstream) {
return `${upstream}...HEAD`;
}
} catch (_) {
// ignore and fallback
}
try {
runGit(['rev-parse', '--verify', 'HEAD~1']);
return 'HEAD~1..HEAD';
} catch (_) {
return null;
}
}
function getRelatedFiles() {
const range = getDiffRange();
if (!range) {
return [];
}
const output = runGit([
'diff',
'--name-only',
'--diff-filter=ACMR',
'-z',
range
]);
const files = output.split('\0').filter(Boolean);
return files.filter(file => {
const lowerFile = file.toLowerCase();
for (const ext of RELATED_EXTENSIONS) {
if (lowerFile.endsWith(ext)) {
return true;
}
}
return false;
});
}
function buildSteps(mode) {
const steps = [
{
name: 'pre-commit 检查',
args: ['run', 'precommit:check']
}
];
if (mode === 'fast') {
const relatedFiles = getRelatedFiles();
if (relatedFiles.length > 0) {
steps.push({
name: `相关测试检查(${relatedFiles.length} 个文件)`,
args: [
'test',
'--',
'--bail',
'--findRelatedTests',
'--passWithNoTests',
...relatedFiles
]
});
} else {
steps.push({
name: '测试检查(未识别差异文件,回退全量测试)',
args: ['test', '--', '--bail']
});
}
return steps;
}
steps.push({
name: '全量测试检查',
args: ['test', '--', '--bail']
});
return steps;
}
function runStep(step) {
console.log(`\n🔎 执行 ${step.name}...`);
const result = spawnSync(npmCmd, step.args, {
stdio: 'inherit',
env: process.env
});
if (result.status !== 0) {
console.error(`\n${step.name}未通过,已阻止 push。`);
process.exit(result.status || 1);
}
}
function main() {
const mode = resolveMode();
console.log(`\n🧭 pre-push 模式: ${mode}`);
const steps = buildSteps(mode);
for (const step of steps) {
runStep(step);
}
console.log('\n✅ pre-push 检查全部通过');
}
main();

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
/**
* prepare-commit-msg:
* 在未填写提交信息时,注入标准模板,降低格式输入错误概率。
*/
const fs = require('fs');
const TEMPLATE_HEADER = '✨ feat(core): 请填写中文描述';
const SKIP_SOURCES = new Set(['merge', 'squash', 'commit']);
function getFirstMeaningfulLine(content) {
const lines = content.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
return trimmed;
}
return '';
}
function main() {
const messageFilePath = process.argv[2];
const commitSource = process.argv[3] || '';
if (!messageFilePath || SKIP_SOURCES.has(commitSource)) {
return;
}
const original = fs.readFileSync(messageFilePath, 'utf8');
const firstLine = getFirstMeaningfulLine(original);
if (firstLine) {
return;
}
const commentLines = original
.split(/\r?\n/)
.filter(line => line.trim().startsWith('#'));
const content = [
TEMPLATE_HEADER,
'',
'# 格式: <emoji> <type>(optional-scope): <中文描述>',
'# 示例: 🐛 fix(simyo): 修复验证码重试逻辑',
...commentLines
].join('\n');
fs.writeFileSync(messageFilePath, content, 'utf8');
}
main();