Closed
Description
This is from my own project but it works and might be useful. I didn't include my tool stuff or permissions but it should give you some ideas anyway
/**
* Comprehensive Checkpointing System
* Creates shadow git repository for safe experimentation and rollback
* Implements nested git repository handling and worktree configuration
*/
import { execSync } from 'child_process';
import { readFile, writeFile, mkdir, access, rename } from 'fs/promises';
import { resolve, relative, join } from 'path';
import * as crypto from 'crypto';
import { Tool } from './types';
import type { ToolParameter, ToolCategory, ToolContext, ToolResult, ApprovalRequest } from './types';
import { Result, ok, err } from '../utils/result';
import * as path from 'path';
import { globby } from 'globby';
import * as os from 'os';
import {
PermissionManager,
OperationType,
RiskLevel,
createCheckpointPermissionRequest
} from '../utils/permission-manager';
interface CheckpointParams extends Record<string, unknown> {
action: 'create' | 'list' | 'restore' | 'diff' | 'delete' | 'status' | 'cleanup';
name?: string;
message?: string;
checkpoint_id?: string;
include_patterns?: string[];
exclude_patterns?: string[];
force?: boolean;
}
interface CheckpointRetentionConfig {
maxAutoCheckpoints: number; // Keep only last N auto-created checkpoints
maxAgeDays: number; // Delete auto-checkpoints older than X days
maxTotalSizeMB: number; // Cleanup when exceeding size limit
preserveNamed: boolean; // Always keep manually named checkpoints
}
interface Checkpoint {
id: string;
name: string;
message: string;
timestamp: string;
commitHash: string; // Git commit hash for this checkpoint
changes: {
additions: number;
deletions: number;
modifications: number;
};
hash: string;
isAutoCreated?: boolean; // Track auto vs manual checkpoints
sizeMB?: number; // Track checkpoint size
}
const GIT_DISABLED_SUFFIX = '_disabled';
export class CheckpointTool extends Tool<CheckpointParams, string> {
readonly name = 'checkpoint';
readonly description = 'Create, restore, and manage safe code checkpoints for experimentation and rollback';
readonly category: ToolCategory = 'file';
readonly parameters: ToolParameter[] = [
{
name: 'action',
type: 'string',
description: 'Action to perform: create, list, restore, diff, delete, or status',
required: true,
enum: ['create', 'list', 'restore', 'diff', 'delete', 'status', 'cleanup']
},
{
name: 'name',
type: 'string',
description: 'Name for the checkpoint (required for create action)',
required: false
},
{
name: 'message',
type: 'string',
description: 'Optional message describing the checkpoint',
required: false
},
{
name: 'checkpoint_id',
type: 'string',
description: 'ID or name of checkpoint (required for restore, diff, delete actions)',
required: false
},
{
name: 'include_patterns',
type: 'array',
description: 'Glob patterns for files to include in checkpoint',
required: false,
items: { type: 'string' }
},
{
name: 'exclude_patterns',
type: 'array',
description: 'Glob patterns for files to exclude from checkpoint',
required: false,
items: { type: 'string' }
},
{
name: 'force',
type: 'boolean',
description: 'Force action without confirmation prompts',
required: false,
default: false
}
];
private shadowRepoPath: string = '';
private workspaceRoot: string;
private workspaceHash: string;
private permissionManager = PermissionManager.getInstance();
private retentionConfig: CheckpointRetentionConfig = {
maxAutoCheckpoints: 10, // Keep last 10 auto-checkpoints
maxAgeDays: 7, // Delete auto-checkpoints older than 7 days
maxTotalSizeMB: 100, // Cleanup when exceeding 100MB
preserveNamed: true // Always keep manually named checkpoints
};
constructor(workspaceRoot?: string) {
super();
this.workspaceRoot = path.resolve(workspaceRoot || process.cwd());
this.workspaceHash = this.hashWorkingDir(this.workspaceRoot);
this.shadowRepoPath = join(this.workspaceRoot, '.ai', 'checkpoints');
}
/**
* Hash workspace directory to unique identifier
*/
private hashWorkingDir(workingDir: string): string {
if (!workingDir) {
throw new Error('Working directory path cannot be empty');
}
let hash = 0;
for (let i = 0; i < workingDir.length; i++) {
hash = (hash * 31 + workingDir.charCodeAt(i)) >>> 0;
}
const bigHash = BigInt(hash);
const numericHash = bigHash.toString().slice(0, 13);
return numericHash;
}
/**
* Validate workspace is not in protected directory
*/
private async validateNotInProtectedDirectory(): Promise<void> {
const homedir = os.homedir();
const desktopPath = join(homedir, 'Desktop');
const documentsPath = join(homedir, 'Documents');
const downloadsPath = join(homedir, 'Downloads');
switch (this.workspaceRoot) {
case homedir:
throw new Error('Cannot use checkpoints in home directory');
case desktopPath:
throw new Error('Cannot use checkpoints in Desktop directory');
case documentsPath:
throw new Error('Cannot use checkpoints in Documents directory');
case downloadsPath:
throw new Error('Cannot use checkpoints in Downloads directory');
}
// Check if directory exists and we have read permissions
try {
await access(this.workspaceRoot);
} catch (error) {
throw new Error(
`Cannot access workspace directory. Please ensure you have permission to access your workspace. Error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Temporarily disable nested git repositories by renaming .git directories.
* This prevents git from treating them as submodules during checkpoint operations.
* This ensures that nested git repositories don't interfere with checkpoint operations
*
* @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
*/
private async renameNestedGitRepos(disable: boolean): Promise<void> {
try {
// Find all .git directories that are not at the root level
const gitPaths = await globby('**/.git' + (disable ? '' : GIT_DISABLED_SUFFIX), {
cwd: this.workspaceRoot,
onlyDirectories: true,
ignore: ['.git'], // Ignore root level .git
dot: true,
markDirectories: false,
suppressErrors: true,
});
// For each nested .git directory, rename it based on operation
for (const gitPath of gitPaths) {
const fullPath = join(this.workspaceRoot, gitPath);
let newPath: string;
if (disable) {
newPath = fullPath + GIT_DISABLED_SUFFIX;
} else {
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX)
? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length)
: fullPath;
}
try {
await rename(fullPath, newPath);
console.log(`Checkpoint ${disable ? 'disabled' : 'enabled'} nested git repo ${gitPath}`);
} catch (error) {
console.error(`Failed to ${disable ? 'disable' : 'enable'} nested git repo ${gitPath}:`, error);
}
}
} catch (error) {
console.warn('Failed to manage nested git repositories:', error);
}
}
/**
* Add files to shadow git repository
*/
private async addCheckpointFiles(): Promise<void> {
try {
// Add all files using git, ignoring permission errors
execSync('git add . --ignore-errors', {
cwd: this.shadowRepoPath,
stdio: 'pipe'
});
} catch (error) {
console.warn('Some files could not be added to checkpoint:', error);
// Continue anyway - git will add what it can
}
}
/**
* Validate parameters before execution
*/
validate(params: CheckpointParams): Result<CheckpointParams> {
try {
// Validate required parameters for specific actions
if (params.action === 'create' && !params.name) {
return err(new Error('name parameter is required for create action'));
}
if (['restore', 'diff', 'delete'].includes(params.action) && !params.checkpoint_id) {
return err(new Error('checkpoint_id parameter is required for restore, diff, and delete actions'));
}
return ok(params);
} catch (error) {
return err(error as Error);
}
}
/**
* Get approval request details
*/
getApprovalRequest(params: CheckpointParams, _context: ToolContext): ApprovalRequest {
const risk = params.action === 'restore' ? 'high' : 'medium';
return {
toolName: this.name,
description: `Checkpoint ${params.action}${params.name ? ` "${params.name}"` : ''}${params.checkpoint_id ? ` (${params.checkpoint_id})` : ''}`,
parameters: params,
risk,
reasoning: `Checkpoint operations can modify files and create/restore system state. Action: ${params.action}`,
affectedPaths: params.action === 'restore' ? ['workspace files'] : ['.ai/checkpoints/']
};
}
/**
* Main execution method for checkpoint operations
*/
async execute(params: CheckpointParams, _context: ToolContext): Promise<Result<ToolResult<string>>> {
try {
// Validate workspace before any operations
await this.validateNotInProtectedDirectory();
// Initialize shadow repository if needed
await this.initializeShadowRepo();
let output: string;
switch (params.action) {
case 'create':
output = await this.createCheckpoint(params);
break;
case 'list':
output = await this.listCheckpoints();
break;
case 'restore':
output = await this.restoreCheckpoint(params);
break;
case 'diff':
output = await this.showCheckpointDiff(params);
break;
case 'delete':
output = await this.deleteCheckpoint(params);
break;
case 'status':
output = await this.showStatus();
break;
case 'cleanup':
output = await this.cleanupOldCheckpoints(params.force);
break;
default:
throw new Error(`Unknown action: ${params.action}`);
}
return ok({
success: true,
data: output,
output,
metadata: {
duration: 0, // Would need to measure actual execution time
filesModified: params.action === 'restore' ? ['workspace files'] : undefined
}
});
} catch (error) {
return err(error as Error);
}
}
/**
* Initialize shadow repository for checkpoints
*/
private async initializeShadowRepo(): Promise<void> {
// Check if shadow repo directory exists
let dirExists = false;
try {
await access(this.shadowRepoPath);
dirExists = true;
} catch {
dirExists = false;
}
if (dirExists) {
// Directory exists - check if it's a valid git repo
try {
execSync('git status', { cwd: this.shadowRepoPath, stdio: 'pipe' });
// Verify worktree configuration
try {
const worktree = execSync('git config core.worktree', {
cwd: this.shadowRepoPath,
stdio: 'pipe',
encoding: 'utf8'
}).trim();
if (worktree !== this.workspaceRoot) {
throw new Error(`Checkpoints can only be used in the original workspace: ${worktree}`);
}
} catch {
// If no worktree config, add it
console.log('Adding worktree configuration to existing checkpoint repository...');
execSync(`git config --local core.worktree "${this.workspaceRoot}"`, { cwd: this.shadowRepoPath, stdio: 'pipe' });
}
// Repository is valid, we're done
return;
} catch {
// Directory exists but is not a valid git repo - try to repair
console.warn('Warning: Shadow repo directory exists but git is corrupted. Attempting to repair...');
try {
// Check if it contains checkpoint data before destroying
const checkpointsFile = join(this.shadowRepoPath, 'checkpoints.json');
const backupData = await readFile(checkpointsFile, 'utf8');
// Remove .git directory and reinitialize
execSync('rm -rf .git', { cwd: this.shadowRepoPath, stdio: 'pipe' });
await this.initializeNewShadowRepo();
// Restore checkpoints.json
await writeFile(checkpointsFile, backupData);
// Add and commit existing data
execSync('git add .', { cwd: this.shadowRepoPath, stdio: 'pipe' });
execSync('git commit -m "Restored checkpoint repository"', {
cwd: this.shadowRepoPath,
stdio: 'pipe'
});
console.log('Successfully repaired corrupted checkpoint repository');
return;
} catch {
// No checkpoint data exists, safe to reinitialize
console.log('Shadow repo directory exists but is not a git repo and contains no checkpoints. Initializing...');
}
}
}
// Directory doesn't exist OR exists but has no checkpoints - safe to create fresh
await mkdir(this.shadowRepoPath, { recursive: true });
await this.initializeNewShadowRepo();
}
/**
* Initialize new shadow git repository
*/
private async initializeNewShadowRepo(): Promise<void> {
// Initialize git repository
execSync('git init', { cwd: this.shadowRepoPath, stdio: 'pipe' });
// Configure repo with git settings (using --local for safety)
execSync(`git config --local core.worktree "${this.workspaceRoot}"`, { cwd: this.shadowRepoPath, stdio: 'pipe' });
execSync('git config --local commit.gpgSign false', { cwd: this.shadowRepoPath, stdio: 'pipe' });
execSync('git config --local user.name "AI Checkpoint"', { cwd: this.shadowRepoPath, stdio: 'pipe' });
execSync('git config --local user.email "[email protected]"', { cwd: this.shadowRepoPath, stdio: 'pipe' });
// Create initial files
const gitignorePath = join(this.shadowRepoPath, '.gitignore');
await writeFile(gitignorePath, '#Checkpoints Shadow Repository\n*.tmp\n*.log\n');
const metadataPath = join(this.shadowRepoPath, 'workspace-metadata.json');
const metadata = {
workspaceRoot: resolve(this.workspaceRoot),
workspaceHash: this.workspaceHash,
createdAt: new Date().toISOString(),
version: '2.0.0',
description: 'Checkpoint system architecture'
};
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
// Initialize empty checkpoints file
const checkpointsFile = join(this.shadowRepoPath, 'checkpoints.json');
await writeFile(checkpointsFile, JSON.stringify([], null, 2));
// Add files and create initial commit
await this.addCheckpointFiles();
execSync('git commit --allow-empty -m "initial commit"', {
cwd: this.shadowRepoPath,
stdio: 'pipe'
});
}
/**
* Create a new checkpoint
*/
private async createCheckpoint(params: CheckpointParams): Promise<string> {
if (!params.name) {
throw new Error('Checkpoint name is required');
}
const name = params.name;
const message = params.message || `Checkpoint created at ${new Date().toISOString()}`;
const checkpointId = this.generateCheckpointId(name);
// Check if checkpoint already exists
const checkpoints = await this.loadCheckpoints();
if (checkpoints.some(cp => cp.name === name)) {
if (!params.force) {
throw new Error(`Checkpoint "${name}" already exists. Use force=true to overwrite.`);
}
}
// Disable nested git repos before creating checkpoint
await this.renameNestedGitRepos(true);
try {
// Add all files using git (respecting .gitignore)
await this.addCheckpointFiles();
// Create commit
const commitMessage = `checkpoint-${this.workspaceHash}-${checkpointId}: ${name}`;
const commitResult = execSync(`git commit --allow-empty --no-verify -m "${commitMessage}"`, {
cwd: this.shadowRepoPath,
stdio: 'pipe',
encoding: 'utf8'
});
// Extract commit hash from output
const hashMatch = commitResult.match(/\[[\w\s]+\s+([a-f0-9]+)\]/);
const commitHash = hashMatch ? hashMatch[1] : '';
// Get file statistics
const stats = await this.getChangeStats();
const checkpoint: Checkpoint = {
id: checkpointId,
name,
message,
timestamp: new Date().toISOString(),
commitHash,
changes: stats,
hash: crypto.createHash('md5').update(`${name}-${Date.now()}`).digest('hex'),
isAutoCreated: false
};
// Save checkpoint metadata
const updatedCheckpoints = checkpoints.filter(cp => cp.name !== name);
updatedCheckpoints.push(checkpoint);
await this.saveCheckpoints(updatedCheckpoints);
// Auto-cleanup old checkpoints if needed
await this.autoCleanupIfNeeded();
return this.formatCheckpointCreated(checkpoint);
} finally {
// Re-enable nested git repos
await this.renameNestedGitRepos(false);
}
}
/**
* Create an auto-checkpoint (for file save triggers)
*/
async createAutoCheckpoint(triggerFile?: string): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const autoName = `auto-${timestamp}`;
const message = triggerFile
? `Auto-checkpoint after modifying ${triggerFile}`
: 'Auto-checkpoint on file save';
// Disable nested git repos before creating checkpoint
await this.renameNestedGitRepos(true);
try {
const checkpointId = this.generateCheckpointId(autoName);
// Add all files using git
await this.addCheckpointFiles();
// Create commit
const commitMessage = `checkpoint-${this.workspaceHash}-${checkpointId}: ${autoName}`;
const commitResult = execSync(`git commit --allow-empty --no-verify -m "${commitMessage}"`, {
cwd: this.shadowRepoPath,
stdio: 'pipe',
encoding: 'utf8'
});
// Extract commit hash
const hashMatch = commitResult.match(/\[[\w\s]+\s+([a-f0-9]+)\]/);
const commitHash = hashMatch ? hashMatch[1] : '';
const stats = await this.getChangeStats();
const checkpoint: Checkpoint = {
id: checkpointId,
name: autoName,
message,
timestamp: new Date().toISOString(),
commitHash,
changes: stats,
hash: crypto.createHash('md5').update(`${autoName}-${Date.now()}`).digest('hex'),
isAutoCreated: true
};
const checkpoints = await this.loadCheckpoints();
checkpoints.push(checkpoint);
await this.saveCheckpoints(checkpoints);
// Auto-cleanup after creating auto-checkpoint
await this.autoCleanupIfNeeded();
return `Auto-checkpoint created: ${autoName} (${commitHash.substring(0, 8)})`;
} finally {
// Re-enable nested git repos
await this.renameNestedGitRepos(false);
}
}
/**
* Get change statistics from git diff analysis
*/
private async getChangeStats(): Promise<{ additions: number; deletions: number; modifications: number }> {
try {
// Get detailed diff stats with line counts
const diffStats = execSync('git diff --cached --numstat', {
cwd: this.shadowRepoPath,
encoding: 'utf8',
stdio: 'pipe'
});
let modifications = 0;
let additions = 0;
// Parse numstat output: "additions\tdeletions\tfilename"
const lines = diffStats.split('\n').filter(line => line.trim());
for (const line of lines) {
const parts = line.split('\t');
if (parts.length >= 3) {
const addedLines = parseInt(parts[0]) || 0;
const deletedLines = parseInt(parts[1]) || 0;
// Determine if this is a new file, modification, or deletion
if (deletedLines === 0 && addedLines > 0) {
// New file
additions++;
} else if (addedLines > 0 || deletedLines > 0) {
// Modified file
modifications++;
}
}
}
// Get list of new files (not tracked by git yet)
try {
const statusOutput = execSync('git status --porcelain', {
cwd: this.shadowRepoPath,
encoding: 'utf8',
stdio: 'pipe'
});
const statusLines = statusOutput.split('\n').filter(line => line.trim());
for (const line of statusLines) {
const status = line.substring(0, 2);
if (status.includes('A') && !status.includes('M')) {
// File is staged as new (A) but not modified (M)
additions++;
}
}
} catch {
// Ignore status errors - we'll use what we have from numstat
}
return {
additions,
deletions: 0, // We track file-level deletions, not individual files deleted
modifications
};
} catch {
return { additions: 0, deletions: 0, modifications: 0 };
}
}
/**
* Restore to a specific checkpoint using git reset
*/
private async restoreCheckpoint(params: CheckpointParams): Promise<string> {
if (!params.checkpoint_id) {
throw new Error('checkpoint_id is required for restore action');
}
const checkpointId = params.checkpoint_id;
const checkpoints = await this.loadCheckpoints();
const checkpoint = checkpoints.find(cp => cp.id === checkpointId || cp.name === checkpointId);
if (!checkpoint) {
throw new Error(`Checkpoint "${checkpointId}" not found. Use action: "list" to see available checkpoints.`);
}
if (!checkpoint.commitHash) {
throw new Error(`Checkpoint "${checkpoint.name}" has no commit hash. It may be from an older version.`);
}
// Show preview of changes before restore
const diffResult = await this.generateRestoreDiff(checkpoint);
if (!params.force) {
// Request permission for high-risk restore operation
const permissionRequest = createCheckpointPermissionRequest(
OperationType.CHECKPOINT_RESTORE,
checkpoint.name,
`Restore checkpoint "${checkpoint.name}"`,
RiskLevel.HIGH,
`Restore Preview:\n\n${diffResult}\n\nWarning: This will overwrite current files using git reset.`
);
const permissionGrant = await this.permissionManager.requestPermission(permissionRequest);
if (!permissionGrant.granted) {
return permissionGrant.feedback || 'Checkpoint restore cancelled by user';
}
}
// Disable nested git repos during restore
await this.renameNestedGitRepos(true);
try {
// Reset to checkpoint commit (hard reset)
execSync(`git reset --hard ${checkpoint.commitHash}`, {
cwd: this.shadowRepoPath,
stdio: 'pipe'
});
return this.formatRestoreComplete(checkpoint);
} finally {
// Re-enable nested git repos
await this.renameNestedGitRepos(false);
}
}
/**
* Generate diff using git diff
*/
private async generateRestoreDiff(checkpoint: Checkpoint): Promise<string> {
if (!checkpoint.commitHash) {
return `Error: Cannot generate diff: checkpoint "${checkpoint.name}" has no commit hash.`;
}
// Disable nested git repos for diff operation
await this.renameNestedGitRepos(true);
try {
// Stage current changes so they appear in diff
await this.addCheckpointFiles();
// Get diff between current state and checkpoint
const diffOutput = execSync(`git diff ${checkpoint.commitHash}`, {
cwd: this.shadowRepoPath,
encoding: 'utf8',
stdio: 'pipe'
});
if (!diffOutput.trim()) {
return `No differences found between current state and checkpoint "${checkpoint.name}".`;
}
// Count changed files
const changedFiles = (diffOutput.match(/^diff --git/gm) || []).length;
return `Differences for checkpoint "${checkpoint.name}" (${changedFiles} file${changedFiles > 1 ? 's' : ''} changed):\n\n${diffOutput}`;
} catch (error) {
return `Failed to generate diff: ${error}`;
} finally {
// Re-enable nested git repos
await this.renameNestedGitRepos(false);
}
}
/**
* List all checkpoints
*/
private async listCheckpoints(): Promise<string> {
const checkpoints = await this.loadCheckpoints();
if (checkpoints.length === 0) {
return `No checkpoints found.\n\nCreate your first checkpoint with:\n action: "create"\n name: "my-checkpoint"\n message: "Description of changes"`;
}
const results: string[] = [];
results.push(`Available Checkpoints (${checkpoints.length}):\n`);
results.push(`Workspace: ${relative(process.cwd(), this.workspaceRoot)} (${this.workspaceHash})\n`);
// Sort by timestamp (newest first)
const sortedCheckpoints = checkpoints.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
for (const checkpoint of sortedCheckpoints) {
const timeAgo = this.getTimeAgo(new Date(checkpoint.timestamp));
const { additions, modifications } = checkpoint.changes;
const autoMarker = checkpoint.isAutoCreated ? '[AUTO]' : '[MANUAL]';
results.push(`\n${autoMarker} ${checkpoint.name} (${checkpoint.id})`);
results.push(` Message: ${checkpoint.message}`);
results.push(` Created: ${timeAgo}`);
results.push(` Changes: ${additions} new, ${modifications} modified`);
if (checkpoint.commitHash) {
results.push(` Commit: ${checkpoint.commitHash.substring(0, 8)}`);
}
}
results.push(`\nUse action: "restore" with checkpoint_id to restore a checkpoint.`);
results.push(`Use action: "diff" with checkpoint_id to see changes.`);
return results.join('\n');
}
/**
* Show diff between current state and checkpoint
*/
private async showCheckpointDiff(params: CheckpointParams): Promise<string> {
if (!params.checkpoint_id) {
throw new Error('checkpoint_id is required for diff action');
}
const checkpointId = params.checkpoint_id;
const checkpoints = await this.loadCheckpoints();
const checkpoint = checkpoints.find(cp => cp.id === checkpointId || cp.name === checkpointId);
if (!checkpoint) {
throw new Error(`Checkpoint "${checkpointId}" not found.`);
}
return await this.generateRestoreDiff(checkpoint);
}
/**
* Delete a checkpoint
*/
private async deleteCheckpoint(params: CheckpointParams): Promise<string> {
if (!params.checkpoint_id) {
throw new Error('checkpoint_id is required for delete action');
}
const checkpointId = params.checkpoint_id;
const checkpoints = await this.loadCheckpoints();
const checkpointIndex = checkpoints.findIndex(cp => cp.id === checkpointId || cp.name === checkpointId);
if (checkpointIndex === -1) {
throw new Error(`Checkpoint "${checkpointId}" not found.`);
}
const checkpoint = checkpoints[checkpointIndex];
if (!params.force) {
// Request permission for checkpoint deletion
const permissionRequest = createCheckpointPermissionRequest(
OperationType.CHECKPOINT_DELETE,
checkpoint.name,
`Delete checkpoint "${checkpoint.name}"`,
RiskLevel.MEDIUM,
`Delete checkpoint "${checkpoint.name}"?\n\nCheckpoint Info:\n Message: ${checkpoint.message}\n Created: ${this.getTimeAgo(new Date(checkpoint.timestamp))}\n Commit: ${checkpoint.commitHash?.substring(0, 8) || 'no hash'}\n\nThis action cannot be undone.`
);
const permissionGrant = await this.permissionManager.requestPermission(permissionRequest);
if (!permissionGrant.granted) {
return permissionGrant.feedback || 'Checkpoint deletion cancelled by user';
}
}
// Remove from metadata (git commits remain in history)
checkpoints.splice(checkpointIndex, 1);
await this.saveCheckpoints(checkpoints);
return `Checkpoint "${checkpoint.name}" deleted successfully.\n\nRemaining checkpoints: ${checkpoints.length}`;
}
/**
* Show status of checkpointing system
*/
private async showStatus(): Promise<string> {
const checkpoints = await this.loadCheckpoints();
const autoCheckpoints = checkpoints.filter(cp => cp.isAutoCreated);
const namedCheckpoints = checkpoints.filter(cp => !cp.isAutoCreated);
const results: string[] = [];
results.push('Checkpoint System Status\n');
results.push('─'.repeat(40));
results.push(`Shadow Repository: ${relative(this.workspaceRoot, this.shadowRepoPath)}`);
results.push(`Workspace Hash: ${this.workspaceHash}`);
results.push(`Total Checkpoints: ${checkpoints.length}`);
results.push(` Auto-created: ${autoCheckpoints.length}`);
results.push(` Named: ${namedCheckpoints.length}`);
if (checkpoints.length > 0) {
const latest = checkpoints.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
)[0];
results.push(`Latest: "${latest.name}" (${this.getTimeAgo(new Date(latest.timestamp))})`);
}
// Show retention policy
results.push(`\nRetention Policy:`);
results.push(` • Keep last ${this.retentionConfig.maxAutoCheckpoints} auto-checkpoints`);
results.push(` • Delete auto-checkpoints older than ${this.retentionConfig.maxAgeDays} days`);
results.push(` • Cleanup when total size exceeds ${this.retentionConfig.maxTotalSizeMB}MB`);
if (this.retentionConfig.preserveNamed) {
results.push(' • Always preserve manually named checkpoints');
}
// Check workspace git status
try {
const gitStatus = execSync('git status --porcelain', {
cwd: this.workspaceRoot,
encoding: 'utf-8',
stdio: 'pipe'
});
const hasChanges = gitStatus.trim().length > 0;
results.push(`\nWorkspace Status: ${hasChanges ? 'Modified (has uncommitted changes)' : 'Clean'}`);
if (hasChanges) {
results.push('Consider creating a checkpoint before major changes.');
}
} catch {
results.push('\nWorkspace Status: Not a git repository');
}
return results.join('\n');
}
/**
* Clean up old checkpoints based on retention policy
*/
private async cleanupOldCheckpoints(force = false): Promise<string> {
const checkpoints = await this.loadCheckpoints();
const toDelete: Checkpoint[] = [];
const stats = {
total: checkpoints.length,
autoCheckpoints: 0,
namedCheckpoints: 0,
deletedByAge: 0,
deletedByCount: 0,
deletedBySize: 0
};
// Calculate current statistics
for (const checkpoint of checkpoints) {
if (checkpoint.isAutoCreated) {
stats.autoCheckpoints++;
} else {
stats.namedCheckpoints++;
}
}
// Sort checkpoints by age (newest first)
const sortedCheckpoints = checkpoints.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// 1. Delete auto-checkpoints older than maxAgeDays
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - this.retentionConfig.maxAgeDays);
for (const checkpoint of sortedCheckpoints) {
if (checkpoint.isAutoCreated && new Date(checkpoint.timestamp) < cutoffDate) {
toDelete.push(checkpoint);
stats.deletedByAge++;
}
}
// 2. Keep only maxAutoCheckpoints most recent auto-checkpoints
const autoCheckpoints = sortedCheckpoints
.filter(cp => cp.isAutoCreated && !toDelete.includes(cp));
if (autoCheckpoints.length > this.retentionConfig.maxAutoCheckpoints) {
const excess = autoCheckpoints.slice(this.retentionConfig.maxAutoCheckpoints);
toDelete.push(...excess);
stats.deletedByCount += excess.length;
}
// 3. Size-based cleanup - enforce maxTotalSizeMB limit
try {
const getFolderSize = (await import('get-folder-size')).default;
let currentSizeBytes = await getFolderSize.loose(this.shadowRepoPath);
let currentSizeMB = currentSizeBytes / (1024 * 1024);
if (currentSizeMB > this.retentionConfig.maxTotalSizeMB) {
console.log(`Checkpoint size (${currentSizeMB.toFixed(1)}MB) exceeds limit (${this.retentionConfig.maxTotalSizeMB}MB), performing size-based cleanup`);
// Delete oldest auto-checkpoints until we're under the size limit
const remainingAutoCheckpoints = sortedCheckpoints
.filter(cp => cp.isAutoCreated && !toDelete.includes(cp))
.reverse(); // Oldest first for deletion
for (const checkpoint of remainingAutoCheckpoints) {
// Calculate individual checkpoint size estimate
const checkpointSizeEstimate = await this.estimateCheckpointSize(checkpoint);
toDelete.push(checkpoint);
stats.deletedBySize++;
// Update estimated size after marking for deletion
currentSizeMB -= checkpointSizeEstimate;
// Stop when we're projected to be under the limit
if (currentSizeMB <= this.retentionConfig.maxTotalSizeMB) {
break;
}
}
}
} catch (error) {
console.warn('Failed to calculate checkpoint size for cleanup:', error);
}
if (toDelete.length === 0) {
return this.formatCleanupResults(stats, []);
}
if (!force) {
const summary = toDelete.map(cp =>
` • ${cp.name} (${this.getTimeAgo(new Date(cp.timestamp))})`
).join('\n');
return `Cleanup Preview - ${toDelete.length} checkpoints to delete:\n\n${summary}\n\nUse force=true to proceed with cleanup.\n\nCurrent: ${stats.total} checkpoints\nAfter cleanup: ${stats.total - toDelete.length} checkpoints`;
}
// Perform cleanup by removing from metadata
// Note: Git commits remain in history
const remaining = checkpoints.filter(cp => !toDelete.includes(cp));
await this.saveCheckpoints(remaining);
const deleted = toDelete.map(cp => cp.name);
return this.formatCleanupResults(stats, deleted);
}
/**
* Estimate the size of a single checkpoint by analyzing its git objects
*/
private async estimateCheckpointSize(checkpoint: Checkpoint): Promise<number> {
try {
if (!checkpoint.commitHash) {
// For checkpoints without commit hash, use a conservative estimate
return 1; // 1MB default estimate
}
// Get the size of objects for this specific commit
const sizeOutput = execSync(
`git cat-file -s ${checkpoint.commitHash} 2>/dev/null || echo "0"`,
{
cwd: this.shadowRepoPath,
encoding: 'utf8',
stdio: 'pipe'
}
).trim();
const commitObjectSize = parseInt(sizeOutput) || 0;
// Get the number of files changed in this commit for better estimation
const diffStats = execSync(
`git diff-tree --numstat ${checkpoint.commitHash} 2>/dev/null | wc -l || echo "1"`,
{
cwd: this.shadowRepoPath,
encoding: 'utf8',
stdio: 'pipe'
}
).trim();
const filesChanged = parseInt(diffStats) || 1;
// Estimate total checkpoint size:
// - Base commit object size
// - Plus estimated file content (files changed * average file size)
// - Convert to MB
const estimatedBytes = commitObjectSize + (filesChanged * 5000); // 5KB average per file
const estimatedMB = estimatedBytes / (1024 * 1024);
// Return at least 0.1MB per checkpoint to avoid zero estimates
return Math.max(0.1, estimatedMB);
} catch {
// Conservative fallback estimate if git operations fail
return 1; // 1MB
}
}
/**
* Auto-cleanup after checkpoint creation
*/
private async autoCleanupIfNeeded(): Promise<void> {
const checkpoints = await this.loadCheckpoints();
// Quick check: do we have too many auto-checkpoints?
const autoCheckpoints = checkpoints.filter(cp => cp.isAutoCreated);
let needsCleanup = autoCheckpoints.length > this.retentionConfig.maxAutoCheckpoints;
// Also check size if not already triggered by count
if (!needsCleanup) {
try {
const getFolderSize = (await import('get-folder-size')).default;
const currentSizeBytes = await getFolderSize.loose(this.shadowRepoPath);
const currentSizeMB = currentSizeBytes / (1024 * 1024);
needsCleanup = currentSizeMB > this.retentionConfig.maxTotalSizeMB;
if (needsCleanup) {
console.log(`Auto-cleanup triggered: checkpoint size (${currentSizeMB.toFixed(1)}MB) exceeds limit (${this.retentionConfig.maxTotalSizeMB}MB)`);
}
} catch (error) {
console.warn('Failed to check checkpoint size for auto-cleanup:', error);
}
}
if (needsCleanup) {
try {
await this.cleanupOldCheckpoints(true); // Force cleanup
} catch (error) {
console.warn('Auto-cleanup failed:', error);
}
}
}
/**
* Format cleanup results
*/
private formatCleanupResults(stats: any, deleted: string[]): string {
const results: string[] = [];
if (deleted.length > 0) {
results.push(`Cleanup completed - ${deleted.length} checkpoints deleted from metadata`);
} else {
results.push('No cleanup needed');
}
results.push('');
results.push('Cleanup Statistics:');
results.push(` Total checkpoints: ${stats.total}`);
results.push(` Auto-created: ${stats.autoCheckpoints}`);
results.push(` Named: ${stats.namedCheckpoints}`);
if (deleted.length > 0) {
results.push('');
results.push('Deleted breakdown:');
if (stats.deletedByAge > 0) results.push(` By age (>${this.retentionConfig.maxAgeDays}d): ${stats.deletedByAge}`);
if (stats.deletedByCount > 0) results.push(` By count (>${this.retentionConfig.maxAutoCheckpoints}): ${stats.deletedByCount}`);
if (stats.deletedBySize > 0) results.push(` By size (>${this.retentionConfig.maxTotalSizeMB}MB): ${stats.deletedBySize}`);
results.push('');
results.push('Note: Git commits remain in repository history for safety');
}
return results.join('\n');
}
/**
* Load checkpoints from metadata
*/
private async loadCheckpoints(): Promise<Checkpoint[]> {
const metadataPath = join(this.shadowRepoPath, 'checkpoints.json');
try {
const content = await readFile(metadataPath, 'utf-8');
return JSON.parse(content);
} catch {
return [];
}
}
/**
* Save all checkpoints
*/
private async saveCheckpoints(checkpoints: Checkpoint[]): Promise<void> {
const metadataPath = join(this.shadowRepoPath, 'checkpoints.json');
await writeFile(metadataPath, JSON.stringify(checkpoints, null, 2));
}
/**
* Generate unique checkpoint ID
*/
private generateCheckpointId(name: string): string {
const timestamp = Date.now().toString();
const hash = crypto.createHash('md5').update(`${name}-${timestamp}`).digest('hex');
return `${name.replace(/[^a-zA-Z0-9-]/g, '-')}-${hash.substring(0, 8)}`;
}
/**
* Format checkpoint creation success message
*/
private formatCheckpointCreated(checkpoint: Checkpoint): string {
const { additions, modifications } = checkpoint.changes;
return [
`Checkpoint "${checkpoint.name}" created successfully!`,
'',
`Checkpoint Details:`,
` ID: ${checkpoint.id}`,
` Message: ${checkpoint.message}`,
` Changes: ${additions} new, ${modifications} modified`,
` Created: ${new Date(checkpoint.timestamp).toLocaleString()}`,
` Commit: ${checkpoint.commitHash.substring(0, 8)}`,
'',
`Use action: "restore" with checkpoint_id: "${checkpoint.name}" to restore this state.`
].join('\n');
}
/**
* Format restore completion message
*/
private formatRestoreComplete(checkpoint: Checkpoint): string {
return [
`Restored to checkpoint "${checkpoint.name}"!`,
'',
`Restore Summary:`,
` Checkpoint from: ${new Date(checkpoint.timestamp).toLocaleString()}`,
` Message: ${checkpoint.message}`,
` Commit: ${checkpoint.commitHash.substring(0, 8)}`,
'',
`Your workspace has been restored using git reset --hard.`,
`All files now match the checkpoint state exactly.`
].join('\n');
}
/**
* Get human-readable time ago
*/
private getTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffDays > 0) {
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
} else if (diffHours > 0) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
} else {
return 'just now';
}
}
}```
result.ts (from imports)
```typescript
/**
* Result Type Utility
* Provides type-safe error handling similar to Rust's Result type
*/
export type Result<T, E = Error> =
| { ok: true; value: T; error?: never }
| { ok: false; value?: never; error: E };
/**
* Create a successful result
*/
export function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
/**
* Create an error result
*/
export function err<E = Error>(error: E): Result<never, E> {
return { ok: false, error };
}
// Type aliases for convenience
export type Ok<T> = { ok: true; value: T };
export type Err<E = Error> = { ok: false; error: E };
/**
* Check if a result is successful
*/
export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
return result.ok;
}
/**
* Check if a result is an error
*/
export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {
return !result.ok;
}
/**
* Transform a successful result value
*/
export function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
if (result.ok) {
return { ok: true, value: fn(result.value) };
}
return { ok: false, error: result.error };
}
/**
* Transform an error result
*/
export function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
if (!result.ok) {
return { ok: false, error: fn(result.error) };
}
return { ok: true, value: result.value };
}
/**
* Chain results together
*/
export function andThen<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>,
): Result<U, E> {
if (result.ok) {
return fn(result.value);
}
return { ok: false, error: result.error } as Result<U, E>;
}
/**
* Unwrap a result value or throw an error
*/
export function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) {
return result.value;
}
throw result.error;
}
/**
* Unwrap a result value or return a default value
*/
export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
if (result.ok) {
return result.value;
}
return defaultValue;
}
/**
* Convert a promise that might throw into a Result
*/
export async function fromPromise<T>(promise: Promise<T>): Promise<Result<T, Error>> {
try {
const value = await promise;
return ok(value);
} catch (error) {
return err(error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Convert a function that might throw into a Result
*/
export function fromThrowable<T, Args extends any[]>(
fn: (...args: Args) => T,
): (...args: Args) => Result<T, Error> {
return (...args: Args) => {
try {
const value = fn(...args);
return ok(value);
} catch (error) {
return err(error instanceof Error ? error : new Error(String(error)));
}
};
}
/**
* Combine multiple results into a single result with an array of values
*/
export function combine<T, E>(results: Result<T, E>[]): Result<T[], E> {
const values: T[] = [];
for (const result of results) {
if (!result.ok) {
return { ok: false, error: result.error };
}
values.push(result.value);
}
return { ok: true, value: values };
}
/**
* Custom error types for better error handling
*/
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly cause?: unknown
) {
super(message);
this.name = 'AppError';
}
}
export class ValidationError extends AppError {
constructor(message: string, cause?: unknown) {
super(message, 'VALIDATION_ERROR', cause);
this.name = 'ValidationError';
}
}
export class DatabaseError extends AppError {
constructor(message: string, cause?: unknown) {
super(message, 'DATABASE_ERROR', cause);
this.name = 'DatabaseError';
}
}
export class NetworkError extends AppError {
constructor(message: string, cause?: unknown) {
super(message, 'NETWORK_ERROR', cause);
this.name = 'NetworkError';
}
}
export class FileSystemError extends AppError {
constructor(message: string, cause?: unknown) {
super(message, 'FILESYSTEM_ERROR', cause);
this.name = 'FileSystemError';
}
}
export class ConfigurationError extends AppError {
constructor(message: string, cause?: unknown) {
super(message, 'CONFIGURATION_ERROR', cause);
this.name = 'ConfigurationError';
}
}
export class ToolExecutionError extends AppError {
constructor(message: string, public readonly toolName: string, cause?: unknown) {
super(message, 'TOOL_EXECUTION_ERROR', cause);
this.name = 'ToolExecutionError';
}
}
/**
* Convert a standard Error to an AppError
*/
export function toAppError(error: unknown, defaultCode: string = 'UNKNOWN_ERROR'): AppError {
if (error instanceof AppError) {
return error;
}
if (error instanceof Error) {
return new AppError(error.message, defaultCode, error);
}
return new AppError(String(error), defaultCode, error);
}
/**
* Try-catch helper with Result type
*/
export function tryCatch<T>(
fn: () => T,
mapError?: (error: unknown) => Error
): Result<T, Error> {
try {
return ok(fn());
} catch (error) {
if (mapError) {
return err(mapError(error));
}
return err(error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Async try-catch helper with Result type
*/
export async function tryCatchAsync<T>(
fn: () => Promise<T>,
mapError?: (error: unknown) => Error
): Promise<Result<T, Error>> {
try {
const value = await fn();
return ok(value);
} catch (error) {
if (mapError) {
return err(mapError(error));
}
return err(error instanceof Error ? error : new Error(String(error)));
}
}Reacted by Simon Hackler, Federico Berto, Taine Zhao, mathe00 and ghaith chaabane
Reacted by Yashank
Metadata
Metadata
Assignees
Labels
No labels
Activity
rekram1-node commentedon Dec 28, 2025
[automated] Closing due to 90+ days of inactivity. Feel free to reopen if you still need this!