feat: 实现检查点系统 (Shadow Git 架构)
- 添加 Shadow Git 存储后端,使用隔离的 git 仓库管理快照 - 实现检查点管理器,支持自动/手动检查点创建 - 添加 5 个检查点工具: - undo: 快速撤销到上一个检查点 - checkpoint_create: 创建命名检查点 - checkpoint_list: 列出所有检查点 - checkpoint_diff: 显示检查点与当前状态的差异 - checkpoint_restore: 恢复到指定检查点 - 支持嵌套 .git 目录处理,避免冲突 - 添加事件系统监听检查点生命周期 - 编写完整测试用例 (21 个测试)
This commit is contained in:
@@ -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';
|
||||
@@ -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<string, CheckpointMetadata> = new Map();
|
||||
private initialized = false;
|
||||
private lastCheckpointTime = 0;
|
||||
private eventListeners: Set<CheckpointEventListener> = new Set();
|
||||
|
||||
// 防止重复创建检查点的最小间隔 (毫秒)
|
||||
private static readonly MIN_CHECKPOINT_INTERVAL = 1000;
|
||||
|
||||
constructor(workDir: string, config: Partial<CheckpointConfig> = {}) {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>
|
||||
): Promise<string | null> {
|
||||
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, unknown>
|
||||
): 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<string, unknown> };
|
||||
}): Promise<CheckpointMetadata> {
|
||||
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<CheckpointMetadata> {
|
||||
return this.createCheckpoint({
|
||||
name,
|
||||
description,
|
||||
trigger: 'manual',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有检查点
|
||||
*/
|
||||
async listCheckpoints(): Promise<CheckpointMetadata[]> {
|
||||
await this.initialize();
|
||||
|
||||
return Array.from(this.checkpointsIndex.values()).sort(
|
||||
(a, b) => b.timestamp - a.timestamp
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定检查点
|
||||
*/
|
||||
async getCheckpoint(idOrHash: string): Promise<CheckpointMetadata | null> {
|
||||
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<CheckpointMetadata | null> {
|
||||
const checkpoints = await this.listCheckpoints();
|
||||
return checkpoints[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取检查点与当前工作区的差异
|
||||
*/
|
||||
async getDiff(checkpointId: string): Promise<DiffInfo> {
|
||||
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<DiffInfo> {
|
||||
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<FileDiff> {
|
||||
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<RollbackResult> {
|
||||
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<RollbackResult> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.cleanup();
|
||||
} catch (error) {
|
||||
console.warn('Checkpoint cleanup failed:', error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期检查点
|
||||
*/
|
||||
async cleanup(): Promise<number> {
|
||||
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<CheckpointConfig>
|
||||
): Promise<CheckpointManager> {
|
||||
globalCheckpointManager = new CheckpointManager(workDir, config);
|
||||
await globalCheckpointManager.initialize();
|
||||
return globalCheckpointManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置全局检查点管理器 (用于测试)
|
||||
*/
|
||||
export function resetCheckpointManager(): void {
|
||||
globalCheckpointManager = null;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// 创建目录
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
const nestedDirs: string[] = [];
|
||||
|
||||
const walk = async (dir: string, depth = 0): Promise<void> => {
|
||||
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<void> {
|
||||
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<string> {
|
||||
await this.initialize();
|
||||
const { stdout } = await this.git(['rev-parse', 'HEAD']);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置到指定 commit
|
||||
*/
|
||||
async resetHard(commitHash: string): Promise<void> {
|
||||
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<DiffInfo> {
|
||||
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<FileChange['type']> {
|
||||
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<FileDiff> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
await this.initialize();
|
||||
|
||||
try {
|
||||
const { stdout } = await this.git(['show', `${commitHash}:${filePath}`]);
|
||||
return stdout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧的 commit(保留最近 N 个)
|
||||
*/
|
||||
async cleanup(keepCount: number): Promise<number> {
|
||||
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<boolean> {
|
||||
await this.initialize();
|
||||
|
||||
const { stdout } = await this.git(['status', '--porcelain']);
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作目录与 HEAD 的差异
|
||||
*/
|
||||
async getWorkingDirDiff(): Promise<DiffInfo> {
|
||||
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);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
/** 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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点事件监听器
|
||||
*/
|
||||
export type CheckpointEventListener = (event: CheckpointEvent) => void;
|
||||
@@ -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<string, unknown>): Promise<ToolResult> => {
|
||||
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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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<string, unknown>): Promise<ToolResult> => {
|
||||
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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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<string, unknown>): Promise<ToolResult> => {
|
||||
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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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<string, unknown>): Promise<ToolResult> => {
|
||||
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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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<string, unknown>): Promise<ToolResult> => {
|
||||
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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user