Files
codetyper.cli/src/services/snapshot-service.ts
Carlos Gutierrez a3c407d89a feat: implement hooks, plugins, session forks, and vim motions
Add 4 major features to codetyper-cli:

- Hooks System: Lifecycle hooks (PreToolUse, PostToolUse, SessionStart,
  SessionEnd, UserPromptSubmit, Stop) with exit code control flow
- Plugin System: Custom tools, commands, and hooks via plugin manifest
- Session Forking: Snapshots, rewind, fork, and switch between branches
- Vim Motions: Normal/Insert/Command/Visual modes with keyboard navigation

New files:
- src/types/{hooks,plugin,session-fork,vim}.ts
- src/constants/{hooks,plugin,session-fork,vim}.ts
- src/services/{hooks-service,plugin-loader,plugin-service,session-fork-service}.ts
- src/stores/vim-store.ts (vanilla)
- src/tui/hooks/{useVimMode,useVimStore,useTodoStore,useThemeStore}.ts
- src/tui/components/VimStatusLine.tsx

Modified:
- src/services/agent.ts (hook integration)
- src/tools/index.ts (plugin tool registration)
- src/stores/{todo-store,theme-store}.ts (converted to vanilla)
- TUI components (updated hook imports)
2026-01-31 22:22:04 -05:00

454 lines
12 KiB
TypeScript

/**
* Snapshot Service - Git-based differential snapshots
*
* Provides:
* - Git-based differential snapshots
* - Automatic 7-day retention pruning
* - Patch generation and validation
* - FileDiff tracking (additions, deletions, files changed)
*/
import { execSync, exec } from "child_process";
import fs from "fs/promises";
import path from "path";
import { v4 as uuidv4 } from "uuid";
const SNAPSHOTS_DIR = ".codetyper/snapshots";
const RETENTION_DAYS = 7;
const SNAPSHOT_BRANCH_PREFIX = "codetyper-snapshot-";
export interface FileDiff {
path: string;
status: "added" | "modified" | "deleted" | "renamed";
additions: number;
deletions: number;
oldPath?: string; // For renamed files
}
export interface Snapshot {
id: string;
timestamp: number;
message: string;
commitHash: string;
parentHash: string | null;
files: FileDiff[];
stats: {
filesChanged: number;
additions: number;
deletions: number;
};
}
export interface SnapshotMetadata {
id: string;
timestamp: number;
message: string;
commitHash: string;
}
const fileExists = async (filePath: string): Promise<boolean> => {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
};
const isGitRepository = async (workingDir: string): Promise<boolean> => {
return fileExists(path.join(workingDir, ".git"));
};
const runGitCommand = (
command: string,
cwd: string,
): { success: boolean; output: string; error?: string } => {
try {
const output = execSync(command, {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return { success: true, output: output.trim() };
} catch (err) {
const error = err as { stderr?: string; message?: string };
return {
success: false,
output: "",
error: error.stderr ?? error.message ?? "Unknown error",
};
}
};
const runGitCommandAsync = (
command: string,
cwd: string,
): Promise<{ success: boolean; output: string; error?: string }> => {
return new Promise((resolve) => {
exec(command, { cwd, encoding: "utf-8" }, (err, stdout, stderr) => {
if (err) {
resolve({ success: false, output: "", error: stderr || err.message });
} else {
resolve({ success: true, output: stdout.trim() });
}
});
});
};
const ensureSnapshotsDir = async (workingDir: string): Promise<void> => {
const snapshotsDir = path.join(workingDir, SNAPSHOTS_DIR);
await fs.mkdir(snapshotsDir, { recursive: true });
};
const parseGitDiff = (diffOutput: string): FileDiff[] => {
const files: FileDiff[] = [];
const lines = diffOutput.split("\n").filter((l) => l.trim());
for (const line of lines) {
// Format: 1 2 path or A - path (for additions)
const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
if (match) {
const additions = match[1] === "-" ? 0 : parseInt(match[1], 10);
const deletions = match[2] === "-" ? 0 : parseInt(match[2], 10);
const filePath = match[3];
// Check for rename (old => new)
const renameMatch = filePath.match(/^(.+) => (.+)$/);
if (renameMatch) {
files.push({
path: renameMatch[2],
oldPath: renameMatch[1],
status: "renamed",
additions,
deletions,
});
} else {
// Determine status based on additions/deletions
let status: FileDiff["status"] = "modified";
if (additions > 0 && deletions === 0) {
status = "added";
} else if (deletions > 0 && additions === 0) {
status = "deleted";
}
files.push({
path: filePath,
status,
additions,
deletions,
});
}
}
}
return files;
};
const getCommitDiff = (
workingDir: string,
commitHash: string,
parentHash: string | null,
): FileDiff[] => {
const compareTarget = parentHash ?? `${commitHash}^`;
const result = runGitCommand(
`git diff --numstat ${compareTarget} ${commitHash}`,
workingDir,
);
if (!result.success || !result.output) {
return [];
}
return parseGitDiff(result.output);
};
const getCurrentCommitHash = (workingDir: string): string | null => {
const result = runGitCommand("git rev-parse HEAD", workingDir);
return result.success ? result.output : null;
};
/** Get the most recent commit message */
export const getHeadCommitMessage = (workingDir: string): string => {
const result = runGitCommand("git log -1 --format=%s", workingDir);
return result.success ? result.output : "No message";
};
export const createSnapshot = async (
workingDir: string,
message?: string,
): Promise<Snapshot | null> => {
if (!(await isGitRepository(workingDir))) {
return null;
}
await ensureSnapshotsDir(workingDir);
const id = uuidv4();
const timestamp = Date.now();
const snapshotMessage = message ?? `Snapshot ${new Date(timestamp).toISOString()}`;
// Get current state
const currentCommit = getCurrentCommitHash(workingDir);
if (!currentCommit) {
return null;
}
// Check if there are uncommitted changes
const statusResult = runGitCommand("git status --porcelain", workingDir);
const hasChanges = statusResult.success && statusResult.output.length > 0;
let snapshotCommit = currentCommit;
let parentHash: string | null = null;
if (hasChanges) {
// Stash current changes, create snapshot, then restore
const stashResult = runGitCommand(
`git stash push -m "codetyper-temp-${id}"`,
workingDir,
);
if (!stashResult.success) {
return null;
}
// Get the parent commit (before stash)
parentHash = getCurrentCommitHash(workingDir);
} else {
// Get parent of current commit
const parentResult = runGitCommand("git rev-parse HEAD^", workingDir);
parentHash = parentResult.success ? parentResult.output : null;
}
// Calculate diff
const files = getCommitDiff(workingDir, snapshotCommit, parentHash);
const stats = {
filesChanged: files.length,
additions: files.reduce((sum, f) => sum + f.additions, 0),
deletions: files.reduce((sum, f) => sum + f.deletions, 0),
};
// Restore stashed changes if any
if (hasChanges) {
runGitCommand("git stash pop", workingDir);
}
// Save snapshot metadata
const snapshot: Snapshot = {
id,
timestamp,
message: snapshotMessage,
commitHash: snapshotCommit,
parentHash,
files,
stats,
};
const snapshotPath = path.join(workingDir, SNAPSHOTS_DIR, `${id}.json`);
await fs.writeFile(snapshotPath, JSON.stringify(snapshot, null, 2));
return snapshot;
};
export const getSnapshot = async (
workingDir: string,
snapshotId: string,
): Promise<Snapshot | null> => {
const snapshotPath = path.join(workingDir, SNAPSHOTS_DIR, `${snapshotId}.json`);
try {
const content = await fs.readFile(snapshotPath, "utf-8");
return JSON.parse(content) as Snapshot;
} catch {
return null;
}
};
export const listSnapshots = async (workingDir: string): Promise<SnapshotMetadata[]> => {
const snapshotsDir = path.join(workingDir, SNAPSHOTS_DIR);
if (!(await fileExists(snapshotsDir))) {
return [];
}
try {
const files = await fs.readdir(snapshotsDir);
const snapshots: SnapshotMetadata[] = [];
for (const file of files) {
if (!file.endsWith(".json")) continue;
try {
const content = await fs.readFile(path.join(snapshotsDir, file), "utf-8");
const snapshot = JSON.parse(content) as Snapshot;
snapshots.push({
id: snapshot.id,
timestamp: snapshot.timestamp,
message: snapshot.message,
commitHash: snapshot.commitHash,
});
} catch {
// Skip invalid snapshot files
}
}
// Sort by timestamp descending (newest first)
return snapshots.sort((a, b) => b.timestamp - a.timestamp);
} catch {
return [];
}
};
export const deleteSnapshot = async (
workingDir: string,
snapshotId: string,
): Promise<boolean> => {
const snapshotPath = path.join(workingDir, SNAPSHOTS_DIR, `${snapshotId}.json`);
try {
await fs.unlink(snapshotPath);
return true;
} catch {
return false;
}
};
export const pruneOldSnapshots = async (workingDir: string): Promise<number> => {
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000;
const snapshots = await listSnapshots(workingDir);
let deleted = 0;
for (const snapshot of snapshots) {
if (snapshot.timestamp < cutoff) {
if (await deleteSnapshot(workingDir, snapshot.id)) {
deleted++;
}
}
}
return deleted;
};
export const generatePatch = async (
workingDir: string,
snapshotId: string,
): Promise<string | null> => {
const snapshot = await getSnapshot(workingDir, snapshotId);
if (!snapshot) {
return null;
}
const compareTarget = snapshot.parentHash ?? `${snapshot.commitHash}^`;
const result = runGitCommand(
`git diff ${compareTarget} ${snapshot.commitHash}`,
workingDir,
);
return result.success ? result.output : null;
};
export const validatePatch = async (
workingDir: string,
patch: string,
): Promise<{ valid: boolean; errors: string[] }> => {
// Write patch to temp file
const tempPatchPath = path.join(workingDir, SNAPSHOTS_DIR, `temp-${Date.now()}.patch`);
try {
await fs.writeFile(tempPatchPath, patch);
// Try to apply patch with --check (dry run)
const result = await runGitCommandAsync(
`git apply --check "${tempPatchPath}"`,
workingDir,
);
return {
valid: result.success,
errors: result.error ? [result.error] : [],
};
} finally {
// Clean up temp file
try {
await fs.unlink(tempPatchPath);
} catch {
// Ignore cleanup errors
}
}
};
export const restoreSnapshot = async (
workingDir: string,
snapshotId: string,
): Promise<{ success: boolean; error?: string }> => {
const snapshot = await getSnapshot(workingDir, snapshotId);
if (!snapshot) {
return { success: false, error: "Snapshot not found" };
}
// Check if commit exists
const result = runGitCommand(
`git cat-file -t ${snapshot.commitHash}`,
workingDir,
);
if (!result.success) {
return { success: false, error: "Snapshot commit no longer exists" };
}
// Create a new branch from the snapshot
const branchName = `${SNAPSHOT_BRANCH_PREFIX}${snapshotId.slice(0, 8)}`;
const branchResult = runGitCommand(
`git checkout -b ${branchName} ${snapshot.commitHash}`,
workingDir,
);
if (!branchResult.success) {
return { success: false, error: branchResult.error };
}
return { success: true };
};
export const getWorkingDirectoryDiff = async (
workingDir: string,
): Promise<FileDiff[]> => {
if (!(await isGitRepository(workingDir))) {
return [];
}
// Get diff between HEAD and working directory
const stagedResult = runGitCommand("git diff --numstat --cached", workingDir);
const unstagedResult = runGitCommand("git diff --numstat", workingDir);
const files: FileDiff[] = [];
if (stagedResult.success && stagedResult.output) {
files.push(...parseGitDiff(stagedResult.output));
}
if (unstagedResult.success && unstagedResult.output) {
const unstagedFiles = parseGitDiff(unstagedResult.output);
// Merge with staged, preferring staged status
for (const file of unstagedFiles) {
if (!files.some((f) => f.path === file.path)) {
files.push(file);
}
}
}
return files;
};
export const snapshotService = {
createSnapshot,
getSnapshot,
listSnapshots,
deleteSnapshot,
pruneOldSnapshots,
generatePatch,
validatePatch,
restoreSnapshot,
getWorkingDirectoryDiff,
isGitRepository,
};