From a17c66a37d5526b6efd067de5024baff1f8376d1 Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Thu, 3 Sep 2026 10:23:14 +1000
Subject: [PATCH] fix(app): gate worktree creation to Git projects
---
.../new-session-workspace-branch.spec.ts | 93 ++++++++-------
.../session-worktree-capabilities.spec.ts | 112 ++++++++++++++++++
packages/app/src/new-session/view.tsx | 2 +-
.../new-session/workspace/controller.test.ts | 6 +-
.../src/new-session/workspace/controller.ts | 5 +-
.../src/session/timeline/message-timeline.tsx | 4 +-
.../timeline/session-workspace-menu.tsx | 5 +-
7 files changed, 176 insertions(+), 51 deletions(-)
create mode 100644 packages/app/e2e/regression/session-worktree-capabilities.spec.ts
diff --git a/packages/app/e2e/regression/new-session-workspace-branch.spec.ts b/packages/app/e2e/regression/new-session-workspace-branch.spec.ts
index 4ccbc1609f00..919acdb290b5 100644
--- a/packages/app/e2e/regression/new-session-workspace-branch.spec.ts
+++ b/packages/app/e2e/regression/new-session-workspace-branch.spec.ts
@@ -6,48 +6,55 @@ const draftID = "draft_new_session_workspace_branch"
const directory = "C:/OpenCode/WorkspaceBranch"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
-test("selects a base branch for a new workspace", async ({ page }) => {
- await mockOpenCodeServer(page, {
- directory,
- project: {
- id: "proj_new_session_workspace_branch",
- worktree: directory,
- vcs: "git",
- name: "workspace-branch",
- time: { created: 1700000000000, updated: 1700000000000 },
- sandboxes: [],
- },
- provider: { all: [], connected: [], default: {} },
- sessions: [],
- pageMessages: () => ({ items: [] }),
- vcsBranches: ["feature/api", "main", "origin/release"],
- })
- await page.addInitScript(
- ({ directory, draftID, server }) => {
- localStorage.setItem(
- "opencode.global.dat:server",
- JSON.stringify({
- projects: { local: [{ worktree: directory, expanded: true }] },
- lastProject: { local: directory },
- }),
- )
- localStorage.setItem(
- "opencode.window.browser.dat:tabs",
- JSON.stringify([{ type: "draft", draftID, server, directory }]),
- )
- },
- { directory, draftID, server },
- )
+for (const vcs of ["git", "hg"]) {
+ test(`gates new workspace selection for ${vcs}`, async ({ page }) => {
+ await mockOpenCodeServer(page, {
+ directory,
+ project: {
+ id: "proj_new_session_workspace_branch",
+ worktree: directory,
+ vcs,
+ name: "workspace-branch",
+ time: { created: 1700000000000, updated: 1700000000000 },
+ sandboxes: [],
+ },
+ provider: { all: [], connected: [], default: {} },
+ sessions: [],
+ pageMessages: () => ({ items: [] }),
+ vcsBranches: ["feature/api", "main", "origin/release"],
+ })
+ await page.addInitScript(
+ ({ directory, draftID, server }) => {
+ localStorage.setItem(
+ "opencode.global.dat:server",
+ JSON.stringify({
+ projects: { local: [{ worktree: directory, expanded: true }] },
+ lastProject: { local: directory },
+ }),
+ )
+ localStorage.setItem(
+ "opencode.window.browser.dat:tabs",
+ JSON.stringify([{ type: "draft", draftID, server, directory }]),
+ )
+ },
+ { directory, draftID, server },
+ )
- await page.goto(`/new-session?draftId=${draftID}`)
- await expectAppVisible(page.locator('[data-component="composer-editor"]'))
- await page.getByRole("button", { name: "Local", exact: true }).click()
- await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
- await page.getByRole("button", { name: "from main", exact: true }).click()
- await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
+ await page.goto(`/new-session?draftId=${draftID}`)
+ await expectAppVisible(page.locator('[data-component="composer-editor"]'))
+ if (vcs !== "git") {
+ await expect(page.getByText("main", { exact: true })).toBeVisible()
+ await expect(page.getByRole("button", { name: "Local", exact: true })).toHaveCount(0)
+ return
+ }
+ await page.getByRole("button", { name: "Local", exact: true }).click()
+ await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
+ await page.getByRole("button", { name: "from main", exact: true }).click()
+ await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
- const selected = page.getByRole("button", { name: "from feature/api", exact: true })
- await expect(selected).toBeVisible()
- await selected.click()
- await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
-})
+ const selected = page.getByRole("button", { name: "from feature/api", exact: true })
+ await expect(selected).toBeVisible()
+ await selected.click()
+ await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
+ })
+}
diff --git a/packages/app/e2e/regression/session-worktree-capabilities.spec.ts b/packages/app/e2e/regression/session-worktree-capabilities.spec.ts
new file mode 100644
index 000000000000..86a0f9def5f8
--- /dev/null
+++ b/packages/app/e2e/regression/session-worktree-capabilities.spec.ts
@@ -0,0 +1,112 @@
+import { expect, test, type Page } from "@playwright/test"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { fixture } from "../smoke/session-timeline.fixture"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { installSseTransport } from "../utils/sse-transport"
+
+test.use({ serviceWorkers: "block" })
+
+for (const viewport of [
+ { name: "desktop", width: 1280, height: 900 },
+ { name: "mobile", width: 390, height: 844 },
+]) {
+ for (const vcs of ["git", "hg"]) {
+ test(`gates worktree creation but keeps existing moves for ${vcs} on ${viewport.name}`, async ({ page }, info) => {
+ await page.setViewportSize(viewport)
+ const destination = "C:/OpenCode/existing-checkout"
+ const session = {
+ id: "ses_worktree_capabilities",
+ projectID: fixture.project.id,
+ directory: fixture.directory,
+ title: "Worktree capabilities",
+ }
+ const transport = await installSseTransport(page, { server: fixture.serverKey })
+ await mockOpenCodeServer(page, {
+ directory: fixture.directory,
+ project: { ...fixture.project, vcs, sandboxes: [destination] },
+ provider: fixture.provider,
+ sessions: [session],
+ pageMessages: () => ({
+ items: [{ id: "msg_saved", type: "user", text: "Review this change", time: { created: 1 } }],
+ }),
+ fileList: () => [],
+ vcsDiff: [
+ { file: "file.txt", patch: "@@ -1 +1 @@\n-before\n+after", additions: 1, deletions: 1, status: "modified" },
+ ],
+ })
+ const creations: string[] = []
+ page.on("request", (request) => {
+ if (request.method() === "POST" && new URL(request.url()).pathname === `/api/worktree/${fixture.project.id}`)
+ creations.push(request.url())
+ })
+ await page.route(`**/api/session/${session.id}/move`, (route) =>
+ route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }),
+ )
+ await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${session.id}`)
+ await expect(page.getByText("Review this change", { exact: true })).toBeVisible()
+ const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
+ await prompt.fill("Keep this draft")
+ await openDetails(page, viewport.name === "mobile")
+
+ const summary = page.locator('[data-component="session-summary-panel"]')
+ await expect(summary.getByRole("button", { name: /^1 Changed file/ })).toBeVisible()
+ await info.attach("summary", {
+ body: await page.screenshot({ path: info.outputPath("summary.png"), animations: "disabled" }),
+ contentType: "image/png",
+ })
+ await expect
+ .soft(summary.getByRole("button", { name: "Move to worktree", exact: true }))
+ .toHaveCount(vcs === "git" ? 1 : 0)
+
+ await summary.getByRole("button", { name: "Local repository", exact: true }).click()
+ const create = page.getByRole("menuitem", { name: "New worktree", exact: true })
+ await expect(create).toBeVisible()
+ await expect.soft(create).toBeEnabled({ enabled: vcs === "git" })
+ await info.attach("destinations", {
+ body: await page.screenshot({ path: info.outputPath("destinations.png"), animations: "disabled" }),
+ contentType: "image/png",
+ })
+ await page.getByRole("menuitem", { name: "Worktree", exact: true }).press("ArrowRight")
+ const existing = page.getByRole("menuitem", { name: "existing-checkout", exact: true })
+ await expect(existing).toBeVisible()
+ const move = page.waitForRequest(
+ (request) =>
+ request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${session.id}/move`,
+ )
+ await existing.press("Enter")
+ expect((await move).postDataJSON()).toEqual({ directory: destination })
+ session.directory = destination
+ await transport.send({
+ id: "evt_worktree_capabilities_moved",
+ type: "session.moved",
+ created: 2,
+ durable: { aggregateID: session.id, seq: 1, version: 1 },
+ data: { sessionID: session.id, location: { directory: destination }, projectID: fixture.project.id },
+ })
+ await expect(
+ page.locator('[data-type="location-switched"]').getByText(destination, { exact: true }),
+ ).toBeVisible()
+ if (viewport.name === "mobile") await openDetails(page, true)
+ await expect(summary.getByRole("button", { name: "existing-checkout", exact: true })).toBeVisible()
+ await summary.getByRole("button", { name: "existing-checkout", exact: true }).click()
+ await expect(page.getByRole("menuitem", { name: "Local repository", exact: true })).toBeEnabled()
+ await page.keyboard.press("Escape")
+ await page.keyboard.press("Escape")
+ await expect(prompt).toHaveText("Keep this draft")
+ await expect(page.getByText("Review this change", { exact: true })).toBeVisible()
+ expect(creations).toEqual([])
+ })
+ }
+}
+
+async function openDetails(page: Page, mobile: boolean) {
+ if (mobile) {
+ await page
+ .locator('[data-slot="session-mobile-view-navigation"]')
+ .getByRole("button", { name: "More options", exact: true })
+ .click()
+ await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
+ return
+ }
+ await page.getByRole("button", { name: "Session details", exact: true }).click()
+}
diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx
index ee7210c40a9f..fe2d8893d64c 100644
--- a/packages/app/src/new-session/view.tsx
+++ b/packages/app/src/new-session/view.tsx
@@ -70,7 +70,7 @@ export function NewSessionView(props: {
fallback={
}
diff --git a/packages/app/src/new-session/workspace/controller.test.ts b/packages/app/src/new-session/workspace/controller.test.ts
index 44949821938a..2fe9bc72b9af 100644
--- a/packages/app/src/new-session/workspace/controller.test.ts
+++ b/packages/app/src/new-session/workspace/controller.test.ts
@@ -7,6 +7,7 @@ describe("new session workspace selection", () => {
resolveNewSessionWorktree({
enabled: false,
selected: "/project/feature",
+ fallback: "create",
}),
).toBe("main")
})
@@ -59,9 +60,10 @@ describe("new session workspace selection", () => {
).toBe("release")
})
- test("uses location VCS state when the project inventory is stale", () => {
- expect(resolveNewSessionGit({ branch: "dev" })).toBe(true)
+ test("requires Git project metadata before enabling worktree creation", () => {
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
+ expect(resolveNewSessionGit({ projectVcs: "hg" })).toBe(false)
+ expect(resolveNewSessionGit({ projectVcs: "custom" })).toBe(false)
expect(resolveNewSessionGit({})).toBe(false)
})
})
diff --git a/packages/app/src/new-session/workspace/controller.ts b/packages/app/src/new-session/workspace/controller.ts
index b0e42ddb91f9..b335f78acfc1 100644
--- a/packages/app/src/new-session/workspace/controller.ts
+++ b/packages/app/src/new-session/workspace/controller.ts
@@ -33,8 +33,8 @@ export function resolveNewSessionBranch(input: {
return input.worktreeBranch(directory)
}
-export function resolveNewSessionGit(input: { projectVcs?: string; branch?: string }) {
- return input.projectVcs === "git" || input.branch !== undefined
+export function resolveNewSessionGit(input: { projectVcs?: string }) {
+ return input.projectVcs === "git"
}
export function createNewSessionWorkspaceController(input: {
@@ -59,7 +59,6 @@ export function createNewSessionWorkspaceController(input: {
const visible = createMemo(() =>
resolveNewSessionGit({
projectVcs: currentProject()?.vcs,
- branch: data.location.vcs.info({ directory: sdk().directory })?.branch.current,
}),
)
const selected = createMemo(() => {
diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx
index f0a56539d3c9..92ce161c6995 100644
--- a/packages/app/src/session/timeline/message-timeline.tsx
+++ b/packages/app/src/session/timeline/message-timeline.tsx
@@ -292,7 +292,9 @@ export function SessionSummaryPanel(props: {
- 0 && props.moveEligible}>
+ 0 && props.moveEligible && props.project.vcs === "git"}
+ >
- void move("create")}>
+ void move("create")}
+ >
{language.t("workspace.new")}