diff --git a/package-lock.json b/package-lock.json index 470687a..a263a38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "minimatch": "^10.1.1", "ora": "^8.1.0", "qwen-ai-provider-v5": "^1.0.2", + "simple-git": "^3.30.0", "tree-sitter-bash": "^0.25.1", "uuid": "^13.0.0", "vscode-jsonrpc": "^8.2.1", @@ -1022,6 +1023,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -2780,6 +2796,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-git": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.30.0.tgz", + "integrity": "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 993ab05..0594cf6 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "minimatch": "^10.1.1", "ora": "^8.1.0", "qwen-ai-provider-v5": "^1.0.2", + "simple-git": "^3.30.0", "tree-sitter-bash": "^0.25.1", "uuid": "^13.0.0", "vscode-jsonrpc": "^8.2.1", diff --git a/src/core/agent.ts b/src/core/agent.ts index 735871c..48235e2 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -20,6 +20,7 @@ import { agentRegistry, AgentExecutor } from '../agent/index.js'; import { loadVisionConfig } from '../utils/config.js'; import { getModelFactory } from './providers.js'; import { getHookManager } from '../hooks/index.js'; +import { getGitManager } from '../git/index.js'; export class Agent { private getModel: (model: string) => LanguageModel; @@ -193,28 +194,42 @@ export class Agent { ); result = afterOutput.result; - // 对于文件操作工具,触发相应的文件 hook + // 对于文件操作工具,触发相应的文件 hook 和 Git 自动提交 if (result.success) { const filePath = finalArgs.path as string | undefined; if (filePath) { + const gitManager = getGitManager(); + if (tool.name === 'write_file') { await hookManager.triggerFileCreated({ path: filePath, tool: tool.name, sessionId, }); + // Git 自动提交 + if (gitManager) { + await gitManager.onFileChanged(filePath, 'create'); + } } else if (tool.name === 'edit_file') { await hookManager.triggerFileEdited({ path: filePath, tool: tool.name, sessionId, }); + // Git 自动提交 + if (gitManager) { + await gitManager.onFileChanged(filePath, 'modify'); + } } else if (tool.name === 'delete_file') { await hookManager.triggerFileDeleted({ path: filePath, tool: tool.name, sessionId, }); + // Git 自动提交 + if (gitManager) { + await gitManager.onFileChanged(filePath, 'delete'); + } } } } diff --git a/src/git/auto-commit.ts b/src/git/auto-commit.ts new file mode 100644 index 0000000..582b827 --- /dev/null +++ b/src/git/auto-commit.ts @@ -0,0 +1,196 @@ +/** + * 自动提交管理器 + * + * 参考 aider 的 auto_commit 实现 + * 支持 immediate、batch、manual 三种模式 + */ + +import { minimatch } from 'minimatch'; +import type { GitRepo } from './repo.js'; +import type { AutoCommitConfig, CommitResult } from './types.js'; +import { MessageGenerator } from './message-generator.js'; + +export class AutoCommitManager { + private repo: GitRepo; + private config: AutoCommitConfig; + private messageGenerator: MessageGenerator; + + /** 待提交的文件 */ + private pendingFiles: Set = new Set(); + + /** 批量提交定时器 */ + private batchTimer: NodeJS.Timeout | null = null; + + /** 提交回调 */ + private onCommitCallback?: (result: CommitResult) => void; + + constructor(repo: GitRepo, config: AutoCommitConfig, messageGenerator: MessageGenerator) { + this.repo = repo; + this.config = config; + this.messageGenerator = messageGenerator; + } + + /** + * 设置提交回调 + */ + setOnCommit(callback: (result: CommitResult) => void): void { + this.onCommitCallback = callback; + } + + /** + * 文件变更后调用 + */ + async onFileChanged(filePath: string, changeType: 'create' | 'modify' | 'delete'): Promise { + if (!this.config.enabled) { + return; + } + + // 检查是否应该排除 + if (this.shouldExclude(filePath)) { + return; + } + + // 如果启用脏文件提交,检查文件是否为脏状态 + if (this.config.dirtyCommits && changeType !== 'create') { + const isDirty = await this.repo.isDirty(filePath); + if (isDirty) { + // 在新的编辑之前,先提交脏文件 + await this.commitDirtyFile(filePath); + } + } + + // 添加到待提交列表 + this.pendingFiles.add(filePath); + + // 根据模式处理 + switch (this.config.mode) { + case 'immediate': + await this.executeCommit(); + break; + + case 'batch': + this.scheduleBatchCommit(); + break; + + case 'manual': + // 不自动提交,只记录 + break; + } + } + + /** + * 计划批量提交 + */ + private scheduleBatchCommit(): void { + if (this.batchTimer) { + clearTimeout(this.batchTimer); + } + + this.batchTimer = setTimeout(async () => { + this.batchTimer = null; + await this.executeCommit(); + }, this.config.batchDelay); + } + + /** + * 执行提交 + */ + private async executeCommit(): Promise { + if (this.pendingFiles.size === 0) { + return null; + } + + const files = Array.from(this.pendingFiles); + this.pendingFiles.clear(); + + try { + // 获取差异用于生成消息 + const diff = await this.repo.getDiff({ staged: false }); + + // 生成提交消息 + const message = this.messageGenerator.generate(diff, files); + + // 执行提交 + const result = await this.repo.commit({ + files, + message, + aiEdits: true, + }); + + // 调用回调 + if (result.success && this.onCommitCallback) { + this.onCommitCallback(result); + } + + return result; + } catch (error) { + // 恢复 pending 状态 + files.forEach((f) => this.pendingFiles.add(f)); + + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * 提交脏文件 + */ + private async commitDirtyFile(filePath: string): Promise { + try { + await this.repo.commit({ + files: [filePath], + message: `chore: save changes to ${filePath} before AI edit`, + aiEdits: false, // 用户的脏文件,不标记为 AI 编辑 + }); + } catch { + // 脏文件提交失败不影响主流程 + } + } + + /** + * 检查文件是否应该排除 + */ + private shouldExclude(filePath: string): boolean { + return this.config.excludePatterns.some((pattern) => + minimatch(filePath, pattern, { matchBase: true }) + ); + } + + /** + * 强制立即提交 + */ + async flush(): Promise { + if (this.batchTimer) { + clearTimeout(this.batchTimer); + this.batchTimer = null; + } + return this.executeCommit(); + } + + /** + * 获取待提交文件 + */ + getPendingFiles(): string[] { + return Array.from(this.pendingFiles); + } + + /** + * 清除待提交文件 + */ + clearPending(): void { + if (this.batchTimer) { + clearTimeout(this.batchTimer); + this.batchTimer = null; + } + this.pendingFiles.clear(); + } + + /** + * 是否有待提交文件 + */ + hasPendingFiles(): boolean { + return this.pendingFiles.size > 0; + } +} diff --git a/src/git/index.ts b/src/git/index.ts new file mode 100644 index 0000000..fb9877b --- /dev/null +++ b/src/git/index.ts @@ -0,0 +1,53 @@ +/** + * Git 深度集成模块 + * + * 提供自动提交、智能 commit message 生成、undo 等功能 + * 参考 aider 的实现 + */ + +// Git 管理器 +export { + GitManager, + getGitManager, + initGitManager, + resetGitManager, +} from './manager.js'; + +// GitRepo +export { GitRepo } from './repo.js'; + +// 自动提交 +export { AutoCommitManager } from './auto-commit.js'; + +// 消息生成 +export { MessageGenerator } from './message-generator.js'; + +// Undo 管理 +export { UndoManager } from './undo-manager.js'; + +// 类型导出 +export type { + GitConfig, + AutoCommitConfig, + UndoConfig, + MessageFormatConfig, + AttributionConfig, + GitStatus, + FileChange, + ChangeStatus, + CommitInfo, + DiffResult, + FileDiff, + DiffHunk, + DiffStats, + UndoEntry, + UndoResult, + CommitOptions, + CommitResult, + BranchInfo, + GitEventType, + GitEvent, + GitEventListener, +} from './types.js'; + +export { DEFAULT_GIT_CONFIG } from './types.js'; diff --git a/src/git/manager.ts b/src/git/manager.ts new file mode 100644 index 0000000..05bd7e2 --- /dev/null +++ b/src/git/manager.ts @@ -0,0 +1,329 @@ +/** + * Git 管理器 + * + * 整合 GitRepo、AutoCommitManager、MessageGenerator、UndoManager + * 提供统一的 Git 集成接口 + */ + +import type { + GitConfig, + GitStatus, + CommitResult, + UndoResult, + UndoEntry, + DiffResult, + CommitInfo, + GitEvent, + GitEventListener, +} from './types.js'; +import { DEFAULT_GIT_CONFIG } from './types.js'; +import { GitRepo } from './repo.js'; +import { AutoCommitManager } from './auto-commit.js'; +import { MessageGenerator } from './message-generator.js'; +import { UndoManager } from './undo-manager.js'; + +export class GitManager { + private repo: GitRepo; + private autoCommit: AutoCommitManager; + private messageGenerator: MessageGenerator; + private undoManager: UndoManager; + private config: GitConfig; + private workdir: string; + + /** 事件监听器 */ + private eventListeners: GitEventListener[] = []; + + /** 是否已初始化 */ + private initialized: boolean = false; + + constructor(workdir: string, config?: Partial) { + this.workdir = workdir; + this.config = { ...DEFAULT_GIT_CONFIG, ...config }; + + // 创建组件 + this.repo = new GitRepo(workdir, this.config); + this.messageGenerator = new MessageGenerator(this.config.messageFormat); + this.autoCommit = new AutoCommitManager( + this.repo, + this.config.autoCommit, + this.messageGenerator + ); + this.undoManager = new UndoManager(this.repo, this.config.undo); + + // 设置自动提交回调 + this.autoCommit.setOnCommit((result) => { + if (result.success && result.shortHash && result.message) { + // 记录到 undo 历史 + const files = this.autoCommit.getPendingFiles(); + this.undoManager.recordCommit( + result.hash!, + result.shortHash, + result.message, + files + ); + + // 发送事件 + this.emitEvent('commit', { + hash: result.shortHash, + message: result.message, + auto: true, + }); + } + }); + } + + /** + * 初始化 Git 管理器 + */ + async initialize(): Promise { + if (!this.config.enabled) { + return false; + } + + const isRepo = await this.repo.initialize(); + this.initialized = isRepo; + return isRepo; + } + + /** + * 检查是否已初始化 + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * 获取仓库状态 + */ + async getStatus(): Promise { + return this.repo.getStatus(); + } + + /** + * 文件变更后调用(由工具执行器调用) + */ + async onFileChanged( + filePath: string, + changeType: 'create' | 'modify' | 'delete' + ): Promise { + if (!this.initialized || !this.config.enabled) { + return; + } + + await this.autoCommit.onFileChanged(filePath, changeType); + } + + /** + * 手动提交 + */ + async commit(options: { + message?: string; + files?: string[]; + all?: boolean; + } = {}): Promise { + if (!this.initialized) { + return { success: false, error: 'Git not initialized' }; + } + + // 如果有待提交的文件,先处理 + if (this.autoCommit.hasPendingFiles()) { + await this.autoCommit.flush(); + } + + // 获取差异用于生成消息 + const diff = await this.repo.getDiff({ staged: options.all }); + const message = options.message || this.messageGenerator.generate(diff); + + const result = await this.repo.commit({ + message, + files: options.files, + all: options.all, + aiEdits: true, + }); + + if (result.success && result.shortHash && result.message) { + // 记录到 undo 历史 + const files = options.files || []; + this.undoManager.recordCommit( + result.hash!, + result.shortHash, + result.message, + files + ); + + // 发送事件 + this.emitEvent('commit', { + hash: result.shortHash, + message: result.message, + auto: false, + }); + } + + return result; + } + + /** + * 撤销上一次 AI 提交 + */ + async undo(): Promise { + if (!this.initialized) { + return { success: false, message: 'Git not initialized' }; + } + + const result = await this.undoManager.undo(); + + if (result.success) { + this.emitEvent('undo', { + hash: result.commitHash, + files: result.restoredFiles, + }); + } + + return result; + } + + /** + * 获取 undo 预览 + */ + getUndoPreview(): UndoEntry | null { + return this.undoManager.getUndoPreview(); + } + + /** + * 获取 undo 历史 + */ + getUndoHistory(): UndoEntry[] { + return this.undoManager.getHistory(); + } + + /** + * 检查是否可以 undo + */ + async canUndo(): Promise<{ canUndo: boolean; reason?: string }> { + return this.undoManager.canUndo(); + } + + /** + * 获取差异 + */ + async getDiff(options: { + staged?: boolean; + file?: string; + commit?: string; + } = {}): Promise { + return this.repo.getDiff(options); + } + + /** + * 获取最近的提交 + */ + async getRecentCommits(count: number = 10): Promise { + return this.repo.getRecentCommits(count); + } + + /** + * 获取当前分支 + */ + async getCurrentBranch(): Promise { + return this.repo.getCurrentBranch(); + } + + /** + * 强制刷新待提交 + */ + async flushPendingCommits(): Promise { + return this.autoCommit.flush(); + } + + /** + * 获取待提交文件 + */ + getPendingFiles(): string[] { + return this.autoCommit.getPendingFiles(); + } + + /** + * 添加事件监听器 + */ + addEventListener(listener: GitEventListener): void { + this.eventListeners.push(listener); + } + + /** + * 移除事件监听器 + */ + removeEventListener(listener: GitEventListener): void { + const index = this.eventListeners.indexOf(listener); + if (index !== -1) { + this.eventListeners.splice(index, 1); + } + } + + /** + * 发送事件 + */ + private emitEvent(type: GitEvent['type'], data: unknown): void { + const event: GitEvent = { + type, + timestamp: Date.now(), + data, + }; + + for (const listener of this.eventListeners) { + try { + listener(event); + } catch (error) { + console.error('Git event listener error:', error); + } + } + } + + /** + * 获取配置 + */ + getConfig(): GitConfig { + return { ...this.config }; + } + + /** + * 更新配置 + */ + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } +} + +// 全局 Git 管理器实例 +let gitManager: GitManager | null = null; + +/** + * 获取全局 Git 管理器 + */ +export function getGitManager(): GitManager | null { + return gitManager; +} + +/** + * 初始化全局 Git 管理器 + */ +export async function initGitManager( + workdir: string, + config?: Partial +): Promise { + gitManager = new GitManager(workdir, config); + const initialized = await gitManager.initialize(); + + if (!initialized) { + gitManager = null; + return null; + } + + return gitManager; +} + +/** + * 重置全局 Git 管理器 + */ +export function resetGitManager(): void { + gitManager = null; +} diff --git a/src/git/message-generator.ts b/src/git/message-generator.ts new file mode 100644 index 0000000..8ff5457 --- /dev/null +++ b/src/git/message-generator.ts @@ -0,0 +1,276 @@ +/** + * 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}`; + } +} diff --git a/src/git/repo.ts b/src/git/repo.ts new file mode 100644 index 0000000..95ce08c --- /dev/null +++ b/src/git/repo.ts @@ -0,0 +1,534 @@ +/** + * Git 仓库管理类 + * + * 封装 simple-git,提供 Git 操作的基础功能 + * 参考 aider 的 repo.py 实现 + */ + +import { simpleGit, type SimpleGit, type StatusResult } from 'simple-git'; +import * as path from 'path'; +import type { + GitStatus, + FileChange, + CommitInfo, + DiffResult, + FileDiff, + DiffHunk, + ChangeStatus, + BranchInfo, + GitConfig, + CommitOptions, + CommitResult, + AttributionConfig, +} from './types.js'; + +export class GitRepo { + private git: SimpleGit; + private workdir: string; + private config: GitConfig; + + /** 当前会话中 AI 生成的提交哈希 */ + private aiCommitHashes: Set = new Set(); + + constructor(workdir: string, config: GitConfig) { + this.workdir = workdir; + this.config = config; + this.git = simpleGit(workdir); + } + + /** + * 初始化并验证是否为 Git 仓库 + */ + async initialize(): Promise { + try { + const isRepo = await this.git.checkIsRepo(); + return isRepo; + } catch { + return false; + } + } + + /** + * 获取仓库根目录 + */ + async getRoot(): Promise { + const root = await this.git.revparse(['--show-toplevel']); + return root.trim(); + } + + /** + * 获取仓库状态 + */ + async getStatus(): Promise { + const status = await this.git.status(); + + return { + branch: status.current || 'HEAD', + ahead: status.ahead, + behind: status.behind, + staged: this.parseStatusFiles(status.staged, status), + unstaged: this.parseStatusFiles(status.modified, status), + untracked: status.not_added, + hasConflicts: status.conflicted.length > 0, + conflicts: status.conflicted, + isDirty: status.files.length > 0, + }; + } + + /** + * 检查文件是否为脏状态 + */ + async isDirty(filePath?: string): Promise { + const status = await this.git.status(); + + if (!filePath) { + return status.files.length > 0; + } + + const normalizedPath = this.normalizePath(filePath); + return status.files.some( + (f) => this.normalizePath(f.path) === normalizedPath + ); + } + + /** + * 获取差异 + */ + async getDiff(options: { + staged?: boolean; + file?: string; + commit?: string; + } = {}): Promise { + let diffOutput: string; + + try { + if (options.commit) { + diffOutput = await this.git.diff([`${options.commit}^`, options.commit]); + } else if (options.staged) { + const args = ['--cached']; + if (options.file) args.push('--', options.file); + diffOutput = await this.git.diff(args); + } else { + const args: string[] = []; + if (options.file) args.push('--', options.file); + diffOutput = await this.git.diff(args); + } + } catch { + diffOutput = ''; + } + + return this.parseDiff(diffOutput); + } + + /** + * 获取两个提交之间的差异 + */ + async getDiffBetween(fromCommit: string, toCommit: string): Promise { + const diffOutput = await this.git.diff([fromCommit, toCommit]); + return this.parseDiff(diffOutput); + } + + /** + * 暂存文件 + */ + async stage(files: string | string[]): Promise { + const fileList = Array.isArray(files) ? files : [files]; + await this.git.add(fileList); + } + + /** + * 取消暂存文件 + */ + async unstage(files: string | string[]): Promise { + const fileList = Array.isArray(files) ? files : [files]; + await this.git.reset(['HEAD', '--', ...fileList]); + } + + /** + * 提交变更 + */ + async commit(options: CommitOptions = {}): Promise { + try { + // 暂存文件 + if (options.files && options.files.length > 0) { + await this.stage(options.files); + } else if (options.all) { + await this.git.add('.'); + } + + // 检查是否有可提交的内容 + const status = await this.git.status(); + if (status.staged.length === 0) { + return { + success: false, + error: 'Nothing to commit', + }; + } + + // 构建提交消息 + let message = options.message || 'Update files'; + + // 添加属性标记 + if (options.aiEdits && this.config.attribution.coAuthoredBy) { + message += `\n\nCo-authored-by: ${this.config.attribution.markerName} `; + } + + // 设置作者信息 (如果需要标记) + const commitOptions: string[] = []; + if (options.aiEdits && this.config.attribution.attributeAuthor) { + const originalName = await this.getConfigValue('user.name'); + if (originalName) { + commitOptions.push( + '--author', + `${originalName} (${this.config.attribution.markerName}) <${await this.getConfigValue('user.email')}>` + ); + } + } + + // 执行提交 + const result = await this.git.commit(message, undefined, { + '--no-verify': null, // 跳过 hooks 以避免干扰 + ...Object.fromEntries(commitOptions.map((opt, i) => + i % 2 === 0 ? [opt, commitOptions[i + 1]] : [] + ).filter(arr => arr.length > 0)), + }); + + const hash = result.commit; + const shortHash = hash.slice(0, 7); + + // 记录 AI 提交 + if (options.aiEdits) { + this.aiCommitHashes.add(shortHash); + } + + return { + success: true, + hash, + shortHash, + message: message.split('\n')[0], // 只返回第一行 + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * 获取提交信息 + */ + async getCommitInfo(hash: string): Promise { + try { + const log = await this.git.log({ + maxCount: 1, + from: hash, + to: hash, + }); + + if (log.all.length === 0) { + return null; + } + + const entry = log.all[0]; + const files = await this.getCommitFiles(hash); + + return { + hash: entry.hash, + shortHash: entry.hash.slice(0, 7), + message: entry.message, + author: entry.author_name, + email: entry.author_email, + date: new Date(entry.date), + files, + isAIGenerated: this.aiCommitHashes.has(entry.hash.slice(0, 7)), + }; + } catch { + return null; + } + } + + /** + * 获取最近的提交 + */ + async getRecentCommits(count: number = 10): Promise { + const log = await this.git.log({ maxCount: count }); + const commits: CommitInfo[] = []; + + for (const entry of log.all) { + const files = await this.getCommitFiles(entry.hash); + commits.push({ + hash: entry.hash, + shortHash: entry.hash.slice(0, 7), + message: entry.message, + author: entry.author_name, + email: entry.author_email, + date: new Date(entry.date), + files, + isAIGenerated: this.aiCommitHashes.has(entry.hash.slice(0, 7)), + }); + } + + return commits; + } + + /** + * 获取 HEAD 提交哈希 + */ + async getHeadCommit(): Promise { + try { + const hash = await this.git.revparse(['HEAD']); + return hash.trim(); + } catch { + return null; + } + } + + /** + * 获取 HEAD 短哈希 + */ + async getHeadShortHash(): Promise { + try { + const hash = await this.git.revparse(['--short', 'HEAD']); + return hash.trim(); + } catch { + return null; + } + } + + /** + * 检查提交是否已推送到远程 + */ + async isCommitPushed(hash: string): Promise { + try { + const branch = await this.git.revparse(['--abbrev-ref', 'HEAD']); + const remoteBranch = `origin/${branch.trim()}`; + + // 检查远程分支是否存在 + try { + await this.git.revparse([remoteBranch]); + } catch { + return false; // 远程分支不存在 + } + + // 检查提交是否在远程分支上 + const result = await this.git.raw([ + 'branch', '-r', '--contains', hash + ]); + + return result.includes(remoteBranch); + } catch { + return false; + } + } + + /** + * 软重置到指定提交 + */ + async resetSoft(commit: string): Promise { + await this.git.reset(['--soft', commit]); + } + + /** + * 检出文件 + */ + async checkoutFile(commit: string, filePath: string): Promise { + await this.git.checkout([commit, '--', filePath]); + } + + /** + * 获取分支列表 + */ + async getBranches(): Promise { + const summary = await this.git.branch(['-vv']); + const branches: BranchInfo[] = []; + + for (const [name, data] of Object.entries(summary.branches)) { + branches.push({ + name, + current: data.current, + remote: undefined, // simple-git 不直接提供 + upstream: undefined, + ahead: 0, + behind: 0, + }); + } + + return branches; + } + + /** + * 获取当前分支名 + */ + async getCurrentBranch(): Promise { + const branch = await this.git.revparse(['--abbrev-ref', 'HEAD']); + return branch.trim(); + } + + /** + * 获取提交涉及的文件 + */ + private async getCommitFiles(hash: string): Promise { + try { + const result = await this.git.raw([ + 'diff-tree', '--no-commit-id', '--name-only', '-r', hash + ]); + return result.trim().split('\n').filter(Boolean); + } catch { + return []; + } + } + + /** + * 获取 Git 配置值 + */ + private async getConfigValue(key: string): Promise { + try { + const value = await this.git.getConfig(key); + return value.value || null; + } catch { + return null; + } + } + + /** + * 解析状态文件 + */ + private parseStatusFiles(files: string[], status: StatusResult): FileChange[] { + return files.map((file) => { + const fileStatus = status.files.find((f) => f.path === file); + return { + path: file, + status: this.mapStatus(fileStatus?.index || 'M'), + additions: 0, + deletions: 0, + }; + }); + } + + /** + * 映射状态字符到 ChangeStatus + */ + private mapStatus(code: string): ChangeStatus { + switch (code) { + case 'A': + case '?': + return 'added'; + case 'D': + return 'deleted'; + case 'R': + return 'renamed'; + case 'C': + return 'copied'; + case 'M': + default: + return 'modified'; + } + } + + /** + * 解析 diff 输出 + */ + private parseDiff(diffOutput: string): DiffResult { + const files: FileDiff[] = []; + let totalInsertions = 0; + let totalDeletions = 0; + + if (!diffOutput.trim()) { + return { files, stats: { filesChanged: 0, insertions: 0, deletions: 0 } }; + } + + const fileSections = diffOutput.split(/(?=diff --git)/); + + for (const section of fileSections) { + if (!section.trim()) continue; + + const pathMatch = section.match(/diff --git a\/(.*) b\/(.*)/); + if (!pathMatch) continue; + + const hunks: DiffHunk[] = []; + const hunkMatches = section.matchAll( + /@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@([\s\S]*?)(?=@@|$)/g + ); + + for (const match of hunkMatches) { + const content = match[5] || ''; + const adds = (content.match(/^\+[^+]/gm) || []).length; + const dels = (content.match(/^-[^-]/gm) || []).length; + totalInsertions += adds; + totalDeletions += dels; + + hunks.push({ + oldStart: parseInt(match[1]), + oldLines: parseInt(match[2]) || 1, + newStart: parseInt(match[3]), + newLines: parseInt(match[4]) || 1, + content, + }); + } + + files.push({ + path: pathMatch[2], + oldPath: pathMatch[1] !== pathMatch[2] ? pathMatch[1] : undefined, + status: this.detectDiffStatus(section), + hunks, + binary: section.includes('Binary files'), + }); + } + + return { + files, + stats: { + filesChanged: files.length, + insertions: totalInsertions, + deletions: totalDeletions, + }, + }; + } + + /** + * 检测 diff 中的变更状态 + */ + private detectDiffStatus(diffSection: string): ChangeStatus { + if (diffSection.includes('new file mode')) return 'added'; + if (diffSection.includes('deleted file mode')) return 'deleted'; + if (diffSection.includes('rename from')) return 'renamed'; + if (diffSection.includes('copy from')) return 'copied'; + return 'modified'; + } + + /** + * 规范化文件路径 + */ + private normalizePath(filePath: string): string { + return path.normalize(filePath).replace(/\\/g, '/'); + } + + /** + * 获取 AI 提交哈希集合 + */ + getAICommitHashes(): Set { + return this.aiCommitHashes; + } + + /** + * 检查提交是否为 AI 生成 + */ + isAICommit(shortHash: string): boolean { + return this.aiCommitHashes.has(shortHash); + } + + /** + * 添加 AI 提交哈希 + */ + addAICommitHash(shortHash: string): void { + this.aiCommitHashes.add(shortHash); + } + + /** + * 移除 AI 提交哈希 + */ + removeAICommitHash(shortHash: string): void { + this.aiCommitHashes.delete(shortHash); + } +} diff --git a/src/git/types.ts b/src/git/types.ts new file mode 100644 index 0000000..0003667 --- /dev/null +++ b/src/git/types.ts @@ -0,0 +1,346 @@ +/** + * Git 深度集成类型定义 + * + * 参考 aider 的实现 + */ + +/** + * Git 配置 + */ +export interface GitConfig { + /** 是否启用 Git 集成 */ + enabled: boolean; + /** 自动提交配置 */ + autoCommit: AutoCommitConfig; + /** Undo 配置 */ + undo: UndoConfig; + /** Commit message 格式配置 */ + messageFormat: MessageFormatConfig; + /** 属性配置 */ + attribution: AttributionConfig; +} + +/** + * 自动提交配置 + */ +export interface AutoCommitConfig { + /** 是否启用自动提交 */ + enabled: boolean; + /** 提交模式 */ + mode: 'immediate' | 'batch' | 'manual'; + /** batch 模式下的延迟 (ms) */ + batchDelay: number; + /** 排除的文件模式 */ + excludePatterns: string[]; + /** 是否在编辑前提交脏文件 */ + dirtyCommits: boolean; +} + +/** + * Undo 配置 + */ +export interface UndoConfig { + /** 最大历史记录数 */ + maxHistory: number; +} + +/** + * Commit message 格式配置 + */ +export interface MessageFormatConfig { + /** 格式风格 */ + style: 'conventional' | 'simple' | 'detailed'; + /** 是否包含文件列表 */ + includeFileList: boolean; + /** 最大长度 */ + maxLength: number; + /** 是否使用 AI 生成 */ + useAI: boolean; + /** 提交消息语言 */ + language?: string; +} + +/** + * 属性配置 (作者/提交者署名) + */ +export interface AttributionConfig { + /** 是否在作者名添加标记 */ + attributeAuthor: boolean; + /** 是否在提交者名添加标记 */ + attributeCommitter: boolean; + /** 是否添加 Co-authored-by */ + coAuthoredBy: boolean; + /** 标记名称 */ + markerName: string; +} + +/** + * Git 仓库状态 + */ +export interface GitStatus { + /** 当前分支 */ + branch: string; + /** 领先远程的提交数 */ + ahead: number; + /** 落后远程的提交数 */ + behind: number; + /** 暂存的文件 */ + staged: FileChange[]; + /** 未暂存的修改 */ + unstaged: FileChange[]; + /** 未跟踪的文件 */ + untracked: string[]; + /** 是否有冲突 */ + hasConflicts: boolean; + /** 冲突文件 */ + conflicts: string[]; + /** 是否为脏状态 */ + isDirty: boolean; +} + +/** + * 文件变更 + */ +export interface FileChange { + /** 文件路径 */ + path: string; + /** 变更状态 */ + status: ChangeStatus; + /** 添加行数 */ + additions: number; + /** 删除行数 */ + deletions: number; + /** 原路径 (重命名时) */ + oldPath?: string; +} + +/** + * 变更状态 + */ +export type ChangeStatus = + | 'added' + | 'modified' + | 'deleted' + | 'renamed' + | 'copied' + | 'untracked'; + +/** + * 提交信息 + */ +export interface CommitInfo { + /** 完整哈希 */ + hash: string; + /** 短哈希 */ + shortHash: string; + /** 提交消息 */ + message: string; + /** 作者名 */ + author: string; + /** 作者邮箱 */ + email: string; + /** 提交时间 */ + date: Date; + /** 涉及的文件 */ + files: string[]; + /** 是否由 AI 生成 */ + isAIGenerated: boolean; +} + +/** + * Diff 结果 + */ +export interface DiffResult { + /** 文件差异列表 */ + files: FileDiff[]; + /** 统计信息 */ + stats: DiffStats; +} + +/** + * 文件差异 + */ +export interface FileDiff { + /** 文件路径 */ + path: string; + /** 原路径 */ + oldPath?: string; + /** 变更状态 */ + status: ChangeStatus; + /** 差异块 */ + hunks: DiffHunk[]; + /** 是否为二进制文件 */ + binary: boolean; +} + +/** + * 差异块 + */ +export interface DiffHunk { + /** 旧文件起始行 */ + oldStart: number; + /** 旧文件行数 */ + oldLines: number; + /** 新文件起始行 */ + newStart: number; + /** 新文件行数 */ + newLines: number; + /** 差异内容 */ + content: string; +} + +/** + * 差异统计 + */ +export interface DiffStats { + /** 变更文件数 */ + filesChanged: number; + /** 插入行数 */ + insertions: number; + /** 删除行数 */ + deletions: number; +} + +/** + * Undo 历史条目 + */ +export interface UndoEntry { + /** 条目 ID */ + id: string; + /** 时间戳 */ + timestamp: number; + /** 提交哈希 */ + commitHash: string; + /** 提交消息 */ + message: string; + /** 涉及的文件 */ + files: string[]; + /** 是否可以撤销 */ + canUndo: boolean; +} + +/** + * Undo 操作结果 + */ +export interface UndoResult { + /** 是否成功 */ + success: boolean; + /** 结果消息 */ + message: string; + /** 撤销的提交哈希 */ + commitHash?: string; + /** 恢复的文件 */ + restoredFiles?: string[]; +} + +/** + * 提交选项 + */ +export interface CommitOptions { + /** 提交消息 (可选,不填则自动生成) */ + message?: string; + /** 要提交的文件 (可选,不填则提交所有暂存文件) */ + files?: string[]; + /** 是否提交所有变更 */ + all?: boolean; + /** 是否为 AI 编辑 */ + aiEdits?: boolean; + /** 上下文信息 (用于生成消息) */ + context?: string; +} + +/** + * 提交结果 + */ +export interface CommitResult { + /** 是否成功 */ + success: boolean; + /** 提交哈希 */ + hash?: string; + /** 短哈希 */ + shortHash?: string; + /** 提交消息 */ + message?: string; + /** 错误信息 */ + error?: string; +} + +/** + * 分支信息 + */ +export interface BranchInfo { + /** 分支名 */ + name: string; + /** 是否为当前分支 */ + current: boolean; + /** 远程名称 */ + remote?: string; + /** 上游分支 */ + upstream?: string; + /** 领先远程的提交数 */ + ahead: number; + /** 落后远程的提交数 */ + behind: number; +} + +/** + * Git 事件类型 + */ +export type GitEventType = + | 'commit' + | 'undo' + | 'file_staged' + | 'file_unstaged' + | 'branch_switch' + | 'pull' + | 'push'; + +/** + * Git 事件 + */ +export interface GitEvent { + type: GitEventType; + timestamp: number; + data: unknown; +} + +/** + * Git 事件监听器 + */ +export type GitEventListener = (event: GitEvent) => void; + +/** + * 默认配置 + */ +export const DEFAULT_GIT_CONFIG: GitConfig = { + enabled: true, + autoCommit: { + enabled: true, + mode: 'batch', + batchDelay: 3000, + excludePatterns: [ + '*.log', + 'node_modules/**', + '.git/**', + '.ai-assistant/**', + '*.tmp', + '*.swp', + ], + dirtyCommits: true, + }, + undo: { + maxHistory: 50, + }, + messageFormat: { + style: 'conventional', + includeFileList: true, + maxLength: 72, + useAI: false, + }, + attribution: { + attributeAuthor: true, + attributeCommitter: true, + coAuthoredBy: false, + markerName: 'ai-assistant', + }, +}; diff --git a/src/git/undo-manager.ts b/src/git/undo-manager.ts new file mode 100644 index 0000000..af6246f --- /dev/null +++ b/src/git/undo-manager.ts @@ -0,0 +1,194 @@ +/** + * Undo 管理器 + * + * 参考 aider 的 undo 实现 + * 仅支持撤销 AI 生成的提交,且需要通过多项安全检查 + */ + +import type { GitRepo } from './repo.js'; +import type { UndoConfig, UndoEntry, UndoResult, CommitInfo } from './types.js'; + +export class UndoManager { + private repo: GitRepo; + private config: UndoConfig; + + /** Undo 历史 */ + private history: UndoEntry[] = []; + + constructor(repo: GitRepo, config: UndoConfig) { + this.repo = repo; + this.config = config; + } + + /** + * 记录提交到 undo 历史 + */ + recordCommit(commitHash: string, shortHash: string, message: string, files: string[]): void { + const entry: UndoEntry = { + id: `undo-${Date.now()}`, + timestamp: Date.now(), + commitHash: shortHash, + message, + files, + canUndo: true, + }; + + this.history.push(entry); + + // 限制历史记录数量 + if (this.history.length > this.config.maxHistory) { + this.history = this.history.slice(-this.config.maxHistory); + } + } + + /** + * 执行 undo 操作 + * + * 安全检查清单(参考 aider): + * 1. 提交是否由 AI 生成 + * 2. 提交是否已推送到远程 + * 3. 是否为合并提交 + * 4. 文件是否有未提交的更改 + */ + async undo(): Promise { + // 1. 检查是否有可撤销的提交 + if (this.history.length === 0) { + return { + success: false, + message: 'Nothing to undo. No AI commits in this session.', + }; + } + + // 2. 获取最后一条记录 + const lastEntry = this.history[this.history.length - 1]; + + // 3. 获取 HEAD 提交 + const headShortHash = await this.repo.getHeadShortHash(); + if (!headShortHash) { + return { + success: false, + message: 'Unable to get HEAD commit.', + }; + } + + // 4. 验证最后的提交是否与记录匹配 + if (headShortHash !== lastEntry.commitHash) { + return { + success: false, + message: `The last commit (${headShortHash}) does not match the recorded AI commit (${lastEntry.commitHash}). The repository may have changed since the AI edit.`, + }; + } + + // 5. 验证是否为 AI 生成的提交 + if (!this.repo.isAICommit(headShortHash)) { + return { + success: false, + message: `The commit ${headShortHash} was not made by AI in this session.`, + }; + } + + // 6. 检查提交是否已推送到远程 + const headHash = await this.repo.getHeadCommit(); + if (headHash) { + const isPushed = await this.repo.isCommitPushed(headHash); + if (isPushed) { + return { + success: false, + message: 'The commit has already been pushed to remote. Undo is not safe.', + }; + } + } + + // 7. 检查文件是否有未提交的更改 + for (const file of lastEntry.files) { + const isDirty = await this.repo.isDirty(file); + if (isDirty) { + return { + success: false, + message: `The file ${file} has uncommitted changes. Please commit or stash them first.`, + }; + } + } + + // 8. 执行撤销操作 + try { + // 恢复文件到上一个版本 + const restoredFiles: string[] = []; + for (const file of lastEntry.files) { + try { + await this.repo.checkoutFile('HEAD~1', file); + restoredFiles.push(file); + } catch { + // 文件可能在前一个提交中不存在(新建的文件) + // 这种情况下跳过 + } + } + + // 软重置 HEAD + await this.repo.resetSoft('HEAD~1'); + + // 从历史中移除 + this.history.pop(); + + // 从 AI 提交记录中移除 + this.repo.removeAICommitHash(lastEntry.commitHash); + + return { + success: true, + message: `Undone: ${lastEntry.commitHash} - ${lastEntry.message}`, + commitHash: lastEntry.commitHash, + restoredFiles, + }; + } catch (error) { + return { + success: false, + message: `Undo failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * 预览将要撤销的内容 + */ + getUndoPreview(): UndoEntry | null { + if (this.history.length === 0) { + return null; + } + return this.history[this.history.length - 1]; + } + + /** + * 获取 undo 历史 + */ + getHistory(): UndoEntry[] { + return [...this.history]; + } + + /** + * 清除历史 + */ + clearHistory(): void { + this.history = []; + } + + /** + * 检查是否可以 undo + */ + async canUndo(): Promise<{ canUndo: boolean; reason?: string }> { + if (this.history.length === 0) { + return { canUndo: false, reason: 'No AI commits to undo' }; + } + + const lastEntry = this.history[this.history.length - 1]; + const headShortHash = await this.repo.getHeadShortHash(); + + if (headShortHash !== lastEntry.commitHash) { + return { + canUndo: false, + reason: 'Repository has changed since last AI commit', + }; + } + + return { canUndo: true }; + } +} diff --git a/tests/git/git.test.ts b/tests/git/git.test.ts new file mode 100644 index 0000000..785e9b6 --- /dev/null +++ b/tests/git/git.test.ts @@ -0,0 +1,432 @@ +/** + * Git 深度集成测试 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import { + GitRepo, + GitManager, + MessageGenerator, + UndoManager, + initGitManager, + getGitManager, + resetGitManager, + DEFAULT_GIT_CONFIG, + type DiffResult, +} from '../../src/git/index.js'; + +describe('MessageGenerator', () => { + const generator = new MessageGenerator({ + style: 'conventional', + includeFileList: true, + maxLength: 72, + useAI: false, + }); + + it('should generate conventional commit message', () => { + const diff: DiffResult = { + files: [ + { path: 'src/test.ts', status: 'modified', hunks: [], binary: false }, + ], + stats: { filesChanged: 1, insertions: 10, deletions: 5 }, + }; + + const message = generator.generate(diff, ['src/test.ts']); + expect(message).toMatch(/^(feat|fix|chore|test|docs|style|refactor)/); + expect(message).toContain('test'); + }); + + it('should detect test type for test files', () => { + const diff: DiffResult = { + files: [ + { path: 'tests/foo.test.ts', status: 'added', hunks: [], binary: false }, + ], + stats: { filesChanged: 1, insertions: 50, deletions: 0 }, + }; + + const message = generator.generate(diff, ['tests/foo.test.ts']); + expect(message).toMatch(/^test/); + }); + + it('should detect docs type for markdown files', () => { + const diff: DiffResult = { + files: [ + { path: 'README.md', status: 'modified', hunks: [], binary: false }, + ], + stats: { filesChanged: 1, insertions: 20, deletions: 10 }, + }; + + const message = generator.generate(diff, ['README.md']); + expect(message).toMatch(/^docs/); + }); + + it('should include scope when files are in same directory', () => { + const diff: DiffResult = { + files: [ + { path: 'src/git/repo.ts', status: 'modified', hunks: [], binary: false }, + { path: 'src/git/types.ts', status: 'modified', hunks: [], binary: false }, + ], + stats: { filesChanged: 2, insertions: 30, deletions: 20 }, + }; + + const message = generator.generate(diff, ['src/git/repo.ts', 'src/git/types.ts']); + expect(message).toContain('(git)'); + }); + + it('should generate simple format message', () => { + const simpleGenerator = new MessageGenerator({ + style: 'simple', + includeFileList: false, + maxLength: 72, + useAI: false, + }); + + const diff: DiffResult = { + files: [ + { path: 'src/index.ts', status: 'modified', hunks: [], binary: false }, + ], + stats: { filesChanged: 1, insertions: 5, deletions: 2 }, + }; + + const message = simpleGenerator.generate(diff, ['src/index.ts']); + expect(message).toContain('index'); + expect(message.length).toBeLessThanOrEqual(72); + }); +}); + +describe('GitRepo', () => { + let tempDir: string; + let repo: GitRepo; + + beforeEach(async () => { + // 创建临时目录 + tempDir = path.join(os.tmpdir(), `git-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + // 初始化 Git 仓库 + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.name "Test User"', { cwd: tempDir, stdio: 'ignore' }); + + // 创建初始提交 + await fs.writeFile(path.join(tempDir, 'README.md'), '# Test'); + execSync('git add .', { cwd: tempDir, stdio: 'ignore' }); + execSync('git commit -m "Initial commit"', { cwd: tempDir, stdio: 'ignore' }); + + repo = new GitRepo(tempDir, DEFAULT_GIT_CONFIG); + await repo.initialize(); + }); + + afterEach(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + it('should initialize and detect git repo', async () => { + const isRepo = await repo.initialize(); + expect(isRepo).toBe(true); + }); + + it('should get status', async () => { + const status = await repo.getStatus(); + expect(status.branch).toMatch(/^(main|master)$/); + expect(status.isDirty).toBe(false); + }); + + it('should detect dirty files', async () => { + await fs.writeFile(path.join(tempDir, 'new.txt'), 'content'); + + const isDirty = await repo.isDirty(); + expect(isDirty).toBe(true); + }); + + it('should get diff', async () => { + await fs.writeFile(path.join(tempDir, 'README.md'), '# Test\n\nUpdated'); + + const diff = await repo.getDiff(); + expect(diff.files.length).toBe(1); + expect(diff.files[0].path).toBe('README.md'); + }); + + it('should commit changes', async () => { + await fs.writeFile(path.join(tempDir, 'new.txt'), 'new content'); + + const result = await repo.commit({ + files: ['new.txt'], + message: 'Add new file', + aiEdits: true, + }); + + expect(result.success).toBe(true); + expect(result.shortHash).toBeDefined(); + expect(repo.isAICommit(result.shortHash!)).toBe(true); + }); + + it('should get recent commits', async () => { + const commits = await repo.getRecentCommits(5); + expect(commits.length).toBeGreaterThanOrEqual(1); + expect(commits[0].message).toBe('Initial commit'); + }); + + it('should get HEAD commit', async () => { + const head = await repo.getHeadCommit(); + expect(head).toBeDefined(); + expect(head!.length).toBe(40); + }); + + it('should get current branch', async () => { + const branch = await repo.getCurrentBranch(); + expect(['main', 'master']).toContain(branch); + }); +}); + +describe('UndoManager', () => { + let tempDir: string; + let repo: GitRepo; + let undoManager: UndoManager; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `undo-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.name "Test User"', { cwd: tempDir, stdio: 'ignore' }); + + await fs.writeFile(path.join(tempDir, 'README.md'), '# Test'); + execSync('git add .', { cwd: tempDir, stdio: 'ignore' }); + execSync('git commit -m "Initial commit"', { cwd: tempDir, stdio: 'ignore' }); + + repo = new GitRepo(tempDir, DEFAULT_GIT_CONFIG); + await repo.initialize(); + + undoManager = new UndoManager(repo, { maxHistory: 50 }); + }); + + afterEach(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('should return error when nothing to undo', async () => { + const result = await undoManager.undo(); + expect(result.success).toBe(false); + expect(result.message).toContain('Nothing to undo'); + }); + + it('should record and undo AI commit', async () => { + // 创建 AI 提交 + await fs.writeFile(path.join(tempDir, 'new.txt'), 'content'); + const commitResult = await repo.commit({ + files: ['new.txt'], + message: 'Add new file', + aiEdits: true, + }); + + expect(commitResult.success).toBe(true); + + // 记录到 undo 历史 + undoManager.recordCommit( + commitResult.hash!, + commitResult.shortHash!, + commitResult.message!, + ['new.txt'] + ); + + // 执行 undo + const undoResult = await undoManager.undo(); + expect(undoResult.success).toBe(true); + expect(undoResult.commitHash).toBe(commitResult.shortHash); + }); + + it('should reject undo when repository changed', async () => { + // 创建 AI 提交 + await fs.writeFile(path.join(tempDir, 'new.txt'), 'content'); + const commitResult = await repo.commit({ + files: ['new.txt'], + message: 'Add new file', + aiEdits: true, + }); + + undoManager.recordCommit( + commitResult.hash!, + commitResult.shortHash!, + commitResult.message!, + ['new.txt'] + ); + + // 在 AI 提交后又创建了一个手动提交 + await fs.writeFile(path.join(tempDir, 'manual.txt'), 'manual'); + execSync('git add .', { cwd: tempDir, stdio: 'ignore' }); + execSync('git commit -m "Manual commit"', { cwd: tempDir, stdio: 'ignore' }); + + // 尝试 undo + const undoResult = await undoManager.undo(); + expect(undoResult.success).toBe(false); + expect(undoResult.message).toContain('does not match'); + }); + + it('should get undo preview', () => { + // 没有记录时返回 null + expect(undoManager.getUndoPreview()).toBeNull(); + + // 记录后可以获取预览 + undoManager.recordCommit('abc1234', 'abc1234', 'Test commit', ['test.txt']); + const preview = undoManager.getUndoPreview(); + expect(preview).not.toBeNull(); + expect(preview!.message).toBe('Test commit'); + }); + + it('should check if can undo', async () => { + let canUndo = await undoManager.canUndo(); + expect(canUndo.canUndo).toBe(false); + + // 创建并记录 AI 提交 + await fs.writeFile(path.join(tempDir, 'new.txt'), 'content'); + const commitResult = await repo.commit({ + files: ['new.txt'], + message: 'Add new file', + aiEdits: true, + }); + + undoManager.recordCommit( + commitResult.hash!, + commitResult.shortHash!, + commitResult.message!, + ['new.txt'] + ); + + canUndo = await undoManager.canUndo(); + expect(canUndo.canUndo).toBe(true); + }); +}); + +describe('GitManager', () => { + let tempDir: string; + + beforeEach(async () => { + resetGitManager(); + + tempDir = path.join(os.tmpdir(), `manager-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.name "Test User"', { cwd: tempDir, stdio: 'ignore' }); + + await fs.writeFile(path.join(tempDir, 'README.md'), '# Test'); + execSync('git add .', { cwd: tempDir, stdio: 'ignore' }); + execSync('git commit -m "Initial commit"', { cwd: tempDir, stdio: 'ignore' }); + }); + + afterEach(async () => { + resetGitManager(); + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('should initialize git manager', async () => { + const manager = await initGitManager(tempDir); + expect(manager).not.toBeNull(); + expect(manager!.isInitialized()).toBe(true); + }); + + it('should return null for non-git directory', async () => { + const nonGitDir = path.join(os.tmpdir(), `non-git-${Date.now()}`); + await fs.mkdir(nonGitDir, { recursive: true }); + + const manager = await initGitManager(nonGitDir); + expect(manager).toBeNull(); + + await fs.rm(nonGitDir, { recursive: true, force: true }); + }); + + it('should get global manager', async () => { + expect(getGitManager()).toBeNull(); + + await initGitManager(tempDir); + expect(getGitManager()).not.toBeNull(); + }); + + it('should get status', async () => { + const manager = await initGitManager(tempDir); + const status = await manager!.getStatus(); + + expect(status.branch).toMatch(/^(main|master)$/); + expect(status.isDirty).toBe(false); + }); + + it('should commit manually', async () => { + const manager = await initGitManager(tempDir); + + await fs.writeFile(path.join(tempDir, 'new.txt'), 'content'); + + const result = await manager!.commit({ + files: ['new.txt'], + message: 'Add new file', + }); + + expect(result.success).toBe(true); + expect(result.shortHash).toBeDefined(); + }); + + it('should undo AI commit', async () => { + const manager = await initGitManager(tempDir); + + // 创建并提交文件 + await fs.writeFile(path.join(tempDir, 'new.txt'), 'content'); + const commitResult = await manager!.commit({ + files: ['new.txt'], + message: 'Add new file', + }); + + expect(commitResult.success).toBe(true); + + // 执行 undo + const undoResult = await manager!.undo(); + expect(undoResult.success).toBe(true); + }); + + it('should get undo history', async () => { + const manager = await initGitManager(tempDir); + + expect(manager!.getUndoHistory()).toHaveLength(0); + + await fs.writeFile(path.join(tempDir, 'new.txt'), 'content'); + await manager!.commit({ + files: ['new.txt'], + message: 'Add new file', + }); + + expect(manager!.getUndoHistory()).toHaveLength(1); + }); + + it('should emit events', async () => { + const manager = await initGitManager(tempDir); + const events: any[] = []; + + manager!.addEventListener((event) => events.push(event)); + + await fs.writeFile(path.join(tempDir, 'new.txt'), 'content'); + await manager!.commit({ + files: ['new.txt'], + message: 'Add new file', + }); + + expect(events.length).toBe(1); + expect(events[0].type).toBe('commit'); + }); +});