feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user