feat: 实现检查点系统 (Shadow Git 架构)

- 添加 Shadow Git 存储后端,使用隔离的 git 仓库管理快照
- 实现检查点管理器,支持自动/手动检查点创建
- 添加 5 个检查点工具:
  - undo: 快速撤销到上一个检查点
  - checkpoint_create: 创建命名检查点
  - checkpoint_list: 列出所有检查点
  - checkpoint_diff: 显示检查点与当前状态的差异
  - checkpoint_restore: 恢复到指定检查点
- 支持嵌套 .git 目录处理,避免冲突
- 添加事件系统监听检查点生命周期
- 编写完整测试用例 (21 个测试)
This commit is contained in:
2025-12-11 23:00:47 +08:00
parent 9818e02ed1
commit 89673e28cb
17 changed files with 2439 additions and 0 deletions
+35
View File
@@ -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';
+613
View File
@@ -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;
}
+576
View File
@@ -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);
}
+191
View File
@@ -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;
+89
View File
@@ -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),
};
}
},
};
+158
View File
@@ -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),
};
}
},
};
+91
View File
@@ -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),
};
}
},
};
+157
View File
@@ -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),
};
}
},
};
+9
View File
@@ -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';
+143
View File
@@ -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.
+14
View File
@@ -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).
+16
View File
@@ -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
+298
View File
@@ -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();
}
});
});