#!/usr/bin/env node /** * PreToolUse Hook: git commit 消息规范检查 * 匹配器: Bash * 触发: 检测到 git commit -m 时,验证消息格式 * 退出码: 0=放行, 2=格式不符(ask确认) * * 规范: * - 不得为空 * - 首行不超过 72 字符 * - 建议包含类型前缀: feat/fix/refactor/docs/test/chore/style/perf/ci * - 允许中英文混合 * - Co-Authored-By 行不计入首行长度 */ const COMMIT_TYPES = [ 'feat', 'fix', 'refactor', 'docs', 'test', 'chore', 'style', 'perf', 'ci', 'build', 'revert', 'hotfix' ]; const TYPE_PATTERN = new RegExp( `^(${COMMIT_TYPES.join('|')})(\\(.+\\))?[!]?:\\s.+`, 'i' ); const readStdin = require('./lib/read-stdin.js'); /** * 从 bash 命令中提取 git commit 消息 * @param {string} command - bash 命令字符串 * @returns {string} 提取到的消息,无法提取则返回空字符串 */ function extractCommitMessage(command) { if (!command || !command.match(/git\s+commit/i)) { return ''; } // 匹配 heredoc 格式 (支持任意分隔符: EOF, COMMIT_MSG, END 等) const heredocMatch = command.match(/cat\s+<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1/); if (heredocMatch) { return heredocMatch[2].trim(); } // 匹配 -m "message" 格式(支持转义引号) const doubleQuoteMatch = command.match(/git\s+commit\s+.*-m\s+"((?:[^"\\]|\\.)*)"/); const singleQuoteMatch = command.match(/git\s+commit\s+.*-m\s+'([^']*)'/); const simpleMatch = doubleQuoteMatch || singleQuoteMatch; if (simpleMatch) { return simpleMatch[1].replace(/\\(.)/g, '$1').trim(); } return ''; } /** * 对 commit 消息进行规范检查 * @param {string} message - commit 消息 * @returns {string[]} 警告列表,空数组表示通过 */ function lintMessage(message) { const warnings = []; const lines = (message || '').split('\n'); const firstLine = lines[0].trim(); if (firstLine.length === 0) { warnings.push('commit 消息首行不得为空'); } if (firstLine.length > 72) { warnings.push(`首行 ${firstLine.length} 字符,建议不超过 72 字符`); } // 检查类型前缀 (建议但不强制) if (!TYPE_PATTERN.test(firstLine)) { // 中文消息也允许,只要不是太短 if (firstLine.length < 4) { warnings.push('commit 消息过短,请提供更详细的描述'); } } return warnings; } function main() { readStdin({ maxSize: 1024 * 1024 }).then(input => { if (input.tool_name !== 'Bash') { process.exit(0); } const command = (input.tool_input && input.tool_input.command) || ''; const message = extractCommitMessage(command); if (!message) { process.exit(0); } const warnings = lintMessage(message); if (warnings.length === 0) { process.exit(0); } const firstLine = message.split('\n')[0].trim(); const output = { hookSpecificOutput: { permissionDecision: 'ask' }, systemMessage: `[commit-lint] ${warnings.join('; ')}。当前消息: "${firstLine.substring(0, 50)}${firstLine.length > 50 ? '...' : ''}"` }; process.stderr.write(JSON.stringify(output)); process.exit(2); }).catch((e) => { // PreToolUse: 异常时 fail-closed (ask),不静默放行 process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' }, systemMessage: `[commit-lint] Hook 解析异常: ${e.message}` })); process.exit(2); }); } /** * 可导出的 commit 消息检查函数 (供 dispatcher 调用) * @param {string} command - bash 命令 * @param {object} input - 完整的 hook stdin 输入 * @returns {object|null} 检查结果,null 表示放行 * { decision: 'ask', message: string } */ function checkCommit(command, input) { const message = extractCommitMessage(command); if (!message) return null; const warnings = lintMessage(message); if (warnings.length === 0) return null; const firstLine = message.split('\n')[0].trim(); return { decision: 'ask', message: `[commit-lint] ${warnings.join('; ')}。当前消息: "${firstLine.substring(0, 50)}${firstLine.length > 50 ? '...' : ''}"`, }; } // 导出纯函数供单元测试和 dispatcher 使用 if (typeof module !== 'undefined') { module.exports = { COMMIT_TYPES, TYPE_PATTERN, extractCommitMessage, lintMessage, checkCommit }; } if (require.main === module) { main(); }