Files
ai-terminal-assistant/src/git/message-generator.ts
T
kurihada 2208179514 feat: 实现 Git 深度集成
参考 aider 的实现,添加 Git 深度集成功能:

- GitRepo: 封装 simple-git 的基础 Git 操作
- AutoCommitManager: 支持 immediate/batch/manual 三种自动提交模式
- MessageGenerator: 智能生成 conventional/simple/detailed 格式的 commit message
- UndoManager: 安全的 undo 机制,仅允许撤销 AI 生成的提交

主要特性:
- 文件变更后自动提交 (batch 模式默认 3 秒延迟)
- 智能检测变更类型 (feat/fix/docs/test/chore 等)
- 自动检测 scope (从文件路径推断)
- 多重安全检查的 undo 机制
- 追踪 AI 生成的提交,防止误撤销用户提交
- 与 Hook 系统集成

添加 simple-git 依赖
编写完整测试用例 (26 个测试)
2025-12-11 23:41:51 +08:00

277 lines
7.4 KiB
TypeScript

/**
* 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}`;
}
}