test(core): add snapshot parity and benchmark scripts

This commit is contained in:
Shoubhit Dash
2026-09-29 09:04:43 +05:30
parent 2e3b4408cf
commit ef062be96c
7 changed files with 1419 additions and 0 deletions
@@ -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 <store>...
*/
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)
@@ -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<typeof TestLLM.stop>) =>
Stream.unwrap(Effect.sleep(ttft).pipe(Effect.as(Stream.fromIterable(response as Iterable<LLMEvent>))))
const run = (snapshots: Layer.Layer<Snapshot.Service>) =>
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`,
)
@@ -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)
+385
View File
@@ -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<string, Fixture> = {
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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
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 = <A, E, R>(count: number, effect: (index: number) => Effect.Effect<Sample, E, R>) =>
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<boolean>) {
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<A>(setup: (dir: string) => Promise<void>, body: (dir: string, data: string) => Promise<A>) {
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))
@@ -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")
+20
View File
@@ -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 <baseline-worktree> [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
+706
View File
@@ -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 <result.json> [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<string, string | undefined> = {
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<void>
readonly remove: (file: string) => Promise<void>
readonly git: (args: string[], cwd?: string) => Promise<string>
readonly capture: (label: string, location?: string) => Promise<void>
readonly files: (from: string, to: string) => Promise<void>
readonly diff: (from: string, to: string, paths?: string[]) => Promise<void>
readonly restore: (label: string, files: Record<string, string>) => Promise<void>
readonly digest: (label: string) => Promise<void>
}
type Scenario = (ctx: Context) => Promise<void>
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<string, Scenario> = {
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<string, string> = {}
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<string, Snapshot.ID | undefined>()
// One long-lived runtime per Location, as in production, so in-process caches are exercised.
const runtimes = new Map<string, ManagedRuntime.ManagedRuntime<Snapshot.Service, never>>()
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<Snapshot.Service>,
)
runtimes.set(location, created)
return created
}
const use = <A>(location: string | undefined, body: (snapshot: Snapshot.Interface) => Effect.Effect<A, unknown>) =>
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("<unreadable>"))
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<string, unknown> = {}
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))