/** * Commit Message 生成器 * * 参考 aider 的 message_generator 实现 * 支持 conventional、simple、detailed 三种格式 */ import * as path from 'path'; import type { DiffResult, MessageFormatConfig, FileDiff } from './types.js'; export class MessageGenerator { private config: MessageFormatConfig; constructor(config: MessageFormatConfig) { this.config = config; } /** * 生成 commit message */ generate(diff: DiffResult, files?: string[]): string { const fileList = files || diff.files.map((f) => f.path); switch (this.config.style) { case 'conventional': return this.generateConventional(diff, fileList); case 'simple': return this.generateSimple(diff, fileList); case 'detailed': return this.generateDetailed(diff, fileList); default: return this.generateSimple(diff, fileList); } } /** * Conventional Commits 格式 * type(scope): subject */ private generateConventional(diff: DiffResult, files: string[]): string { const type = this.detectChangeType(diff, files); const scope = this.detectScope(files); const subject = this.generateSubject(diff, files); let message = type; if (scope) { message += `(${scope})`; } message += `: ${subject}`; // 截断到最大长度 if (message.length > this.config.maxLength) { message = message.slice(0, this.config.maxLength - 3) + '...'; } // 添加文件列表 if (this.config.includeFileList && files.length <= 5) { const fileListStr = files.map((f) => `- ${f}`).join('\n'); message += `\n\nFiles:\n${fileListStr}`; } return message; } /** * 简单格式 * action file(s) */ private generateSimple(diff: DiffResult, files: string[]): string { const action = this.detectAction(diff, files); if (files.length === 1) { return `${action} ${path.basename(files[0])}`; } else if (files.length <= 3) { const fileNames = files.map((f) => path.basename(f)); return `${action} ${fileNames.join(', ')}`; } else { return `${action} ${files.length} files`; } } /** * 详细格式 * type(scope): subject * * body with file list * * footer with stats */ private generateDetailed(diff: DiffResult, files: string[]): string { const header = this.generateConventional(diff, files).split('\n')[0]; const body = this.generateBody(diff, files); const footer = this.generateFooter(diff); return [header, '', body, '', footer].filter(Boolean).join('\n'); } /** * 检测变更类型 */ private detectChangeType(diff: DiffResult, files: string[]): string { const hasNewFiles = diff.files.some((f) => f.status === 'added'); const hasDeletedFiles = diff.files.some((f) => f.status === 'deleted'); // 检查特殊文件类型 const hasTestFiles = files.some((f) => f.includes('test') || f.includes('spec') || f.includes('.test.') ); const hasDocFiles = files.some((f) => f.match(/\.(md|txt|rst|doc)$/i) ); const hasConfigFiles = files.some((f) => f.match(/(config|\.json|\.yaml|\.yml|\.toml|\.ini)$/i) || f.includes('package.json') || f.includes('tsconfig') ); const hasStyleFiles = files.some((f) => f.match(/\.(css|scss|less|sass|styl)$/i) ); // 根据文件类型判断 if (hasTestFiles && files.every((f) => f.includes('test') || f.includes('spec'))) { return 'test'; } if (hasDocFiles && files.every((f) => f.match(/\.(md|txt|rst|doc)$/i))) { return 'docs'; } if (hasConfigFiles && files.every((f) => f.match(/(config|\.json|\.yaml|\.yml|\.toml|\.ini)$/i) || f.includes('package.json') )) { return 'chore'; } if (hasStyleFiles && files.every((f) => f.match(/\.(css|scss|less|sass|styl)$/i))) { return 'style'; } // 根据变更类型判断 if (hasNewFiles && !hasDeletedFiles) { return 'feat'; } if (hasDeletedFiles && !hasNewFiles) { return 'refactor'; } // 分析内容判断是 fix 还是 feat const content = diff.files .map((f) => f.hunks.map((h) => h.content).join('')) .join(''); if ( content.toLowerCase().includes('fix') || content.toLowerCase().includes('bug') || content.toLowerCase().includes('error') || content.toLowerCase().includes('issue') ) { return 'fix'; } // 默认为 feat return 'feat'; } /** * 检测范围 (scope) */ private detectScope(files: string[]): string | null { if (files.length === 0) return null; // 找共同目录 const dirs = files.map((f) => { const parts = f.split('/'); // 排除 src 目录,取第一个有意义的目录 return parts.filter((p) => p !== 'src' && p !== '.' && !p.includes('.'))[0]; }); // 如果所有文件在同一目录下 const uniqueDirs = [...new Set(dirs.filter(Boolean))]; if (uniqueDirs.length === 1) { return uniqueDirs[0]; } return null; } /** * 生成主题行 */ private generateSubject(diff: DiffResult, files: string[]): string { const action = this.detectAction(diff, files); if (files.length === 1) { const fileName = path.basename(files[0]); const ext = path.extname(fileName); const name = path.basename(fileName, ext); return `${action} ${name}`; } // 找共同特征 const extensions = [...new Set(files.map((f) => path.extname(f)))]; if (extensions.length === 1 && extensions[0]) { return `${action} ${files.length} ${extensions[0].slice(1)} files`; } const dirs = [...new Set(files.map((f) => f.split('/')[0]))]; if (dirs.length === 1 && dirs[0] !== '.') { return `${action} ${dirs[0]} module`; } return `${action} ${files.length} files`; } /** * 检测操作动词 */ private detectAction(diff: DiffResult, files: string[]): string { const hasAdded = diff.files.some((f) => f.status === 'added'); const hasDeleted = diff.files.some((f) => f.status === 'deleted'); const hasModified = diff.files.some((f) => f.status === 'modified'); const hasRenamed = diff.files.some((f) => f.status === 'renamed'); if (hasAdded && !hasModified && !hasDeleted) return 'add'; if (hasDeleted && !hasModified && !hasAdded) return 'remove'; if (hasRenamed) return 'rename'; if (hasModified && !hasAdded && !hasDeleted) return 'update'; return 'update'; } /** * 生成正文 */ private generateBody(diff: DiffResult, files: string[]): string { const changes: string[] = []; for (const file of files) { const fileDiff = diff.files.find((f) => f.path === file); const action = fileDiff ? this.getActionWord(fileDiff.status) : 'Modified'; changes.push(`- ${action}: ${file}`); } return changes.join('\n'); } /** * 获取操作词 */ private getActionWord(status: string): string { switch (status) { case 'added': return 'Added'; case 'deleted': return 'Removed'; case 'renamed': return 'Renamed'; case 'copied': return 'Copied'; case 'modified': default: return 'Modified'; } } /** * 生成页脚 */ private generateFooter(diff: DiffResult): string { const { stats } = diff; if (stats.filesChanged === 0) { return ''; } return `Stats: ${stats.filesChanged} file(s), +${stats.insertions}/-${stats.deletions}`; } }