From ef062be96c0fb6c0d958afe054f33a99958e5b22 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 29 Sep 2026 09:04:43 +0530 Subject: [PATCH] test(core): add snapshot parity and benchmark scripts --- .../core/script/benchmark-snapshot-compact.ts | 64 ++ .../core/script/benchmark-snapshot-session.ts | 194 +++++ .../core/script/benchmark-snapshot-worker.ts | 28 + packages/core/script/benchmark-snapshot.ts | 385 ++++++++++ .../core/script/snapshot-parity-compare.ts | 22 + packages/core/script/snapshot-parity-run.sh | 20 + packages/core/script/snapshot-parity.ts | 706 ++++++++++++++++++ 7 files changed, 1419 insertions(+) create mode 100644 packages/core/script/benchmark-snapshot-compact.ts create mode 100644 packages/core/script/benchmark-snapshot-session.ts create mode 100644 packages/core/script/benchmark-snapshot-worker.ts create mode 100644 packages/core/script/benchmark-snapshot.ts create mode 100644 packages/core/script/snapshot-parity-compare.ts create mode 100755 packages/core/script/snapshot-parity-run.sh create mode 100644 packages/core/script/snapshot-parity.ts diff --git a/packages/core/script/benchmark-snapshot-compact.ts b/packages/core/script/benchmark-snapshot-compact.ts new file mode 100644 index 00000000000..74768176b99 --- /dev/null +++ b/packages/core/script/benchmark-snapshot-compact.ts @@ -0,0 +1,64 @@ +/** + * Copy existing snapshot stores and run the real compaction on the copies. Verifies + * that every locally stored object survives and reports size, time, and peak RSS + * of the Git child. The originals are only read. + * + * bun run script/benchmark-snapshot-compact.ts ... + */ +import { $ } from "bun" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Effect, Logger } from "effect" +import { AppNodeBuilder } from "../src/effect/app-node-builder" +import { Git } from "../src/git" +import { AbsolutePath } from "../src/schema" + +const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-compact") +await fs.mkdir(root, { recursive: true }) + +// Local objects only: alternates are hidden so borrowed source objects do not count. +async function objects(store: string) { + const alternates = path.join(store, "objects", "info", "alternates") + const borrowed = await fs.readFile(alternates, "utf8").catch(() => undefined) + if (borrowed !== undefined) await fs.rm(alternates) + const listed = await $`git --git-dir ${store} --work-tree ${store} cat-file --batch-all-objects ${"--batch-check=%(objectname)"}` + .quiet() + .text() + if (borrowed !== undefined) await fs.writeFile(alternates, borrowed) + return listed.split("\n").filter(Boolean).toSorted() +} + +const size = async (directory: string) => Number((await $`du -sk ${directory}`.quiet().text()).split("\t")[0]) +const loose = async (store: string) => + (await $`find ${path.join(store, "objects")} -path '*/objects/??/*' -type f`.quiet().text()).split("\n").filter(Boolean) + .length + +let failed = false +for (const source of process.argv.slice(2)) { + const copy = await fs.mkdtemp(path.join(root, "store-")) + await $`cp -R ${source}/. ${copy}`.quiet() + const before = { objects: await objects(copy), size: await size(copy), loose: await loose(copy) } + const started = performance.now() + await Effect.runPromise( + Effect.gen(function* () { + const git = yield* Git.Service + yield* git.objects.compact( + new Git.Repository({ + worktree: AbsolutePath.make(copy), + gitDirectory: AbsolutePath.make(copy), + commonDirectory: AbsolutePath.make(copy), + }), + ) + }).pipe(Effect.provide(AppNodeBuilder.build(Git.node)), Effect.provide(Logger.layer([]))), + ) + const elapsed = performance.now() - started + const after = { objects: await objects(copy), size: await size(copy), loose: await loose(copy) } + const same = before.objects.join("\n") === after.objects.join("\n") + if (!same) failed = true + console.log( + `${path.basename(source)} ${before.size} KiB -> ${after.size} KiB loose ${before.loose} -> ${after.loose} objects ${before.objects.length} -> ${after.objects.length} ${same ? "identical" : "MISMATCH"} ${elapsed.toFixed(0)} ms`, + ) + await fs.rm(copy, { recursive: true, force: true }) +} +if (failed) process.exit(1) diff --git a/packages/core/script/benchmark-snapshot-session.ts b/packages/core/script/benchmark-snapshot-session.ts new file mode 100644 index 00000000000..4f66f25be7f --- /dev/null +++ b/packages/core/script/benchmark-snapshot-session.ts @@ -0,0 +1,194 @@ +/** + * Drives the real SessionStep.attempt through a scripted agent trace against a real + * snapshot store, then repeats it with snapshots disabled. The difference is the + * wall-clock cost snapshots add to the session. + * + * bun run script/benchmark-snapshot-session.ts [--fixture opencode] [--ttft 400] [--rounds 2] + * + * Uses the fixtures generated by benchmark-snapshot.ts. + */ +import { $ } from "bun" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { LanguageModel, LLM, LLMEvent } from "@opencode/ai" +import { OpenAIChat } from "@opencode/ai/protocols/openai-chat" +import { TestLLM } from "@opencode/ai/testing" +import { Global } from "@opencode/util/global" +import { AppProcess } from "@opencode/util/process" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Money } from "@opencode/schema/money" +import { Effect, Layer, Logger, Stream } from "effect" +import { Agent } from "../src/agent" +import { Bus } from "../src/bus" +import { Database } from "../src/database/database" +import { AppNodeBuilder } from "../src/effect/app-node-builder" +import { Location } from "../src/location" +import { Project } from "../src/project" +import { ProjectTable } from "../src/project/sql" +import { AbsolutePath } from "../src/schema" +import { Session } from "../src/session" +import { SessionMessage } from "../src/session/message" +import { SessionProjector } from "../src/session/projector" +import { SessionRunnerModel } from "../src/session/runner/model" +import { SessionStep } from "../src/session/runner/step" +import { SessionTable } from "../src/session/sql" +import { Snapshot } from "../src/snapshot" +import { ToolOutput } from "../src/tool-output" + +const args = process.argv.slice(2) +const flag = (name: string) => { + const index = args.indexOf(`--${name}`) + return index === -1 ? undefined : args[index + 1] +} +const fixture = flag("fixture") ?? "opencode" +const ttft = Number(flag("ttft") ?? 400) +const rounds = Number(flag("rounds") ?? 2) +const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-bench") +const directory = path.join(root, "fixtures", fixture) + +// A read-heavy coding loop: explore, edit, run checks, answer. +const trace = [ + "read", + "grep", + "read", + "glob", + "read", + "edit", + "shell", + "read", + "edit", + "shell", + "grep", + "read", + "edit", + "shell", + "text", +] as const + +let spawns = 0 +const countingProcess = Layer.effect( + AppProcess.Service, + Effect.gen(function* () { + const real = yield* AppProcess.Service + return AppProcess.Service.of({ + ...real, + run: (command, options) => { + spawns++ + return real.run(command, options) + }, + }) + }), +).pipe(Layer.provide(AppNodeBuilder.build(AppProcess.node))) + +const snapshotLayer = (data: string) => + AppNodeBuilder.build(Snapshot.node, [ + Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })), + AppProcess.node.replace(countingProcess), + ]) + +const files = (await $`git ls-files -z -- '*.ts'`.cwd(directory).text()).split("\0").filter(Boolean).slice(0, 50) + +const model = SessionRunnerModel.resolved( + LanguageModel.make({ id: "bench-model", provider: "test", route: OpenAIChat.route }), + { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + limit: { context: 100_000, output: 1_000 }, + cost: [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) }, + }, + ], + }, +) + +const delayed = (response: ReturnType) => + Stream.unwrap(Effect.sleep(ttft).pipe(Effect.as(Stream.fromIterable(response as Iterable)))) + +const run = (snapshots: Layer.Layer) => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const llm = yield* TestLLM.Test + const sessionID = Session.ID.create() + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make(directory), sandboxes: [] }) + .onConflictDoNothing() + .run() + yield* db + .insert(SessionTable) + .values({ id: sessionID, project_id: Project.ID.global, slug: "bench", directory, version: "bench" }) + .run() + const steps = yield* SessionStep.make + const snapshot = yield* Snapshot.Service + // Warm the snapshot store so the first step does not pay repository creation. + yield* snapshot.capture() + const before = spawns + const start = performance.now() + let edits = 0 + for (let round = 0; round < rounds; round++) + for (const kind of trace) { + const call = `call-${round}-${edits}-${kind}-${Math.random().toString(36).slice(2)}` + yield* llm.push( + delayed( + kind === "text" + ? TestLLM.text("Done.", `text-${call}`) + : TestLLM.tool(call, kind, { path: files[edits % files.length] }), + ), + ) + yield* steps.attempt({ + isLocationClosed: () => false, + sessionID, + assistantMessageID: SessionMessage.ID.create(), + agent: Agent.defaultID, + model, + prepared: { + retry: () => Effect.void, + request: LLM.request({ model: model.model, prompt: "bench" }), + options: {}, + executeTool: (input) => + Effect.gen(function* () { + if (input.call.name === "edit") + yield* Effect.promise(() => + Bun.write(path.join(directory, files[edits++ % files.length]!), `// bench ${edits}\n`), + ) + return { content: [{ type: "text" as const, text: "ok" }] } + }), + }, + retry: () => Effect.succeed({ retry: false as const }), + recoverContinuation: false, + recoverOverflow: Effect.succeed(false), + }) + } + return { ms: performance.now() - start, spawns: spawns - before } + }).pipe( + Effect.provide(snapshots), + Effect.provide( + Layer.merge( + AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, ToolOutput.node]), [ + Bus.node.replace(Bus.configured({ persist: true })), + ]), + TestLLM.testLayer(), + ), + ), + Effect.provide(Logger.layer([])), + ) + +await $`git -c core.fsmonitor=false reset -q --hard`.cwd(directory).quiet() +const data = await fs.mkdtemp(path.join(root, "session-data-")) +const noop = await Effect.runPromise(run(Snapshot.noopLayer)) +await $`git -c core.fsmonitor=false reset -q --hard`.cwd(directory).quiet() +const real = await Effect.runPromise(run(snapshotLayer(data))) +await $`git -c core.fsmonitor=false reset -q --hard`.cwd(directory).quiet() +await fs.rm(data, { recursive: true, force: true }) + +const count = trace.length * rounds +console.log(`fixture ${fixture}, ${count} steps, simulated time to first event ${ttft} ms`) +console.log(`no snapshots ${noop.ms.toFixed(0).padStart(7)} ms`) +console.log(`snapshots ${real.ms.toFixed(0).padStart(7)} ms git ${real.spawns}`) +console.log( + `overhead ${(real.ms - noop.ms).toFixed(0).padStart(7)} ms ${((real.ms - noop.ms) / count).toFixed(1)} ms/step ${(real.spawns / count).toFixed(1)} git/step`, +) diff --git a/packages/core/script/benchmark-snapshot-worker.ts b/packages/core/script/benchmark-snapshot-worker.ts new file mode 100644 index 00000000000..4a63b9c1371 --- /dev/null +++ b/packages/core/script/benchmark-snapshot-worker.ts @@ -0,0 +1,28 @@ +// Child process for the cross-process snapshot probe: edits its own file and captures repeatedly, +// printing the number of failed captures. +import path from "path" +import { Effect, Logger } from "effect" +import { AppNodeBuilder } from "../src/effect/app-node-builder" +import { Location } from "../src/location" +import { AbsolutePath } from "../src/schema" +import { Snapshot } from "../src/snapshot" +import { Global } from "@opencode/util/global" + +const [data, directory, id, count] = process.argv.slice(2) +const layer = AppNodeBuilder.build(Snapshot.node, [ + Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory!) }))), + Global.node.replace(Global.layerWith({ data: data!, config: path.join(data!, "config") })), +]) + +const failures = await Effect.runPromise( + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + let failed = 0 + for (let index = 0; index < Number(count); index++) { + yield* Effect.promise(() => Bun.write(path.join(directory!, `worker-${id}.txt`), `${index}\n`)) + if ((yield* snapshot.capture()) === undefined) failed++ + } + return failed + }).pipe(Effect.provide(layer), Effect.provide(Logger.layer([]))), +) +console.log(failures) diff --git a/packages/core/script/benchmark-snapshot.ts b/packages/core/script/benchmark-snapshot.ts new file mode 100644 index 00000000000..983b545b053 --- /dev/null +++ b/packages/core/script/benchmark-snapshot.ts @@ -0,0 +1,385 @@ +/** + * Snapshot benchmark + robustness probe. + * + * bun run script/benchmark-snapshot.ts [--fixtures small,medium,large,opencode] [--iterations 10] [--json out.json] + * + * Fixtures are generated once under $TMPDIR/opencode-snapshot-bench and reset before each run. + * Every timed scenario also reports how many git processes it spawned. + */ +import { $ } from "bun" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Effect, Layer, Logger } from "effect" +import { AppNodeBuilder } from "../src/effect/app-node-builder" +import { Git } from "../src/git" +import { Location } from "../src/location" +import { AbsolutePath, RelativePath } from "../src/schema" +import { Snapshot } from "../src/snapshot" +import { Global } from "@opencode/util/global" +import { AppProcess } from "@opencode/util/process" + +const args = process.argv.slice(2) +const flag = (name: string) => { + const index = args.indexOf(`--${name}`) + return index === -1 ? undefined : args[index + 1] +} +const iterations = Number(flag("iterations") ?? 10) +const selected = (flag("fixtures") ?? "small,medium,large,opencode").split(",") +const jsonOut = flag("json") +const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-bench") + +type Fixture = { name: string; tracked: number; ignored: number; dirs: number } +const fixtures: Record = { + small: { name: "small", tracked: 1_000, ignored: 0, dirs: 20 }, + medium: { name: "medium", tracked: 20_000, ignored: 20_000, dirs: 400 }, + large: { name: "large", tracked: 100_000, ignored: 50_000, dirs: 2_000 }, +} + +// ---------- fixture generation ---------- + +async function writeMany(files: Array<[string, string]>) { + const dirs = new Set(files.map(([file]) => path.dirname(file))) + await Promise.all([...dirs].map((dir) => fs.mkdir(dir, { recursive: true }))) + for (let index = 0; index < files.length; index += 512) + await Promise.all(files.slice(index, index + 512).map(([file, content]) => Bun.write(file, content))) +} + +async function generate(fixture: Fixture) { + const dir = path.join(root, "fixtures", fixture.name) + if (await Bun.file(path.join(dir, ".bench-ready")).exists()) return dir + await fs.rm(dir, { recursive: true, force: true }) + console.error(`generating fixture ${fixture.name} (${fixture.tracked} tracked, ${fixture.ignored} ignored)`) + const tracked = Array.from({ length: fixture.tracked }, (_, index) => { + const file = path.join(dir, "src", `d${index % fixture.dirs}`, `f${index}.ts`) + return [file, `export const value${index} = ${index}\n`.repeat(8)] as [string, string] + }) + const ignored = Array.from({ length: fixture.ignored }, (_, index) => { + const file = path.join(dir, "node_modules", `pkg${index % 500}`, `f${index}.js`) + return [file, `module.exports = ${index}\n`] as [string, string] + }) + await writeMany([...tracked, ...ignored, [path.join(dir, ".gitignore"), "node_modules\ndist\n"]]) + await gitInit(dir) + await Bun.write(path.join(dir, ".bench-ready"), "") + return dir +} + +async function cloneOpencode() { + const dir = path.join(root, "fixtures", "opencode") + if (await Bun.file(path.join(dir, ".bench-ready")).exists()) return dir + await fs.rm(dir, { recursive: true, force: true }) + const source = (await $`git rev-parse --show-toplevel`.cwd(import.meta.dir).text()).trim() + console.error(`cloning ${source} into fixture opencode`) + await $`git clone --quiet --local --no-hardlinks ${source} ${dir}`.quiet() + // A realistic ignored dependency tree without paying for a full install. + await writeMany( + Array.from({ length: 30_000 }, (_, index) => [ + path.join(dir, "node_modules", `pkg${index % 700}`, `f${index}.js`), + `module.exports = ${index}\n`, + ]), + ) + await Bun.write(path.join(dir, ".git", "info", "exclude"), ".bench-ready\n") + await Bun.write(path.join(dir, ".bench-ready"), "") + return dir +} + +async function gitInit(dir: string) { + await $`git init -q`.cwd(dir).quiet() + await Bun.write(path.join(dir, ".git", "info", "exclude"), ".bench-ready\n") + await $`git -c core.fsmonitor=false add -A`.cwd(dir).quiet() + await $`git -c user.email=bench@opencode.test -c user.name=Bench commit -q --no-gpg-sign -m initial`.cwd(dir).quiet() +} + +async function reset(dir: string) { + await $`git -c core.fsmonitor=false reset -q --hard`.cwd(dir).quiet() + await $`git -c core.fsmonitor=false clean -qfd`.cwd(dir).quiet() +} + +// ---------- harness ---------- + +let spawns = 0 +const countingProcess = Layer.effect( + AppProcess.Service, + Effect.gen(function* () { + const real = yield* AppProcess.Service + return AppProcess.Service.of({ + ...real, + run: (command, options) => { + spawns++ + return real.run(command, options) + }, + }) + }), +).pipe(Layer.provide(AppNodeBuilder.build(AppProcess.node))) + +function snapshotLayer(data: string, directory: string) { + return AppNodeBuilder.build(Snapshot.node, [ + Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })), + AppProcess.node.replace(countingProcess), + ]) +} + +type Sample = { ms: number; spawns: number } +const results: Array<{ fixture: string; scenario: string; samples: Sample[] }> = [] + +const time = (effect: Effect.Effect) => + Effect.gen(function* () { + const before = spawns + const start = performance.now() + const value = yield* effect + return { value, sample: { ms: performance.now() - start, spawns: spawns - before } } + }) + +function record(fixture: string, scenario: string, samples: Sample[]) { + results.push({ fixture, scenario, samples }) + const sorted = samples.map((sample) => sample.ms).toSorted((a, b) => a - b) + const p = (value: number) => sorted[Math.min(Math.ceil(sorted.length * value) - 1, sorted.length - 1)] ?? 0 + const mean = sorted.reduce((total, value) => total + value, 0) / sorted.length + const spawned = samples.reduce((total, sample) => total + sample.spawns, 0) / samples.length + console.log( + `${fixture.padEnd(9)} ${scenario.padEnd(22)} p50 ${p(0.5).toFixed(1).padStart(8)} ms mean ${mean + .toFixed(1) + .padStart(8)} ms max ${(sorted.at(-1) ?? 0).toFixed(1).padStart(8)} ms git ${spawned.toFixed(1).padStart(5)}`, + ) +} + +const edit = (dir: string, files: string[], tag: string) => + Effect.promise(() => Promise.all(files.map((file) => Bun.write(path.join(dir, file), `// ${tag}\n${file}\n`)))) + +function trackedSample(dir: string, count: number) { + return $`git ls-files -z` + .cwd(dir) + .text() + .then((text) => { + const files = text.split("\0").filter((file) => file.endsWith(".ts") || file.endsWith(".md")) + const stride = Math.max(1, Math.floor(files.length / count)) + return Array.from({ length: count }, (_, index) => files[(index * stride) % files.length]!) + }) +} + +const repeat = (count: number, effect: (index: number) => Effect.Effect) => + Effect.forEach( + Array.from({ length: count }, (_, index) => index), + effect, + ) + +async function bench(name: string, dir: string) { + await reset(dir) + const data = await fs.mkdtemp(path.join(root, "data-")) + const sample = await trackedSample(dir, 100) + const program = Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const capture = snapshot + .capture() + .pipe(Effect.flatMap((id) => (id ? Effect.succeed(id) : Effect.die(new Error("capture returned undefined"))))) + + const cold = yield* time(capture) + record(name, "capture cold", [cold.sample]) + + record( + name, + "capture clean", + yield* repeat(iterations, () => time(capture).pipe(Effect.map((result) => result.sample))), + ) + + record( + name, + "capture edit 1", + yield* repeat(iterations, (index) => + edit(dir, [sample[0]!], `edit-${index}`).pipe( + Effect.andThen(time(capture)), + Effect.map((result) => result.sample), + ), + ), + ) + + record( + name, + "capture add 1", + yield* repeat(iterations, (index) => + edit(dir, [`src/new-${index}.ts`], "new").pipe( + Effect.andThen(time(capture)), + Effect.map((result) => result.sample), + ), + ), + ) + + const before = yield* capture + const edited = yield* repeat(Math.max(3, Math.ceil(iterations / 2)), (index) => + edit(dir, sample, `bulk-${index}`).pipe( + Effect.andThen(time(capture)), + Effect.map((result) => result.sample), + ), + ) + record(name, "capture edit 100", edited) + const after = yield* capture + + record( + name, + "files (100 changed)", + yield* repeat(iterations, () => + time(snapshot.files({ from: before, to: after })).pipe(Effect.map((result) => result.sample)), + ), + ) + record( + name, + "diff (100 changed)", + yield* repeat(iterations, () => + time(snapshot.diff({ from: before, to: after })).pipe(Effect.map((result) => result.sample)), + ), + ) + const plan = new Map(sample.map((file) => [RelativePath.make(file), before] as const)) + record( + name, + "restore 100", + yield* repeat(Math.max(3, Math.ceil(iterations / 2)), (index) => + edit(dir, sample, `restore-${index}`).pipe( + Effect.andThen(time(snapshot.restore({ files: plan }))), + Effect.map((result) => result.sample), + ), + ), + ) + + // A read-mostly agent loop under the step policy: capture at attempt start, capture at settlement, + // then list changed files. One step in five edits two files. + const steps = yield* repeat(20, (index) => + Effect.gen(function* () { + const before = spawns + const start = performance.now() + const from = yield* capture + if (index % 5 === 0) yield* edit(dir, [sample[index]!, sample[index + 1]!], `step-${index}`) + const to = yield* capture + if (from !== to) yield* snapshot.files({ from, to }) + return { ms: performance.now() - start, spawns: spawns - before } + }), + ) + record(name, "step loop (per step)", steps) + }).pipe(Effect.provide(snapshotLayer(data, dir))) + await Effect.runPromise(program.pipe(Effect.provide(Logger.layer([])))) + await reset(dir) + const store = (await $`find ${path.join(data, "snapshot")} -mindepth 2 -maxdepth 2 -type d`.text()).trim() + const before = Number((await $`du -sk ${store}`.text()).split("\t")[0]) + const started = performance.now() + await Effect.runPromise( + Effect.gen(function* () { + const git = yield* Git.Service + // The baseline worktree predates compaction. + if (!("objects" in git)) return + yield* git.objects.compact( + new Git.Repository({ + worktree: AbsolutePath.make(dir), + gitDirectory: AbsolutePath.make(store), + commonDirectory: AbsolutePath.make(store), + }), + ) + }).pipe(Effect.provide(AppNodeBuilder.build(Git.node)), Effect.provide(Logger.layer([]))), + ) + const after = Number((await $`du -sk ${store}`.text()).split("\t")[0]) + console.log( + `${name.padEnd(9)} compaction ${before} KiB -> ${after} KiB in ${(performance.now() - started).toFixed(0)} ms`, + ) + const size = await $`du -sk ${data}`.text() + const loose = (await $`find ${data}/snapshot -path '*/objects/??/*' -type f`.text()).split("\n").filter(Boolean) + const files = (await $`find ${data}/snapshot -maxdepth 3 -type f`.text()).split("\n").filter(Boolean) + console.log( + `${name.padEnd(9)} snapshot store ${size.split("\t")[0]} KiB, ${loose.length} loose objects, top-level files: ${files.map((file) => path.basename(file)).join(" ")}`, + ) + await fs.rm(data, { recursive: true, force: true }) +} + +// ---------- robustness probes ---------- + +async function probe(label: string, run: () => Promise) { + const ok = await run().catch((error) => { + console.error(error) + return false + }) + console.log(`probe ${label.padEnd(44)} ${ok ? "PASS" : "FAIL"}`) + results.push({ fixture: "probe", scenario: label, samples: [{ ms: ok ? 1 : 0, spawns: 0 }] }) +} + +async function withRepo(setup: (dir: string) => Promise, body: (dir: string, data: string) => Promise) { + const dir = await fs.realpath(await fs.mkdtemp(path.join(root, "probe-"))) + const project = path.join(dir, "project") + await fs.mkdir(project) + await setup(project) + await gitInit(project) + const result = await body(project, path.join(dir, "data")) + await fs.rm(dir, { recursive: true, force: true }) + return result +} + +const captureOnce = (data: string, directory: string) => + Effect.runPromise( + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + return yield* snapshot.capture() + }).pipe(Effect.provide(snapshotLayer(data, directory)), Effect.provide(Logger.layer([]))), + ) + +async function snapshotGitDir(data: string) { + const projects = await fs.readdir(path.join(data, "snapshot")) + const project = path.join(data, "snapshot", projects[0]!) + return path.join(project, (await fs.readdir(project))[0]!) +} + +async function probes() { + await probe("capture self-heals a zeroed index", () => + withRepo( + (dir) => Bun.write(path.join(dir, "a.txt"), "a\n").then(() => {}), + async (dir, data) => { + await captureOnce(data, dir) + const gitDir = await snapshotGitDir(data) + await Bun.write(path.join(gitDir, "index"), new Uint8Array(1024)) + await Bun.write(path.join(dir, "a.txt"), "b\n") + return (await captureOnce(data, dir)) !== undefined + }, + ), + ) + + await probe("capture survives a stale index.lock", () => + withRepo( + (dir) => Bun.write(path.join(dir, "a.txt"), "a\n").then(() => {}), + async (dir, data) => { + await captureOnce(data, dir) + const gitDir = await snapshotGitDir(data) + await Bun.write(path.join(gitDir, "index.lock"), "") + await Bun.write(path.join(dir, "a.txt"), "b\n") + return (await captureOnce(data, dir)) !== undefined + }, + ), + ) + + await probe("capture works in a `..scope` directory", () => + withRepo( + (dir) => Bun.write(path.join(dir, "..scope", "a.txt"), "a\n").then(() => {}), + async (dir, data) => (await captureOnce(data, path.join(dir, "..scope"))) !== undefined, + ), + ) + + await probe("two processes capture concurrently (4x25)", () => + withRepo( + (dir) => writeMany(Array.from({ length: 2000 }, (_, i) => [path.join(dir, `d${i % 20}`, `f${i}.txt`), `${i}\n`])), + async (dir, data) => { + await captureOnce(data, dir) + const worker = path.join(import.meta.dir, "benchmark-snapshot-worker.ts") + const outputs = await Promise.all( + Array.from({ length: 4 }, (_, id) => $`bun run ${worker} ${data} ${dir} ${id} 25`.nothrow().quiet()), + ) + const failures = outputs.map((output) => Number(output.stdout.toString().trim() || "25")) + console.log(` concurrent capture failures per process: ${failures.join(", ")}`) + return failures.every((count) => count === 0) + }, + ), + ) +} + +await fs.mkdir(root, { recursive: true }) +for (const name of selected) { + if (name === "probes") continue + const dir = name === "opencode" ? await cloneOpencode() : await generate(fixtures[name]!) + await bench(name, dir) +} +if (selected.includes("probes") || !flag("fixtures")) await probes() +if (jsonOut) await Bun.write(jsonOut, JSON.stringify(results, null, 2)) diff --git a/packages/core/script/snapshot-parity-compare.ts b/packages/core/script/snapshot-parity-compare.ts new file mode 100644 index 00000000000..3561a2c8ef4 --- /dev/null +++ b/packages/core/script/snapshot-parity-compare.ts @@ -0,0 +1,22 @@ +// Compare two snapshot-parity.ts outputs and print every diverging step per scenario. +const [left, right] = await Promise.all(process.argv.slice(2, 4).map((file) => Bun.file(file!).json())) +let divergent = 0 +for (const name of new Set([...Object.keys(left), ...Object.keys(right)])) { + const a: unknown[] = left[name] ?? [] + const b: unknown[] = right[name] ?? [] + const differences = Array.from({ length: Math.max(a.length, b.length) }, (_, index) => index).filter( + (index) => JSON.stringify(a[index]) !== JSON.stringify(b[index]), + ) + if (!differences.length) { + console.log(`same ${name}`) + continue + } + divergent++ + console.log(`DIFFERENT ${name}`) + for (const index of differences) { + console.log(` step ${index}`) + console.log(` base: ${JSON.stringify(a[index])}`) + console.log(` new: ${JSON.stringify(b[index])}`) + } +} +console.log(divergent ? `${divergent} divergent scenarios` : "all scenarios identical") diff --git a/packages/core/script/snapshot-parity-run.sh b/packages/core/script/snapshot-parity-run.sh new file mode 100755 index 00000000000..59a851ba2bf --- /dev/null +++ b/packages/core/script/snapshot-parity-run.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Run the snapshot parity corpus from two worktrees under every Git config variant and compare. +# script/snapshot-parity-run.sh [variant...] +set -e +base="$1" +shift +here="$(cd "$(dirname "$0")/.." && pwd)" +out="${SNAPSHOT_BENCH_ROOT:-${TMPDIR:-/tmp}}/opencode-snapshot-parity-results" +mkdir -p "$out" +cp "$here/script/snapshot-parity.ts" "$base/packages/core/script/snapshot-parity.ts" +variants="${*:-user empty split-index skip-hash autocrlf-and-no-untracked-cache fsmonitor template-hook}" +status=0 +for name in $variants; do + variant="$(echo "$name" | tr '-' ' ')" + (cd "$base/packages/core" && PARITY_CONFIG="$variant" bun run script/snapshot-parity.ts "$out/$name-base.json" >/dev/null 2>&1) + (cd "$here" && PARITY_CONFIG="$variant" bun run script/snapshot-parity.ts "$out/$name-new.json" >/dev/null 2>&1) + echo "=== $variant" + bun run "$here/script/snapshot-parity-compare.ts" "$out/$name-base.json" "$out/$name-new.json" | grep -v "^same" || status=1 +done +exit $status diff --git a/packages/core/script/snapshot-parity.ts b/packages/core/script/snapshot-parity.ts new file mode 100644 index 00000000000..1849ef97d8b --- /dev/null +++ b/packages/core/script/snapshot-parity.ts @@ -0,0 +1,706 @@ +/** + * Differential corpus for the snapshot engine. Run the same file from two worktrees + * and compare the JSON: tree IDs, changed-file lists, diffs, and post-restore + * worktree digests must be byte-identical. + * + * bun run script/snapshot-parity.ts [scenario...] + */ +import { $ } from "bun" +import crypto from "crypto" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Effect, Layer, Logger, ManagedRuntime } from "effect" +import { AppNodeBuilder } from "../src/effect/app-node-builder" +import { Location } from "../src/location" +import { AbsolutePath, RelativePath } from "../src/schema" +import { Snapshot } from "../src/snapshot" +import { Global } from "@opencode/util/global" + +const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-parity") + +// User-level Git configuration reaches every snapshot command, so the corpus runs under several. +const variants: Record = { + user: undefined, + empty: "", + "split index": "[core]\n\tsplitIndex = true\n", + "skip hash": "[index]\n\tskipHash = true\n[feature]\n\tmanyFiles = true\n", + "autocrlf and no untracked cache": "[core]\n\tautocrlf = true\n\tuntrackedCache = false\n", + fsmonitor: "[core]\n\tfsmonitor = true\n", + "template hook": "[init]\n\ttemplateDir = TEMPLATE\n", +} +const variant = process.env.PARITY_CONFIG ?? "user" +if (variants[variant] !== undefined) { + await fs.mkdir(root, { recursive: true }) + const template = path.join(root, `template-${process.pid}`) + await fs.mkdir(path.join(template, "hooks"), { recursive: true }) + await fs.writeFile(path.join(template, "hooks", "post-checkout"), '#!/bin/sh\necho "checkout $3" > .hooklog\n', { + mode: 0o755, + }) + // Stores are created with `git init`, so template ignore rules must keep applying to them. + await fs.mkdir(path.join(template, "info"), { recursive: true }) + await fs.writeFile(path.join(template, "info", "exclude"), "template-ignored/\n") + const config = path.join(root, `gitconfig-${process.pid}`) + await fs.writeFile(config, variants[variant]!.replace("TEMPLATE", template)) + process.env.GIT_CONFIG_GLOBAL = config +} + +const env = { + ...process.env, + GIT_ALLOW_PROTOCOL: "file", + GIT_AUTHOR_NAME: "Parity", + GIT_AUTHOR_EMAIL: "parity@opencode.test", + GIT_COMMITTER_NAME: "Parity", + GIT_COMMITTER_EMAIL: "parity@opencode.test", + GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z", + GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z", +} + +type Context = { + readonly dir: string + readonly write: (file: string, content: string | Uint8Array) => Promise + readonly remove: (file: string) => Promise + readonly git: (args: string[], cwd?: string) => Promise + readonly capture: (label: string, location?: string) => Promise + readonly files: (from: string, to: string) => Promise + readonly diff: (from: string, to: string, paths?: string[]) => Promise + readonly restore: (label: string, files: Record) => Promise + readonly digest: (label: string) => Promise +} + +type Scenario = (ctx: Context) => Promise + +const commit = async (ctx: Context, message = "initial") => { + await ctx.git(["add", "-A"]) + await ctx.git(["commit", "-q", "--no-gpg-sign", "--allow-empty", "-m", message]) +} + +const scenarios: Record = { + async basic(ctx) { + await ctx.write("a.txt", "a\n") + await ctx.write("b.txt", "b\n") + await ctx.write("dir/c.txt", "c\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.write("a.txt", "a2\n") + await ctx.remove("b.txt") + await ctx.write("dir/new.txt", "new\n") + await ctx.capture("t1") + await ctx.capture("t1-again") + await ctx.files("t0", "t1") + await ctx.diff("t0", "t1") + await ctx.diff("t0", "t1", ["a.txt"]) + await ctx.restore("undo", { "a.txt": "t0", "b.txt": "t0", "dir/new.txt": "t0" }) + await ctx.capture("t2") + await ctx.files("t0", "t2") + }, + + async "rename directory"(ctx) { + for (let i = 0; i < 20; i++) await ctx.write(`src/f${i}.ts`, `${i}\n`) + await commit(ctx) + await ctx.capture("t0") + await fs.rename(path.join(ctx.dir, "src"), path.join(ctx.dir, "lib")) + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.diff("t0", "t1") + }, + + async "file becomes directory"(ctx) { + await ctx.write("a", "file\n") + await ctx.write("keep.txt", "keep\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.remove("a") + await ctx.write("a/b", "nested\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.restore("undo", { a: "t0", "a/b": "t0" }) + await ctx.capture("t2") + }, + + async "directory becomes file"(ctx) { + await ctx.write("d/x", "x\n") + await ctx.write("d/y", "y\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.remove("d") + await ctx.write("d", "file now\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.restore("undo", { "d/x": "t0", "d/y": "t0", d: "t0" }) + await ctx.capture("t2") + }, + + async "embedded repository"(ctx) { + await ctx.write("top.txt", "top\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.write("sub/inner.txt", "inner\n") + await ctx.git(["init", "-q"], path.join(ctx.dir, "sub")) + await ctx.git(["add", "-A"], path.join(ctx.dir, "sub")) + await ctx.git(["commit", "-q", "--no-gpg-sign", "-m", "inner"], path.join(ctx.dir, "sub")) + await ctx.capture("t1") + await ctx.write("sub/inner.txt", "changed\n") + await ctx.capture("t2") + await ctx.git(["commit", "-q", "--no-gpg-sign", "-am", "inner 2"], path.join(ctx.dir, "sub")) + await ctx.capture("t3") + await ctx.files("t0", "t3") + await ctx.diff("t0", "t3") + }, + + async symlinks(ctx) { + await ctx.write("target.txt", "target\n") + await ctx.write("dir/inside.txt", "inside\n") + await ctx.write("plain.txt", "plain\n") + await commit(ctx) + await ctx.capture("t0") + await fs.symlink("target.txt", path.join(ctx.dir, "link")) + await fs.symlink("dir", path.join(ctx.dir, "dirlink")) + await fs.symlink("missing", path.join(ctx.dir, "dangling")) + await ctx.remove("plain.txt") + await fs.symlink("target.txt", path.join(ctx.dir, "plain.txt")) + await ctx.capture("t1") + await ctx.write("target.txt", "target 2\n") + await ctx.capture("t2") + await ctx.files("t0", "t2") + await ctx.diff("t0", "t2") + await ctx.restore("undo", { link: "t0", dirlink: "t0", dangling: "t0", "plain.txt": "t0", "target.txt": "t0" }) + await ctx.capture("t3") + }, + + async "executable bit"(ctx) { + await ctx.write("run.sh", "echo hi\n") + await commit(ctx) + await ctx.capture("t0") + await fs.chmod(path.join(ctx.dir, "run.sh"), 0o755) + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.diff("t0", "t1") + await ctx.restore("undo", { "run.sh": "t0" }) + await ctx.capture("t2") + }, + + async "special names"(ctx) { + const names = [ + "with space.txt", + "caf\u00e9.txt", + "cafe\u0301-nfd.txt", + 'quote"s.txt', + "-rf", + ":colon.txt", + "!bang.txt", + "#hash.txt", + "app/[slug]/page.tsx", + "star*.txt", + "q?.txt", + "back\\slash.txt", + "tab\tname.txt", + "new\nline.txt", + "\u65e5\u672c\u8a9e/\u30d5\u30a1\u30a4\u30eb.txt", + ] + for (const name of names) await ctx.write(name, `${name}\n`) + await commit(ctx) + await ctx.capture("t0") + for (const name of names) await ctx.write(name, `${name} changed\n`) + await ctx.write("added [x].txt", "added\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.diff("t0", "t1") + await ctx.restore("undo", Object.fromEntries([...names, "added [x].txt"].map((name) => [name, "t0"]))) + await ctx.capture("t2") + }, + + async "ignore rules"(ctx) { + await ctx.write(".gitignore", "*.log\nbuild/\n!keep.log\n") + await ctx.write("keep.log", "keep\n") + await ctx.write("src/a.ts", "a\n") + await ctx.write("build/forced.txt", "forced\n") + await ctx.git(["add", "-A"]) + await ctx.git(["add", "-f", "build/forced.txt"]) + await ctx.git(["commit", "-q", "--no-gpg-sign", "-m", "initial"]) + await ctx.write(".git/info/exclude", "secret/\n*.local\n") + await ctx.capture("t0") + await ctx.write("debug.log", "ignored\n") + await ctx.write("keep.log", "keep 2\n") + await ctx.write("build/forced.txt", "forced 2\n") + await ctx.write("build/out.js", "out\n") + await ctx.write("secret/key.txt", "key\n") + await ctx.write("config.local", "local\n") + await ctx.write("template-ignored/x.txt", "template\n") + await ctx.write("src/a.ts", "a2\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.diff("t0", "t1") + await ctx.write(".git/info/exclude", "") + await ctx.capture("t2") + await ctx.files("t1", "t2") + }, + + async "gitignore changes"(ctx) { + await ctx.write("gen/out.txt", "1\n") + await ctx.write("src.txt", "src\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.write(".gitignore", "gen/\n") + await ctx.write("gen/out.txt", "2\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.write(".gitignore", "") + await ctx.capture("t2") + await ctx.files("t1", "t2") + }, + + async "large files"(ctx) { + await ctx.write("tracked.bin", "small\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.write("big.bin", new Uint8Array(3 * 1024 * 1024).fill(7)) + await ctx.write("tracked.bin", new Uint8Array(3 * 1024 * 1024).fill(9)) + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.write("big.bin", "now small\n") + await ctx.capture("t2") + await ctx.files("t1", "t2") + }, + + async "line endings"(ctx) { + await ctx.write(".gitattributes", "* text=auto\n*.bat eol=crlf\n") + await ctx.write("unix.txt", "one\ntwo\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.write("unix.txt", "one\r\ntwo\r\nthree\r\n") + await ctx.write("run.bat", "echo\r\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.diff("t0", "t1") + await ctx.restore("undo", { "unix.txt": "t0", "run.bat": "t0" }) + await ctx.digest("after-undo") + }, + + async "merge conflict in source"(ctx) { + await ctx.write("c.txt", "base\n") + await commit(ctx) + await ctx.git(["checkout", "-q", "-b", "other"]) + await ctx.write("c.txt", "other\n") + await commit(ctx, "other") + await ctx.git(["checkout", "-q", "-"]) + await ctx.write("c.txt", "main\n") + await commit(ctx, "main") + await ctx.git(["merge", "-q", "other"]).catch(() => "") + await ctx.capture("t0") + await ctx.write("c.txt", "resolved\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + }, + + async "case-only rename"(ctx) { + await ctx.write("Readme.md", "readme\n") + await commit(ctx) + await ctx.capture("t0") + await fs.rename(path.join(ctx.dir, "Readme.md"), path.join(ctx.dir, "README.md")) + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.write("README.md", "changed\n") + await ctx.capture("t2") + await ctx.files("t0", "t2") + }, + + async "location subdirectory"(ctx) { + await ctx.write("pkg/a.txt", "a\n") + await ctx.write("other/b.txt", "b\n") + await commit(ctx) + await ctx.capture("t0", "pkg") + await ctx.write("pkg/a.txt", "a2\n") + await ctx.write("pkg/new.txt", "new\n") + await ctx.write("other/b.txt", "b2\n") + await ctx.capture("t1", "pkg") + await ctx.files("t0", "t1") + await ctx.capture("t2", ".") + await ctx.files("t1", "t2") + await ctx.capture("t3", "pkg") + await ctx.files("t2", "t3") + }, + + async "many untracked"(ctx) { + await ctx.write("seed.txt", "seed\n") + await commit(ctx) + for (let i = 0; i < 3000; i++) await ctx.write(`gen/d${i % 30}/f${i}.txt`, `${i}\n`) + await ctx.capture("t0") + await ctx.capture("t0-again") + }, + + async "empty directories and delete all"(ctx) { + await ctx.write("a/b/c.txt", "c\n") + await ctx.write("d.txt", "d\n") + await commit(ctx) + await ctx.capture("t0") + await fs.mkdir(path.join(ctx.dir, "empty/nested"), { recursive: true }) + await ctx.capture("t1") + await ctx.remove("a") + await ctx.remove("d.txt") + await ctx.capture("t2") + await ctx.files("t0", "t2") + await ctx.restore("undo", { "a/b/c.txt": "t0", "d.txt": "t0" }) + await ctx.capture("t3") + }, + + async "restore with overlapping paths"(ctx) { + await ctx.write("a", "file a\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.remove("a") + await ctx.write("a/b", "nested b\n") + await ctx.capture("t1") + await ctx.remove("a") + await ctx.write("a", "file again\n") + await ctx.capture("t2") + await ctx.restore("order-1", { a: "t0", "a/b": "t0" }) + await ctx.restore("order-2", { "a/b": "t1", a: "t1" }) + await ctx.restore("order-3", { a: "t1", "a/b": "t1" }) + await ctx.restore("order-4", { "a/b": "t0", a: "t2" }) + }, + + async "restore from several trees"(ctx) { + await ctx.write("x.txt", "x0\n") + await ctx.write("y.txt", "y0\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.write("x.txt", "x1\n") + await ctx.write("z.txt", "z1\n") + await ctx.capture("t1") + await ctx.write("x.txt", "x2\n") + await ctx.write("y.txt", "y2\n") + await ctx.write("z.txt", "z2\n") + await ctx.write("w.txt", "w2\n") + await ctx.restore("mixed", { "x.txt": "t1", "y.txt": "t0", "z.txt": "t0", "w.txt": "t1" }) + }, + + async "staged changes in source"(ctx) { + await ctx.write("a.txt", "a\n") + await commit(ctx) + await ctx.write("a.txt", "staged\n") + await ctx.write("new.txt", "staged new\n") + await ctx.git(["add", "-A"]) + await ctx.write("a.txt", "worktree\n") + await ctx.capture("t0") + await ctx.write("a.txt", "worktree 2\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + }, + + async "binary and replaced types"(ctx) { + await ctx.write("img.bin", new Uint8Array([0, 1, 2, 3, 0, 255])) + await ctx.write("f.txt", "file\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.write("img.bin", new Uint8Array([0, 9, 9, 3, 0, 255, 1])) + await ctx.remove("f.txt") + await fs.symlink("img.bin", path.join(ctx.dir, "f.txt")) + await ctx.capture("t1") + await ctx.files("t0", "t1") + await ctx.diff("t0", "t1") + await ctx.remove("f.txt") + await ctx.write("f.txt", "file\n") + await ctx.capture("t2") + await ctx.files("t0", "t2") + }, + + async "sparse checkout source"(ctx) { + await ctx.write("in/a.txt", "a\n") + await ctx.write("out/b.txt", "b\n") + await commit(ctx) + await ctx.git(["sparse-checkout", "set", "in"]) + await ctx.capture("t0") + await ctx.write("in/a.txt", "a2\n") + await ctx.write("in/new.txt", "new\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + }, + + async "unreadable file"(ctx) { + await ctx.write("ok.txt", "ok\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.write("locked.txt", "locked\n") + await fs.chmod(path.join(ctx.dir, "locked.txt"), 0o000) + await ctx.capture("t1") + await fs.chmod(path.join(ctx.dir, "locked.txt"), 0o644) + await ctx.capture("t2") + await ctx.files("t0", "t2") + }, + + async "no commits yet"(ctx) { + await ctx.write("a.txt", "a\n") + await ctx.capture("t0") + await ctx.git(["add", "a.txt"]) + await ctx.write("b.txt", "b\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + }, + + async "special files"(ctx) { + await ctx.write("a.txt", "a\n") + await commit(ctx) + await ctx.capture("t0") + await $`mkfifo ${path.join(ctx.dir, "pipe")}`.quiet() + await ctx.capture("t1") + await ctx.remove("pipe") + await ctx.capture("t2") + await ctx.files("t0", "t2") + }, + + async "tracked submodule"(ctx) { + const other = path.join(path.dirname(ctx.dir), "library") + await fs.mkdir(other) + await ctx.git(["init", "-q"], other) + await fs.writeFile(path.join(other, "lib.txt"), "lib\n") + await ctx.git(["add", "-A"], other) + await ctx.git(["commit", "-q", "--no-gpg-sign", "-m", "lib"], other) + await ctx.git(["-c", "protocol.file.allow=always", "submodule", "add", "-q", other, "vendor/library"]) + // The absolute temporary URL differs between runs; pin it so trees are comparable. + await ctx.git(["config", "-f", ".gitmodules", "submodule.vendor/library.url", "../library"]) + await commit(ctx) + await ctx.capture("t0") + await ctx.write("vendor/library/lib.txt", "changed\n") + await ctx.capture("t1") + await ctx.git(["commit", "-q", "--no-gpg-sign", "-am", "bump"], path.join(ctx.dir, "vendor/library")) + await ctx.capture("t2") + await ctx.files("t0", "t2") + await ctx.diff("t0", "t2") + }, + + async "index flags in source"(ctx) { + await ctx.write("assumed.txt", "a\n") + await ctx.write("skipped.txt", "s\n") + await ctx.write("plain.txt", "p\n") + await commit(ctx) + await ctx.git(["update-index", "--assume-unchanged", "assumed.txt"]) + await ctx.git(["update-index", "--skip-worktree", "skipped.txt"]) + await ctx.write("intent.txt", "intent\n") + await ctx.git(["add", "-N", "intent.txt"]) + await ctx.capture("t0") + await ctx.write("assumed.txt", "a2\n") + await ctx.write("skipped.txt", "s2\n") + await ctx.write("intent.txt", "intent 2\n") + await ctx.write("plain.txt", "p2\n") + await ctx.capture("t1") + await ctx.files("t0", "t1") + }, + + async "restore key spellings"(ctx) { + await ctx.write("Readme.md", "readme\n") + await ctx.write("dir/a.txt", "a\n") + await ctx.write("dir/b.txt", "b\n") + await ctx.write("caf\u00e9.txt", "nfc\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.write("Readme.md", "changed\n") + await ctx.write("dir/a.txt", "a2\n") + await ctx.write("dir/c.txt", "c2\n") + await ctx.write("caf\u00e9.txt", "nfc 2\n") + await ctx.restore("case", { "readme.md": "t0" }) + await ctx.restore("directory", { dir: "t0" }) + await ctx.restore("nfd", { "cafe\u0301.txt": "t0" }) + await ctx.restore("dot-slash", { "./Readme.md": "t0" }) + }, + + async "large batched restore"(ctx) { + for (let i = 0; i < 300; i++) await ctx.write(`src/m${i % 7}/f${i}.ts`, `export const v = ${i}\n`) + await commit(ctx) + await ctx.capture("t0") + for (let i = 0; i < 300; i += 2) await ctx.write(`src/m${i % 7}/f${i}.ts`, `changed ${i}\n`) + for (let i = 0; i < 50; i++) await ctx.write(`src/new/n${i}.ts`, `new ${i}\n`) + for (let i = 1; i < 300; i += 3) await ctx.remove(`src/m${i % 7}/f${i}.ts`) + await ctx.capture("t1") + await ctx.files("t0", "t1") + const changed = await Promise.resolve().then(async () => { + const files: Record = {} + for (let i = 0; i < 300; i++) files[`src/m${i % 7}/f${i}.ts`] = "t0" + for (let i = 0; i < 50; i++) files[`src/new/n${i}.ts`] = "t0" + return files + }) + await ctx.restore("all", changed) + await ctx.capture("t2") + await ctx.files("t0", "t2") + }, + + async "restore blocked by a file"(ctx) { + await ctx.write("a/b.txt", "b\n") + await ctx.write("z.txt", "z\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.remove("a") + await ctx.write("a", "now a file\n") + await ctx.write("z.txt", "z2\n") + await ctx.restore("blocked", { "a/b.txt": "t0", "z.txt": "t0" }) + }, + + async "location with pattern characters"(ctx) { + await ctx.write("app/[id]/page.tsx", "page\n") + await ctx.write("app/i/other.tsx", "other\n") + await commit(ctx) + await ctx.capture("t0", "app/[id]") + await ctx.write("app/[id]/page.tsx", "page 2\n") + await ctx.write("app/i/other.tsx", "other 2\n") + await ctx.capture("t1", "app/[id]") + await ctx.files("t0", "t1") + }, + + async "restore paths absent from both sides"(ctx) { + await ctx.write("a.txt", "a\n") + await commit(ctx) + await ctx.capture("t0") + await ctx.restore("missing", { "never.txt": "t0", "gone/deep/x.txt": "t0" }) + await ctx.write("later.txt", "later\n") + await ctx.restore("remove-later", { "later.txt": "t0" }) + }, +} + +async function run(name: string, scenario: Scenario) { + const base = await fs.realpath(await fs.mkdtemp(path.join(root, "case-"))) + const dir = path.join(base, "project") + const data = path.join(base, "data") + await fs.mkdir(dir, { recursive: true }) + const git = (args: string[], cwd = dir) => + $`git -c core.fsmonitor=false -c core.splitIndex=false -c init.defaultBranch=main ${args}`.cwd(cwd).env(env).quiet().text() + await git(["init", "-q"]) + const log: unknown[] = [] + const trees = new Map() + // One long-lived runtime per Location, as in production, so in-process caches are exercised. + const runtimes = new Map>() + const runtime = (location = ".") => { + const existing = runtimes.get(location) + if (existing) return existing + const created = ManagedRuntime.make( + Layer.provide(snapshotLayer(data, path.join(dir, location)), Logger.layer([])) as Layer.Layer, + ) + runtimes.set(location, created) + return created + } + const use = (location: string | undefined, body: (snapshot: Snapshot.Interface) => Effect.Effect) => + runtime(location) + .runPromise( + Effect.gen(function* () { + return yield* body(yield* Snapshot.Service) + }).pipe(Effect.exit), + ) + .then((exit) => (exit._tag === "Success" ? { ok: exit.value } : { error: true })) + const tree = (label: string) => { + const id = trees.get(label) + if (!id) throw new Error(`no tree ${label}`) + return id + } + const ctx: Context = { + dir, + write: async (file, content) => { + await fs.mkdir(path.dirname(path.join(dir, file)), { recursive: true }) + await fs.writeFile(path.join(dir, file), content) + }, + remove: (file) => fs.rm(path.join(dir, file), { recursive: true, force: true }), + git, + capture: async (label, location) => { + const result = await use(location, (snapshot) => snapshot.capture()) + const id = "ok" in result ? result.ok : undefined + trees.set(label, id) + log.push({ capture: label, tree: id ?? null, entries: id ? await listing(data, id) : null }) + }, + files: async (from, to) => { + if (!trees.get(from) || !trees.get(to)) return void log.push({ files: [from, to], skipped: true }) + log.push({ files: [from, to], result: await use(undefined, (s) => s.files({ from: tree(from), to: tree(to) })) }) + }, + diff: async (from, to, paths) => { + if (!trees.get(from) || !trees.get(to)) return void log.push({ diff: [from, to], skipped: true }) + const result = await use(undefined, (s) => + s.diff({ from: tree(from), to: tree(to), paths: paths?.map((file) => RelativePath.make(file)) }), + ) + log.push({ + diff: [from, to, paths ?? null], + result: + "ok" in result && result.ok + ? result.ok.map((file) => ({ + ...file, + patch: crypto.createHash("sha1").update(file.patch).digest("hex"), + })) + : result, + }) + }, + restore: async (label, files) => { + const plan = new Map( + Object.entries(files).flatMap(([file, from]) => { + const id = trees.get(from) + return id ? [[RelativePath.make(file), id] as const] : [] + }), + ) + const result = await use(undefined, (s) => s.restore({ files: plan })) + log.push({ restore: label, result: "ok" in result ? "ok" : result, worktree: await digest(dir) }) + }, + digest: async (label) => { + log.push({ digest: label, worktree: await digest(dir) }) + }, + } + const failure = await scenario(ctx).then( + () => undefined, + (error: unknown) => String(error), + ) + if (failure) log.push({ scenarioError: failure }) + await Promise.all([...runtimes.values()].map((item) => item.dispose())) + await $`chmod -R u+rwX ${base}`.quiet().nothrow() + if (process.env.PARITY_KEEP) console.error(`kept ${base}`) + if (!process.env.PARITY_KEEP) await fs.rm(base, { recursive: true, force: true }) + return log +} + +function snapshotLayer(data: string, directory: string) { + return AppNodeBuilder.build(Snapshot.node, [ + Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })), + ]) +} + +/** The recursive listing of a captured tree, read from whichever snapshot store holds it. */ +async function listing(data: string, tree: string) { + const stores = (await $`find ${path.join(data, "snapshot")} -mindepth 2 -maxdepth 2 -type d`.quiet().text()) + .split("\n") + .filter(Boolean) + for (const store of stores) { + const result = await $`git --git-dir ${store} ls-tree -r -z ${tree}`.quiet().nothrow() + if (result.exitCode === 0) return result.stdout.toString().split("\0").filter(Boolean) + } + return null +} + +/** Paths, types, modes, and content hashes of the worktree, excluding `.git`. */ +async function digest(dir: string) { + const entries: string[] = [] + const walk = async (current: string) => { + for (const entry of (await fs.readdir(current, { withFileTypes: true })).toSorted((a, b) => + a.name < b.name ? -1 : 1, + )) { + const full = path.join(current, entry.name) + const relative = path.relative(dir, full) + if (relative === ".git" || relative.startsWith(".git/")) continue + const stat = await fs.lstat(full) + if (stat.isSymbolicLink()) entries.push(`L ${relative} -> ${await fs.readlink(full)}`) + else if (stat.isDirectory()) { + entries.push(`D ${relative}`) + await walk(full) + } else { + const content = await fs.readFile(full).catch(() => Buffer.from("")) + entries.push( + `F ${relative} ${stat.mode & 0o111 ? "x" : "-"} ${crypto.createHash("sha1").update(content).digest("hex")}`, + ) + } + } + } + await walk(dir) + return entries +} + +await fs.mkdir(root, { recursive: true }) +const [outputFile, ...selected] = process.argv.slice(2) +const output: Record = {} +for (const [name, scenario] of Object.entries(scenarios)) { + if (selected.length && !selected.includes(name)) continue + output[name] = await run(name, scenario) +} +await Bun.write(outputFile!, JSON.stringify(output, null, 2))