Ctrl K
ComunidadGuías…

server/checkpoints.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<void> {
  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<void> {
  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();
  });
});