src / backup.ts
src / backup.ts
import { existsSync } from "fs";
import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
import { basename, dirname, join } from "path";
import type { Workspace } from "./workspace";
export const BACKUP_DIR = ".lmstudio-backups";
const MAX_BACKUPS_PER_FILE = 10;
/**
* Snapshots a file before it is modified, so a bad edit by a small model is
* always recoverable. Backups live in a hidden folder inside the workspace and
* are capped per file. A file that does not exist yet is a no-op.
*/
export async function snapshot(ws: Workspace, absolutePath: string): Promise<string | undefined> {
if (!existsSync(absolutePath)) return undefined;
const info = await stat(absolutePath);
if (!info.isFile()) return undefined;
const relPath = ws.rel(absolutePath);
const safeName = relPath.replace(/[\\/:]/g, "__");
const dir = join(ws.root, BACKUP_DIR);
await mkdir(dir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const target = join(dir, `${safeName}.${stamp}.bak`);
await copyFile(absolutePath, target);
await pruneOldBackups(dir, safeName);
return target;
}
async function pruneOldBackups(dir: string, safeName: string): Promise<void> {
const entries = (await readdir(dir)).filter(
(name) => name.startsWith(`${safeName}.`) && name.endsWith(".bak"),
);
if (entries.length <= MAX_BACKUPS_PER_FILE) return;
entries.sort();
const doomed = entries.slice(0, entries.length - MAX_BACKUPS_PER_FILE);
const { unlink } = await import("fs/promises");
await Promise.all(doomed.map((name) => unlink(join(dir, name)).catch(() => undefined)));
}
export interface BackupEntry {
fileName: string;
originalPath: string;
takenAt: string;
size: number;
}
export async function listBackups(ws: Workspace): Promise<BackupEntry[]> {
const dir = join(ws.root, BACKUP_DIR);
if (!existsSync(dir)) return [];
const names = (await readdir(dir)).filter((name) => name.endsWith(".bak"));
const entries: BackupEntry[] = [];
for (const name of names) {
const match = /^(.*)\.([0-9T-]+Z)\.bak$/.exec(name);
if (match === null) continue;
const info = await stat(join(dir, name));
entries.push({
fileName: name,
originalPath: match[1].replace(/__/g, "/"),
takenAt: match[2],
size: info.size,
});
}
return entries.sort((a, b) => b.takenAt.localeCompare(a.takenAt));
}
export async function restoreBackup(ws: Workspace, backupFileName: string): Promise<string> {
const dir = join(ws.root, BACKUP_DIR);
const source = join(dir, basename(backupFileName));
if (!existsSync(source)) {
throw new Error(`No backup named "${backupFileName}". Use list_backups to see what exists.`);
}
const match = /^(.*)\.([0-9T-]+Z)\.bak$/.exec(basename(backupFileName));
if (match === null) throw new Error(`"${backupFileName}" is not a recognisable backup name.`);
const originalRel = match[1].replace(/__/g, "/");
const target = ws.resolveInRoot(originalRel);
await mkdir(dirname(target), { recursive: true });
// Snapshot what is there now, so a restore is itself undoable.
await snapshot(ws, target);
await copyFile(source, target);
return originalRel;
}
/** Reads a backup's contents, for diffing against the current file. */
export async function readBackup(ws: Workspace, backupFileName: string): Promise<string> {
const source = join(ws.root, BACKUP_DIR, basename(backupFileName));
if (!existsSync(source)) throw new Error(`No backup named "${backupFileName}".`);
return readFile(source, "utf-8");
}
import { existsSync } from "fs";
import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
import { basename, dirname, join } from "path";
import type { Workspace } from "./workspace";
export const BACKUP_DIR = ".lmstudio-backups";
const MAX_BACKUPS_PER_FILE = 10;
/**
* Snapshots a file before it is modified, so a bad edit by a small model is
* always recoverable. Backups live in a hidden folder inside the workspace and
* are capped per file. A file that does not exist yet is a no-op.
*/
export async function snapshot(ws: Workspace, absolutePath: string): Promise<string | undefined> {
if (!existsSync(absolutePath)) return undefined;
const info = await stat(absolutePath);
if (!info.isFile()) return undefined;
const relPath = ws.rel(absolutePath);
const safeName = relPath.replace(/[\\/:]/g, "__");
const dir = join(ws.root, BACKUP_DIR);
await mkdir(dir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const target = join(dir, `${safeName}.${stamp}.bak`);
await copyFile(absolutePath, target);
await pruneOldBackups(dir, safeName);
return target;
}
async function pruneOldBackups(dir: string, safeName: string): Promise<void> {
const entries = (await readdir(dir)).filter(
(name) => name.startsWith(`${safeName}.`) && name.endsWith(".bak"),
);
if (entries.length <= MAX_BACKUPS_PER_FILE) return;
entries.sort();
const doomed = entries.slice(0, entries.length - MAX_BACKUPS_PER_FILE);
const { unlink } = await import("fs/promises");
await Promise.all(doomed.map((name) => unlink(join(dir, name)).catch(() => undefined)));
}
export interface BackupEntry {
fileName: string;
originalPath: string;
takenAt: string;
size: number;
}
export async function listBackups(ws: Workspace): Promise<BackupEntry[]> {
const dir = join(ws.root, BACKUP_DIR);
if (!existsSync(dir)) return [];
const names = (await readdir(dir)).filter((name) => name.endsWith(".bak"));
const entries: BackupEntry[] = [];
for (const name of names) {
const match = /^(.*)\.([0-9T-]+Z)\.bak$/.exec(name);
if (match === null) continue;
const info = await stat(join(dir, name));
entries.push({
fileName: name,
originalPath: match[1].replace(/__/g, "/"),
takenAt: match[2],
size: info.size,
});
}
return entries.sort((a, b) => b.takenAt.localeCompare(a.takenAt));
}
export async function restoreBackup(ws: Workspace, backupFileName: string): Promise<string> {
const dir = join(ws.root, BACKUP_DIR);
const source = join(dir, basename(backupFileName));
if (!existsSync(source)) {
throw new Error(`No backup named "${backupFileName}". Use list_backups to see what exists.`);
}
const match = /^(.*)\.([0-9T-]+Z)\.bak$/.exec(basename(backupFileName));
if (match === null) throw new Error(`"${backupFileName}" is not a recognisable backup name.`);
const originalRel = match[1].replace(/__/g, "/");
const target = ws.resolveInRoot(originalRel);
await mkdir(dirname(target), { recursive: true });
// Snapshot what is there now, so a restore is itself undoable.
await snapshot(ws, target);
await copyFile(source, target);
return originalRel;
}
/** Reads a backup's contents, for diffing against the current file. */
export async function readBackup(ws: Workspace, backupFileName: string): Promise<string> {
const source = join(ws.root, BACKUP_DIR, basename(backupFileName));
if (!existsSync(source)) throw new Error(`No backup named "${backupFileName}".`);
return readFile(source, "utf-8");
}