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

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

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

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

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 };
}
}