From 01df53a9df732c37cd1e000eef3bda63fed99190 Mon Sep 17 00:00:00 2001 From: Abner <22141172+Silentely@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:13:44 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20chore(hooks):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=8F=90=E4=BA=A4=E4=B8=8E=E6=8E=A8=E9=80=81=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .githooks/commit-msg | 3 + .githooks/pre-commit | 3 + .githooks/pre-push | 3 + .githooks/prepare-commit-msg | 3 + README.md | 29 +++++- README_EN.md | 29 +++++- package.json | 6 ++ scripts/commit-msg-lint.js | 165 +++++++++++++++++++++++++++++ scripts/pre-commit-check.js | 189 ++++++++++++++++++++++++++++++++++ scripts/pre-push-check.js | 143 +++++++++++++++++++++++++ scripts/prepare-commit-msg.js | 54 ++++++++++ 11 files changed, 621 insertions(+), 6 deletions(-) create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push create mode 100755 .githooks/prepare-commit-msg create mode 100644 scripts/commit-msg-lint.js create mode 100644 scripts/pre-commit-check.js create mode 100644 scripts/pre-push-check.js create mode 100644 scripts/prepare-commit-msg.js diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..317f166 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +node scripts/commit-msg-lint.js "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e7ec89d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +node scripts/pre-commit-check.js diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..2685cec --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +node scripts/pre-push-check.js --mode=fast diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg new file mode 100755 index 0000000..2474bae --- /dev/null +++ b/.githooks/prepare-commit-msg @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +node scripts/prepare-commit-msg.js "$1" "$2" "$3" diff --git a/README.md b/README.md index 4cbc90f..1afb69b 100644 --- a/README.md +++ b/README.md @@ -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. 提交格式:` (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` + ## 📞 支持与反馈 遇到问题或有建议? diff --git a/README_EN.md b/README_EN.md index 0342355..52899cd 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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: ` (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? diff --git a/package.json b/package.json index 1db92f9..fc1ac42 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/commit-msg-lint.js b/scripts/commit-msg-lint.js new file mode 100644 index 0000000..5811280 --- /dev/null +++ b/scripts/commit-msg-lint.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +/** + * commit-msg 校验: + * 1. 首段必须为 emoji(或 :shortcode:); + * 2. 后续遵循 Conventional Commits 头部格式; + * 3. emoji 与 type 必须匹配; + * 4. 描述必须包含中文字符。 + * + * 格式: (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(' (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(); diff --git a/scripts/pre-commit-check.js b/scripts/pre-commit-check.js new file mode 100644 index 0000000..c34f0b7 --- /dev/null +++ b/scripts/pre-commit-check.js @@ -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(); diff --git a/scripts/pre-push-check.js b/scripts/pre-push-check.js new file mode 100644 index 0000000..f9f23c9 --- /dev/null +++ b/scripts/pre-push-check.js @@ -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(); diff --git a/scripts/prepare-commit-msg.js b/scripts/prepare-commit-msg.js new file mode 100644 index 0000000..f0f2671 --- /dev/null +++ b/scripts/prepare-commit-msg.js @@ -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, + '', + '# 格式: (optional-scope): <中文描述>', + '# 示例: 🐛 fix(simyo): 修复验证码重试逻辑', + ...commentLines + ].join('\n'); + + fs.writeFileSync(messageFilePath, content, 'utf8'); +} + +main();