import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, describe, expect, it, vi } from "vitest"; // Shadow repos live under DATA_DIR, which server/testing/setup.ts points at a disposable home before config.ts loads. import { CHECKPOINTS_DIR, GC_EVERY_COMMITS, diffWorkingTree, refusalReason, release, snapshot } from "./checkpoints.ts"; import { removeTempDir } from "./testing/cleanup.ts"; const scratchDirs: string[] = []; afterAll(async () => { for (const dir of scratchDirs) await removeTempDir(dir); }); let seq = 0; function workspace() { const cwd = mkdtempSync(join(tmpdir(), "omb-ckpt-ws-")); scratchDirs.push(cwd); seq += 1; return { bot: `ckpt-test-bot-${seq}`, cwd }; } function shadowOf(bot: string, cwd: string): string { return join(CHECKPOINTS_DIR, bot, createHash("sha256").update(realpathSync(cwd)).digest("hex").slice(0, 16)); } function snapshotsIn(bot: string, cwd: string): { hash: string; label: string }[] { const lines = userGit(shadowOf(bot, cwd), "log", "--format=%H%x09%s").split("\n").filter(Boolean); lines.pop(); // the root of the log is always the empty base marker return lines.map((line) => { const [hash, ...label] = line.split("\t"); return { hash: hash!, label: label.join("\t") }; }); } /** release() queues behind the folder's operations, and releasing a pin never taken is a no-op. */ async function drain(bot: string, cwd: string): Promise { await release(bot, cwd, "test-drain"); } /** GC_EVERY_COMMITS changed snapshots make a collection due; a later snapshot or the last settled diff releases it. */ async function churnUntilCollected(bot: string, cwd: string): Promise { let hash: string | null = null; for (let turn = 0; turn < GC_EVERY_COMMITS; turn += 1) { writeFileSync(join(cwd, "churn.txt"), `churn ${turn}`); hash = await snapshot(bot, cwd, `churn ${turn}`); expect(hash).toMatch(/^[0-9a-f]{40}$/); } await diffWorkingTree(bot, cwd, hash!); await drain(bot, cwd); } /** The user's own git, as the user would run it — no shadow env involved. */ function userGit(cwd: string, ...args: string[]): string { return execFileSync("git", args, { cwd, encoding: "utf8", env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_AUTHOR_NAME: "User", GIT_AUTHOR_EMAIL: "user@example.com", GIT_COMMITTER_NAME: "User", GIT_COMMITTER_EMAIL: "user@example.com", }, }); } describe("snapshot", () => { it("cancels optional digest capture without disabling later checkpoints", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "one"); expect(await snapshot(bot, cwd, "cancelled capture", AbortSignal.abort())).toBeNull(); expect(existsSync(shadowOf(bot, cwd))).toBe(false); expect(await snapshot(bot, cwd, "next turn")).toMatch(/^[0-9a-f]{40}$/); }); it("creates a checkpoint commit and is idempotent while nothing changes", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "one"); const first = await snapshot(bot, cwd, "turn 11111111"); expect(first).toMatch(/^[0-9a-f]{40}$/); const again = await snapshot(bot, cwd, "turn 22222222"); expect(again).toBe(first); expect(snapshotsIn(bot, cwd)).toEqual([{ hash: first, label: "turn 11111111" }]); writeFileSync(join(cwd, "a.txt"), "two"); const second = await snapshot(bot, cwd, "turn 33333333"); expect(second).toMatch(/^[0-9a-f]{40}$/); expect(second).not.toBe(first); expect(snapshotsIn(bot, cwd).map((c) => c.label)).toEqual(["turn 33333333"]); }); it("keeps only the newest usable checkpoint", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "obsolete payload"); await snapshot(bot, cwd, "old turn"); writeFileSync(join(cwd, "a.txt"), "latest payload"); const latest = await snapshot(bot, cwd, "latest turn"); expect(snapshotsIn(bot, cwd).map(c => c.hash)).toEqual([latest]); expect(readFileSync(join(cwd, "a.txt"), "utf8")).toBe("latest payload"); }); it("reclaims obsolete content on the GC throttle, behind the turn's settled diff, never every snapshot", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "obsolete payload"); const first = await snapshot(bot, cwd, "old turn"); const shadow = shadowOf(bot, cwd); const oldBlob = userGit(shadow, "rev-parse", `${first}:a.txt`).trim(); writeFileSync(join(cwd, "a.txt"), "payload 2"); await snapshot(bot, cwd, "turn 2"); await drain(bot, cwd); expect(() => userGit(shadow, "cat-file", "-e", oldBlob)).not.toThrow(); expect(GC_EVERY_COMMITS).toBeGreaterThan(3); for (let turn = 3; turn < GC_EVERY_COMMITS; turn += 1) { writeFileSync(join(cwd, "a.txt"), `payload ${turn}`); expect(await snapshot(bot, cwd, `turn ${turn}`)).toMatch(/^[0-9a-f]{40}$/); } await drain(bot, cwd); expect(() => userGit(shadow, "cat-file", "-e", oldBlob)).not.toThrow(); writeFileSync(join(cwd, "a.txt"), "latest payload"); const latest = await snapshot(bot, cwd, "latest turn"); await drain(bot, cwd); expect(() => userGit(shadow, "cat-file", "-e", oldBlob)).not.toThrow(); expect(await diffWorkingTree(bot, cwd, latest!)).toEqual({ changed: [], added: [], deleted: [] }); await drain(bot, cwd); expect(snapshotsIn(bot, cwd).map(c => c.hash)).toEqual([latest]); expect(() => userGit(shadow, "cat-file", "-e", oldBlob)).toThrow(); expect(userGit(shadow, "cat-file", "-p", `${latest}:a.txt`)).toBe("latest payload"); }); // Each case below is one where an incremental `add -A` alone would differ from a from-empty rebuild. // Case-insensitive volumes (macOS default APFS, Windows NTFS): the shadow // repo gets core.ignorecase=true, so an incremental add keeps old spellings. const caseInsensitiveTmp = (() => { const probe = mkdtempSync(join(tmpdir(), "omb-ckpt-case-")); scratchDirs.push(probe); writeFileSync(join(probe, "probe"), ""); return existsSync(join(probe, "PROBE")); })(); const treeNames = (shadow: string, hash: string | null) => userGit(shadow, "ls-tree", "-r", "-z", "--name-only", hash!).split("\0").filter(Boolean).map(name => name.normalize("NFC")).sort(); it.runIf((process.platform === "darwin" || process.platform === "win32") && caseInsensitiveTmp)( "records a case-only rename of a file, a folder and a dotfile like a from-empty rebuild", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "r0"), "file"); mkdirSync(join(cwd, "Docs")); writeFileSync(join(cwd, "Docs", "a.txt"), "in a folder"); writeFileSync(join(cwd, ".GITIGNORE"), "# rules\n"); const shadow = shadowOf(bot, cwd); const first = await snapshot(bot, cwd, "original spelling"); expect(treeNames(shadow, first)).toEqual([".GITIGNORE", "Docs/a.txt", "r0"]); renameSync(join(cwd, "r0"), join(cwd, "R0")); renameSync(join(cwd, "Docs"), join(cwd, "docs")); renameSync(join(cwd, ".GITIGNORE"), join(cwd, ".gitignore")); expect(await diffWorkingTree(bot, cwd, first!)).toEqual({ changed: [], added: [".gitignore", "R0", "docs/a.txt"], deleted: [".GITIGNORE", "Docs/a.txt", "r0"], }); const renamed = await snapshot(bot, cwd, "new spelling"); expect(treeNames(shadow, renamed)).toEqual([".gitignore", "R0", "docs/a.txt"]); expect(await snapshot(bot, cwd, "unchanged")).toBe(renamed); }, ); it.runIf((process.platform === "darwin" || process.platform === "win32") && caseInsensitiveTmp)( "compares decomposed (NFD) names in NFC when looking for a case-only rename", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "École.txt"), "accent"); const shadow = shadowOf(bot, cwd); expect(treeNames(shadow, await snapshot(bot, cwd, "upper"))).toEqual(["École.txt"]); renameSync(join(cwd, "École.txt"), join(cwd, "école.txt")); expect(treeNames(shadow, await snapshot(bot, cwd, "lower"))).toEqual(["école.txt"]); }, ); it("drops a newly ignored file from the next snapshot and the settled diff", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "source"); writeFileSync(join(cwd, "secret.txt"), "tracked until ignored"); const first = await snapshot(bot, cwd, "before ignore"); const shadow = shadowOf(bot, cwd); expect(userGit(shadow, "ls-tree", "-r", "--name-only", first!).trim().split("\n")).toEqual(["a.txt", "secret.txt"]); writeFileSync(join(cwd, ".gitignore"), "secret.txt\n"); expect(await diffWorkingTree(bot, cwd, first!)).toEqual({ changed: [], added: [".gitignore"], deleted: ["secret.txt"] }); const second = await snapshot(bot, cwd, "after ignore"); expect(userGit(shadow, "ls-tree", "-r", "--name-only", second!).trim().split("\n")).toEqual([".gitignore", "a.txt"]); expect(readFileSync(join(cwd, "secret.txt"), "utf8")).toBe("tracked until ignored"); }); it("matches a from-empty rebuild when a folder becomes, then stops being, a nested repo", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "outer"); mkdirSync(join(cwd, "lib")); writeFileSync(join(cwd, "lib", "b.txt"), "inner"); const shadow = shadowOf(bot, cwd); const tree = (hash: string | null) => userGit(shadow, "ls-tree", "-r", hash!).trim().split("\n").map(line => { const [meta, path] = line.split("\t"); return `${meta!.split(" ")[0]} ${path}`; }); expect(tree(await snapshot(bot, cwd, "plain folder"))).toEqual(["100644 a.txt", "100644 lib/b.txt"]); userGit(join(cwd, "lib"), "init"); userGit(join(cwd, "lib"), "add", "-A"); userGit(join(cwd, "lib"), "commit", "-m", "inner"); expect(tree(await snapshot(bot, cwd, "nested repo"))).toEqual(["100644 a.txt", "160000 lib"]); rmSync(join(cwd, "lib", ".git"), { recursive: true, force: true }); expect(tree(await snapshot(bot, cwd, "plain again"))).toEqual(["100644 a.txt", "100644 lib/b.txt"]); }); // A full 25-commit GC cycle with real git subprocesses: Windows process startup can exhaust the default limit. it("keeps a pinned pre-turn commit through a sibling turn's snapshot until it is released", async () => { const { bot, cwd } = workspace(); const shadow = shadowOf(bot, cwd); writeFileSync(join(cwd, "a.txt"), "before A"); const hashA = await snapshot(bot, cwd, "turn A", undefined, { pin: "dispatch-A" }); writeFileSync(join(cwd, "a.txt"), "A edited"); const hashB = await snapshot(bot, cwd, "turn B", undefined, { pin: "dispatch-B" }); expect(hashB).not.toBe(hashA); expect(snapshotsIn(bot, cwd).map(c => c.hash)).toEqual([hashB]); expect(await diffWorkingTree(bot, cwd, hashA!)).toEqual({ changed: ["a.txt"], added: [], deleted: [] }); expect(() => userGit(shadow, "cat-file", "-e", `${hashA}^{commit}`)).not.toThrow(); expect(() => userGit(shadow, "cat-file", "-e", `${hashB}^{commit}`)).not.toThrow(); await release(bot, cwd, "dispatch-A"); await release(bot, cwd, "dispatch-B"); await churnUntilCollected(bot, cwd); expect(() => userGit(shadow, "cat-file", "-e", `${hashA}^{commit}`)).toThrow(); expect(() => userGit(shadow, "cat-file", "-e", `${hashB}^{commit}`)).toThrow(); await release(bot, cwd, "dispatch-A"); await release(bot, cwd, "never-pinned"); }, process.platform === "win32" ? 60_000 : 20_000); it("sweeps pins a previous process left behind, on first use of the shadow", async () => { const { bot, cwd } = workspace(); const shadow = shadowOf(bot, cwd); writeFileSync(join(cwd, "a.txt"), "crashed mid-turn"); const leaked = await snapshot(bot, cwd, "turn X", undefined, { pin: "dispatch-X" }); expect(userGit(shadow, "for-each-ref", "refs/omb-live/").trim()).toContain(leaked); // a new process: fresh module state (nothing swept yet), same test home vi.resetModules(); const fresh = await import("./checkpoints.ts"); expect(fresh.CHECKPOINTS_DIR).toBe(CHECKPOINTS_DIR); writeFileSync(join(cwd, "a.txt"), "next start"); const next = await fresh.snapshot(bot, cwd, "turn Y"); expect(next).not.toBe(leaked); expect(userGit(shadow, "for-each-ref", "refs/omb-live/").trim()).toBe(""); expect(await fresh.diffWorkingTree(bot, cwd, next!)).toEqual({ changed: [], added: [], deleted: [] }); await fresh.release(bot, cwd, "test-drain"); expect(() => userGit(shadow, "cat-file", "-e", `${leaked}^{commit}`)).toThrow(); }); it("excludes tagged cache directories", async () => { const { bot, cwd } = workspace(); const cache = join(cwd, "custom-target"); mkdirSync(cache); writeFileSync(join(cwd, "a.txt"), "source"); writeFileSync(join(cache, "artifact"), "old build"); await snapshot(bot, cwd, "before cache tag"); writeFileSync(join(cache, "CACHEDIR.TAG"), "Signature: 8a477f597d28d172789f06886806bc55\n"); const latest = await snapshot(bot, cwd, "tagged cache"); const shadow = join(CHECKPOINTS_DIR, bot, createHash("sha256").update(realpathSync(cwd)).digest("hex").slice(0, 16)); expect(userGit(shadow, "ls-tree", "-r", "--name-only", latest!).trim()).toBe("a.txt"); }); it("never snapshots excluded or gitignored files", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "one"); writeFileSync(join(cwd, ".gitignore"), "secret.txt\n"); writeFileSync(join(cwd, "secret.txt"), "user-ignored, not checkpointed"); mkdirSync(join(cwd, "node_modules")); writeFileSync(join(cwd, "node_modules", "x.txt"), "installed dependency"); writeFileSync(join(cwd, ".env"), "API_KEY=hunter2"); writeFileSync(join(cwd, "run.log"), "log line"); mkdirSync(join(cwd, ".openmausbot-secrets", "thread-1"), { recursive: true }); writeFileSync(join(cwd, ".openmausbot-secrets", "thread-1", "card-1"), "sk_secret_file"); const checkpoint = await snapshot(bot, cwd, "turn 1"); expect(userGit(shadowOf(bot, cwd), "ls-tree", "-r", "--name-only", checkpoint!).trim().split("\n")).toEqual([".gitignore", "a.txt"]); }); it("compacts legacy history even when the workspace has not changed", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "old"); await snapshot(bot, cwd, "original"); const shadow = join(CHECKPOINTS_DIR, bot, createHash("sha256").update(realpathSync(cwd)).digest("hex").slice(0, 16)); for (const text of ["middle", "latest"]) { writeFileSync(join(cwd, "a.txt"), text); userGit(shadow, "--work-tree", cwd, "add", "-A"); userGit(shadow, "commit", "-m", text); } expect(snapshotsIn(bot, cwd)).toHaveLength(3); const hash = await snapshot(bot, cwd, "unchanged"); expect(snapshotsIn(bot, cwd)).toEqual([{ hash, label: "latest" }]); expect(userGit(shadow, "rev-list", "--count", "HEAD").trim()).toBe("2"); expect(readFileSync(join(cwd, "a.txt"), "utf8")).toBe("latest"); }); it.skipIf(process.platform === "win32")("keeps the last complete backup when a new snapshot cannot read a file", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "recoverable"); const first = await snapshot(bot, cwd, "complete"); writeFileSync(join(cwd, "a.txt"), "unfinished"); const locked = join(cwd, "locked.txt"); writeFileSync(locked, "not readable"); chmodSync(locked, 0o000); try { expect(await snapshot(bot, cwd, "incomplete")).toBeNull(); expect(snapshotsIn(bot, cwd).map(c => c.hash)).toEqual([first]); expect(readFileSync(join(cwd, "a.txt"), "utf8")).toBe("unfinished"); } finally { chmodSync(locked, 0o600); } }); it("never touches the user's folder itself (no .git appears in cwd)", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "one"); await snapshot(bot, cwd, "turn 1"); expect(existsSync(join(cwd, ".git"))).toBe(false); }); it("serializes concurrent snapshots instead of corrupting the shadow index", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "one"); const hashes = await Promise.all([ snapshot(bot, cwd, "turn a"), snapshot(bot, cwd, "turn b"), snapshot(bot, cwd, "turn c"), ]); for (const hash of hashes) expect(hash).toMatch(/^[0-9a-f]{40}$/); expect(new Set(hashes).size).toBe(1); }); }); describe("nested and user-owned git repos", () => { it("accepts a nested git repo as a gitlink and stays idempotent while it churns", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "one"); const nested = join(cwd, "lib"); mkdirSync(nested); userGit(nested, "init"); writeFileSync(join(nested, "inner.txt"), "inner"); userGit(nested, "add", "-A"); userGit(nested, "commit", "-m", "inner commit"); const nestedHead = userGit(nested, "rev-parse", "HEAD").trim(); const first = await snapshot(bot, cwd, "turn 1"); expect(first).toMatch(/^[0-9a-f]{40}$/); writeFileSync(join(nested, "inner.txt"), "inner edited, uncommitted"); const second = await snapshot(bot, cwd, "turn 2"); expect(second).toBe(first); expect(userGit(nested, "rev-parse", "HEAD").trim()).toBe(nestedHead); }); it("never touches the user's own repo when the workspace IS one", async () => { const { bot, cwd } = workspace(); userGit(cwd, "init"); writeFileSync(join(cwd, "a.txt"), "one"); userGit(cwd, "add", "-A"); userGit(cwd, "commit", "-m", "user's own commit"); const userHead = userGit(cwd, "rev-parse", "HEAD").trim(); const checkpoint = await snapshot(bot, cwd, "turn 1"); expect(checkpoint).toMatch(/^[0-9a-f]{40}$/); writeFileSync(join(cwd, "a.txt"), "two"); expect(await diffWorkingTree(bot, cwd, checkpoint!)).toEqual({ changed: ["a.txt"], added: [], deleted: [] }); expect(userGit(cwd, "rev-parse", "HEAD").trim()).toBe(userHead); expect(userGit(cwd, "log", "--format=%s").trim()).toBe("user's own commit"); expect(userGit(cwd, "status", "--porcelain")).toBe(" M a.txt\n"); }); }); describe("refusals", () => { it("refuses the home folder, protected folders, and missing paths", async () => { const { bot } = workspace(); expect(refusalReason(homedir())).not.toBeNull(); expect(refusalReason("/")).not.toBeNull(); expect(refusalReason(join(homedir(), "Documents"))).not.toBeNull(); expect(refusalReason(join(tmpdir(), "omb-ckpt-definitely-missing-xyz"))).not.toBeNull(); expect(refusalReason("relative/path")).not.toBeNull(); expect(await snapshot(bot, homedir(), "turn 1")).toBeNull(); const { cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "one"); expect(await snapshot(bot, cwd, "turn 2")).toMatch(/^[0-9a-f]{40}$/); }); it("refuses a symlink that resolves to the home folder", async () => { const { bot, cwd } = workspace(); const linkedHome = join(cwd, "linked-home"); symlinkSync(homedir(), linkedHome, process.platform === "win32" ? "junction" : "dir"); expect(refusalReason(linkedHome)).toBe("checkpoints are not taken in the home folder"); expect(await snapshot(bot, linkedHome, "turn 1")).toBeNull(); }); // Windows folder names are case-insensitive. Side-effect-free checks only: a missed refusal would stage the whole home. it.runIf(process.platform === "win32")("refuses the home and protected folders in any Windows spelling", () => { expect(refusalReason(homedir().toLowerCase())).toBe("checkpoints are not taken in the home folder"); expect(refusalReason(homedir().toUpperCase())).toBe("checkpoints are not taken in the home folder"); for (const [name, spelling] of [["Desktop", "DESKTOP"], ["Documents", "documents"], ["Downloads", "DownLoads"]]) { // A machine without the folder answers "does not exist", also a refusal. const expected = existsSync(join(homedir(), name!)) ? `checkpoints are not taken in the ${name} folder` : "the working folder does not exist"; expect(refusalReason(join(homedir(), spelling!))).toBe(expected); } // The 8.3 alias of Documents, on volumes that keep short names. const shortDocuments = join(homedir(), "DOCUME~1"); if (existsSync(shortDocuments) && existsSync(join(homedir(), "Documents"))) { expect(refusalReason(shortDocuments)).toBe("checkpoints are not taken in the Documents folder"); } }); }); describe("diffWorkingTree", () => { it("names settled changes without replacing the pre-turn snapshot", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "keep.txt"), "same"); writeFileSync(join(cwd, "edit.txt"), "before"); writeFileSync(join(cwd, "gone.txt"), "bye"); const before = await snapshot(bot, cwd, "turn aaaaaaaa"); writeFileSync(join(cwd, "edit.txt"), "after"); writeFileSync(join(cwd, "new.txt"), "hello"); unlinkSync(join(cwd, "gone.txt")); expect(before).not.toBeNull(); expect(await diffWorkingTree(bot, cwd, before!)).toEqual({ changed: ["edit.txt"], added: ["new.txt"], deleted: ["gone.txt"], }); expect(snapshotsIn(bot, cwd).map(c => c.hash)).toEqual([before]); }); it("reports no changes and refuses protected folders", async () => { const { bot, cwd } = workspace(); writeFileSync(join(cwd, "a.txt"), "one"); const hash = await snapshot(bot, cwd, "turn bbbbbbbb"); expect(await diffWorkingTree(bot, cwd, hash!)).toEqual({ changed: [], added: [], deleted: [] }); expect(await diffWorkingTree(bot, homedir(), hash!)).toBeNull(); }); });