diff --git a/src/checkpoints/checkpoint-manager.ts b/src/checkpoints/checkpoint-manager.ts index 9cd03e7..6429fad 100644 --- a/src/checkpoints/checkpoint-manager.ts +++ b/src/checkpoints/checkpoint-manager.ts @@ -12,9 +12,13 @@ * - Doesn't clutter the user's stash stack. * - Captures both modifications AND creations (deleting files that * were created after the checkpoint). + * + * Checkpoints are persisted to disk via CheckpointStore, allowing + * restoration across extension restarts. */ import * as vscode from "vscode"; import { resolveInWorkspace } from "../utils/workspace-path"; +import { CheckpointStore } from "./checkpoint-store"; /** * A single file snapshot within a checkpoint. `content === null` means @@ -40,13 +44,53 @@ export class CheckpointManager { private static readonly MAX_FILE_BYTES = 10 * 1024 * 1024; // 10MB per file private checkpoints: Checkpoint[] = []; + private store: CheckpointStore | null; + private loadPromise: Promise = Promise.resolve(); + + constructor( + private readonly workspaceRoot: string, + storagePath?: string, + ) { + if (storagePath) { + this.store = new CheckpointStore(storagePath); + // Load checkpoints from disk on initialization + this.loadPromise = this.loadFromDisk().catch((err) => { + console.warn("Champ: failed to load checkpoints from disk:", err); + }); + } else { + this.store = null; + } + } - constructor(private readonly workspaceRoot: string) {} + /** + * Wait for checkpoints to load from disk. Useful for testing and + * scenarios where checkpoint state must be ready before proceeding. + */ + async waitForLoad(): Promise { + await this.loadPromise; + } + + /** + * Load checkpoints from disk and populate the in-memory list. + * This is called during initialization to restore session state. + */ + private async loadFromDisk(): Promise { + if (!this.store) return; + try { + const loaded = await this.store.loadAll(); + // Sort by timestamp to maintain chronological order + loaded.sort((a, b) => a.timestamp - b.timestamp); + this.checkpoints = loaded; + } catch (err) { + console.warn("Champ: error loading checkpoints from disk:", err); + } + } /** * Create a checkpoint capturing the current state of the given files. * Files that don't exist yet are recorded as "not existed" so a - * restore can delete them. + * restore can delete them. Checkpoints are persisted to disk if a + * storage path was configured. */ async create(label: string, filePaths: string[]): Promise { const snapshots: FileSnapshot[] = []; @@ -90,10 +134,27 @@ export class CheckpointManager { // Prune oldest if we exceed the checkpoint count limit. while (this.checkpoints.length >= CheckpointManager.MAX_CHECKPOINTS) { - this.checkpoints.shift(); + const oldest = this.checkpoints.shift(); + if (oldest && this.store) { + // Delete the oldest checkpoint from disk as well + await this.store.delete(oldest.id).catch((err) => { + console.warn( + "Champ: failed to delete old checkpoint from disk:", + err, + ); + }); + } } this.checkpoints.push(checkpoint); + + // Persist to disk if storage is configured + if (this.store) { + await this.store.save(checkpoint).catch((err) => { + console.warn("Champ: failed to save checkpoint to disk:", err); + }); + } + return checkpoint; } @@ -102,6 +163,8 @@ export class CheckpointManager { * discarded; the requested checkpoint becomes the most recent one * (inclusive on index, but we drop it and everything after so the * restore "becomes" the new latest state). + * + * Deleted checkpoints are also removed from disk if storage is configured. */ async restore(checkpointId: string): Promise { const idx = this.checkpoints.findIndex((c) => c.id === checkpointId); @@ -141,6 +204,19 @@ export class CheckpointManager { } } + // Delete discarded checkpoints from disk + const discarded = this.checkpoints.slice(idx + 1); + for (const cp of discarded) { + if (this.store) { + await this.store.delete(cp.id).catch((err) => { + console.warn( + "Champ: failed to delete discarded checkpoint from disk:", + err, + ); + }); + } + } + // Drop the restored checkpoint and everything after it. this.checkpoints = this.checkpoints.slice(0, idx); } @@ -150,9 +226,26 @@ export class CheckpointManager { return [...this.checkpoints]; } - /** Discard all checkpoints without restoring anything. */ + /** Discard all checkpoints without restoring anything. Clears disk asynchronously in background. */ clear(): void { + const toDelete = [...this.checkpoints]; this.checkpoints = []; + + // Delete checkpoints from disk asynchronously in the background + if (this.store && toDelete.length > 0) { + Promise.all( + toDelete.map((cp) => + this.store!.delete(cp.id).catch((err) => { + console.warn( + "Champ: failed to delete checkpoint from disk during clear:", + err, + ); + }), + ), + ).catch((err) => { + console.warn("Champ: error during checkpoint cleanup:", err); + }); + } } private generateId(): string { diff --git a/src/checkpoints/checkpoint-store.ts b/src/checkpoints/checkpoint-store.ts new file mode 100644 index 0000000..de7f133 --- /dev/null +++ b/src/checkpoints/checkpoint-store.ts @@ -0,0 +1,97 @@ +/** + * CheckpointStore: filesystem persistence for checkpoints. + * + * Each checkpoint is stored as a JSON file under the storage root + * directory. Files are human-readable and can be tracked in version control. + */ +import * as fs from "fs"; +import * as path from "path"; +import type { Checkpoint } from "./checkpoint-manager"; + +export class CheckpointStore { + constructor(private readonly storageRoot: string) {} + + /** + * Persist a checkpoint to /.json. + * Creates the storage directory if it doesn't exist. + */ + async save(checkpoint: Checkpoint): Promise { + await fs.promises.mkdir(this.storageRoot, { recursive: true }); + const filePath = this.checkpointPath(checkpoint.id); + + // Convert Uint8Array to base64 strings for JSON serialization + const serialized = { + ...checkpoint, + snapshots: checkpoint.snapshots.map(snap => ({ + filePath: snap.filePath, + content: snap.content ? Buffer.from(snap.content).toString('base64') : null, + existed: snap.existed, + })), + }; + + const json = JSON.stringify(serialized, null, 2); + // Write to temp file then rename for atomic writes. + const tmpPath = `${filePath}.tmp`; + await fs.promises.writeFile(tmpPath, json, "utf-8"); + await fs.promises.rename(tmpPath, filePath); + } + + /** + * Load all checkpoints from disk. Corrupted files are skipped with + * a console warning rather than crashing. + */ + async loadAll(): Promise { + let entries: string[]; + try { + entries = await fs.promises.readdir(this.storageRoot); + } catch { + return []; + } + + const checkpoints: Checkpoint[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const filePath = path.join(this.storageRoot, entry); + try { + const data = await fs.promises.readFile(filePath, "utf-8"); + const parsed = JSON.parse(data) as any; + if (parsed.id && parsed.label && Array.isArray(parsed.snapshots)) { + // Convert base64 strings back to Uint8Array + const checkpoint: Checkpoint = { + id: parsed.id, + label: parsed.label, + timestamp: parsed.timestamp, + snapshots: parsed.snapshots.map((snap: any) => ({ + filePath: snap.filePath, + content: snap.content ? new Uint8Array(Buffer.from(snap.content, 'base64')) : null, + existed: snap.existed, + })), + }; + checkpoints.push(checkpoint); + } + } catch (err) { + console.warn( + `Champ: skipping corrupted checkpoint file ${filePath}:`, + err instanceof Error ? err.message : err, + ); + } + } + return checkpoints; + } + + /** + * Delete a checkpoint file. + */ + async delete(id: string): Promise { + const filePath = this.checkpointPath(id); + try { + await fs.promises.unlink(filePath); + } catch { + // File may already be deleted. + } + } + + private checkpointPath(id: string): string { + return path.join(this.storageRoot, `${id}.json`); + } +} diff --git a/src/extension.ts b/src/extension.ts index 0a197a3..080abd5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -273,7 +273,10 @@ export async function activate( const globalMemoryBank = new GlobalMemoryBank(); const checkpointManager = workspaceRoot - ? new CheckpointManager(workspaceRoot) + ? new CheckpointManager( + workspaceRoot, + path.join(workspaceRoot, ".champ", "checkpoints"), + ) : null; workflowStore = workspaceRoot ? new WorkflowStore(workspaceRoot) : undefined; diff --git a/test/unit/checkpoints/checkpoint-manager.test.ts b/test/unit/checkpoints/checkpoint-manager.test.ts index 1b68cfe..10b04bb 100644 --- a/test/unit/checkpoints/checkpoint-manager.test.ts +++ b/test/unit/checkpoints/checkpoint-manager.test.ts @@ -2,9 +2,12 @@ * TDD: Tests for CheckpointManager. * Shadow-copy snapshot and restore. */ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { CheckpointManager } from "@/checkpoints/checkpoint-manager"; import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; describe("CheckpointManager", () => { let manager: CheckpointManager; @@ -101,3 +104,138 @@ describe("CheckpointManager", () => { await expect(manager.restore("nonexistent")).rejects.toThrow(); }); }); + +describe("CheckpointManager - Persistence", () => { + let tempDir: string; + let manager: CheckpointManager; + + beforeEach(async () => { + // Create a temporary directory for tests + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "checkpoint-manager-test-")); + manager = new CheckpointManager("/test-workspace", tempDir); + }); + + afterEach(async () => { + // Clean up temporary directory + try { + await fs.promises.rm(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + }); + + it("should save checkpoints to disk", async () => { + const fileContent = new TextEncoder().encode("original content"); + ( + vscode.workspace.fs.readFile as ReturnType + ).mockResolvedValue(fileContent); + + // Wait for async initialization to complete + await manager.waitForLoad(); + + const checkpoint = await manager.create("Persistent CP", ["src/main.ts"]); + + // Check that the file was saved to disk + const files = await fs.promises.readdir(tempDir); + expect(files).toContain(`${checkpoint.id}.json`); + }); + + it("should load checkpoints from disk on initialization", async () => { + const fileContent = new TextEncoder().encode("original content"); + ( + vscode.workspace.fs.readFile as ReturnType + ).mockResolvedValue(fileContent); + + // Wait for async initialization to complete + await manager.waitForLoad(); + + // Create a checkpoint with the first manager + const checkpoint = await manager.create("CP 1", ["src/main.ts"]); + const cpId = checkpoint.id; + + // Create a new manager instance (simulating extension restart) + const newManager = new CheckpointManager("/test-workspace", tempDir); + // Wait for async initialization to complete + await newManager.waitForLoad(); + + // The new manager should have loaded the checkpoint from disk + const list = newManager.list(); + expect(list.length).toBeGreaterThan(0); + expect(list.some(c => c.id === cpId)).toBe(true); + }); + + it("should restore checkpoints persisted to disk", async () => { + const fileContent = new TextEncoder().encode("original"); + ( + vscode.workspace.fs.readFile as ReturnType + ).mockResolvedValue(fileContent); + ( + vscode.workspace.fs.writeFile as ReturnType + ).mockResolvedValue(undefined); + + // Wait for async initialization to complete + await manager.waitForLoad(); + + // Create a checkpoint + const checkpoint = await manager.create("Before edit", ["main.ts"]); + + // Create a new manager and restore + const newManager = new CheckpointManager("/test-workspace", tempDir); + // Wait for async initialization to complete + await newManager.waitForLoad(); + await newManager.restore(checkpoint.id); + + expect(vscode.workspace.fs.writeFile).toHaveBeenCalledWith( + expect.anything(), + fileContent, + ); + }); + + it("should persist multiple checkpoints", async () => { + const fileContent = new TextEncoder().encode("content"); + ( + vscode.workspace.fs.readFile as ReturnType + ).mockResolvedValue(fileContent); + + // Wait for async initialization to complete + await manager.waitForLoad(); + + await manager.create("CP 1", ["a.ts"]); + await manager.create("CP 2", ["b.ts"]); + + // Load a new manager instance + const newManager = new CheckpointManager("/test-workspace", tempDir); + // Wait for async initialization to complete + await newManager.waitForLoad(); + const list = newManager.list(); + + expect(list.length).toBeGreaterThanOrEqual(2); + }); + + it("should delete persisted checkpoints from memory immediately", async () => { + const fileContent = new TextEncoder().encode("content"); + ( + vscode.workspace.fs.readFile as ReturnType + ).mockResolvedValue(fileContent); + + // Wait for async initialization to complete + await manager.waitForLoad(); + + const checkpoint = await manager.create("CP to delete", ["test.ts"]); + const cpId = checkpoint.id; + + // Verify checkpoint exists in memory + expect(manager.list()).toContainEqual(expect.objectContaining({ id: cpId })); + + // Delete via clear - should clear from memory immediately + manager.clear(); + + // Memory should be cleared immediately + expect(manager.list()).toHaveLength(0); + + // Files should be deleted asynchronously (within 2 seconds) + await new Promise(resolve => setTimeout(resolve, 1500)); + const files = await fs.promises.readdir(tempDir); + expect(files).not.toContain(`${cpId}.json`); + }); +}); diff --git a/test/unit/checkpoints/checkpoint-store.test.ts b/test/unit/checkpoints/checkpoint-store.test.ts new file mode 100644 index 0000000..4c98303 --- /dev/null +++ b/test/unit/checkpoints/checkpoint-store.test.ts @@ -0,0 +1,169 @@ +/** + * TDD: Tests for CheckpointStore persistence. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { CheckpointStore } from "@/checkpoints/checkpoint-store"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import type { Checkpoint } from "@/checkpoints/checkpoint-manager"; + +describe("CheckpointStore", () => { + let tempDir: string; + let store: CheckpointStore; + + beforeEach(async () => { + // Create a temporary directory for tests + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "checkpoint-store-test-")); + store = new CheckpointStore(tempDir); + }); + + afterEach(async () => { + // Clean up temporary directory + try { + await fs.promises.rm(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + }); + + it("should save and load a checkpoint", async () => { + const checkpoint: Checkpoint = { + id: "cp_test_1", + label: "Test Checkpoint", + timestamp: Date.now(), + snapshots: [ + { + filePath: "src/test.ts", + content: new Uint8Array([1, 2, 3, 4, 5]), + existed: true, + }, + ], + }; + + await store.save(checkpoint); + + const loaded = await store.loadAll(); + expect(loaded).toHaveLength(1); + expect(loaded[0].id).toBe("cp_test_1"); + expect(loaded[0].label).toBe("Test Checkpoint"); + expect(loaded[0].snapshots[0].filePath).toBe("src/test.ts"); + expect(loaded[0].snapshots[0].content).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + }); + + it("should handle checkpoints with null content (deleted files)", async () => { + const checkpoint: Checkpoint = { + id: "cp_test_deleted", + label: "Deleted File", + timestamp: Date.now(), + snapshots: [ + { + filePath: "deleted.ts", + content: null, + existed: false, + }, + ], + }; + + await store.save(checkpoint); + + const loaded = await store.loadAll(); + expect(loaded).toHaveLength(1); + expect(loaded[0].snapshots[0].content).toBeNull(); + expect(loaded[0].snapshots[0].existed).toBe(false); + }); + + it("should load multiple checkpoints", async () => { + const cp1: Checkpoint = { + id: "cp_1", + label: "Checkpoint 1", + timestamp: Date.now(), + snapshots: [], + }; + + const cp2: Checkpoint = { + id: "cp_2", + label: "Checkpoint 2", + timestamp: Date.now() + 1000, + snapshots: [], + }; + + await store.save(cp1); + await store.save(cp2); + + const loaded = await store.loadAll(); + expect(loaded).toHaveLength(2); + expect(loaded.map(c => c.id)).toContain("cp_1"); + expect(loaded.map(c => c.id)).toContain("cp_2"); + }); + + it("should delete a checkpoint", async () => { + const checkpoint: Checkpoint = { + id: "cp_to_delete", + label: "To Delete", + timestamp: Date.now(), + snapshots: [], + }; + + await store.save(checkpoint); + await store.delete("cp_to_delete"); + + const loaded = await store.loadAll(); + expect(loaded).toHaveLength(0); + }); + + it("should skip corrupted files", async () => { + // Save a valid checkpoint + const checkpoint: Checkpoint = { + id: "cp_valid", + label: "Valid", + timestamp: Date.now(), + snapshots: [], + }; + await store.save(checkpoint); + + // Write a corrupted JSON file + const corruptedPath = path.join(tempDir, "corrupted.json"); + await fs.promises.writeFile(corruptedPath, "{ invalid json", "utf-8"); + + // loadAll should skip the corrupted file + const loaded = await store.loadAll(); + expect(loaded).toHaveLength(1); + expect(loaded[0].id).toBe("cp_valid"); + }); + + it("should handle missing storage directory gracefully", async () => { + const nonexistentDir = path.join(tempDir, "nonexistent"); + const storeNonexistent = new CheckpointStore(nonexistentDir); + + const loaded = await storeNonexistent.loadAll(); + expect(loaded).toHaveLength(0); + }); + + it("should handle large file content (base64 encoding)", async () => { + // Create a larger buffer (1MB) + const largeContent = new Uint8Array(1024 * 1024); + for (let i = 0; i < largeContent.length; i++) { + largeContent[i] = i % 256; + } + + const checkpoint: Checkpoint = { + id: "cp_large", + label: "Large File", + timestamp: Date.now(), + snapshots: [ + { + filePath: "large.bin", + content: largeContent, + existed: true, + }, + ], + }; + + await store.save(checkpoint); + + const loaded = await store.loadAll(); + expect(loaded).toHaveLength(1); + expect(loaded[0].snapshots[0].content).toEqual(largeContent); + }); +});