mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 12:15:59 +00:00
refactor(core): simplify snapshot restore and fixtures
This commit is contained in:
parent
24a226ead0
commit
0002d1df81
5 changed files with 265 additions and 276 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]]) })
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue