2208179514
参考 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 个测试)
195 lines
4.9 KiB
TypeScript
195 lines
4.9 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|
|
}
|