import { $ } from "bun" import path from "path" import fs from "fs/promises" import { Log } from "../util/log" import { Global } from "../global" import z from "zod" import { Config } from "../config/config" import { Instance } from "../project/instance" import { Scheduler } from "../scheduler" export namespace Snapshot { const log = Log.create({ service: "snapshot" }) const hour = 60 * 60 * 1000 const prune = "7.days" export function init() { Scheduler.register({ id: "snapshot.cleanup", interval: hour, run: cleanup, scope: "instance", }) } export async function cleanup() { if (Instance.project.vcs !== "git") return const cfg = await Config.get() if (cfg.snapshot === false) return const git = gitdir() const exists = await fs .stat(git) .then(() => true) .catch(() => false) if (!exists) return const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}` .quiet() .cwd(Instance.directory) .nothrow() if (result.exitCode !== 0) { log.warn("cleanup failed", { exitCode: result.exitCode, stderr: result.stderr.toString(), stdout: result.stdout.toString(), }) return } log.info("cleanup", { prune }) } export async function track() { if (Instance.project.vcs !== "git") return const cfg = await Config.get() if (cfg.snapshot === false) return const git = gitdir() if (await fs.mkdir(git, { recursive: true })) { await $`git init` .env({ ...process.env, GIT_DIR: git, GIT_WORK_TREE: Instance.worktree, }) .quiet() .nothrow() // Configure git to not convert line endings on Windows await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow() log.info("initialized") } await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree` .quiet() .cwd(Instance.directory) .nothrow() .text() log.info("tracking", { hash, cwd: Instance.directory, git }) return hash.trim() } export const Patch = z.object({ hash: z.string(), files: z.string().array(), }) export type Patch = z.infer export async function patch(hash: string): Promise { const git = gitdir() await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() const result = await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` .quiet() .cwd(Instance.directory) .nothrow() // If git diff fails, return empty patch if (result.exitCode !== 0) { log.warn("failed to get diff", { hash, exitCode: result.exitCode }) return { hash, files: [] } } const files = result.text() return { hash, files: files .trim() .split("\n") .map((x) => x.trim()) .filter(Boolean) .map((x) => unquote(x)) .map((x) => path.join(Instance.worktree, x)), } } export async function restore(snapshot: string) { log.info("restore", { commit: snapshot }) const git = gitdir() const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f` .quiet() .cwd(Instance.worktree) .nothrow() if (result.exitCode !== 0) { log.error("failed to restore snapshot", { snapshot, exitCode: result.exitCode, stderr: result.stderr.toString(), stdout: result.stdout.toString(), }) } } export async function revert(patches: Patch[]) { const files = new Set() const git = gitdir() for (const item of patches) { for (const file of item.files) { if (files.has(file)) continue log.info("reverting", { file, hash: item.hash }) const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}` .quiet() .cwd(Instance.worktree) .nothrow() if (result.exitCode !== 0) { const relativePath = path.relative(Instance.worktree, file) const checkTree = await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}` .quiet() .cwd(Instance.worktree) .nothrow() if (checkTree.exitCode === 0 && checkTree.text().trim()) { log.info("file existed in snapshot but checkout failed, keeping", { file, }) } else { log.info("file did not exist in snapshot, deleting", { file }) await fs.unlink(file).catch(() => {}) } } files.add(file) } } } export async function diff(hash: string) { const git = gitdir() await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() const result = await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` .quiet() .cwd(Instance.worktree) .nothrow() if (result.exitCode !== 0) { log.warn("failed to get diff", { hash, exitCode: result.exitCode, stderr: result.stderr.toString(), stdout: result.stdout.toString(), }) return "" } return result.text().trim() } export const FileDiff = z .object({ file: z.string(), before: z.string(), after: z.string(), additions: z.number(), deletions: z.number(), }) .meta({ ref: "FileDiff", }) export type FileDiff = z.infer export async function diffFull(from: string, to: string): Promise { const git = gitdir() const result: FileDiff[] = [] for await (const line of $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` .quiet() .cwd(Instance.directory) .nothrow() .lines()) { if (!line) continue const [additions, deletions, rawFile] = line.split("\t") const file = unquote(rawFile) const isBinaryFile = additions === "-" && deletions === "-" const beforeResult = isBinaryFile ? { exitCode: 0, text: () => "", stderr: Buffer.from("") } : await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}` .quiet() .nothrow() const before = beforeResult.exitCode === 0 ? beforeResult.text() : `[DEBUG ERROR] git show ${from}:${file} failed: ${beforeResult.stderr.toString()}` const afterResult = isBinaryFile ? { exitCode: 0, text: () => "", stderr: Buffer.from("") } : await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}` .quiet() .nothrow() const after = afterResult.exitCode === 0 ? afterResult.text() : `[DEBUG ERROR] git show ${to}:${file} failed: ${afterResult.stderr.toString()}` const added = isBinaryFile ? 0 : parseInt(additions) const deleted = isBinaryFile ? 0 : parseInt(deletions) result.push({ file, before, after, additions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, }) } return result } export function unquote(path: string): string { // If the path is wrapped in quotes, it might contain octal escapes if (path.startsWith('"') && path.endsWith('"')) { const quoted = path.slice(1, -1) // Decode escaped characters const buffer: number[] = [] for (let i = 0; i < quoted.length; i++) { if (quoted[i] === "\\") { i++ // Check for octal escape (e.g. \344) if (i + 2 < quoted.length && /^[0-7]{3}$/.test(quoted.slice(i, i + 3))) { const octal = quoted.slice(i, i + 3) buffer.push(parseInt(octal, 8)) i += 2 } else { // Handle standard escapes switch (quoted[i]) { case "b": buffer.push(8) break case "t": buffer.push(9) break case "n": buffer.push(10) break case "v": buffer.push(11) break case "f": buffer.push(12) break case "r": buffer.push(13) break case '"': buffer.push(34) break case "\\": buffer.push(92) break default: // If unknown escape, keep original (or char code of escaped char) buffer.push(quoted.charCodeAt(i)) } } } else { const charCode = quoted.charCodeAt(i) if (charCode < 128) { buffer.push(charCode) } else { const charBuffer = Buffer.from(quoted[i]) for (const byte of charBuffer) { buffer.push(byte) } } } } return Buffer.from(buffer).toString("utf8") } return path } function gitdir() { const project = Instance.project return path.join(Global.Path.data, "snapshot", project.id) } }