diff --git a/src/checkpoint/index.ts b/src/checkpoint/index.ts new file mode 100644 index 0000000..a8db08a --- /dev/null +++ b/src/checkpoint/index.ts @@ -0,0 +1,35 @@ +/** + * 检查点系统模块 + * + * 提供工作区快照和回滚功能,使用 Shadow Git 架构 + * 参考 Cline 的实现 + */ + +// 检查点管理器 +export { + CheckpointManager, + getCheckpointManager, + initCheckpointManager, + resetCheckpointManager, +} from './manager.js'; + +// Shadow Git +export { ShadowGit, createShadowGit, hashWorkingDir } from './shadow-git.js'; + +// 类型 +export type { + CheckpointMetadata, + CheckpointConfig, + CheckpointTrigger, + FileChange, + FileChangeType, + DiffInfo, + FileDiff, + RollbackOptions, + RollbackResult, + CheckpointEvent, + CheckpointEventType, + CheckpointEventListener, +} from './types.js'; + +export { DEFAULT_CHECKPOINT_CONFIG } from './types.js'; diff --git a/src/checkpoint/manager.ts b/src/checkpoint/manager.ts new file mode 100644 index 0000000..250217a --- /dev/null +++ b/src/checkpoint/manager.ts @@ -0,0 +1,613 @@ +/** + * 检查点管理器 + * 管理检查点的创建、回滚、清理等操作 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { nanoid } from 'nanoid'; +import { ShadowGit, createShadowGit } from './shadow-git.js'; +import type { + CheckpointMetadata, + CheckpointConfig, + CheckpointTrigger, + RollbackOptions, + RollbackResult, + DiffInfo, + FileDiff, + CheckpointEvent, + CheckpointEventListener, + DEFAULT_CHECKPOINT_CONFIG, +} from './types.js'; + +/** + * 检查点提交消息前缀 + */ +const CHECKPOINT_PREFIX = 'checkpoint:'; + +/** + * 检查点管理器 + */ +export class CheckpointManager { + private shadowGit: ShadowGit; + private config: CheckpointConfig; + private workDir: string; + private checkpointsIndex: Map = new Map(); + private initialized = false; + private lastCheckpointTime = 0; + private eventListeners: Set = new Set(); + + // 防止重复创建检查点的最小间隔 (毫秒) + private static readonly MIN_CHECKPOINT_INTERVAL = 1000; + + constructor(workDir: string, config: Partial = {}) { + this.workDir = path.resolve(workDir); + this.config = { + enabled: true, + autoCheckpoint: { + beforeWrite: true, + beforeEdit: true, + beforeDelete: true, + beforeMove: true, + beforeBash: false, + }, + maxCheckpoints: 100, + maxAge: 7 * 24 * 60 * 60 * 1000, + storageDir: path.join(os.homedir(), '.ai-assist', 'checkpoints'), + ...config, + }; + + this.shadowGit = createShadowGit(this.workDir, this.config.storageDir); + } + + /** + * 初始化检查点管理器 + */ + async initialize(): Promise { + if (this.initialized) return; + + if (!this.config.enabled) { + this.initialized = true; + return; + } + + // 初始化 Shadow Git + await this.shadowGit.initialize(); + + // 加载检查点索引 + await this.loadCheckpointsIndex(); + + this.initialized = true; + } + + /** + * 加载检查点索引 + */ + private async loadCheckpointsIndex(): Promise { + try { + const commits = await this.shadowGit.getCommits(this.config.maxCheckpoints); + + for (const commit of commits) { + if (commit.message.startsWith(CHECKPOINT_PREFIX)) { + try { + const jsonStr = commit.message.slice(CHECKPOINT_PREFIX.length); + const metadata = JSON.parse(jsonStr) as CheckpointMetadata; + metadata.commitHash = commit.hash; + this.checkpointsIndex.set(metadata.id, metadata); + } catch { + // 解析失败,跳过 + } + } + } + } catch { + // 仓库可能是空的 + } + } + + /** + * 判断是否应该为指定工具创建检查点 + */ + shouldCreateCheckpoint(tool: string): boolean { + if (!this.config.enabled) return false; + + const { autoCheckpoint } = this.config; + + switch (tool) { + case 'write_file': + return autoCheckpoint.beforeWrite; + case 'edit_file': + return autoCheckpoint.beforeEdit; + case 'delete_file': + return autoCheckpoint.beforeDelete; + case 'move_file': + case 'copy_file': + return autoCheckpoint.beforeMove; + case 'bash': + return autoCheckpoint.beforeBash; + default: + return false; + } + } + + /** + * 在工具执行前创建检查点 + */ + async beforeToolExecution( + tool: string, + params: Record + ): Promise { + if (!this.shouldCreateCheckpoint(tool)) { + return null; + } + + // 防止过于频繁的检查点创建 + const now = Date.now(); + if (now - this.lastCheckpointTime < CheckpointManager.MIN_CHECKPOINT_INTERVAL) { + return null; + } + + try { + const checkpoint = await this.createCheckpoint({ + trigger: `tool:${tool}` as CheckpointTrigger, + toolCall: { tool, params }, + description: this.generateDescription(tool, params), + }); + + this.lastCheckpointTime = now; + return checkpoint.id; + } catch (error) { + console.warn('Failed to create checkpoint:', error); + return null; + } + } + + /** + * 生成检查点描述 + */ + private generateDescription( + tool: string, + params: Record + ): string { + switch (tool) { + case 'write_file': + return `Write file: ${params.file_path || params.path}`; + case 'edit_file': + return `Edit file: ${params.file_path || params.path}`; + case 'delete_file': + return `Delete file: ${params.file_path || params.path}`; + case 'move_file': + return `Move: ${params.source} -> ${params.destination}`; + case 'copy_file': + return `Copy: ${params.source} -> ${params.destination}`; + case 'bash': + return `Bash: ${String(params.command).slice(0, 50)}`; + default: + return `Tool: ${tool}`; + } + } + + /** + * 创建检查点 + */ + async createCheckpoint(options: { + name?: string; + description?: string; + trigger?: CheckpointTrigger; + toolCall?: { tool: string; params: Record }; + }): Promise { + await this.initialize(); + + if (!this.config.enabled) { + throw new Error('Checkpoint system is disabled'); + } + + const id = nanoid(10); + const timestamp = Date.now(); + + // 创建元数据 + const metadata: CheckpointMetadata = { + id, + name: options.name, + description: options.description, + timestamp, + trigger: options.trigger || 'manual', + toolCall: options.toolCall, + commitHash: '', // 待填充 + filesChanged: 0, // 待填充 + }; + + // 获取变更文件数 + try { + const diff = await this.shadowGit.getWorkingDirDiff(); + metadata.filesChanged = diff.files.length; + } catch { + // 忽略 + } + + // 创建 commit + const commitMessage = CHECKPOINT_PREFIX + JSON.stringify(metadata); + const commitHash = await this.shadowGit.createCommit(commitMessage); + metadata.commitHash = commitHash; + + // 更新索引 + this.checkpointsIndex.set(id, metadata); + + // 触发事件 + this.emitEvent({ + type: 'created', + checkpoint: metadata, + timestamp, + }); + + // 异步清理 + this.cleanupAsync(); + + return metadata; + } + + /** + * 创建命名检查点 + */ + async createNamedCheckpoint(name: string, description?: string): Promise { + return this.createCheckpoint({ + name, + description, + trigger: 'manual', + }); + } + + /** + * 获取所有检查点 + */ + async listCheckpoints(): Promise { + await this.initialize(); + + return Array.from(this.checkpointsIndex.values()).sort( + (a, b) => b.timestamp - a.timestamp + ); + } + + /** + * 获取指定检查点 + */ + async getCheckpoint(idOrHash: string): Promise { + await this.initialize(); + + // 先按 ID 查找 + if (this.checkpointsIndex.has(idOrHash)) { + return this.checkpointsIndex.get(idOrHash)!; + } + + // 再按 commit hash 查找 + for (const checkpoint of this.checkpointsIndex.values()) { + if (checkpoint.commitHash.startsWith(idOrHash)) { + return checkpoint; + } + } + + return null; + } + + /** + * 获取最近的检查点 + */ + async getLatestCheckpoint(): Promise { + const checkpoints = await this.listCheckpoints(); + return checkpoints[0] || null; + } + + /** + * 获取检查点与当前工作区的差异 + */ + async getDiff(checkpointId: string): Promise { + await this.initialize(); + + const checkpoint = await this.getCheckpoint(checkpointId); + if (!checkpoint) { + throw new Error(`Checkpoint not found: ${checkpointId}`); + } + + return this.shadowGit.getDiffSummary(checkpoint.commitHash, 'HEAD'); + } + + /** + * 获取两个检查点之间的差异 + */ + async getDiffBetween(fromId: string, toId: string): Promise { + await this.initialize(); + + const fromCheckpoint = await this.getCheckpoint(fromId); + const toCheckpoint = await this.getCheckpoint(toId); + + if (!fromCheckpoint) { + throw new Error(`Checkpoint not found: ${fromId}`); + } + if (!toCheckpoint) { + throw new Error(`Checkpoint not found: ${toId}`); + } + + return this.shadowGit.getDiffSummary( + fromCheckpoint.commitHash, + toCheckpoint.commitHash + ); + } + + /** + * 获取文件差异详情 + */ + async getFileDiff(checkpointId: string, filePath: string): Promise { + await this.initialize(); + + const checkpoint = await this.getCheckpoint(checkpointId); + if (!checkpoint) { + throw new Error(`Checkpoint not found: ${checkpointId}`); + } + + const head = await this.shadowGit.getHead(); + return this.shadowGit.getFileDiff(checkpoint.commitHash, head, filePath); + } + + /** + * 回滚到检查点 + */ + async rollback(options: RollbackOptions): Promise { + await this.initialize(); + + const checkpoint = await this.getCheckpoint(options.target); + if (!checkpoint) { + throw new Error(`Checkpoint not found: ${options.target}`); + } + + // 获取当前 HEAD 用于可能的撤销 + const previousCommit = await this.shadowGit.getHead(); + + // 预览模式 + if (options.dryRun) { + const diff = await this.getDiff(checkpoint.id); + return { + success: true, + restoredFiles: diff.files.map((f) => f.path), + errors: [], + previousCommit, + }; + } + + const result: RollbackResult = { + success: true, + restoredFiles: [], + errors: [], + previousCommit, + }; + + try { + if (options.files && options.files.length > 0) { + // 选择性回滚 + await this.shadowGit.checkoutFiles(checkpoint.commitHash, options.files); + result.restoredFiles = options.files; + } else { + // 完整回滚 + await this.shadowGit.resetHard(checkpoint.commitHash); + + // 获取恢复的文件列表 + const diff = await this.shadowGit.getDiffSummary( + previousCommit, + checkpoint.commitHash + ); + result.restoredFiles = diff.files.map((f) => f.path); + } + + // 触发事件 + this.emitEvent({ + type: 'restored', + checkpoint, + timestamp: Date.now(), + details: { + files: result.restoredFiles, + previousCommit, + }, + }); + } catch (error) { + result.success = false; + result.errors.push({ + file: '*', + error: error instanceof Error ? error.message : String(error), + }); + } + + return result; + } + + /** + * 撤销操作 (回滚到上一个检查点) + */ + async undo(): Promise { + const latest = await this.getLatestCheckpoint(); + if (!latest) { + throw new Error('No checkpoints available'); + } + + // 找到倒数第二个检查点 + const checkpoints = await this.listCheckpoints(); + if (checkpoints.length < 2) { + // 只有一个检查点,回滚到它 + return this.rollback({ target: latest.id }); + } + + // 回滚到倒数第二个检查点 + return this.rollback({ target: checkpoints[1].id }); + } + + /** + * 删除检查点 + */ + async deleteCheckpoint(checkpointId: string): Promise { + await this.initialize(); + + if (!this.checkpointsIndex.has(checkpointId)) { + return false; + } + + const checkpoint = this.checkpointsIndex.get(checkpointId)!; + this.checkpointsIndex.delete(checkpointId); + + // 触发事件 + this.emitEvent({ + type: 'deleted', + checkpoint, + timestamp: Date.now(), + }); + + return true; + } + + /** + * 异步清理过期检查点 + */ + private async cleanupAsync(): Promise { + setTimeout(async () => { + try { + await this.cleanup(); + } catch (error) { + console.warn('Checkpoint cleanup failed:', error); + } + }, 100); + } + + /** + * 清理过期检查点 + */ + async cleanup(): Promise { + await this.initialize(); + + const checkpoints = await this.listCheckpoints(); + const now = Date.now(); + let deletedCount = 0; + + // 按时间过期清理 + for (const checkpoint of checkpoints) { + if (now - checkpoint.timestamp > this.config.maxAge) { + await this.deleteCheckpoint(checkpoint.id); + deletedCount++; + } + } + + // 按数量限制清理 + const remaining = checkpoints.length - deletedCount; + if (remaining > this.config.maxCheckpoints) { + const toDelete = checkpoints.slice(this.config.maxCheckpoints); + for (const checkpoint of toDelete) { + if (this.checkpointsIndex.has(checkpoint.id)) { + await this.deleteCheckpoint(checkpoint.id); + deletedCount++; + } + } + } + + if (deletedCount > 0) { + // 触发清理事件 + this.emitEvent({ + type: 'cleanup', + timestamp: now, + details: { deletedCount }, + }); + + // 运行 git gc + await this.shadowGit.cleanup(this.config.maxCheckpoints); + } + + return deletedCount; + } + + /** + * 获取检查点存储统计 + */ + async getStats(): Promise<{ + count: number; + oldestTimestamp: number | null; + newestTimestamp: number | null; + }> { + const checkpoints = await this.listCheckpoints(); + + return { + count: checkpoints.length, + oldestTimestamp: checkpoints.length > 0 + ? checkpoints[checkpoints.length - 1].timestamp + : null, + newestTimestamp: checkpoints.length > 0 ? checkpoints[0].timestamp : null, + }; + } + + /** + * 添加事件监听器 + */ + addEventListener(listener: CheckpointEventListener): void { + this.eventListeners.add(listener); + } + + /** + * 移除事件监听器 + */ + removeEventListener(listener: CheckpointEventListener): void { + this.eventListeners.delete(listener); + } + + /** + * 触发事件 + */ + private emitEvent(event: CheckpointEvent): void { + for (const listener of this.eventListeners) { + try { + listener(event); + } catch (error) { + console.warn('Checkpoint event listener error:', error); + } + } + } + + /** + * 检查是否启用 + */ + isEnabled(): boolean { + return this.config.enabled; + } + + /** + * 获取配置 + */ + getConfig(): CheckpointConfig { + return { ...this.config }; + } +} + +// 全局检查点管理器实例 +let globalCheckpointManager: CheckpointManager | null = null; + +/** + * 获取全局检查点管理器实例 + */ +export function getCheckpointManager(): CheckpointManager { + if (!globalCheckpointManager) { + globalCheckpointManager = new CheckpointManager(process.cwd()); + } + return globalCheckpointManager; +} + +/** + * 初始化全局检查点管理器 + */ +export async function initCheckpointManager( + workDir: string, + config?: Partial +): Promise { + globalCheckpointManager = new CheckpointManager(workDir, config); + await globalCheckpointManager.initialize(); + return globalCheckpointManager; +} + +/** + * 重置全局检查点管理器 (用于测试) + */ +export function resetCheckpointManager(): void { + globalCheckpointManager = null; +} diff --git a/src/checkpoint/shadow-git.ts b/src/checkpoint/shadow-git.ts new file mode 100644 index 0000000..f1c9eec --- /dev/null +++ b/src/checkpoint/shadow-git.ts @@ -0,0 +1,576 @@ +/** + * Shadow Git 存储实现 + * 使用隔离的 Git 仓库存储检查点,不影响用户的主仓库 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import type { FileChange, DiffInfo, FileDiff } from './types.js'; + +const execFileAsync = promisify(execFile); + +/** + * 计算工作目录哈希 + * 使用 31 进制哈希算法,与 Cline 保持一致 + */ +export function hashWorkingDir(workingDir: string): string { + let hash = 0; + for (let i = 0; i < workingDir.length; i++) { + hash = ((hash * 31 + workingDir.charCodeAt(i)) >>> 0) % 2147483647; + } + return hash.toString().slice(0, 13).padStart(13, '0'); +} + +/** + * 需要排除的目录列表 + */ +const EXCLUDED_DIRS = [ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '__pycache__', + '.pytest_cache', + 'coverage', + '.nyc_output', + '.ai-assist', +]; + +/** + * Shadow Git 管理器 + */ +export class ShadowGit { + private workDir: string; + private shadowGitDir: string; + private initialized = false; + private cwdHash: string; + + constructor(workDir: string, storageBaseDir: string) { + this.workDir = path.resolve(workDir); + this.cwdHash = hashWorkingDir(this.workDir); + this.shadowGitDir = path.join(storageBaseDir, this.cwdHash); + } + + /** + * 获取工作目录哈希 + */ + getCwdHash(): string { + return this.cwdHash; + } + + /** + * 获取 Shadow Git 目录 + */ + getShadowGitDir(): string { + return this.shadowGitDir; + } + + /** + * 初始化 Shadow Git 仓库 + */ + async initialize(): Promise { + if (this.initialized) return; + + const gitDir = path.join(this.shadowGitDir, '.git'); + + try { + await fs.access(gitDir); + // 已存在,验证配置 + await this.verifyConfig(); + } catch { + // 不存在,创建新仓库 + await this.createRepository(); + } + + this.initialized = true; + } + + /** + * 创建新的 Shadow Git 仓库 + */ + private async createRepository(): Promise { + // 创建目录 + await fs.mkdir(this.shadowGitDir, { recursive: true }); + + // 初始化 Git 仓库 + await this.git(['init']); + + // 配置用户信息 + await this.git(['config', 'user.name', 'AI Assistant Checkpoint']); + await this.git(['config', 'user.email', 'checkpoint@ai-assist.local']); + + // 配置工作目录 + await this.git(['config', 'core.worktree', this.workDir]); + + // 禁用 GPG 签名 + await this.git(['config', 'commit.gpgsign', 'false']); + + // 创建 .gitignore + const gitignoreContent = EXCLUDED_DIRS.map((d) => `${d}/`).join('\n') + '\n'; + await fs.writeFile( + path.join(this.shadowGitDir, '.gitignore'), + gitignoreContent + ); + + // 创建初始提交 + await this.git(['add', '.gitignore']); + await this.git([ + 'commit', + '--allow-empty', + '-m', + 'Initial checkpoint repository', + ]); + } + + /** + * 验证现有配置 + */ + private async verifyConfig(): Promise { + try { + const { stdout } = await this.git(['config', 'core.worktree']); + const configuredWorkDir = stdout.trim(); + + if (configuredWorkDir !== this.workDir) { + // 更新工作目录配置 + await this.git(['config', 'core.worktree', this.workDir]); + } + } catch { + // 配置不存在,添加 + await this.git(['config', 'core.worktree', this.workDir]); + } + } + + /** + * 执行 Git 命令 + */ + private async git( + args: string[], + options: { cwd?: string } = {} + ): Promise<{ stdout: string; stderr: string }> { + const cwd = options.cwd || this.shadowGitDir; + const gitDir = path.join(this.shadowGitDir, '.git'); + + try { + const result = await execFileAsync( + 'git', + ['--git-dir', gitDir, '--work-tree', this.workDir, ...args], + { + cwd, + maxBuffer: 50 * 1024 * 1024, // 50MB + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }, + } + ); + return result; + } catch (error: any) { + // 某些 git 命令失败是正常的 (如空提交) + if (error.stdout !== undefined) { + return { stdout: error.stdout || '', stderr: error.stderr || '' }; + } + throw error; + } + } + + /** + * 创建检查点提交 + */ + async createCommit(message: string): Promise { + await this.initialize(); + + // 暂时禁用嵌套 .git 目录 + const nestedGitDirs = await this.findNestedGitDirs(); + await this.renameNestedGitDirs(nestedGitDirs, true); + + try { + // 添加所有文件 + await this.git(['add', '.', '--ignore-errors']); + + // 创建提交 + await this.git([ + 'commit', + '--allow-empty', + '--no-verify', + '-m', + message, + ]); + + // 获取 commit hash + const { stdout } = await this.git(['rev-parse', 'HEAD']); + return stdout.trim(); + } finally { + // 恢复嵌套 .git 目录 + await this.renameNestedGitDirs(nestedGitDirs, false); + } + } + + /** + * 查找嵌套的 .git 目录 + */ + private async findNestedGitDirs(): Promise { + const nestedDirs: string[] = []; + + const walk = async (dir: string, depth = 0): Promise => { + if (depth > 5) return; // 限制深度 + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const fullPath = path.join(dir, entry.name); + + // 跳过排除的目录 + if (EXCLUDED_DIRS.includes(entry.name)) continue; + + if (entry.name === '.git') { + nestedDirs.push(fullPath); + } else if (!entry.name.startsWith('.')) { + await walk(fullPath, depth + 1); + } + } + } catch { + // 忽略无法访问的目录 + } + }; + + await walk(this.workDir); + return nestedDirs; + } + + /** + * 重命名嵌套 .git 目录 + */ + private async renameNestedGitDirs( + dirs: string[], + disable: boolean + ): Promise { + for (const dir of dirs) { + const disabledName = dir + '_disabled'; + try { + if (disable) { + await fs.rename(dir, disabledName); + } else { + await fs.rename(disabledName, dir); + } + } catch { + // 忽略错误 + } + } + } + + /** + * 获取当前 HEAD commit hash + */ + async getHead(): Promise { + await this.initialize(); + const { stdout } = await this.git(['rev-parse', 'HEAD']); + return stdout.trim(); + } + + /** + * 重置到指定 commit + */ + async resetHard(commitHash: string): Promise { + await this.initialize(); + + // 暂时禁用嵌套 .git 目录 + const nestedGitDirs = await this.findNestedGitDirs(); + await this.renameNestedGitDirs(nestedGitDirs, true); + + try { + await this.git(['reset', '--hard', commitHash]); + } finally { + await this.renameNestedGitDirs(nestedGitDirs, false); + } + } + + /** + * 获取 commit 列表 + */ + async getCommits(limit = 100): Promise< + Array<{ + hash: string; + message: string; + timestamp: number; + }> + > { + await this.initialize(); + + const { stdout } = await this.git([ + 'log', + `--max-count=${limit}`, + '--format=%H|%s|%ct', + ]); + + if (!stdout.trim()) return []; + + return stdout + .trim() + .split('\n') + .map((line) => { + const [hash, message, timestamp] = line.split('|'); + return { + hash, + message, + timestamp: parseInt(timestamp, 10) * 1000, + }; + }); + } + + /** + * 获取两个 commit 之间的差异摘要 + */ + async getDiffSummary(fromCommit: string, toCommit = 'HEAD'): Promise { + await this.initialize(); + + const { stdout } = await this.git([ + 'diff', + '--stat', + '--numstat', + fromCommit, + toCommit, + ]); + + const files: FileChange[] = []; + let totalInsertions = 0; + let totalDeletions = 0; + + // 解析 numstat 输出 + const lines = stdout.trim().split('\n'); + for (const line of lines) { + const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/); + if (match) { + const insertions = match[1] === '-' ? 0 : parseInt(match[1], 10); + const deletions = match[2] === '-' ? 0 : parseInt(match[2], 10); + const filePath = match[3]; + + // 检测重命名 + const renameMatch = filePath.match(/^(.+)\{(.+) => (.+)\}(.*)$/); + if (renameMatch) { + const prefix = renameMatch[1]; + const oldName = renameMatch[2]; + const newName = renameMatch[3]; + const suffix = renameMatch[4]; + files.push({ + path: prefix + newName + suffix, + oldPath: prefix + oldName + suffix, + type: 'renamed', + insertions, + deletions, + }); + } else { + // 获取文件状态 + const type = await this.getFileChangeType(fromCommit, toCommit, filePath); + files.push({ + path: filePath, + type, + insertions, + deletions, + }); + } + + totalInsertions += insertions; + totalDeletions += deletions; + } + } + + return { + from: fromCommit, + to: toCommit, + files, + totalInsertions, + totalDeletions, + }; + } + + /** + * 获取文件变更类型 + */ + private async getFileChangeType( + fromCommit: string, + toCommit: string, + filePath: string + ): Promise { + try { + const { stdout } = await this.git([ + 'diff', + '--name-status', + fromCommit, + toCommit, + '--', + filePath, + ]); + + const status = stdout.trim().charAt(0); + switch (status) { + case 'A': + return 'added'; + case 'D': + return 'deleted'; + case 'R': + return 'renamed'; + default: + return 'modified'; + } + } catch { + return 'modified'; + } + } + + /** + * 获取文件内容差异 + */ + async getFileDiff( + fromCommit: string, + toCommit: string, + filePath: string + ): Promise { + await this.initialize(); + + const type = await this.getFileChangeType(fromCommit, toCommit, filePath); + + let oldContent: string | undefined; + let newContent: string | undefined; + let patch: string | undefined; + + try { + if (type !== 'added') { + const { stdout } = await this.git(['show', `${fromCommit}:${filePath}`]); + oldContent = stdout; + } + } catch { + // 文件在旧 commit 中不存在 + } + + try { + if (type !== 'deleted') { + const { stdout } = await this.git(['show', `${toCommit}:${filePath}`]); + newContent = stdout; + } + } catch { + // 文件在新 commit 中不存在 + } + + try { + const { stdout } = await this.git([ + 'diff', + fromCommit, + toCommit, + '--', + filePath, + ]); + patch = stdout; + } catch { + // 无法生成 diff + } + + return { + path: filePath, + type, + oldContent, + newContent, + patch, + }; + } + + /** + * 检出指定 commit 的特定文件 + */ + async checkoutFiles(commitHash: string, files: string[]): Promise { + await this.initialize(); + + for (const file of files) { + try { + await this.git(['checkout', commitHash, '--', file]); + } catch (error) { + // 文件可能不存在于该 commit + console.warn(`Failed to checkout ${file} from ${commitHash}:`, error); + } + } + } + + /** + * 获取指定 commit 中文件的内容 + */ + async getFileContent(commitHash: string, filePath: string): Promise { + await this.initialize(); + + try { + const { stdout } = await this.git(['show', `${commitHash}:${filePath}`]); + return stdout; + } catch { + return null; + } + } + + /** + * 清理旧的 commit(保留最近 N 个) + */ + async cleanup(keepCount: number): Promise { + await this.initialize(); + + const commits = await this.getCommits(keepCount + 100); + + if (commits.length <= keepCount) { + return 0; + } + + // 使用 git gc 清理 + try { + await this.git(['gc', '--aggressive', '--prune=now']); + } catch { + // gc 可能失败,忽略 + } + + return commits.length - keepCount; + } + + /** + * 检查是否有未提交的变更 + */ + async hasChanges(): Promise { + await this.initialize(); + + const { stdout } = await this.git(['status', '--porcelain']); + return stdout.trim().length > 0; + } + + /** + * 获取工作目录与 HEAD 的差异 + */ + async getWorkingDirDiff(): Promise { + await this.initialize(); + + // 先添加所有文件到暂存区以检测新文件 + const nestedGitDirs = await this.findNestedGitDirs(); + await this.renameNestedGitDirs(nestedGitDirs, true); + + try { + await this.git(['add', '.', '--ignore-errors']); + const result = await this.getDiffSummary('HEAD', '--staged'); + + // 重置暂存区 + await this.git(['reset', 'HEAD']); + + return result; + } finally { + await this.renameNestedGitDirs(nestedGitDirs, false); + } + } +} + +/** + * 创建 Shadow Git 实例 + */ +export function createShadowGit( + workDir: string, + storageBaseDir: string +): ShadowGit { + return new ShadowGit(workDir, storageBaseDir); +} diff --git a/src/checkpoint/types.ts b/src/checkpoint/types.ts new file mode 100644 index 0000000..4a8ac71 --- /dev/null +++ b/src/checkpoint/types.ts @@ -0,0 +1,191 @@ +/** + * 检查点系统类型定义 + * 基于 Cline 的 Shadow Git 架构 + */ + +/** + * 检查点触发类型 + */ +export type CheckpointTrigger = + | 'auto' // 自动创建 + | 'manual' // 用户手动 + | 'tool:write_file' // 写文件前 + | 'tool:edit_file' // 编辑文件前 + | 'tool:delete_file' // 删除文件前 + | 'tool:move_file' // 移动文件前 + | 'tool:copy_file' // 复制文件前 + | 'tool:bash' // bash 命令前 + | 'task_start' // 任务开始 + | 'task_complete'; // 任务完成 + +/** + * 检查点元数据 + */ +export interface CheckpointMetadata { + /** 唯一标识 */ + id: string; + /** 用户可读名称 */ + name?: string; + /** 描述信息 */ + description?: string; + /** 创建时间戳 */ + timestamp: number; + /** 触发类型 */ + trigger: CheckpointTrigger; + /** 关联的工具调用 */ + toolCall?: { + tool: string; + params: Record; + }; + /** Git commit hash */ + commitHash: string; + /** 受影响的文件数 */ + filesChanged: number; +} + +/** + * 检查点配置 + */ +export interface CheckpointConfig { + /** 是否启用检查点系统 */ + enabled: boolean; + /** 自动检查点配置 */ + autoCheckpoint: { + /** 写文件前创建检查点 */ + beforeWrite: boolean; + /** 编辑文件前创建检查点 */ + beforeEdit: boolean; + /** 删除文件前创建检查点 */ + beforeDelete: boolean; + /** 移动/复制文件前创建检查点 */ + beforeMove: boolean; + /** bash 命令前创建检查点 */ + beforeBash: boolean; + }; + /** 最大保留检查点数量 */ + maxCheckpoints: number; + /** 检查点最大保留时间 (毫秒) */ + maxAge: number; + /** Shadow Git 存储目录 */ + storageDir: string; +} + +/** + * 默认配置 + */ +export const DEFAULT_CHECKPOINT_CONFIG: CheckpointConfig = { + enabled: true, + autoCheckpoint: { + beforeWrite: true, + beforeEdit: true, + beforeDelete: true, + beforeMove: true, + beforeBash: false, + }, + maxCheckpoints: 100, + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天 + storageDir: '.ai-assist/checkpoints', +}; + +/** + * 文件变更类型 + */ +export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed'; + +/** + * 文件变更信息 + */ +export interface FileChange { + /** 文件路径 */ + path: string; + /** 变更类型 */ + type: FileChangeType; + /** 旧路径 (重命名时) */ + oldPath?: string; + /** 添加的行数 */ + insertions?: number; + /** 删除的行数 */ + deletions?: number; +} + +/** + * 差异信息 + */ +export interface DiffInfo { + /** 源检查点/commit */ + from: string; + /** 目标检查点/commit (HEAD 表示当前工作区) */ + to: string; + /** 变更的文件列表 */ + files: FileChange[]; + /** 总添加行数 */ + totalInsertions: number; + /** 总删除行数 */ + totalDeletions: number; +} + +/** + * 文件内容差异 + */ +export interface FileDiff { + /** 文件路径 */ + path: string; + /** 变更类型 */ + type: FileChangeType; + /** 旧内容 */ + oldContent?: string; + /** 新内容 */ + newContent?: string; + /** 差异补丁 (unified diff 格式) */ + patch?: string; +} + +/** + * 回滚选项 + */ +export interface RollbackOptions { + /** 检查点 ID 或 commit hash */ + target: string; + /** 只回滚指定文件 */ + files?: string[]; + /** 预览模式 (不实际执行) */ + dryRun?: boolean; +} + +/** + * 回滚结果 + */ +export interface RollbackResult { + /** 是否成功 */ + success: boolean; + /** 恢复的文件列表 */ + restoredFiles: string[]; + /** 错误列表 */ + errors: Array<{ file: string; error: string }>; + /** 回滚前的 commit hash (用于撤销回滚) */ + previousCommit?: string; +} + +/** + * 检查点事件类型 + */ +export type CheckpointEventType = + | 'created' // 检查点已创建 + | 'restored' // 已回滚到检查点 + | 'deleted' // 检查点已删除 + | 'cleanup'; // 清理过期检查点 + +/** + * 检查点事件 + */ +export interface CheckpointEvent { + type: CheckpointEventType; + checkpoint?: CheckpointMetadata; + timestamp: number; + details?: Record; +} + +/** + * 检查点事件监听器 + */ +export type CheckpointEventListener = (event: CheckpointEvent) => void; diff --git a/src/tools/checkpoint/checkpoint_create.ts b/src/tools/checkpoint/checkpoint_create.ts new file mode 100644 index 0000000..91e785b --- /dev/null +++ b/src/tools/checkpoint/checkpoint_create.ts @@ -0,0 +1,89 @@ +/** + * 创建检查点工具 + */ + +import type { ToolResult } from '../../types/index.js'; +import type { ToolWithMetadata } from '../types.js'; +import { loadDescription } from '../load_description.js'; +import { getCheckpointManager } from '../../checkpoint/index.js'; + +export const checkpointCreateTool: ToolWithMetadata = { + name: 'checkpoint_create', + description: loadDescription('checkpoint_create'), + metadata: { + name: 'checkpoint_create', + category: 'core', + description: '创建一个新的工作区检查点快照', + keywords: [ + 'checkpoint', + 'create', + 'snapshot', + 'save', + '检查点', + '快照', + '保存', + ], + deferLoading: true, + }, + parameters: { + name: { + type: 'string', + description: '检查点名称 (可选)', + required: false, + }, + description: { + type: 'string', + description: '检查点描述 (可选)', + required: false, + }, + }, + execute: async (params: Record): Promise => { + const name = params.name as string | undefined; + const description = params.description as string | undefined; + + try { + const manager = getCheckpointManager(); + + if (!manager.isEnabled()) { + return { + success: false, + output: '', + error: '检查点系统已禁用', + }; + } + + await manager.initialize(); + + const checkpoint = await manager.createCheckpoint({ + name, + description, + trigger: 'manual', + }); + + const lines = [ + `✓ 检查点已创建`, + ` ID: ${checkpoint.id}`, + ` Commit: ${checkpoint.commitHash.slice(0, 8)}`, + ]; + + if (checkpoint.name) { + lines.push(` 名称: ${checkpoint.name}`); + } + if (checkpoint.filesChanged > 0) { + lines.push(` 文件变更: ${checkpoint.filesChanged} 个`); + } + lines.push(` 时间: ${new Date(checkpoint.timestamp).toLocaleString()}`); + + return { + success: true, + output: lines.join('\n'), + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}; diff --git a/src/tools/checkpoint/checkpoint_diff.ts b/src/tools/checkpoint/checkpoint_diff.ts new file mode 100644 index 0000000..44b781c --- /dev/null +++ b/src/tools/checkpoint/checkpoint_diff.ts @@ -0,0 +1,158 @@ +/** + * 检查点差异工具 + */ + +import type { ToolResult } from '../../types/index.js'; +import type { ToolWithMetadata } from '../types.js'; +import { loadDescription } from '../load_description.js'; +import { getCheckpointManager } from '../../checkpoint/index.js'; + +export const checkpointDiffTool: ToolWithMetadata = { + name: 'checkpoint_diff', + description: loadDescription('checkpoint_diff'), + metadata: { + name: 'checkpoint_diff', + category: 'core', + description: '显示检查点与当前工作区的差异', + keywords: [ + 'checkpoint', + 'diff', + 'compare', + 'changes', + '检查点', + '差异', + '比较', + '变更', + ], + deferLoading: true, + }, + parameters: { + checkpoint_id: { + type: 'string', + description: '检查点 ID 或 commit hash (默认为最近的检查点)', + required: false, + }, + file: { + type: 'string', + description: '指定文件路径查看详细差异 (可选)', + required: false, + }, + }, + execute: async (params: Record): Promise => { + const checkpointId = params.checkpoint_id as string | undefined; + const file = params.file as string | undefined; + + try { + const manager = getCheckpointManager(); + + if (!manager.isEnabled()) { + return { + success: false, + output: '', + error: '检查点系统已禁用', + }; + } + + await manager.initialize(); + + // 获取目标检查点 + let targetCheckpoint; + if (checkpointId) { + targetCheckpoint = await manager.getCheckpoint(checkpointId); + if (!targetCheckpoint) { + return { + success: false, + output: '', + error: `找不到检查点: ${checkpointId}`, + }; + } + } else { + targetCheckpoint = await manager.getLatestCheckpoint(); + if (!targetCheckpoint) { + return { + success: true, + output: '暂无检查点', + }; + } + } + + // 显示文件详细差异 + if (file) { + const fileDiff = await manager.getFileDiff(targetCheckpoint.id, file); + + const lines = [ + `文件差异: ${file}`, + `检查点: ${targetCheckpoint.commitHash.slice(0, 8)}`, + `变更类型: ${fileDiff.type}`, + '', + ]; + + if (fileDiff.patch) { + lines.push('```diff'); + lines.push(fileDiff.patch); + lines.push('```'); + } else if (fileDiff.type === 'added') { + lines.push('(新文件)'); + } else if (fileDiff.type === 'deleted') { + lines.push('(已删除)'); + } + + return { + success: true, + output: lines.join('\n'), + }; + } + + // 显示概要差异 + const diff = await manager.getDiff(targetCheckpoint.id); + + if (diff.files.length === 0) { + return { + success: true, + output: `检查点 ${targetCheckpoint.commitHash.slice(0, 8)} 与当前工作区相同`, + }; + } + + const lines = [ + `检查点 ${targetCheckpoint.commitHash.slice(0, 8)} 与当前工作区的差异:`, + '', + ` +${diff.totalInsertions} 行添加 -${diff.totalDeletions} 行删除`, + '', + '变更的文件:', + ]; + + for (const fileChange of diff.files) { + const symbol = + fileChange.type === 'added' + ? '+' + : fileChange.type === 'deleted' + ? '-' + : fileChange.type === 'renamed' + ? 'R' + : 'M'; + + let line = ` ${symbol} ${fileChange.path}`; + if (fileChange.oldPath) { + line = ` ${symbol} ${fileChange.oldPath} -> ${fileChange.path}`; + } + + if (fileChange.insertions || fileChange.deletions) { + line += ` (+${fileChange.insertions || 0} -${fileChange.deletions || 0})`; + } + + lines.push(line); + } + + return { + success: true, + output: lines.join('\n'), + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}; diff --git a/src/tools/checkpoint/checkpoint_list.ts b/src/tools/checkpoint/checkpoint_list.ts new file mode 100644 index 0000000..f444534 --- /dev/null +++ b/src/tools/checkpoint/checkpoint_list.ts @@ -0,0 +1,91 @@ +/** + * 列出检查点工具 + */ + +import type { ToolResult } from '../../types/index.js'; +import type { ToolWithMetadata } from '../types.js'; +import { loadDescription } from '../load_description.js'; +import { getCheckpointManager } from '../../checkpoint/index.js'; + +export const checkpointListTool: ToolWithMetadata = { + name: 'checkpoint_list', + description: loadDescription('checkpoint_list'), + metadata: { + name: 'checkpoint_list', + category: 'core', + description: '列出所有可用的检查点', + keywords: [ + 'checkpoint', + 'list', + 'show', + 'history', + '检查点', + '列表', + '历史', + ], + deferLoading: true, + }, + parameters: { + limit: { + type: 'number', + description: '最多显示的检查点数量 (默认 10)', + required: false, + }, + }, + execute: async (params: Record): Promise => { + const limit = (params.limit as number) || 10; + + try { + const manager = getCheckpointManager(); + + if (!manager.isEnabled()) { + return { + success: false, + output: '', + error: '检查点系统已禁用', + }; + } + + await manager.initialize(); + + const checkpoints = await manager.listCheckpoints(); + + if (checkpoints.length === 0) { + return { + success: true, + output: '暂无检查点', + }; + } + + const displayCheckpoints = checkpoints.slice(0, limit); + const lines = [`共 ${checkpoints.length} 个检查点:\n`]; + + for (const cp of displayCheckpoints) { + const date = new Date(cp.timestamp).toLocaleString(); + const hash = cp.commitHash.slice(0, 8); + const name = cp.name ? ` "${cp.name}"` : ''; + const files = cp.filesChanged > 0 ? ` (${cp.filesChanged} files)` : ''; + + lines.push(` ${hash}${name}${files}`); + lines.push(` ${cp.description || cp.trigger}`); + lines.push(` ${date}`); + lines.push(''); + } + + if (checkpoints.length > limit) { + lines.push(` ... 还有 ${checkpoints.length - limit} 个检查点`); + } + + return { + success: true, + output: lines.join('\n'), + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}; diff --git a/src/tools/checkpoint/checkpoint_restore.ts b/src/tools/checkpoint/checkpoint_restore.ts new file mode 100644 index 0000000..6447e21 --- /dev/null +++ b/src/tools/checkpoint/checkpoint_restore.ts @@ -0,0 +1,157 @@ +/** + * 恢复检查点工具 + */ + +import type { ToolResult } from '../../types/index.js'; +import type { ToolWithMetadata } from '../types.js'; +import { loadDescription } from '../load_description.js'; +import { getCheckpointManager } from '../../checkpoint/index.js'; + +export const checkpointRestoreTool: ToolWithMetadata = { + name: 'checkpoint_restore', + description: loadDescription('checkpoint_restore'), + metadata: { + name: 'checkpoint_restore', + category: 'core', + description: '恢复到指定的检查点', + keywords: [ + 'checkpoint', + 'restore', + 'rollback', + 'undo', + '检查点', + '恢复', + '回滚', + '撤销', + ], + deferLoading: true, + }, + parameters: { + checkpoint_id: { + type: 'string', + description: '要恢复的检查点 ID 或 commit hash', + required: true, + }, + files: { + type: 'string', + description: '只恢复指定文件,多个文件用逗号分隔 (可选)', + required: false, + }, + dry_run: { + type: 'boolean', + description: '预览模式,不实际执行恢复', + required: false, + }, + }, + execute: async (params: Record): Promise => { + const checkpointId = params.checkpoint_id as string; + const filesStr = params.files as string | undefined; + const dryRun = params.dry_run as boolean | undefined; + + if (!checkpointId) { + return { + success: false, + output: '', + error: '请指定要恢复的检查点 ID', + }; + } + + try { + const manager = getCheckpointManager(); + + if (!manager.isEnabled()) { + return { + success: false, + output: '', + error: '检查点系统已禁用', + }; + } + + await manager.initialize(); + + // 验证检查点存在 + const checkpoint = await manager.getCheckpoint(checkpointId); + if (!checkpoint) { + return { + success: false, + output: '', + error: `找不到检查点: ${checkpointId}`, + }; + } + + // 解析文件列表 + const files = filesStr + ? filesStr.split(',').map((f) => f.trim()).filter(Boolean) + : undefined; + + // 执行恢复 + const result = await manager.rollback({ + target: checkpointId, + files, + dryRun, + }); + + if (dryRun) { + const lines = [ + `预览: 恢复到检查点 ${checkpoint.commitHash.slice(0, 8)}`, + '', + '将恢复以下文件:', + ]; + + for (const file of result.restoredFiles) { + lines.push(` - ${file}`); + } + + lines.push(''); + lines.push('(使用 dry_run=false 执行实际恢复)'); + + return { + success: true, + output: lines.join('\n'), + }; + } + + if (!result.success) { + const errorLines = ['恢复失败:']; + for (const err of result.errors) { + errorLines.push(` ${err.file}: ${err.error}`); + } + return { + success: false, + output: '', + error: errorLines.join('\n'), + }; + } + + const lines = [ + `✓ 已恢复到检查点 ${checkpoint.commitHash.slice(0, 8)}`, + '', + `恢复了 ${result.restoredFiles.length} 个文件:`, + ]; + + for (const file of result.restoredFiles.slice(0, 10)) { + lines.push(` - ${file}`); + } + + if (result.restoredFiles.length > 10) { + lines.push(` ... 还有 ${result.restoredFiles.length - 10} 个文件`); + } + + if (result.previousCommit) { + lines.push(''); + lines.push(`提示: 可以使用 checkpoint_restore 恢复到 ${result.previousCommit.slice(0, 8)} 来撤销此操作`); + } + + return { + success: true, + output: lines.join('\n'), + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}; diff --git a/src/tools/checkpoint/index.ts b/src/tools/checkpoint/index.ts new file mode 100644 index 0000000..671b2ca --- /dev/null +++ b/src/tools/checkpoint/index.ts @@ -0,0 +1,9 @@ +/** + * 检查点工具模块 + */ + +export { checkpointCreateTool } from './checkpoint_create.js'; +export { checkpointListTool } from './checkpoint_list.js'; +export { checkpointDiffTool } from './checkpoint_diff.js'; +export { checkpointRestoreTool } from './checkpoint_restore.js'; +export { undoTool } from './undo.js'; diff --git a/src/tools/checkpoint/undo.ts b/src/tools/checkpoint/undo.ts new file mode 100644 index 0000000..8b9050c --- /dev/null +++ b/src/tools/checkpoint/undo.ts @@ -0,0 +1,143 @@ +/** + * 撤销操作工具 (快捷回滚) + */ + +import type { ToolResult } from '../../types/index.js'; +import type { ToolWithMetadata } from '../types.js'; +import { loadDescription } from '../load_description.js'; +import { getCheckpointManager } from '../../checkpoint/index.js'; + +export const undoTool: ToolWithMetadata = { + name: 'undo', + description: loadDescription('undo'), + metadata: { + name: 'undo', + category: 'core', + description: '撤销上一次文件操作,回滚到最近的检查点', + keywords: ['undo', 'rollback', 'revert', '撤销', '回滚', '恢复'], + deferLoading: false, // 常用命令,始终加载 + }, + parameters: { + confirm: { + type: 'boolean', + description: '确认执行撤销操作 (默认 true)', + required: false, + }, + }, + execute: async (params: Record): Promise => { + const confirm = params.confirm !== false; + + try { + const manager = getCheckpointManager(); + + if (!manager.isEnabled()) { + return { + success: false, + output: '', + error: '检查点系统已禁用,无法执行撤销操作', + }; + } + + await manager.initialize(); + + // 获取最近两个检查点 + const checkpoints = await manager.listCheckpoints(); + + if (checkpoints.length === 0) { + return { + success: false, + output: '', + error: '没有可用的检查点,无法执行撤销操作', + }; + } + + // 预览模式 + if (!confirm) { + const targetCheckpoint = + checkpoints.length > 1 ? checkpoints[1] : checkpoints[0]; + + const diff = await manager.getDiff(targetCheckpoint.id); + + const lines = [ + '预览: 撤销将恢复以下文件变更:', + '', + `目标检查点: ${targetCheckpoint.commitHash.slice(0, 8)}`, + ` ${targetCheckpoint.description || targetCheckpoint.trigger}`, + ` ${new Date(targetCheckpoint.timestamp).toLocaleString()}`, + '', + ]; + + if (diff.files.length > 0) { + lines.push('将恢复的文件:'); + for (const file of diff.files.slice(0, 10)) { + const symbol = + file.type === 'added' + ? '+' + : file.type === 'deleted' + ? '-' + : 'M'; + lines.push(` ${symbol} ${file.path}`); + } + if (diff.files.length > 10) { + lines.push(` ... 还有 ${diff.files.length - 10} 个文件`); + } + } else { + lines.push('(无文件变更)'); + } + + lines.push(''); + lines.push('使用 confirm=true 执行撤销'); + + return { + success: true, + output: lines.join('\n'), + }; + } + + // 执行撤销 + const result = await manager.undo(); + + if (!result.success) { + const errorLines = ['撤销失败:']; + for (const err of result.errors) { + errorLines.push(` ${err.file}: ${err.error}`); + } + return { + success: false, + output: '', + error: errorLines.join('\n'), + }; + } + + const lines = [ + '✓ 撤销成功', + '', + `恢复了 ${result.restoredFiles.length} 个文件`, + ]; + + if (result.restoredFiles.length > 0 && result.restoredFiles.length <= 5) { + for (const file of result.restoredFiles) { + lines.push(` - ${file}`); + } + } + + if (result.previousCommit) { + lines.push(''); + lines.push( + `提示: 使用 checkpoint_restore --checkpoint_id ${result.previousCommit.slice(0, 8)} 可以撤销此操作` + ); + } + + return { + success: true, + output: lines.join('\n'), + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}; diff --git a/src/tools/descriptions/checkpoint_create.txt b/src/tools/descriptions/checkpoint_create.txt new file mode 100644 index 0000000..f225e87 --- /dev/null +++ b/src/tools/descriptions/checkpoint_create.txt @@ -0,0 +1,9 @@ +Create a new checkpoint snapshot of the current workspace state. + +Checkpoints are snapshots that can be used to restore the workspace to a previous state. This is useful as a safety net before making changes, or to mark important milestones in your work. + +Parameters: +- name: Optional name for the checkpoint (e.g., "before refactoring") +- description: Optional description of the checkpoint state + +The checkpoint stores all file changes and can be restored later using checkpoint_restore or undo commands. diff --git a/src/tools/descriptions/checkpoint_diff.txt b/src/tools/descriptions/checkpoint_diff.txt new file mode 100644 index 0000000..62177c2 --- /dev/null +++ b/src/tools/descriptions/checkpoint_diff.txt @@ -0,0 +1,12 @@ +Show the differences between a checkpoint and the current workspace. + +Displays what has changed since the checkpoint was created, including: +- Added, modified, and deleted files +- Lines added and removed +- Detailed file diff (when file parameter is provided) + +Parameters: +- checkpoint_id: The checkpoint ID or commit hash to compare (default: most recent) +- file: Specific file path to show detailed diff (optional) + +Use this to preview changes before restoring to a checkpoint. diff --git a/src/tools/descriptions/checkpoint_list.txt b/src/tools/descriptions/checkpoint_list.txt new file mode 100644 index 0000000..db1667b --- /dev/null +++ b/src/tools/descriptions/checkpoint_list.txt @@ -0,0 +1,13 @@ +List all available checkpoints in the workspace. + +Shows checkpoint history including: +- Checkpoint ID/hash +- Name (if provided) +- Description or trigger type +- Creation timestamp +- Number of files changed + +Parameters: +- limit: Maximum number of checkpoints to display (default: 10) + +Use this to see available restore points before using checkpoint_restore or undo. diff --git a/src/tools/descriptions/checkpoint_restore.txt b/src/tools/descriptions/checkpoint_restore.txt new file mode 100644 index 0000000..232a496 --- /dev/null +++ b/src/tools/descriptions/checkpoint_restore.txt @@ -0,0 +1,15 @@ +Restore the workspace to a specified checkpoint state. + +This will revert all files to their state at the time of the checkpoint. Use with caution as it will overwrite current changes. + +Parameters: +- checkpoint_id: Required. The checkpoint ID or commit hash to restore to +- files: Optional. Comma-separated list of specific files to restore (partial restore) +- dry_run: Optional. If true, shows what would be restored without actually doing it + +Examples: +- Full restore: checkpoint_restore checkpoint_id="abc123" +- Partial restore: checkpoint_restore checkpoint_id="abc123" files="src/index.ts,src/utils.ts" +- Preview: checkpoint_restore checkpoint_id="abc123" dry_run=true + +After restore, the previous state is saved and can be restored if needed. diff --git a/src/tools/descriptions/undo.txt b/src/tools/descriptions/undo.txt new file mode 100644 index 0000000..0c0dd54 --- /dev/null +++ b/src/tools/descriptions/undo.txt @@ -0,0 +1,14 @@ +Undo the most recent file operation by restoring to the previous checkpoint. + +This is a quick way to revert the last change. It restores all files to their state at the second-most-recent checkpoint (since the most recent checkpoint captures the current state). + +Parameters: +- confirm: Set to false to preview what would be undone without executing (default: true) + +Usage: +- Quick undo: undo +- Preview first: undo confirm=false + +If you need to undo multiple operations or restore to a specific point, use checkpoint_list and checkpoint_restore instead. + +After undo, you can "redo" by using checkpoint_restore with the previous commit hash (shown in the output). diff --git a/src/tools/index.ts b/src/tools/index.ts index 46b3000..a6595be 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -49,6 +49,15 @@ import { // RepoMap 工具 import { repoMapTool } from './repomap/index.js'; +// 检查点工具 +import { + checkpointCreateTool, + checkpointListTool, + checkpointDiffTool, + checkpointRestoreTool, + undoTool, +} from './checkpoint/index.js'; + // 所有工具列表(用于注册) const allToolsWithMetadata: ToolWithMetadata[] = [ // 核心工具 (deferLoading: false) @@ -94,6 +103,13 @@ const allToolsWithMetadata: ToolWithMetadata[] = [ // RepoMap 工具 (deferLoading: true) repoMapTool, + + // 检查点工具 + undoTool, // deferLoading: false - 常用命令 + checkpointCreateTool, + checkpointListTool, + checkpointDiffTool, + checkpointRestoreTool, ]; // 注册所有工具到 registry diff --git a/tests/checkpoint/checkpoint.test.ts b/tests/checkpoint/checkpoint.test.ts new file mode 100644 index 0000000..6ccbfc7 --- /dev/null +++ b/tests/checkpoint/checkpoint.test.ts @@ -0,0 +1,298 @@ +/** + * 检查点系统测试 + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import { + CheckpointManager, + ShadowGit, + hashWorkingDir, +} from '../../src/checkpoint/index.js'; + +describe('hashWorkingDir', () => { + it('should generate consistent hash for same path', () => { + const path1 = '/Users/test/project'; + const path2 = '/Users/test/project'; + + expect(hashWorkingDir(path1)).toBe(hashWorkingDir(path2)); + }); + + it('should generate different hash for different paths', () => { + const path1 = '/Users/test/project1'; + const path2 = '/Users/test/project2'; + + expect(hashWorkingDir(path1)).not.toBe(hashWorkingDir(path2)); + }); + + it('should generate 13-character hash', () => { + const hash = hashWorkingDir('/some/test/path'); + expect(hash.length).toBe(13); + }); + + it('should only contain digits', () => { + const hash = hashWorkingDir('/some/test/path'); + expect(/^\d+$/.test(hash)).toBe(true); + }); +}); + +describe('ShadowGit', () => { + let tempDir: string; + let storageDir: string; + let shadowGit: ShadowGit; + + beforeAll(async () => { + tempDir = path.join(os.tmpdir(), `checkpoint-test-${Date.now()}`); + storageDir = path.join(os.tmpdir(), `checkpoint-storage-${Date.now()}`); + + await fs.mkdir(tempDir, { recursive: true }); + await fs.mkdir(storageDir, { recursive: true }); + + shadowGit = new ShadowGit(tempDir, storageDir); + }); + + afterAll(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + await fs.rm(storageDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + it('should initialize shadow git repository', async () => { + await shadowGit.initialize(); + + const gitDir = path.join(shadowGit.getShadowGitDir(), '.git'); + const stat = await fs.stat(gitDir); + expect(stat.isDirectory()).toBe(true); + }); + + it('should create commit and return hash', async () => { + // 创建测试文件 + await fs.writeFile(path.join(tempDir, 'test.txt'), 'Hello World'); + + const commitHash = await shadowGit.createCommit('Test commit'); + + expect(commitHash).toBeDefined(); + expect(commitHash.length).toBe(40); // Git SHA-1 hash length + }); + + it('should list commits', async () => { + const commits = await shadowGit.getCommits(10); + + expect(commits.length).toBeGreaterThan(0); + expect(commits[0].hash).toBeDefined(); + expect(commits[0].message).toBeDefined(); + expect(commits[0].timestamp).toBeGreaterThan(0); + }); + + it('should detect changes', async () => { + // 修改文件 + await fs.writeFile(path.join(tempDir, 'test.txt'), 'Modified content'); + + const hasChanges = await shadowGit.hasChanges(); + expect(hasChanges).toBe(true); + }); + + it('should get diff summary', async () => { + // 先提交当前状态 + await shadowGit.createCommit('Before diff test'); + + // 修改文件 + await fs.writeFile(path.join(tempDir, 'test.txt'), 'New content for diff'); + await shadowGit.createCommit('After diff test'); + + const commits = await shadowGit.getCommits(2); + const diff = await shadowGit.getDiffSummary(commits[1].hash, commits[0].hash); + + expect(diff.from).toBe(commits[1].hash); + expect(diff.to).toBe(commits[0].hash); + expect(diff.files.length).toBeGreaterThanOrEqual(0); + }); + + it('should reset to previous commit', async () => { + const commits = await shadowGit.getCommits(2); + if (commits.length < 2) return; // 跳过如果没有足够的 commits + + const olderCommit = commits[1].hash; + + await shadowGit.resetHard(olderCommit); + + const currentHead = await shadowGit.getHead(); + expect(currentHead).toBe(olderCommit); + }); +}); + +describe('CheckpointManager', () => { + let tempDir: string; + let storageDir: string; + let manager: CheckpointManager; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `checkpoint-mgr-test-${Date.now()}`); + storageDir = path.join(os.tmpdir(), `checkpoint-mgr-storage-${Date.now()}`); + + await fs.mkdir(tempDir, { recursive: true }); + + manager = new CheckpointManager(tempDir, { + storageDir, + maxCheckpoints: 10, + maxAge: 24 * 60 * 60 * 1000, + }); + }); + + afterAll(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + await fs.rm(storageDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + it('should initialize manager', async () => { + await manager.initialize(); + expect(manager.isEnabled()).toBe(true); + }); + + it('should create checkpoint', async () => { + await fs.writeFile(path.join(tempDir, 'file1.txt'), 'Content 1'); + + const checkpoint = await manager.createCheckpoint({ + name: 'Test checkpoint', + description: 'A test checkpoint', + }); + + expect(checkpoint.id).toBeDefined(); + expect(checkpoint.name).toBe('Test checkpoint'); + expect(checkpoint.description).toBe('A test checkpoint'); + expect(checkpoint.commitHash).toBeDefined(); + expect(checkpoint.timestamp).toBeGreaterThan(0); + }); + + it('should list checkpoints', async () => { + // 创建几个检查点 + await fs.writeFile(path.join(tempDir, 'file2.txt'), 'Content 2'); + await manager.createCheckpoint({ name: 'Checkpoint 1' }); + + await fs.writeFile(path.join(tempDir, 'file3.txt'), 'Content 3'); + await manager.createCheckpoint({ name: 'Checkpoint 2' }); + + const checkpoints = await manager.listCheckpoints(); + + expect(checkpoints.length).toBeGreaterThanOrEqual(2); + // 应该按时间倒序排列 + expect(checkpoints[0].timestamp).toBeGreaterThanOrEqual(checkpoints[1].timestamp); + }); + + it('should get checkpoint by id', async () => { + await fs.writeFile(path.join(tempDir, 'file4.txt'), 'Content 4'); + const created = await manager.createCheckpoint({ name: 'Find me' }); + + const found = await manager.getCheckpoint(created.id); + + expect(found).not.toBeNull(); + expect(found!.id).toBe(created.id); + expect(found!.name).toBe('Find me'); + }); + + it('should get latest checkpoint', async () => { + await fs.writeFile(path.join(tempDir, 'file5.txt'), 'Content 5'); + const cp1 = await manager.createCheckpoint({ name: 'Older' }); + + // 等待一小段时间确保时间戳不同 + await new Promise((resolve) => setTimeout(resolve, 100)); + + await fs.writeFile(path.join(tempDir, 'file6.txt'), 'Content 6'); + const cp2 = await manager.createCheckpoint({ name: 'Newer' }); + + const latest = await manager.getLatestCheckpoint(); + + expect(latest).not.toBeNull(); + expect(latest!.id).toBe(cp2.id); + }); + + it('should determine if checkpoint should be created for tool', () => { + expect(manager.shouldCreateCheckpoint('write_file')).toBe(true); + expect(manager.shouldCreateCheckpoint('edit_file')).toBe(true); + expect(manager.shouldCreateCheckpoint('delete_file')).toBe(true); + expect(manager.shouldCreateCheckpoint('bash')).toBe(false); // 默认禁用 + expect(manager.shouldCreateCheckpoint('read_file')).toBe(false); + }); + + it('should create checkpoint before tool execution', async () => { + await fs.writeFile(path.join(tempDir, 'before-tool.txt'), 'Before'); + + const checkpointId = await manager.beforeToolExecution('write_file', { + path: path.join(tempDir, 'new-file.txt'), + }); + + expect(checkpointId).not.toBeNull(); + + const checkpoint = await manager.getCheckpoint(checkpointId!); + expect(checkpoint).not.toBeNull(); + expect(checkpoint!.trigger).toBe('tool:write_file'); + }); + + it('should get diff between checkpoint and current state', async () => { + await fs.writeFile(path.join(tempDir, 'diff-test.txt'), 'Original'); + const checkpoint = await manager.createCheckpoint({ name: 'Before change' }); + + // 修改文件 + await fs.writeFile(path.join(tempDir, 'diff-test.txt'), 'Modified'); + + const diff = await manager.getDiff(checkpoint.id); + + expect(diff).toBeDefined(); + expect(diff.from).toBe(checkpoint.commitHash); + }); + + it('should rollback to checkpoint (dry run)', async () => { + await fs.writeFile(path.join(tempDir, 'rollback-test.txt'), 'Original'); + const checkpoint = await manager.createCheckpoint({ name: 'Before rollback' }); + + // 修改文件 + await fs.writeFile(path.join(tempDir, 'rollback-test.txt'), 'Modified'); + await manager.createCheckpoint({ name: 'After change' }); + + // 预览回滚 + const result = await manager.rollback({ + target: checkpoint.id, + dryRun: true, + }); + + expect(result.success).toBe(true); + expect(result.restoredFiles.length).toBeGreaterThanOrEqual(0); + + // 文件应该保持修改状态(因为是 dry run) + const content = await fs.readFile(path.join(tempDir, 'rollback-test.txt'), 'utf-8'); + expect(content).toBe('Modified'); + }); + + it('should emit events on checkpoint creation', async () => { + const events: any[] = []; + manager.addEventListener((event) => { + events.push(event); + }); + + await fs.writeFile(path.join(tempDir, 'event-test.txt'), 'Event test'); + await manager.createCheckpoint({ name: 'Event test' }); + + expect(events.length).toBeGreaterThan(0); + expect(events.some((e) => e.type === 'created')).toBe(true); + }); + + it('should get stats', async () => { + const stats = await manager.getStats(); + + expect(stats.count).toBeGreaterThanOrEqual(0); + // oldest 和 newest 可能为 null 如果没有检查点 + if (stats.count > 0) { + expect(stats.oldestTimestamp).not.toBeNull(); + expect(stats.newestTimestamp).not.toBeNull(); + } + }); +});