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)
454 lines
12 KiB
TypeScript
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,
|
|
};
|