diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index d6a1d45388c..f8b5a2fe5f5 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -135,7 +135,7 @@ export interface Interface { }) => Effect.Effect, OperationError> } readonly tree: { - readonly exists: (repository: Repository, tree: TreeID) => Effect.Effect + readonly exists: (repository: Repository, tree: TreeID) => Effect.Effect /** 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( diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index c94af87f947..5e2fa2b2ddf 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -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, diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index 4148f33f0f1..2d5440ce689 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -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]]) }) diff --git a/packages/core/test/session-revert.test.ts b/packages/core/test/session-revert.test.ts index e85029c684e..9d5fe02aff5 100644 --- a/packages/core/test/session-revert.test.ts +++ b/packages/core/test/session-revert.test.ts @@ -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, + 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 } +}) diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index c6dae611a82..8661d8dc5f6 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -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))) + }), ) }