mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
test(core): add snapshot parity and benchmark scripts
This commit is contained in:
@@ -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)
|
||||
@@ -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")
|
||||
Executable
+20
@@ -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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user