diff --git a/.changeset/stable-project-labels.md b/.changeset/stable-project-labels.md new file mode 100644 index 000000000000..27c49859f090 --- /dev/null +++ b/.changeset/stable-project-labels.md @@ -0,0 +1,8 @@ +--- +"@opencode-ai/core": patch +"@opencode-ai/app": patch +--- + +Keep project labels stable when opening multiple clones of the same repository, while still refreshing the canonical path when its directory is renamed or removed. + +Worktree setup scripts receive the selected source directory as `OPENCODE_WORKTREE_BASE` rather than another clone's shared project path. diff --git a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts index e6849c41fde8..ea76fb8ceb4e 100644 --- a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts +++ b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts @@ -25,6 +25,7 @@ for (const viewport of [ const mock = await openDraft(page) const pending = await submitPending(page, mock) + expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })]) await expect(pending.message).toBeInViewport() await expect(pending.shimmer).toBeInViewport() await testInfo.attach("creating-worktree", { @@ -184,6 +185,7 @@ test("restores the draft after closing and revisiting a pending session that fai async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) { const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>() const calls: string[] = [] + const worktreeRequests: Record[] = [] const creates: Record[] = [] const prompts: { sessionID: string; body: Record }[] = [] const project = { @@ -216,7 +218,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) page.on("request", (request) => { if (request.method() !== "POST") return const path = new URL(request.url()).pathname - if (path === `/api/worktree/${projectID}`) calls.push("worktree") + if (path === `/api/worktree/${projectID}`) { + calls.push("worktree") + worktreeRequests.push(request.postDataJSON()) + } if (path === "/api/session") calls.push("session") if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt") }) @@ -274,7 +279,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) await page.getByRole("menuitem", { name: "New workspace", exact: true }).click() await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible() await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() - return { worktree, calls, creates, prompts } + return { worktree, worktreeRequests, calls, creates, prompts } } async function submitPending(page: Page, mock: Awaited>) { diff --git a/packages/app/src/new-session/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 892d2c5e9a75..1a5a98faf12e 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -1,5 +1,4 @@ import { base64Encode } from "@opencode-ai/util/encode" -import { getDirectory } from "@opencode-ai/util/path" import type { SessionMessageUser } from "@opencode-ai/client/promise" import { Session } from "@opencode-ai/schema/session" import { startTransition } from "solid-js" @@ -13,6 +12,7 @@ import { useData, useServer } from "@/runtime/server/current" import { type ServerSDK, useServerSDK } from "@/runtime/server/client" import { useTabs } from "@/shell/tabs/tabs" import { useWorkspaceLocation } from "@/workspaces/location" +import { createWorktree } from "@/workspaces/create" import { useSessionKey } from "@/session/session-layout" import { showToast } from "@/shell/notifications/toast" import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope" @@ -192,25 +192,17 @@ async function resolveSessionDirectory(input: { if (input.worktree === "main") return input.projectDirectory if (input.worktree !== "create") return input.worktree - return input.serverSDK.api.worktree - .create({ - projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "", - strategy: "git", - branch: input.branch, - directory: getDirectory( - input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory, - ), - }) - .then(async (created) => { - await input.serverSDK.api.location.get({ location: { directory: created.directory } }) - return created.directory - }) - .catch((error) => { - showToast({ - title: input.language.t("prompt.toast.worktreeCreateFailed.title"), - description: errorMessage(input.language, error), - }) + return createWorktree({ + api: input.serverSDK.api, + directory: input.projectDirectory, + project: input.data.location.info({ directory: input.projectDirectory })?.project, + branch: input.branch, + }).catch((error) => { + showToast({ + title: input.language.t("prompt.toast.worktreeCreateFailed.title"), + description: errorMessage(input.language, error), }) + }) } function errorMessage(language: ReturnType, error: unknown) { diff --git a/packages/app/src/session/timeline/session-workspace-menu.tsx b/packages/app/src/session/timeline/session-workspace-menu.tsx index cbd9f6f76607..5c688c98f147 100644 --- a/packages/app/src/session/timeline/session-workspace-menu.tsx +++ b/packages/app/src/session/timeline/session-workspace-menu.tsx @@ -1,6 +1,6 @@ import { Menu } from "@opencode-ai/ui/menu" import { Icon } from "@opencode-ai/ui/icon" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/util/path" import { createStore } from "solid-js/store" import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js" import type { Project } from "@/runtime/server/types" @@ -11,6 +11,7 @@ import { useSettingsDialog } from "@/settings/command" import { pathKey } from "@/workspaces/path-key" import { showToast } from "@/shell/notifications/toast" import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths" +import { createWorktree } from "@/workspaces/create" export function SessionWorkspaceMenu(props: { eligible?: boolean @@ -55,7 +56,14 @@ export function SessionWorkspaceMenu(props: { setStore("selected", selection) try { - const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection + const destination = + selection === "create" + ? await createWorktree({ + api: sdk.api, + directory: props.directory, + project: data.location.info({ directory: props.directory })?.project, + }) + : selection if (!destination) return await sdk.api.session.move({ sessionID, directory: destination }) @@ -124,13 +132,3 @@ export function SessionWorkspaceMenu(props: { ) } - -async function createWorkspace(project: Project, serverSDK: ReturnType) { - const created = await serverSDK.api.worktree.create({ - projectID: project.id, - strategy: "git", - directory: getDirectory(project.worktree), - }) - await serverSDK.api.location.get({ location: { directory: created.directory } }) - return created.directory -} diff --git a/packages/app/src/workspaces/create.test.ts b/packages/app/src/workspaces/create.test.ts new file mode 100644 index 000000000000..1b41cc59fde8 --- /dev/null +++ b/packages/app/src/workspaces/create.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test" +import { OpenCode } from "@opencode-ai/client/promise" +import { createWorktree } from "./create" + +describe("worktree creation", () => { + test.each( + [ + { name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" }, + { + name: "clone subdirectory", + directory: "/copies/repo/packages/app", + root: "/copies/repo", + canonical: "/copies/repo", + parent: "/copies/", + }, + { + name: "linked worktree subdirectory", + directory: "/linked/task/packages/app", + root: "/linked/task", + canonical: "/copies/repo", + parent: "/copies/", + }, + { + name: "Windows clone", + directory: "C:\\copies\\repo\\packages\\app", + root: "C:\\copies\\repo", + canonical: "C:\\copies\\repo", + parent: "C:/copies/", + }, + ].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))), + )("uses the clone-local main for $name (cached: $cached)", async (input) => { + const project = { id: "proj_clone", directory: input.root, canonical: input.canonical } + const requests: Request[] = [] + const api = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + requests.push(request) + if (request.method === "POST") return Response.json({ directory: "/created" }) + return Response.json({ directory: new URL(request.url).searchParams.get("location[directory]"), project }) + }, + { preconnect() {} }, + ), + }) + + expect( + await createWorktree({ + api, + directory: input.directory, + project: input.cached ? project : undefined, + branch: "clone-only", + }), + ).toBe("/created") + expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({ + strategy: "git", + from: input.canonical, + branch: "clone-only", + directory: input.parent, + }) + expect(requests.find((request) => request.method === "POST")?.url).toBe( + "http://localhost:3000/api/worktree/proj_clone", + ) + expect( + requests + .filter((request) => request.method === "GET") + .map((request) => new URL(request.url).searchParams.get("location[directory]")), + ).toEqual(input.cached ? ["/created"] : [input.directory, "/created"]) + }) + + test("does not fall back to a shared project when location lookup fails", async () => { + const requests: Request[] = [] + const api = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)) + return Response.json({ message: "unavailable" }, { status: 503 }) + }, + { preconnect() {} }, + ), + }) + + await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({ + reason: "UnexpectedStatus", + cause: { status: 503 }, + }) + expect(requests.map((request) => request.method)).toEqual(["GET"]) + }) +}) diff --git a/packages/app/src/workspaces/create.ts b/packages/app/src/workspaces/create.ts new file mode 100644 index 000000000000..9cc66e802721 --- /dev/null +++ b/packages/app/src/workspaces/create.ts @@ -0,0 +1,20 @@ +import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise" +import { getDirectory } from "@opencode-ai/util/path" + +export async function createWorktree(input: { + api: Pick + directory: string + project?: LocationGetOutput["project"] + branch?: string +}) { + const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project + const created = await input.api.worktree.create({ + projectID: project.id, + strategy: "git", + from: project.canonical, + branch: input.branch, + directory: getDirectory(project.canonical), + }) + await input.api.location.get({ location: { directory: created.directory } }) + return created.directory +} diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index d209ecb7bb11..c2e274c9d5fc 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -41,6 +41,7 @@ export interface Resolved { readonly previous?: ID readonly id: ID readonly directory: AbsolutePath + // This checkout's main directory; the stored project canonical may be another clone. readonly canonical: AbsolutePath readonly vcs?: Vcs readonly vcsBackend?: string @@ -110,11 +111,17 @@ const layer = Layer.effect( .get() .pipe(Effect.orDie) yield* upsertProject(db, project).pipe(Effect.orDie) - if (previous && previous.canonical !== project.canonical) { + // Clones share a project ID; only replace a canonical directory that is gone. + if ( + previous && + previous.canonical !== project.canonical && + !(yield* fs.exists(previous.canonical).pipe(Effect.orElseSucceed(() => true))) + ) { const row = yield* db - .select() - .from(ProjectTable) + .update(ProjectTable) + .set({ worktree: project.canonical }) .where(eq(ProjectTable.id, project.id)) + .returning() .get() .pipe(Effect.orDie) if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row)) diff --git a/packages/core/src/project/sql.ts b/packages/core/src/project/sql.ts index 3f262bfc2baa..6bbee5852094 100644 --- a/packages/core/src/project/sql.ts +++ b/packages/core/src/project/sql.ts @@ -51,11 +51,8 @@ export function upsertProject( .values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] }) .onConflictDoUpdate({ target: ProjectTable.id, - set: { worktree: project.canonical, vcs: vcs ?? null }, - setWhere: or( - ne(ProjectTable.worktree, project.canonical), - vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs), - ), + set: { vcs: vcs ?? null }, + setWhere: vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs), }) .run() } diff --git a/packages/core/src/worktree.ts b/packages/core/src/worktree.ts index b298a5ab7116..ef0569cc2b87 100644 --- a/packages/core/src/worktree.ts +++ b/packages/core/src/worktree.ts @@ -267,20 +267,20 @@ const layer = Layer.effect( }), ) const project = yield* db - .select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands }) + .select({ commands: ProjectTable.commands }) .from(ProjectTable) .where(eq(ProjectTable.id, input.projectID)) .get() .pipe(Effect.orDie) const command = project?.commands?.start?.trim() - if (command && project) { + if (command) { const windows = process.platform === "win32" yield* processService .run( ChildProcess.make(windows ? command : "bash", windows ? [] : ["-lc", command], { cwd: result.directory, env: { - OPENCODE_WORKTREE_BASE: project.worktree, + OPENCODE_WORKTREE_BASE: sourceDirectory, OPENCODE_WORKTREE_PATH: result.directory, }, extendEnv: true, diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 57ed165456e3..8787ee4c5776 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -352,6 +352,44 @@ describe("Project.resolve", () => { }), ) + it.live("keeps the canonical project directory when opening another clone", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const main = path.join(tmp.path, "repo") + const clone = path.join(tmp.path, "other-clone") + const linked = path.join(tmp.path, "linked") + yield* Effect.promise(async () => { + await fs.mkdir(main) + await initRepo(main, { commit: true, remote: "git@github.com:owner/repo.git" }) + await $`git clone --no-hardlinks ${main} ${clone}`.quiet() + await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone).quiet() + await $`git worktree add ${linked} -b linked`.cwd(main).quiet() + }) + const project = yield* Project.Service + const bus = yield* Bus.Service + const initial = yield* project.resolve(abs(main)) + const updates: Project.Info[] = [] + yield* bus.subscribe(ProjectSchema.Event.Updated).pipe( + Stream.runForEach((event) => Effect.sync(() => updates.push(event.data))), + Effect.forkScoped({ startImmediately: true }), + ) + + for (const directory of [clone, linked, main, clone]) { + const resolved = yield* project.resolve(abs(directory)) + expect(resolved.id).toBe(initial.id) + expect(resolved.directory).toBe(abs(directory)) + expect(resolved.canonical).toBe(abs(directory === clone ? clone : main)) + expect((yield* project.list()).find((item) => item.id === initial.id)?.canonical).toBe(abs(main)) + } + yield* Effect.yieldNow + + expect(updates).toEqual([]) + }), + ) + it.live("returns git global for repo with no commits and no remote", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 8ab7af024014..b86f30d31b61 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -92,6 +92,33 @@ function withTmp(f: (directory: string) => Effect.Effect) { } describe("Session.create", () => { + liveIt.live("preserves the project canonical directory when creating a session in another clone", () => + withTmp((directory) => + Effect.gen(function* () { + const main = AbsolutePath.make(path.join(directory, "repo")) + const clone = AbsolutePath.make(path.join(directory, "other-clone")) + yield* Effect.promise(async () => { + await $`git init -q ${main}`.cwd(directory) + await $`git -c user.name=Test -c user.email=test@opencode.test -c commit.gpgsign=false commit --allow-empty -qm root` + .cwd(main) + .quiet() + await $`git remote add origin git@github.com:owner/repo.git`.cwd(main) + await $`git clone --no-hardlinks ${main} ${clone}`.quiet() + await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone) + }) + const sessions = yield* Session.Service + const projects = yield* Project.Service + const first = yield* sessions.create({ location: Location.Ref.make({ directory: main }) }) + const second = yield* sessions.create({ location: Location.Ref.make({ directory: clone }) }) + + expect(second.projectID).toBe(first.projectID) + expect((yield* projects.list()).find((project) => project.id === first.projectID)?.canonical).toBe(main) + expect((yield* sessions.get(first.id)).location.directory).toBe(main) + expect((yield* sessions.get(second.id)).location.directory).toBe(clone) + }), + ), + ) + liveIt.live("follows the directory's project identity established after creation", () => withTmp((directory) => Effect.gen(function* () { diff --git a/packages/core/test/worktree.test.ts b/packages/core/test/worktree.test.ts index 1c209523756c..417f9b604582 100644 --- a/packages/core/test/worktree.test.ts +++ b/packages/core/test/worktree.test.ts @@ -228,6 +228,57 @@ describe("Worktree", () => { }), ) + projectIt.live("creates worktrees and runs setup from the selected clone", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + const main = abs(path.join(root.path, "repo")) + const clone = abs(path.join(root.path, "other-clone")) + yield* Effect.promise(async () => { + await fs.mkdir(main) + await initRepo(main) + await $`git remote add origin git@github.com:owner/repo.git`.cwd(main).quiet() + await $`git clone --no-hardlinks ${main} ${clone}`.quiet() + await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone).quiet() + await $`git -c user.name=Test -c user.email=test@opencode.test -c commit.gpgsign=false commit --allow-empty -m clone` + .cwd(clone) + .quiet() + }) + const projects = yield* Project.Service + const worktrees = yield* Worktree.Service + const initial = yield* projects.resolve(main) + const selected = yield* projects.resolve(clone) + yield* projects.update({ + projectID: initial.id, + commands: { + start: + "bun -e \"await Bun.write('setup.json', JSON.stringify([process.env.OPENCODE_WORKTREE_BASE, process.env.OPENCODE_WORKTREE_PATH, process.cwd()]))\"", + }, + }) + + const created = yield* worktrees.create({ + projectID: selected.id, + strategy: gitWorktree, + from: selected.canonical, + directory: abs(path.join(root.path, "worktrees")), + name: "selected-clone", + }) + + expect(selected.id).toBe(initial.id) + expect((yield* projects.list()).find((project) => project.id === initial.id)?.canonical).toBe(main) + expect(yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(created.directory).text())).toBe( + yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(clone).text()), + ) + expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "setup.json")).json())).toEqual([ + clone, + created.directory, + created.directory, + ]) + }), + ) + it.live("creates a git worktree from a selected branch", () => Effect.gen(function* () { const input = yield* setup() diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index e1edc39c7083..c11cab194217 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -7,28 +7,41 @@ import { useClient } from "../../context/client" import { useToast } from "../../ui/toast" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { useData } from "../../context/data" +import { useLocation } from "../../context/location" export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) { const dialog = useDialog() const client = useClient() const toast = useToast() const data = useData() + const currentLocation = useLocation() const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) const [creatingDots, setCreatingDots] = createSignal(3) const [progress, setProgress] = createSignal() const [destination, setDestination] = createSignal() + function homeLocation() { + const location = currentLocation.ref ?? data.location.default() + return { ...location, directory: location.directory || paths.cwd } + } + async function create(name: string) { - const projectID = await resolveProjectID() - if (!projectID) return setCreating(true) setProgress("Creating worktree") try { + const sessionID = input.sessionID() + const session = sessionID ? await resolveSession(sessionID) : undefined + if (sessionID && !session) throw new Error("Unable to determine current session location") + const location = session?.location ?? homeLocation() + if (!data.location.info(location)) await data.location.syncInfo(location) + const project = data.location.info(location)?.project + if (!project) throw new Error("Unable to determine current project") const result = await client.api.worktree.create({ - projectID, + projectID: project.id, strategy: "git", - directory: path.join(paths.worktree, projectID.slice(0, 6)), + from: project.canonical, + directory: path.join(paths.worktree, project.id.slice(0, 6)), name, }) const directory = result.directory @@ -71,8 +84,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } : { type: "directory", - directory: data.location.default().directory, - subdirectory: data.location.default().directory !== data.location.info()?.project.directory, + directory: homeLocation().directory, + subdirectory: homeLocation().directory !== data.location.info(homeLocation())?.project.directory, }) } onCurrentChange={setDestination} @@ -111,14 +124,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } async function resolveProjectID() { - const projectID = input.projectID() - if (projectID) return projectID const sessionID = input.sessionID() - if (sessionID) return (await resolveSession(sessionID))?.projectID - const current = data.location.info() + if (sessionID) return input.projectID() ?? (await resolveSession(sessionID))?.projectID + const location = homeLocation() + const current = data.location.info(location) if (current) return current.project.id return client.api.project - .current({ location: { directory: data.location.default().directory || paths.cwd } }) + .current({ location: { directory: location.directory, workspace: location.workspaceID } }) .then((project) => project.id) .catch(() => undefined) } diff --git a/packages/tui/test/cli/tui/prompt-move.test.tsx b/packages/tui/test/cli/tui/prompt-move.test.tsx new file mode 100644 index 000000000000..338d387fd60f --- /dev/null +++ b/packages/tui/test/cli/tui/prompt-move.test.tsx @@ -0,0 +1,253 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import path from "path" +import { InputRenderable } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { usePromptMove } from "../../../src/component/prompt/move" +import { ConfigProvider } from "../../../src/config" +import { ClientProvider } from "../../../src/context/client" +import { DataProvider, useData } from "../../../src/context/data" +import { Keymap } from "../../../src/context/keymap" +import { LocationProvider, useLocation } from "../../../src/context/location" +import { RouteProvider } from "../../../src/context/route" +import { ThemeProvider } from "../../../src/context/theme" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider, useToast } from "../../../src/ui/toast" +import { emptyThemeSource } from "../../fixture/fixture" +import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +const main = "/tmp/opencode/main" +const clone = "/tmp/opencode/other-clone" +const linked = "/tmp/opencode/linked" +const created = "/tmp/opencode/proj_t/fresh" + +test.each([ + { name: "a cached session in another clone", directory: clone, warm: true }, + { name: "an uncached session in a clone subdirectory", directory: `${clone}/packages/tui` }, + { name: "an uncached session in a linked worktree", directory: linked, worktree: linked }, + { name: "a session in a linked worktree subdirectory", directory: `${linked}/packages/tui`, worktree: linked }, + { name: "the home/default location", directory: `${clone}/packages/tui`, home: true }, +])("creates from the clone's main worktree for $name", async (input) => { + const fixture = await renderMove(input) + try { + await fixture.data.project.sync() + expect(fixture.data.project.get("proj_test")?.canonical).toBe(main) + if (input.warm) { + await fixture.data.session.sync("ses_clone") + await fixture.data.location.syncInfo({ directory: input.directory }) + } + if (!input.home && !input.warm) { + expect(fixture.data.session.get("ses_clone")).toBeUndefined() + expect(fixture.data.location.info({ directory: input.directory })).toBeUndefined() + } + if (!input.home) fixture.location.set({ directory: main }) + + await fixture.create() + + expect(fixture.requests).toEqual([ + { strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" }, + ]) + expect(fixture.data.location.info({ directory: created })?.project.canonical).toBe(clone) + expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(1) + expect(fixture.reads.session).toBe(input.home ? 0 : 1) + expect(fixture.moves).toEqual(input.home ? [] : [{ directory: created }]) + } finally { + fixture.app.renderer.destroy() + } +}) + +test.each([ + { name: "another clone", launch: main }, + { name: "another project", launch: "/tmp/opencode/elsewhere", launchProjectID: "proj_launch" }, + { name: "another workspace", launch: main, workspaceID: "wrk_clone" }, +])("uses Home's selected location instead of launch in $name", async (input) => { + const fixture = await renderMove({ ...input, directory: `${clone}/packages/tui`, home: true }) + try { + await fixture.data.location.syncInfo() + const selected = { directory: `${clone}/packages/tui`, workspaceID: input.workspaceID } + fixture.location.set(selected) + expect(fixture.data.location.default().directory).toBe(input.launch) + expect(fixture.data.location.info(selected)).toBeUndefined() + + const frame = await fixture.create() + + expect(fixture.reads.worktrees).toEqual(["proj_test"]) + expect(frame).toContain(clone) + expect(frame.indexOf(clone)).toBeLessThan(frame.indexOf(main)) + expect(fixture.requests).toEqual([ + { strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" }, + ]) + expect(fixture.data.location.info(selected)?.project.canonical).toBe(clone) + expect(fixture.moves).toEqual([]) + } finally { + fixture.app.renderer.destroy() + } +}) + +test.each([ + { name: "session", unavailable: "session" as const }, + { name: "location", unavailable: "location" as const }, + { name: "selected Home location", unavailable: "location" as const, home: true, launch: main }, +])("does not create from another clone when $name lookup fails", async (input) => { + const fixture = await renderMove({ ...input, directory: `${linked}/packages/tui`, worktree: linked }) + try { + if (input.home) fixture.location.set({ directory: `${linked}/packages/tui` }) + await fixture.create() + + expect(fixture.requests).toEqual([]) + expect(fixture.moves).toEqual([]) + expect(fixture.toast.currentToast).toMatchObject({ title: "Creating workspace failed", variant: "error" }) + expect(fixture.move.creating()).toBe(false) + } finally { + fixture.app.renderer.destroy() + } +}) + +async function renderMove(input: { + directory: string + worktree?: string + home?: boolean + launch?: string + launchProjectID?: string + unavailable?: "session" | "location" +}) { + const launch = input.launch ?? (input.home ? input.directory : main) + const requests: unknown[] = [] + const moves: unknown[] = [] + const reads = { session: 0, locations: [] as string[], worktrees: [] as string[] } + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/location" || url.pathname === "/api/project/current") { + const directory = url.searchParams.get("location[directory]") ?? launch + const project = { + id: directory === launch ? (input.launchProjectID ?? "proj_test") : "proj_test", + directory: directory === input.directory ? (input.worktree ?? clone) : directory, + canonical: + directory === input.directory || directory === created + ? clone + : input.launchProjectID && directory === launch + ? launch + : main, + } + if (url.pathname === "/api/project/current") return json(project) + reads.locations.push(directory) + if (input.unavailable === "location" && directory === input.directory) + return json({ message: "Location unavailable" }, { status: 503 }) + return json({ + directory, + workspaceID: url.searchParams.get("location[workspace]") ?? undefined, + project, + }) + } + if (url.pathname === "/api/project") + return json([{ id: "proj_test", canonical: main, time: { created: 1, updated: 1 }, sandboxes: [] }]) + if (url.pathname === "/api/session/ses_clone") { + reads.session++ + if (input.unavailable === "session") return json({ message: "Session unavailable" }, { status: 404 }) + return json({ + data: { + id: "ses_clone", + projectID: "proj_test", + location: { directory: input.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + }, + }) + } + if (url.pathname === "/api/worktree/proj_test" || url.pathname === "/api/worktree/proj_launch") { + if (request.method === "GET") { + reads.worktrees.push(url.pathname.slice("/api/worktree/".length)) + return json( + url.pathname === "/api/worktree/proj_launch" + ? [{ directory: launch }] + : [{ directory: main }, { directory: clone }, { directory: linked, strategy: "git" }], + ) + } + if (request.method === "POST") { + requests.push(await request.json()) + return json({ directory: created }) + } + } + if (url.pathname === "/api/worktree/proj_launch/refresh") return new Response(null, { status: 204 }) + if (url.pathname === "/api/session/ses_clone/move") { + moves.push(await request.json()) + return new Response(null, { status: 204 }) + } + return undefined + }, createEventStream()) + let data!: ReturnType + let move!: ReturnType + let toast!: ReturnType + let location!: ReturnType + + function Probe() { + data = useData() + toast = useToast() + location = useLocation() + move = usePromptMove({ + projectID: () => (input.home ? data.location.info()?.project.id : "proj_test"), + sessionID: () => (input.home ? undefined : "ses_clone"), + }) + return null + } + + const app = await testRender( + () => ( + + + + + + + + + + + + + + + + + + + + + + ), + { width: 100, height: 30, kittyKeyboard: true }, + ) + app.renderer.start() + await app.waitFor(() => move !== undefined) + + return { + app, + data, + move, + toast, + location, + requests, + moves, + reads, + async create() { + await move.open() + const frame = await app.waitForFrame( + (frame) => frame.includes("Move session") && (frame.includes(clone) || frame.includes(launch)), + ) + app.mockInput.pressKey("m", { ctrl: true }) + await app.waitForFrame((frame) => frame.includes("Name worktree")) + await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) + await app.mockInput.typeText("fresh") + app.mockInput.pressEnter() + if (input.home) { + await app.waitFor(() => move.pendingNew()) + await move.getDirectory() + return frame + } + await app.waitFor(() => moves.length > 0 || toast.currentToast !== null) + return frame + }, + } +}