src — undo

Module: src-undo Cohesion: 0.80 Members: 0

src — undo

The src/undo module provides a robust checkpointing and undo/redo system for managing file state changes within a working directory. It allows Code Buddy to create snapshots of the file system, revert to previous states, and view differences between checkpoints, ensuring a safety net for potentially destructive operations.

Overview

The primary goal of this module is to offer a reliable mechanism for tracking and reverting changes made by automated tools or user actions. It acts as a local version control system, independent of Git, specifically for the operations performed by Code Buddy.

Key Features:

Core Concepts & Data Structures

The module defines several interfaces to structure the checkpoint data:

The CheckpointManager Class

The CheckpointManager class is the central component of this module. It extends TypedEventEmitter, allowing it to emit type-safe events for various lifecycle actions.

Initialization and Configuration

The CheckpointManager is instantiated with a workingDirectory and an optional Partial. It merges the provided config with DEFAULT_CONFIG.

const manager = new CheckpointManager('/path/to/project', {
  maxCheckpoints: 50,
  autoCheckpoint: true,
});

Upon construction, the initialize() method is called, which:

  1. Ensures the necessary data directories exist (~/.codebuddy/checkpoints//files).
  2. Loads existing checkpoints from index.json within the data directory.
  3. Starts the autoCheckpointTimer if config.autoCheckpoint is enabled.

Data Persistence

Checkpoints are stored on the local filesystem:

The saveIndex() method is responsible for writing the current state of this.checkpoints and this.currentIndex to index.json. This method is called after any operation that modifies the checkpoint history (creation, deletion, tagging, renaming, restoration).

Checkpoint Lifecycle

Creating Checkpoints

The createCheckpoint(options) method is the core function for capturing a snapshot.

  1. It generates a unique ID and timestamp.
  2. It determines which files to checkpoint: either explicitly provided in options.files or all tracked files in the workingDirectory (via getTrackedFiles()).
  3. For each file, snapshotFile() is called:

  1. It fetches Git information (getGitInfoAsync()) to enrich the checkpoint metadata.
  2. The new Checkpoint object is added to this.checkpoints.
  3. Any checkpoints after the new currentIndex are removed (clearing redo history).
  4. enforceMaxCheckpoints() is called to prune older checkpoints if maxCheckpoints is exceeded.
  5. The index is saved, and a checkpoint:created event is emitted.

Managing Checkpoint History (Undo/Redo)

Restoring Checkpoints

The restoreCheckpoint(checkpoint, operation) method is central to reverting file states.

  1. It first creates a safety checkpoint ("Before ${operation}") to allow reverting the restore itself.
  2. It iterates through all files recorded in the targetCheckpoint:

  1. Updates this.currentIndex to point to the restored checkpoint.
  2. Saves the index.
  3. Emits undo:complete, redo:complete, or restore:complete based on the operation parameter.
graph TD
    A[undo() / redo()] --> B{Target Checkpoint Exists?};
    B -- No --> C[Emit :noop event];
    B -- Yes --> D[restoreCheckpoint(target, operation)];
    D --> E[createCheckpoint("Before restore", automatic=true)];
    E --> F{For each file in target Checkpoint};
    F -- File deleted/non-existent --> G[fs.remove(targetPath)];
    F -- File exists --> H[fs.copy(storagePath, targetPath)];
    H --> I[fs.chmod(targetPath, file.mode)];
    I --> J[Add to restoredFiles];
    G --> J;
    J --> F;
    F -- All files processed --> K[Update currentIndex];
    K --> L[saveIndex()];
    L --> M[Emit :complete event];
    M --> N[Return UndoResult];

Diffing Checkpoints

The getDiff(fromId, toId) method calculates the differences between two specified checkpoints.

  1. It retrieves the fromCheckpoint and toCheckpoint by their IDs.
  2. It compares the files present in both checkpoints:

  1. For modified files, it uses the diff_match_patch library (this.dmp) to generate a semantic diff and then converts it to a patch text.
  2. Returns an array of FileChange objects.

Maintaining Checkpoints

Automatic Checkpointing

Eventing

CheckpointManager extends TypedEventEmitter, allowing external components to subscribe to its events:

Usage Examples (Conceptual)

import { createCheckpointManager } from './undo/checkpoint-manager.js';

async function main() {
  const workingDir = '/tmp/my-project';
  class="hl-cmt">// Ensure working directory exists for demonstration
  await fs.ensureDir(workingDir);
  await fs.writeFile(path.join(workingDir, 'file1.txt'), 'Initial content');

  const manager = createCheckpointManager(workingDir, {
    maxCheckpoints: 5,
    autoCheckpoint: false, class="hl-cmt">// Disable for manual control in example
  });

  class="hl-cmt">// 1. Create an initial checkpoint
  let cp1 = await manager.createCheckpoint({
    name: 'Initial State',
    operation: 'init',
    description: 'First checkpoint after project setup.',
  });
  console.log(`Created checkpoint: ${cp1.name} (${cp1.id})`);

  class="hl-cmt">// 2. Make some changes and create another checkpoint
  await fs.writeFile(path.join(workingDir, 'file1.txt'), 'Modified content');
  await fs.writeFile(path.join(workingDir, 'new_file.txt'), 'Hello world');
  let cp2 = await manager.createCheckpoint({
    name: 'Added new file',
    operation: 'add_feature',
    tags: ['feature', 'v1'],
  });
  console.log(`Created checkpoint: ${cp2.name} (${cp2.id})`);

  class="hl-cmt">// 3. View status
  console.log('\n--- Current Status ---');
  console.log(manager.formatStatus());

  class="hl-cmt">// 4. Undo to the previous state
  console.log('\n--- Performing Undo ---');
  const undoResult = await manager.undo();
  if (undoResult?.success) {
    console.log(`Undo successful. Restored files: ${undoResult.restoredFiles.join(', ')}`);
    console.log('Content of file1.txt after undo:', await fs.readFile(path.join(workingDir, 'file1.txt'), 'utf-8'));
    console.log('new_file.txt exists after undo:', await fs.pathExists(path.join(workingDir, 'new_file.txt')));
  } else {
    console.error('Undo failed:', undoResult?.errors);
  }

  class="hl-cmt">// 5. Redo to the next state
  console.log('\n--- Performing Redo ---');
  const redoResult = await manager.redo();
  if (redoResult?.success) {
    console.log(`Redo successful. Restored files: ${redoResult.restoredFiles.join(', ')}`);
    console.log('Content of file1.txt after redo:', await fs.readFile(path.join(workingDir, 'file1.txt'), 'utf-8'));
    console.log('new_file.txt exists after redo:', await fs.pathExists(path.join(workingDir, 'new_file.txt')));
  } else {
    console.error('Redo failed:', redoResult?.errors);
  }

  class="hl-cmt">// 6. Get diff between checkpoints
  console.log('\n--- Getting Diff between CP1 and CP2 ---');
  const diffs = await manager.getDiff(cp1.id, cp2.id);
  diffs.forEach(change => {
    console.log(`File: ${change.path}, Type: ${change.type}`);
    if (change.diff) {
      console.log('Diff:\n', change.diff);
    }
  });

  class="hl-cmt">// 7. Clean up
  manager.dispose();
  await fs.remove(workingDir);
}

main().catch(console.error);

Integration Points

The CheckpointManager is a self-contained module but interacts with several external libraries and is consumed by other parts of the Code Buddy application.

Dependencies (Outgoing Calls)

Consumers (Incoming Calls)

The CheckpointManager is primarily consumed by command handlers, suggesting its integration into Code Buddy's command-line interface or internal operations.