#!/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();