| import { describe, expect } from "bun:test" |
| import { $ } from "bun" |
| import fs from "fs/promises" |
| import path from "path" |
| import { eq } from "drizzle-orm" |
| import { Effect } from "effect" |
| import { MoveSession } from "@opencode-ai/core/control-plane/move-session" |
| import { Database } from "@opencode-ai/core/database/database" |
| import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" |
| import { LayerNode } from "@opencode-ai/core/effect/layer-node" |
| import { EventV2 } from "@opencode-ai/core/event" |
| import { Project } from "@opencode-ai/core/project" |
| import { ProjectTable } from "@opencode-ai/core/project/sql" |
| import { ProjectDirectories } from "@opencode-ai/core/project/directories" |
| import { AbsolutePath } from "@opencode-ai/core/schema" |
| import { SessionV2 } from "@opencode-ai/core/session" |
| import { SessionProjector } from "@opencode-ai/core/session/projector" |
| import { SessionTable } from "@opencode-ai/core/session/sql" |
| import { SessionStore } from "@opencode-ai/core/session/store" |
| import { tmpdir } from "./fixture/tmpdir" |
| import { testEffect } from "./lib/effect" |
|
|
| const it = testEffect( |
| AppNodeBuilder.build( |
| LayerNode.group([ |
| MoveSession.node, |
| Database.node, |
| EventV2.node, |
| ProjectDirectories.node, |
| Project.node, |
| SessionProjector.node, |
| SessionStore.node, |
| ]), |
| ), |
| ) |
|
|
| function abs(input: string) { |
| return AbsolutePath.make(input) |
| } |
|
|
| async function initRepo(directory: string) { |
| await $`git init`.cwd(directory).quiet() |
| await $`git config core.autocrlf false`.cwd(directory).quiet() |
| await $`git config core.fsmonitor false`.cwd(directory).quiet() |
| await $`git config commit.gpgsign false`.cwd(directory).quiet() |
| await $`git config user.email test@opencode.test`.cwd(directory).quiet() |
| await $`git config user.name Test`.cwd(directory).quiet() |
| await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n") |
| await $`git add tracked.txt`.cwd(directory).quiet() |
| await $`git commit -m root`.cwd(directory).quiet() |
| } |
|
|
| describe("MoveSession", () => { |
| it.live("moves session changes to another project directory", () => |
| Effect.gen(function* () { |
| const root = yield* Effect.acquireRelease( |
| Effect.promise(() => tmpdir()), |
| (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), |
| ) |
| yield* Effect.promise(() => initRepo(root.path)) |
| const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) |
| const destination = abs(`${root.path}-move-destination`) |
| yield* Effect.addFinalizer(() => |
| Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore), |
| ) |
| yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet()) |
| const moved = abs(yield* Effect.promise(() => fs.realpath(destination))) |
| yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n")) |
| yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n")) |
|
|
| const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id |
| const sessionID = SessionV2.ID.make("ses_move") |
| const { db } = yield* Database.Service |
| yield* db |
| .insert(ProjectTable) |
| .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) |
| .run() |
| .pipe(Effect.orDie) |
| yield* db |
| .insert(SessionTable) |
| .values({ |
| id: sessionID, |
| project_id: projectID, |
| slug: "move", |
| directory: source, |
| title: "move", |
| version: "test", |
| time_created: 1, |
| time_updated: 1, |
| }) |
| .run() |
| .pipe(Effect.orDie) |
|
|
| yield* MoveSession.Service.use((service) => |
| service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }), |
| ) |
|
|
| expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n") |
| expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n") |
| expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n") |
| expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false) |
| expect( |
| yield* db |
| .select({ directory: SessionTable.directory, path: SessionTable.path }) |
| .from(SessionTable) |
| .where(eq(SessionTable.id, sessionID)) |
| .get(), |
| ).toEqual({ directory: moved, path: "" }) |
| }), |
| ) |
|
|
| it.live("moves within a checkout without transferring existing changes", () => |
| Effect.gen(function* () { |
| const root = yield* Effect.acquireRelease( |
| Effect.promise(() => tmpdir()), |
| (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), |
| ) |
| yield* Effect.promise(() => initRepo(root.path)) |
| const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) |
| const destination = abs(path.join(source, "packages")) |
| yield* Effect.promise(() => fs.mkdir(destination)) |
| yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n")) |
| yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n")) |
|
|
| const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id |
| const sessionID = SessionV2.ID.make("ses_move_nested") |
| const { db } = yield* Database.Service |
| yield* db |
| .insert(ProjectTable) |
| .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) |
| .run() |
| .pipe(Effect.orDie) |
| yield* db |
| .insert(SessionTable) |
| .values({ |
| id: sessionID, |
| project_id: projectID, |
| slug: "move-nested", |
| directory: source, |
| title: "move nested", |
| version: "test", |
| time_created: 1, |
| time_updated: 1, |
| }) |
| .run() |
| .pipe(Effect.orDie) |
|
|
| yield* MoveSession.Service.use((service) => |
| service.moveSession({ sessionID, destination: { directory: destination }, moveChanges: true }), |
| ) |
|
|
| expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n") |
| expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n") |
| expect( |
| yield* db |
| .select({ directory: SessionTable.directory, path: SessionTable.path }) |
| .from(SessionTable) |
| .where(eq(SessionTable.id, sessionID)) |
| .get(), |
| ).toEqual({ directory: destination, path: "packages" }) |
| }), |
| ) |
|
|
| it.live("moves nested session changes without cleaning unrelated files", () => |
| Effect.gen(function* () { |
| const root = yield* Effect.acquireRelease( |
| Effect.promise(() => tmpdir()), |
| (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), |
| ) |
| yield* Effect.promise(() => initRepo(root.path)) |
| const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) |
| const sourceDirectory = abs(path.join(source, "packages")) |
| yield* Effect.promise(() => fs.mkdir(sourceDirectory)) |
| yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n")) |
| yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n")) |
| yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet()) |
| yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet()) |
| const destination = abs(`${root.path}-move-nested-destination`) |
| yield* Effect.addFinalizer(() => |
| Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore), |
| ) |
| yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet()) |
| const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages")) |
| yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n")) |
| yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n")) |
| yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet()) |
| yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n")) |
| yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n")) |
| yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n")) |
|
|
| const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id |
| const sessionID = SessionV2.ID.make("ses_move_nested_checkout") |
| const { db } = yield* Database.Service |
| yield* db |
| .insert(ProjectTable) |
| .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) |
| .run() |
| .pipe(Effect.orDie) |
| yield* db |
| .insert(SessionTable) |
| .values({ |
| id: sessionID, |
| project_id: projectID, |
| slug: "move-nested-checkout", |
| directory: sourceDirectory, |
| title: "move nested checkout", |
| version: "test", |
| time_created: 1, |
| time_updated: 1, |
| }) |
| .run() |
| .pipe(Effect.orDie) |
|
|
| yield* MoveSession.Service.use((service) => |
| service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }), |
| ) |
|
|
| expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n") |
| expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n") |
| expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n") |
| expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe( |
| "initial\n", |
| ) |
| expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false) |
| expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe( |
| "staged\n", |
| ) |
| expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe( |
| "M packages/staged.txt\n", |
| ) |
| expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n") |
| expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n") |
| }), |
| ) |
| }) |
|
|