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:
Generated
+31
@@ -21,6 +21,7 @@
|
|||||||
"minimatch": "^10.1.1",
|
"minimatch": "^10.1.1",
|
||||||
"ora": "^8.1.0",
|
"ora": "^8.1.0",
|
||||||
"qwen-ai-provider-v5": "^1.0.2",
|
"qwen-ai-provider-v5": "^1.0.2",
|
||||||
|
"simple-git": "^3.30.0",
|
||||||
"tree-sitter-bash": "^0.25.1",
|
"tree-sitter-bash": "^0.25.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vscode-jsonrpc": "^8.2.1",
|
"vscode-jsonrpc": "^8.2.1",
|
||||||
@@ -1022,6 +1023,21 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@opentelemetry/api": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
@@ -2780,6 +2796,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"minimatch": "^10.1.1",
|
"minimatch": "^10.1.1",
|
||||||
"ora": "^8.1.0",
|
"ora": "^8.1.0",
|
||||||
"qwen-ai-provider-v5": "^1.0.2",
|
"qwen-ai-provider-v5": "^1.0.2",
|
||||||
|
"simple-git": "^3.30.0",
|
||||||
"tree-sitter-bash": "^0.25.1",
|
"tree-sitter-bash": "^0.25.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vscode-jsonrpc": "^8.2.1",
|
"vscode-jsonrpc": "^8.2.1",
|
||||||
|
|||||||
+16
-1
@@ -20,6 +20,7 @@ import { agentRegistry, AgentExecutor } from '../agent/index.js';
|
|||||||
import { loadVisionConfig } from '../utils/config.js';
|
import { loadVisionConfig } from '../utils/config.js';
|
||||||
import { getModelFactory } from './providers.js';
|
import { getModelFactory } from './providers.js';
|
||||||
import { getHookManager } from '../hooks/index.js';
|
import { getHookManager } from '../hooks/index.js';
|
||||||
|
import { getGitManager } from '../git/index.js';
|
||||||
|
|
||||||
export class Agent {
|
export class Agent {
|
||||||
private getModel: (model: string) => LanguageModel;
|
private getModel: (model: string) => LanguageModel;
|
||||||
@@ -193,28 +194,42 @@ export class Agent {
|
|||||||
);
|
);
|
||||||
result = afterOutput.result;
|
result = afterOutput.result;
|
||||||
|
|
||||||
// 对于文件操作工具,触发相应的文件 hook
|
// 对于文件操作工具,触发相应的文件 hook 和 Git 自动提交
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const filePath = finalArgs.path as string | undefined;
|
const filePath = finalArgs.path as string | undefined;
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
|
const gitManager = getGitManager();
|
||||||
|
|
||||||
if (tool.name === 'write_file') {
|
if (tool.name === 'write_file') {
|
||||||
await hookManager.triggerFileCreated({
|
await hookManager.triggerFileCreated({
|
||||||
path: filePath,
|
path: filePath,
|
||||||
tool: tool.name,
|
tool: tool.name,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
|
// Git 自动提交
|
||||||
|
if (gitManager) {
|
||||||
|
await gitManager.onFileChanged(filePath, 'create');
|
||||||
|
}
|
||||||
} else if (tool.name === 'edit_file') {
|
} else if (tool.name === 'edit_file') {
|
||||||
await hookManager.triggerFileEdited({
|
await hookManager.triggerFileEdited({
|
||||||
path: filePath,
|
path: filePath,
|
||||||
tool: tool.name,
|
tool: tool.name,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
|
// Git 自动提交
|
||||||
|
if (gitManager) {
|
||||||
|
await gitManager.onFileChanged(filePath, 'modify');
|
||||||
|
}
|
||||||
} else if (tool.name === 'delete_file') {
|
} else if (tool.name === 'delete_file') {
|
||||||
await hookManager.triggerFileDeleted({
|
await hookManager.triggerFileDeleted({
|
||||||
path: filePath,
|
path: filePath,
|
||||||
tool: tool.name,
|
tool: tool.name,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
|
// Git 自动提交
|
||||||
|
if (gitManager) {
|
||||||
|
await gitManager.onFileChanged(filePath, 'delete');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user