154 lines
4.3 KiB
JavaScript
154 lines
4.3 KiB
JavaScript
|
|
#!/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();
|
|||
|
|
}
|