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 个测试)
This commit is contained in:
2025-12-11 23:41:51 +08:00
parent 630ce9fd4b
commit 2208179514
11 changed files with 2408 additions and 1 deletions
+31
View File
@@ -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",
+1
View File
@@ -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",
+16 -1
View File
@@ -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');
}
}
}
}
+196
View File
@@ -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<string> = 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<void> {
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<CommitResult | null> {
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<void> {
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<CommitResult | null> {
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;
}
}
+53
View File
@@ -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';
+329
View File
@@ -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<GitConfig>) {
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<boolean> {
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<GitStatus> {
return this.repo.getStatus();
}
/**
* 文件变更后调用(由工具执行器调用)
*/
async onFileChanged(
filePath: string,
changeType: 'create' | 'modify' | 'delete'
): Promise<void> {
if (!this.initialized || !this.config.enabled) {
return;
}
await this.autoCommit.onFileChanged(filePath, changeType);
}
/**
* 手动提交
*/
async commit(options: {
message?: string;
files?: string[];
all?: boolean;
} = {}): Promise<CommitResult> {
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<UndoResult> {
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<DiffResult> {
return this.repo.getDiff(options);
}
/**
* 获取最近的提交
*/
async getRecentCommits(count: number = 10): Promise<CommitInfo[]> {
return this.repo.getRecentCommits(count);
}
/**
* 获取当前分支
*/
async getCurrentBranch(): Promise<string> {
return this.repo.getCurrentBranch();
}
/**
* 强制刷新待提交
*/
async flushPendingCommits(): Promise<CommitResult | null> {
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<GitConfig>): 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<GitConfig>
): Promise<GitManager | null> {
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;
}
+276
View File
@@ -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}`;
}
}
+534
View File
@@ -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<string> = new Set();
constructor(workdir: string, config: GitConfig) {
this.workdir = workdir;
this.config = config;
this.git = simpleGit(workdir);
}
/**
* 初始化并验证是否为 Git 仓库
*/
async initialize(): Promise<boolean> {
try {
const isRepo = await this.git.checkIsRepo();
return isRepo;
} catch {
return false;
}
}
/**
* 获取仓库根目录
*/
async getRoot(): Promise<string> {
const root = await this.git.revparse(['--show-toplevel']);
return root.trim();
}
/**
* 获取仓库状态
*/
async getStatus(): Promise<GitStatus> {
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<boolean> {
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<DiffResult> {
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<DiffResult> {
const diffOutput = await this.git.diff([fromCommit, toCommit]);
return this.parseDiff(diffOutput);
}
/**
* 暂存文件
*/
async stage(files: string | string[]): Promise<void> {
const fileList = Array.isArray(files) ? files : [files];
await this.git.add(fileList);
}
/**
* 取消暂存文件
*/
async unstage(files: string | string[]): Promise<void> {
const fileList = Array.isArray(files) ? files : [files];
await this.git.reset(['HEAD', '--', ...fileList]);
}
/**
* 提交变更
*/
async commit(options: CommitOptions = {}): Promise<CommitResult> {
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} <noreply@ai-assistant.local>`;
}
// 设置作者信息 (如果需要标记)
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<CommitInfo | null> {
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<CommitInfo[]> {
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<string | null> {
try {
const hash = await this.git.revparse(['HEAD']);
return hash.trim();
} catch {
return null;
}
}
/**
* 获取 HEAD 短哈希
*/
async getHeadShortHash(): Promise<string | null> {
try {
const hash = await this.git.revparse(['--short', 'HEAD']);
return hash.trim();
} catch {
return null;
}
}
/**
* 检查提交是否已推送到远程
*/
async isCommitPushed(hash: string): Promise<boolean> {
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<void> {
await this.git.reset(['--soft', commit]);
}
/**
* 检出文件
*/
async checkoutFile(commit: string, filePath: string): Promise<void> {
await this.git.checkout([commit, '--', filePath]);
}
/**
* 获取分支列表
*/
async getBranches(): Promise<BranchInfo[]> {
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<string> {
const branch = await this.git.revparse(['--abbrev-ref', 'HEAD']);
return branch.trim();
}
/**
* 获取提交涉及的文件
*/
private async getCommitFiles(hash: string): Promise<string[]> {
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<string | null> {
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<string> {
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);
}
}
+346
View File
@@ -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',
},
};
+194
View File
@@ -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<UndoResult> {
// 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 };
}
}
+432
View File
@@ -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');
});
});