Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/stable-project-labels.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -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<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const project = {
Expand Down Expand Up @@ -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")
})
Expand Down Expand Up @@ -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<ReturnType<typeof openDraft>>) {
Expand Down
30 changes: 11 additions & 19 deletions packages/app/src/new-session/composer-adapter.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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<typeof useLanguage>, error: unknown) {
Expand Down
22 changes: 10 additions & 12 deletions packages/app/src/session/timeline/session-workspace-menu.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -124,13 +132,3 @@ export function SessionWorkspaceMenu(props: {
</Menu>
)
}

async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
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
}
90 changes: 90 additions & 0 deletions packages/app/src/workspaces/create.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
})
})
20 changes: 20 additions & 0 deletions packages/app/src/workspaces/create.ts
Original file line number Diff line number Diff line change
@@ -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<OpenCodeClient, "location" | "worktree">
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
}
13 changes: 10 additions & 3 deletions packages/core/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
7 changes: 2 additions & 5 deletions packages/core/src/project/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
6 changes: 3 additions & 3 deletions packages/core/src/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions packages/core/test/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions packages/core/test/session-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,33 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}

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* () {
Expand Down
Loading
Loading