mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-26 01:00:04 +00:00
710 lines
26 KiB
TypeScript
710 lines
26 KiB
TypeScript
/**
|
|
* 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))
|