refactor(core): simplify snapshot restore and fixtures

This commit is contained in:
Kit Langton 2026-08-31 13:35:13 -04:00
parent 24a226ead0
commit 0002d1df81
5 changed files with 265 additions and 276 deletions

View file

@ -135,7 +135,7 @@ export interface Interface {
}) => Effect.Effect<ReadonlySet<RelativePath>, OperationError>
}
readonly tree: {
readonly exists: (repository: Repository, tree: TreeID) => Effect.Effect<boolean, OperationError>
readonly exists: (repository: Repository, tree: TreeID) => Effect.Effect<boolean>
/** Retain a tree's borrowed objects in this repository, independent of its alternates. */
readonly retain: (input: {
repository: Repository
@ -495,12 +495,12 @@ const layer = Layer.effect(
return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim())
})
const treeExists = Effect.fn("Git.tree.exists")(function* (repository: Repository, tree: TreeID) {
return yield* repositoryOperation("list_files", repository, ["cat-file", "-e", `${tree}^{tree}`]).pipe(
const treeExists = Effect.fn("Git.tree.exists")((repository: Repository, tree: TreeID) =>
repositoryOperation("list_files", repository, ["cat-file", "-e", `${tree}^{tree}`]).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
Effect.orElseSucceed(() => false),
),
)
const retain = Effect.fn("Git.tree.retain")((input: { repository: Repository; trees: readonly TreeID[] }) =>
locked(
@ -658,20 +658,22 @@ const layer = Layer.effect(
({ file, tree, present }) =>
Effect.gen(function* () {
if (present) {
// Re-index the restored content without foreign alternates so future local captures can read it.
yield* repositoryOperation("restore", input.repository, [
"--literal-pathspecs",
"restore",
`--source=${tree}`,
...(input.repository.objectDirectories?.length ? [] : ["--staged"]),
"--worktree",
"--",
file,
])
yield* repositoryOperation(
"restore",
new Repository({ ...input.repository, objectDirectories: undefined }),
["--literal-pathspecs", "add", "--force", "--sparse", "--", file],
)
// Re-index foreign content without alternates so future local captures can read it.
if (input.repository.objectDirectories?.length)
yield* repositoryOperation(
"restore",
new Repository({ ...input.repository, objectDirectories: undefined }),
["--literal-pathspecs", "add", "--force", "--sparse", "--", file],
)
return
}
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(

View file

@ -100,7 +100,18 @@ const layer = Layer.effect(
: yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
return { source, worktree, snapshotRepository }
return {
source,
worktree,
snapshotRepository,
foreignRepository: (directory: AbsolutePath) =>
new Git.Repository({
worktree,
gitDirectory: directory,
commonDirectory: directory,
objectDirectories: [AbsolutePath.make(path.join(source.commonDirectory, "objects"))],
}),
}
}).pipe(Effect.forkIn(lifetime)),
)
const repository = repositoryFiber.pipe(Effect.uninterruptible, Effect.flatMap(Fiber.join))
@ -141,12 +152,7 @@ const layer = Layer.effect(
.pipe(Effect.mapError((cause) => failure("restore", cause)))
for (const store of stores.filter((entry) => entry.type === "directory" && /^[a-f0-9]{40}$/.test(entry.name))) {
const directory = AbsolutePath.make(path.join(root, project.name, store.name))
const candidate = new Git.Repository({
worktree: repo.worktree,
gitDirectory: directory,
commonDirectory: directory,
objectDirectories: [AbsolutePath.make(path.join(repo.source.commonDirectory, "objects"))],
})
const candidate = repo.foreignRepository(directory)
if (!(yield* git.tree.exists(candidate, tree))) continue
// Identical legacy trees can exist in several stores; skip incomplete copies.
if (
@ -174,15 +180,7 @@ const layer = Layer.effect(
}
for (const [directory, trees] of stores) {
// A renamed checkout can supply the old store's borrowed objects at its new path.
yield* git.tree.retain({
repository: new Git.Repository({
worktree: repo.worktree,
gitDirectory: directory,
commonDirectory: directory,
objectDirectories: [AbsolutePath.make(path.join(repo.source.commonDirectory, "objects"))],
}),
trees,
})
yield* git.tree.retain({ repository: repo.foreignRepository(directory), trees })
}
return new Git.Repository({
...repo.snapshotRepository,

View file

@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(Git.node))
@ -107,10 +107,7 @@ describe("Git worktrees", () => {
describe("Git trees", () => {
it.live("retains borrowed tree objects once for restoration without the source repository", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
const root = yield* tmpdirScoped()
const source = AbsolutePath.make(path.join(root.path, "source"))
const destination = AbsolutePath.make(path.join(root.path, "destination"))
yield* Effect.promise(async () => {
@ -130,6 +127,8 @@ describe("Git trees", () => {
seed,
})
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
expect(yield* git.tree.exists(repository, before)).toBe(true)
expect(yield* git.tree.exists(repository, Git.TreeID.make("0".repeat(40)))).toBe(false)
yield* git.tree.retain({ repository, trees: [before, before] })
yield* Effect.promise(() => Bun.write(path.join(source, "file.txt"), "Snapshot-only content.\n"))
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
@ -138,7 +137,7 @@ describe("Git trees", () => {
yield* git.tree.retain({ repository, trees: [before, after] })
expect(yield* Effect.promise(() => fs.readdir(path.join(repository.gitDirectory, "objects/pack")))).toEqual(packs)
yield* Effect.promise(() => fs.rm(source, { recursive: true }))
const moved = new Git.Repository({ ...repository, worktree: destination })
const moved = new Git.Repository({ ...repository, worktree: destination, objectDirectories: [] })
yield* git.tree.restore({ repository: moved, files: new Map([[RelativePath.make("file.txt"), before]]) })
expect(yield* read(path.join(destination, "file.txt"))).toBe("Borrowed committed content.\n")
yield* git.tree.restore({ repository: moved, files: new Map([[RelativePath.make("file.txt"), after]]) })

View file

@ -24,6 +24,7 @@ import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { initRepo, read } from "./fixture/git"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@ -46,98 +47,58 @@ const it = testEffect(
describe("Session.revert files", () => {
for (const encoding of ["qualified", "legacy"] as const) {
const stored = (id: Snapshot.ID) =>
encoding === "legacy" ? Snapshot.ID.make(id.slice(id.lastIndexOf("/") + 1)) : id
for (const move of [
"repository rename",
"another worktree",
"another project",
"symlink ancestor",
"same worktree subdirectory",
] as const) {
it.live(
move === "symlink ancestor"
? `rejects ${encoding} redo through a destination symlink ancestor`
: `restores ${encoding} staged file changes after moving to ${move}`,
`restores ${encoding} staged file changes after moving to ${move}`,
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const file = move === "symlink ancestor" ? "assets/logo.svg" : "file.txt"
const destination = AbsolutePath.make(
move === "same worktree subdirectory"
? path.join(directory, "nested")
: path.join(tmp.path, "destination"),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true })
await Bun.write(path.join(directory, file), "Before assistant edit.\n")
await fs.mkdir(directory)
await Bun.write(path.join(directory, "file.txt"), "Before assistant edit.\n")
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated source content.\n")
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
await $`git -c user.name=Test -c user.email=test@example.com -c commit.gpgsign=false commit -qm initial`
.cwd(directory)
.quiet()
await initRepo(directory)
await $`git add .`.cwd(directory).quiet()
await $`git commit -qm initial`.cwd(directory).quiet()
if (move === "another worktree")
await $`git worktree add --detach ${destination} HEAD`.cwd(directory).quiet()
if (move === "another project" || move === "symlink ancestor") {
if (move === "another project") {
await fs.mkdir(destination)
if (move === "another project") await Bun.write(path.join(destination, file), "Destination content.\n")
if (move === "symlink ancestor")
await fs.symlink(path.join(directory, "assets"), path.join(destination, "assets"), "dir")
await Bun.write(path.join(destination, "file.txt"), "Destination content.\n")
await $`git init -q`.cwd(destination).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(destination).quiet()
}
if (move === "same worktree subdirectory") await fs.mkdir(destination)
})
const session = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const execution = yield* SessionExecution.Service
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Edit the file", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
const stored = (id: Snapshot.ID) =>
encoding === "legacy" ? Snapshot.ID.make(id.slice(id.lastIndexOf("/") + 1)) : id
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: stored(before),
})
yield* Effect.promise(async () => {
if (move === "symlink ancestor") return fs.rm(path.join(directory, file))
await Bun.write(path.join(directory, file), "After assistant edit.\n")
})
const after = yield* snapshot.capture()
if (!after) throw new Error("Edited snapshot missing")
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: stored(after),
files: yield* snapshot.files({ from: before, to: after }),
})
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
const { session, created, prompt } = yield* recordSnapshotStep(
directory,
() => Bun.write(path.join(directory, "file.txt"), "After assistant edit.\n"),
stored,
)
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
if (encoding === "legacy")
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
expect(reverted.snapshot).toBeDefined()
expect(reverted.files?.map((file) => file.file)).toEqual([file])
expect(yield* Effect.promise(() => Bun.file(path.join(directory, file)).text())).toBe(
"Before assistant edit.\n",
)
expect(reverted.files?.map((file) => file.file)).toEqual(["file.txt"])
expect(yield* read(path.join(directory, "file.txt"))).toBe("Before assistant edit.\n")
if (move === "repository rename") yield* Effect.promise(() => fs.rename(directory, destination))
const root = move === "same worktree subdirectory" ? directory : destination
@ -150,162 +111,195 @@ describe("Session.revert files", () => {
yield* execution.awaitIdle(created.id)
const moved = yield* session.get(created.id)
expect(moved.location.directory).toBe(destination)
if (move === "another project" || move === "symlink ancestor")
expect(moved.projectID).not.toBe(created.projectID)
if (move !== "another project" && move !== "symlink ancestor")
expect(moved.projectID).toBe(created.projectID)
expect(moved.projectID === created.projectID).toBe(move !== "another project")
expect(moved.revert).toEqual(reverted)
if (move === "symlink ancestor") {
const error = yield* session.revert
.clear(created.id)
.pipe(Effect.match({ onSuccess: () => undefined, onFailure: (error) => error }))
expect(yield* Effect.promise(() => Bun.file(path.join(directory, file)).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(path.join(directory, file)).text())).toBe(
"Before assistant edit.\n",
)
expect(error).toBeInstanceOf(Snapshot.Error)
expect(error).toMatchObject({
operation: "restore",
message: expect.stringContaining("Path escapes the project"),
})
expect((yield* session.get(created.id)).revert).toEqual(reverted)
return
}
if (move === "another project") {
const unavailable = {
...reverted,
snapshot: Snapshot.ID.make(
encoding === "legacy" ? "0".repeat(40) : `snapshot:missing/${"0".repeat(40)}/${"0".repeat(40)}`,
),
}
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: unavailable })
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
expect((yield* session.get(created.id)).revert).toEqual(unavailable)
expect(yield* Effect.promise(() => Bun.file(path.join(root, "file.txt")).text())).toBe(
"Destination content.\n",
)
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
yield* Effect.promise(() =>
fs.rename(path.join(directory, ".git"), path.join(tmp.path, "unavailable-seed")),
)
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
expect((yield* session.get(created.id)).revert).toEqual(reverted)
expect(yield* Effect.promise(() => Bun.file(path.join(root, "file.txt")).text())).toBe(
"Destination content.\n",
)
yield* Effect.promise(() =>
fs.rename(path.join(tmp.path, "unavailable-seed"), path.join(directory, ".git")),
)
}
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(path.join(root, "file.txt")).text())).toBe(
"After assistant edit.\n",
)
expect(yield* read(path.join(root, "file.txt"))).toBe("After assistant edit.\n")
expect((yield* session.get(created.id)).revert).toBeUndefined()
expect(yield* Effect.promise(() => Bun.file(path.join(root, "unrelated.txt")).text())).toBe(
"Keep this destination edit.\n",
)
expect(yield* read(path.join(root, "unrelated.txt"))).toBe("Keep this destination edit.\n")
if (move === "another worktree" || move === "another project")
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "file.txt")).text())).toBe(
"Before assistant edit.\n",
)
expect(yield* read(path.join(directory, "file.txt"))).toBe("Before assistant edit.\n")
yield* execution.awaitIdle(created.id)
yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
expect(yield* Effect.promise(() => Bun.file(path.join(root, "file.txt")).text())).toBe(
"Before assistant edit.\n",
)
expect(yield* read(path.join(root, "file.txt"))).toBe("Before assistant edit.\n")
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(path.join(root, "file.txt")).text())).toBe(
"After assistant edit.\n",
)
expect(yield* read(path.join(root, "file.txt"))).toBe("After assistant edit.\n")
expect(yield* Effect.promise(() => Bun.file(indexPath).arrayBuffer())).toEqual(index)
}),
{ timeout: 15_000 },
)
}
it.live(
`rejects ${encoding} redo through a destination symlink ancestor`,
() =>
Effect.gen(function* () {
const source = yield* tmpdirScoped()
const destination = yield* tmpdirScoped()
const file = path.join(source.path, "assets/logo.svg")
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file))
await Bun.write(file, "Before assistant edit.\n")
await initRepo(source.path)
await fs.symlink(path.dirname(file), path.join(destination.path, "assets"), "dir")
await $`git init -q`.cwd(destination.path).quiet()
})
const bus = yield* Bus.Service
const execution = yield* SessionExecution.Service
const { session, created, prompt } = yield* recordSnapshotStep(source.path, () => fs.rm(file), stored)
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
yield* session.move({ sessionID: created.id, directory: AbsolutePath.make(destination.path) })
yield* execution.awaitIdle(created.id)
expect((yield* session.get(created.id)).projectID).not.toBe(created.projectID)
const error = yield* session.revert.clear(created.id).pipe(Effect.flip)
expect(error).toBeInstanceOf(Snapshot.Error)
expect(error).toMatchObject({
operation: "restore",
message: expect.stringContaining("Path escapes the project"),
})
expect(yield* read(file)).toBe("Before assistant edit.\n")
expect((yield* session.get(created.id)).revert).toEqual(reverted)
}),
{ timeout: 15_000 },
)
it.live(
`recovers ${encoding} redo after missing snapshot data returns`,
() =>
Effect.gen(function* () {
const source = yield* tmpdirScoped()
const destination = yield* tmpdirScoped()
yield* Effect.promise(async () => {
await initRepo(source.path)
await Bun.write(path.join(source.path, "file.txt"), "Before assistant edit.\n")
await Bun.write(path.join(source.path, "unrelated.txt"), "Borrowed committed content.\n")
await $`git add .`.cwd(source.path).quiet()
await $`git commit -qm initial`.cwd(source.path).quiet()
await Bun.write(path.join(destination.path, "file.txt"), "Destination content.\n")
await $`git init -q`.cwd(destination.path).quiet()
})
const bus = yield* Bus.Service
const execution = yield* SessionExecution.Service
const { session, created, prompt } = yield* recordSnapshotStep(
source.path,
() => Bun.write(path.join(source.path, "file.txt"), "After assistant edit.\n"),
stored,
)
const staged = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id, files: true })
const reverted = { ...staged, snapshot: staged.snapshot && stored(staged.snapshot) }
yield* session.move({ sessionID: created.id, directory: AbsolutePath.make(destination.path) })
yield* execution.awaitIdle(created.id)
const unavailable = {
...reverted,
snapshot: stored(Snapshot.ID.make(`snapshot:missing/${"0".repeat(40)}/${"0".repeat(40)}`)),
}
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: unavailable })
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
expect((yield* session.get(created.id)).revert).toEqual(unavailable)
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("Destination content.\n")
yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID: created.id, revert: reverted })
const seed = path.join(source.path, ".git")
const missing = path.join(source.path, "unavailable-seed")
yield* Effect.promise(() => fs.rename(seed, missing))
expect(yield* session.revert.clear(created.id).pipe(Effect.flip)).toBeInstanceOf(Snapshot.Error)
expect((yield* session.get(created.id)).revert).toEqual(reverted)
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("Destination content.\n")
yield* Effect.promise(() => fs.rename(missing, seed))
yield* session.revert.clear(created.id)
expect(yield* read(path.join(destination.path, "file.txt"))).toBe("After assistant edit.\n")
expect((yield* session.get(created.id)).revert).toBeUndefined()
}),
{ timeout: 15_000 },
)
}
it.live(
"undoes and restores a file rename without losing either path",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const directory = (yield* tmpdirScoped()).path
const original = path.join(directory, "old name.txt")
const renamed = path.join(directory, "new name.txt")
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await Bun.write(original, "Preserve this content.\n")
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated content.\n")
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const session = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
const { session, created, prompt } = yield* recordSnapshotStep(directory, () => fs.rename(original, renamed))
const services = LocationServiceMap.Service.get(created.location)
const revert = yield* SessionRevert.Service.pipe(Effect.provide(services))
expect(yield* SessionRevert.Service.pipe(Effect.provide(services))).toBe(revert)
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(() => fs.rename(original, renamed))
const after = yield* snapshot.capture()
if (!after) throw new Error("Renamed snapshot missing")
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: after,
files: yield* snapshot.files({ from: before, to: after }),
})
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
expect({
original: yield* Effect.promise(() => Bun.file(original).exists()),
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
}).toEqual({ original: true, renamed: false })
expect(yield* read(original)).toBe("Preserve this content.\n")
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
["new name.txt", "deleted"],
["old name.txt", "added"],
])
expect(yield* read(path.join(directory, "unrelated.txt"))).toBe("Keep this later edit.\n")
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
expect({
original: yield* Effect.promise(() => Bun.file(original).exists()),
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
}).toEqual({ original: true, renamed: false })
expect(yield* Effect.promise(() => Bun.file(original).text())).toBe("Preserve this content.\n")
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
["new name.txt", "deleted"],
["old name.txt", "added"],
])
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(renamed).text())).toBe("Preserve this content.\n")
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
expect((yield* session.get(created.id)).revert).toBeUndefined()
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
expect(yield* read(renamed)).toBe("Preserve this content.\n")
expect(yield* read(path.join(directory, "unrelated.txt"))).toBe("Keep this later edit.\n")
expect((yield* session.get(created.id)).revert).toBeUndefined()
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 15_000 },
)
})
const recordSnapshotStep = Effect.fnUntraced(function* (
directory: string,
mutate: () => Promise<unknown>,
stored = (id: Snapshot.ID) => id,
) {
const session = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Edit the files", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: stored(before),
})
yield* Effect.promise(mutate)
const after = yield* snapshot.capture()
if (!after) throw new Error("Edited snapshot missing")
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: stored(after),
files: yield* snapshot.files({ from: before, to: after }),
})
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
return { session, created, prompt }
})

View file

@ -10,77 +10,73 @@ import { Location } from "@opencode-ai/core/location"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Hash } from "@opencode-ai/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
describe("Snapshot", () => {
for (const transition of ["symlink", "file"] as const) {
testEffect(Layer.empty).live(`restores directory/${transition} transitions without touching external files`, () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const assets = path.join(project, "assets")
const external = path.join(tmp.path, "external")
const leaf = transition === "symlink" ? "assets/logo.svg" : "assets/nested/logo.svg"
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const project = path.join(tmp.path, "project")
const assets = path.join(project, "assets")
const external = path.join(tmp.path, "external")
const leaf = transition === "symlink" ? "assets/logo.svg" : "assets/nested/logo.svg"
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.mkdir(external)
await Bun.write(path.join(external, "logo.svg"), "External content must survive.\n")
if (transition === "symlink") await fs.symlink(external, assets, "dir")
if (transition === "file") {
await fs.mkdir(path.dirname(path.join(project, leaf)), { recursive: true })
await Bun.write(path.join(project, leaf), "Directory content.\n")
}
await initGit(project)
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
yield* Effect.promise(async () => {
await fs.rm(assets, { recursive: true })
if (transition === "file") await Bun.write(assets, "File content.\n")
if (transition === "symlink") {
await fs.mkdir(assets)
await Bun.write(path.join(project, leaf), "Directory content.\n")
}
})
const after = yield* snapshot.capture()
if (!after) throw new Error("Changed snapshot missing")
const files = yield* snapshot.files({ from: before, to: after })
expect(files).toEqual([RelativePath.make("assets"), RelativePath.make(leaf)])
const restored = yield* snapshot
.restore({ files: new Map(files.map((file) => [file, before])) })
.pipe(Effect.exit)
expect(yield* Effect.promise(() => Bun.file(path.join(external, "logo.svg")).exists())).toBe(true)
expect(yield* read(path.join(external, "logo.svg"))).toBe("External content must survive.\n")
yield* restored
if (transition === "symlink") {
expect(yield* Effect.promise(() => fs.readlink(assets))).toBe(external)
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.mkdir(external)
await Bun.write(path.join(external, "logo.svg"), "External content must survive.\n")
if (transition === "symlink") await fs.symlink(external, assets, "dir")
if (transition === "file") {
await fs.mkdir(path.dirname(path.join(project, leaf)), { recursive: true })
await Bun.write(path.join(project, leaf), "Directory content.\n")
}
await initGit(project)
await fs.rm(assets)
await fs.mkdir(assets)
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
yield* Effect.promise(async () => {
await fs.rm(assets, { recursive: true })
if (transition === "file") await Bun.write(assets, "File content.\n")
if (transition === "symlink") {
await fs.mkdir(assets)
await Bun.write(path.join(project, leaf), "Directory content.\n")
}
})
const after = yield* snapshot.capture()
if (!after) throw new Error("Changed snapshot missing")
const files = yield* snapshot.files({ from: before, to: after })
expect(files).toEqual([RelativePath.make("assets"), RelativePath.make(leaf)])
const restored = yield* snapshot
.restore({ files: new Map(files.map((file) => [file, before])) })
.pipe(Effect.exit)
expect(yield* Effect.promise(() => Bun.file(path.join(external, "logo.svg")).exists())).toBe(true)
expect(yield* read(path.join(external, "logo.svg"))).toBe("External content must survive.\n")
yield* restored
if (transition === "symlink") {
expect(yield* Effect.promise(() => fs.readlink(assets))).toBe(external)
yield* Effect.promise(async () => {
await fs.rm(assets)
await fs.mkdir(assets)
})
yield* snapshot.restore({
files: new Map([
[RelativePath.make("assets"), before],
[RelativePath.make(leaf), after],
]),
})
expect(yield* read(path.join(external, "logo.svg"))).toBe("External content must survive.\n")
expect(yield* Effect.promise(async () => (await fs.lstat(assets)).isDirectory())).toBe(true)
expect(yield* read(path.join(project, leaf))).toBe("Directory content.\n")
return
}
expect(yield* read(path.join(project, leaf))).toBe("Directory content.\n")
yield* snapshot.restore({ files: new Map(files.toReversed().map((file) => [file, after])) })
expect(yield* read(assets)).toBe("File content.\n")
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
yield* snapshot.restore({
files: new Map([
[RelativePath.make("assets"), before],
[RelativePath.make(leaf), after],
]),
})
expect(yield* read(path.join(external, "logo.svg"))).toBe("External content must survive.\n")
expect(yield* Effect.promise(async () => (await fs.lstat(assets)).isDirectory())).toBe(true)
expect(yield* read(path.join(project, leaf))).toBe("Directory content.\n")
return
}
expect(yield* read(path.join(project, leaf))).toBe("Directory content.\n")
yield* snapshot.restore({ files: new Map(files.toReversed().map((file) => [file, after])) })
expect(yield* read(assets)).toBe("File content.\n")
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
)
}