diff --git a/packages/app/e2e/regression/new-session-workspace-branch.spec.ts b/packages/app/e2e/regression/new-session-workspace-branch.spec.ts new file mode 100644 index 00000000000..e815f10c68a --- /dev/null +++ b/packages/app/e2e/regression/new-session-workspace-branch.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +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 }, + ) + + 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 workspace", 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() +}) diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index e48cd169985..3f0ffd93b85 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -100,6 +100,7 @@ const Group = HttpApiGroup.make("mock") .add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json })) .add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json })) .add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json })) + .add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json })) .add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json })) .add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json })) .add( diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 0ebad912cf0..33cb846304d 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -21,6 +21,7 @@ export interface MockServerConfig { cursor?: string } vcsDiff?: unknown[] + vcsBranches?: string[] messageDelay?: number beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void @@ -296,6 +297,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }), vcsStatus: () => Effect.succeed({ location: location(config), data: [] }), + vcsBranches: () => Effect.succeed({ location: location(config), data: config.vcsBranches ?? ["main"] }), vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }), fsList: (ctx) => Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe( diff --git a/packages/app/src/new-session/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 6dac74e77fe..279eb121a35 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -20,6 +20,7 @@ import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/ export function createNewSessionComposerAdapter(props: { draftID: string worktree: () => string + branch: () => string | undefined submitted: () => void }) { const route = useSessionKey() @@ -48,6 +49,7 @@ export function createNewSessionComposerAdapter(props: { const sessionDirectory = await resolveSessionDirectory({ projectDirectory, worktree, + branch: props.branch(), data, serverSDK, language, @@ -73,7 +75,7 @@ export function createNewSessionComposerAdapter(props: { return { ok: false as const, error } }, ) - const afterCreation = async (run: () => Promise) => { + const afterCreation = async (run: () => Promise) => { const result = await creation if (!result.ok) throw result.error return run() @@ -83,7 +85,7 @@ export function createNewSessionComposerAdapter(props: { SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id), ) const cleanupReady = startTransition(() => { - tabs.updateDraft(props.draftID, { worktree: undefined }) + tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined }) local.session.promote(sessionDirectory, created.id, { agent: selection.agent, model: selection.model, @@ -161,6 +163,7 @@ function createMessageHandoff(key: string, sessionID: string, event: ServerSDK[" async function resolveSessionDirectory(input: { projectDirectory: string worktree: string + branch?: string data: ReturnType serverSDK: ReturnType language: ReturnType @@ -172,6 +175,7 @@ async function resolveSessionDirectory(input: { .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, ), diff --git a/packages/app/src/new-session/project/controller.ts b/packages/app/src/new-session/project/controller.ts index f758f748636..da1d008d901 100644 --- a/packages/app/src/new-session/project/controller.ts +++ b/packages/app/src/new-session/project/controller.ts @@ -39,6 +39,7 @@ export function createComposerProjectControls(props: { draftId: string }) { server: ServerConnection.key(connection), directory: worktree, worktree: undefined, + branch: undefined, }) } const addProject = (title: string, serverKey?: string) => { diff --git a/packages/app/src/new-session/screen.tsx b/packages/app/src/new-session/screen.tsx index 03b3ad87c6e..29028cb791b 100644 --- a/packages/app/src/new-session/screen.tsx +++ b/packages/app/src/new-session/screen.tsx @@ -21,15 +21,20 @@ export default function NewSessionPage(props: { draftId: string }) { tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId), ) const workspace = createNewSessionWorkspaceController({ - selected: () => draftTab()?.worktree, - setSelected: (worktree) => { + selectedWorktree: () => draftTab()?.worktree, + selectedBranch: () => draftTab()?.branch, + setSelectedWorktree: (worktree) => { if (search.draftId) tabs.updateDraft(search.draftId, { worktree }) }, + setSelectedBranch: (branch) => { + if (search.draftId) tabs.updateDraft(search.draftId, { branch }) + }, onViewAll: openWorkspaces, }) const composer = createNewSessionComposerAdapter({ draftID: props.draftId, worktree: workspace.selection.value, + branch: workspace.bar.branch, submitted: workspace.selection.remember, }) const model = createComposerModel(composer.adapter) diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index fe1e3b374d9..6f4b3136c48 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -69,9 +69,12 @@ export function NewSessionView(props: { value={props.workspace.selection.value()} projectRoot={props.workspace.project.root()} workspaces={props.workspace.project.workspaces()} + branches={props.workspace.project.branches()} branch={props.workspace.bar.branch()} onboarding={onboardingReady() && !onboarding.used} onChange={select} + onCreate={props.workspace.selection.create} + onSearch={props.workspace.project.searchBranches} onDone={props.composer.restoreFocus} onViewAll={props.workspace.project.openAll} /> diff --git a/packages/app/src/new-session/workspace/controller.test.ts b/packages/app/src/new-session/workspace/controller.test.ts index dc46b0c4a3d..4c5f242f772 100644 --- a/packages/app/src/new-session/workspace/controller.test.ts +++ b/packages/app/src/new-session/workspace/controller.test.ts @@ -65,6 +65,17 @@ describe("new session workspace selection", () => { ).toBe(undefined) }) + test("uses a selected branch for a new workspace", () => { + expect( + resolveNewSessionBranch({ + worktree: "create", + directory: "/project/feature", + createBranch: "release", + worktreeBranch: () => "feature", + }), + ).toBe("release") + }) + test("uses location VCS state when the project inventory is stale", () => { expect(resolveNewSessionGit({ branch: "dev" })).toBe(true) expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true) diff --git a/packages/app/src/new-session/workspace/controller.ts b/packages/app/src/new-session/workspace/controller.ts index 8eaae26932f..332113d20b7 100644 --- a/packages/app/src/new-session/workspace/controller.ts +++ b/packages/app/src/new-session/workspace/controller.ts @@ -1,4 +1,6 @@ -import { createEffect, createMemo } from "solid-js" +import { debounce } from "@solid-primitives/scheduled" +import { createEffect, createMemo, createResource } from "solid-js" +import { createStore } from "solid-js/store" import { useWorkspaceLocation } from "@/workspaces/location" import { useServerSDK } from "@/runtime/server/client" import { useData } from "@/runtime/server/current" @@ -32,8 +34,10 @@ export function normalizeNewSessionWorktree(value: string, directory: string, pr export function resolveNewSessionBranch(input: { worktree: string directory: string + createBranch?: string worktreeBranch: (worktree: string) => string | undefined }) { + if (input.worktree === "create" && input.createBranch) return input.createBranch const directory = input.worktree === "main" || input.worktree === "create" ? input.directory : input.worktree return input.worktreeBranch(directory) } @@ -43,14 +47,18 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri } export function createNewSessionWorkspaceController(input: { - selected: () => string | undefined - setSelected: (worktree: string | undefined) => void + selectedWorktree: () => string | undefined + selectedBranch: () => string | undefined + setSelectedWorktree: (worktree: string | undefined) => void + setSelectedBranch: (branch: string | undefined) => void onViewAll: () => void }) { const sdk = useWorkspaceLocation() const serverSDK = useServerSDK() const data = useData() const settings = useSettings() + const [state, setState] = createStore({ search: "" }) + const searchBranches = debounce((search: string) => setState("search", search.trim()), 100) const currentProject = createMemo(() => { const projectID = data.location.info({ directory: sdk().directory })?.project.id const current = projectID ? data.project.get(projectID) : undefined @@ -64,7 +72,7 @@ export function createNewSessionWorkspaceController(input: { ) const selected = createMemo(() => { const project = currentProject() - const worktree = input.selected() + const worktree = input.selectedWorktree() if (!project || !worktree) return return isWorkspaceSelection(project, worktree) ? worktree : undefined }) @@ -86,6 +94,14 @@ export function createNewSessionWorkspaceController(input: { }), ) const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory) + const [branches] = createResource( + () => (visible() ? { directory: projectRoot(), search: state.search } : undefined), + ({ directory, search }) => + serverSDK.api.vcs + .branches({ location: { directory }, search, limit: 50 }) + .then((response) => ({ directory, search, data: response.data })) + .catch(() => ({ directory, search, data: [] })), + ) createEffect(() => { void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch( () => undefined, @@ -98,6 +114,7 @@ export function createNewSessionWorkspaceController(input: { resolveNewSessionBranch({ worktree: value(), directory: sdk().directory, + createBranch: input.selectedBranch(), worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current, }), ) @@ -116,10 +133,19 @@ export function createNewSessionWorkspaceController(input: { const current = value() return current === "create" || (!!project && isWorkspaceDirectory(project, current)) }), - reset: () => input.setSelected(undefined), + reset: () => { + input.setSelectedWorktree(undefined) + input.setSelectedBranch(undefined) + }, remember, set: (worktree: string) => { - input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree)) + input.setSelectedBranch(undefined) + input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree)) + }, + create: (branch: string) => { + input.setSelectedBranch(branch) + input.setSelectedWorktree("create") + remember("create") }, }, project: { @@ -129,6 +155,15 @@ export function createNewSessionWorkspaceController(input: { return project ? workspaceDirectories(project) : [] }, git: visible, + branches: () => { + const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current + const loaded = branches.latest + const list = loaded?.directory === projectRoot() ? loaded.data : [] + return [ + ...new Set([...list, ...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : [])]), + ].slice(0, 50) + }, + searchBranches, openAll: input.onViewAll, }, bar: { diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index 49079fd6f93..e39175abef9 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -1,4 +1,5 @@ -import { createMemo, createSignal, For, Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" +import { createStore } from "solid-js/store" import { Menu } from "@opencode-ai/ui/menu" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Icon } from "@opencode-ai/ui/icon" @@ -10,20 +11,24 @@ export function PromptWorkspaceSelector(props: { value: string projectRoot: string workspaces: string[] + branches: string[] branch?: string onboarding?: boolean onChange: (value: string) => void + onCreate: (branch: string) => void + onSearch: (search: string) => void onDone: () => void onViewAll: () => void }) { const language = useLanguage() - const [search, setSearch] = createSignal("") + const [search, setSearch] = createStore({ workspaces: "", branches: "" }) let searchInput: HTMLInputElement | undefined + let branchSearchInput: HTMLInputElement | undefined let focusSearch = false - let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined + let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value) const workspaces = createMemo(() => { - const query = search().trim().toLowerCase() + const query = search.workspaces.trim().toLowerCase() if (!query) return props.workspaces return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query)) }) @@ -37,12 +42,14 @@ export function PromptWorkspaceSelector(props: { } const onOpenChange = (open: boolean) => { if (open) { - setSearch("") + setSearch({ workspaces: "", branches: "" }) + props.onSearch("") return } const action = pending pending = undefined if (action?.type === "select") props.onChange(action.value) + if (action?.type === "create") props.onCreate(action.branch) if (action?.type === "viewAll") { props.onViewAll() return @@ -120,21 +127,7 @@ export function PromptWorkspaceSelector(props: { select("create")}> - - {language.t("workspace.new")} - - {language.t("session.new.workspace.new.tooltip")} - - - } - class="min-w-0 flex-1" - > - {language.t("workspace.new")} - + {language.t("workspace.new")} @@ -191,11 +184,11 @@ export function PromptWorkspaceSelector(props: { ref={(element) => { searchInput = element }} - value={search()} + value={search.workspaces} placeholder={language.t("session.new.workspace.search.placeholder")} aria-label={language.t("session.new.workspace.search.placeholder")} class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" - onInput={(event) => setSearch(event.currentTarget.value)} + onInput={(event) => setSearch("workspaces", event.currentTarget.value)} onKeyDown={(event) => { if ( event.key === "Escape" || @@ -232,7 +225,94 @@ export function PromptWorkspaceSelector(props: { - + } + > + + { + onOpenChange(open) + if (open) requestAnimationFrame(() => branchSearchInput?.focus()) + }} + > + + + + {language.t("session.new.workspace.fromBranch", { branch: props.branch! })} + + + + + +
+ + { + branchSearchInput = element + }} + value={search.branches} + placeholder={language.t("session.new.workspace.branch.search.placeholder")} + aria-label={language.t("session.new.workspace.branch.search.placeholder")} + class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + onInput={(event) => { + setSearch("branches", event.currentTarget.value) + props.onSearch(event.currentTarget.value) + }} + onKeyDown={(event) => { + if ( + event.key === "Escape" || + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "Enter" + ) + return + event.stopPropagation() + }} + /> + + + +
+
+ + + {(branch) => ( + (pending = { type: "create", branch })} + > + {branch} + + )} + + +
+
+
+
+
+
) } diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 24cbef99888..2146175e45d 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -1151,6 +1151,8 @@ export const dict = { "session.new.workspace.local.tooltip": "Use current checkout", "session.new.workspace.new.tooltip": "Create isolated checkout", "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.createFrom": "Create from branch", + "session.new.workspace.branch.search.placeholder": "Search branches", "session.new.workspace.trigger.tooltip": "Select where to run session", "session.new.workspace.search.placeholder": "Search workspaces", "settings.tab.workspaces": "Workspaces", diff --git a/packages/app/src/shell/tabs/migration.ts b/packages/app/src/shell/tabs/migration.ts index 69617c36833..b541db2f6f3 100644 --- a/packages/app/src/shell/tabs/migration.ts +++ b/packages/app/src/shell/tabs/migration.ts @@ -31,9 +31,19 @@ export function migrateTabs(value: unknown): Tab[] { tab.type === "draft" && typeof tab.draftID === "string" && typeof tab.directory === "string" && - (tab.worktree === undefined || typeof tab.worktree === "string") + (tab.worktree === undefined || typeof tab.worktree === "string") && + (tab.branch === undefined || typeof tab.branch === "string") ) { - return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }] + return [ + { + type: tab.type, + server, + draftID: tab.draftID, + directory: tab.directory, + worktree: tab.worktree, + branch: tab.branch, + }, + ] } return [] }) diff --git a/packages/app/src/shell/tabs/tabs.tsx b/packages/app/src/shell/tabs/tabs.tsx index 3f4f854ffb2..06d01f9a8c7 100644 --- a/packages/app/src/shell/tabs/tabs.tsx +++ b/packages/app/src/shell/tabs/tabs.tsx @@ -29,6 +29,7 @@ export type DraftTab = { server: ServerConnection.Key directory: string worktree?: string + branch?: string } export type Tab = SessionTab | DraftTab diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 045ed3159a1..0243f18b185 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1664,6 +1664,7 @@ export type WorktreeCreateInput = { readonly projectID: Project.ID readonly strategy: Worktree.StrategyID readonly from?: AbsolutePath | undefined + readonly branch?: string | undefined readonly directory: AbsolutePath readonly name?: string | undefined } @@ -1720,6 +1721,14 @@ export type VcsStatusInput = { export type VcsStatusOutput = { readonly location: Location.Info; readonly data: ReadonlyArray } export type VcsStatusOperation = (input?: VcsStatusInput) => Effect.Effect +export type VcsBranchesInput = { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly search?: string | undefined + readonly limit?: number | undefined +} +export type VcsBranchesOutput = { readonly location: Location.Info; readonly data: Vcs.BranchList } +export type VcsBranchesOperation = (input?: VcsBranchesInput) => Effect.Effect + export type VcsDiffInput = { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly mode: Vcs.Mode @@ -1731,6 +1740,7 @@ export type VcsDiffOperation = (input: VcsDiffInput) => Effect.Effect export interface VcsApi { readonly get: VcsGetOperation readonly status: VcsStatusOperation + readonly branches: VcsBranchesOperation readonly diff: VcsDiffOperation } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index d3cb6266a76..feaf87cad74 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -222,6 +222,8 @@ import type { VcsGetOutput, VcsStatusInput, VcsStatusOutput, + VcsBranchesInput, + VcsBranchesOutput, VcsDiffInput, VcsDiffOutput, DebugLocationListOutput, @@ -1248,7 +1250,13 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: Wo preserveEffect()( raw["worktree.create"]({ params: { projectID: input["projectID"] }, - payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] }, + payload: { + strategy: input["strategy"], + from: input["from"], + branch: input["branch"], + directory: input["directory"], + name: input["name"], + }, }).pipe(Effect.mapError(mapClientError)), ) @@ -1300,6 +1308,13 @@ const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusIn raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), ) +const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranchesInput) => + preserveEffect()( + raw["vcs.branches"]({ + query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] }, + }).pipe(Effect.mapError(mapClientError)), + ) + const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) => preserveEffect()( raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe( @@ -1310,6 +1325,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({ get: EndpointVcsGet(raw), status: EndpointVcsStatus(raw), + branches: EndpointVcsBranches(raw), diff: EndpointVcsDiff(raw), }) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 2f6061b8663..d00eb73ee88 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -218,6 +218,8 @@ import type { VcsGetOutput, VcsStatusInput, VcsStatusOutput, + VcsBranchesInput, + VcsBranchesOutput, VcsDiffInput, VcsDiffOutput, DebugLocationListOutput, @@ -1736,6 +1738,7 @@ export function make(options: ClientOptions) { body: { strategy: input["strategy"], from: input["from"], + branch: input["branch"], directory: input["directory"], name: input["name"], }, @@ -1819,6 +1822,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + branches: (input?: VcsBranchesInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/vcs/branches`, + query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), diff: (input: VcsDiffInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 1ed595c5daa..81deaf8b718 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -393,6 +393,8 @@ export type VcsFileStatus = { status: "added" | "deleted" | "modified" } +export type VcsBranchList = Array + export type WebSearchProvider = { id: string; name: string } export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } } @@ -5593,24 +5595,35 @@ export type WorktreeCreateInput = { readonly strategy: { readonly strategy: string readonly from?: string + readonly branch?: string readonly directory: string readonly name?: string }["strategy"] readonly from?: { readonly strategy: string readonly from?: string + readonly branch?: string readonly directory: string readonly name?: string }["from"] + readonly branch?: { + readonly strategy: string + readonly from?: string + readonly branch?: string + readonly directory: string + readonly name?: string + }["branch"] readonly directory: { readonly strategy: string readonly from?: string + readonly branch?: string readonly directory: string readonly name?: string }["directory"] readonly name?: { readonly strategy: string readonly from?: string + readonly branch?: string readonly directory: string readonly name?: string }["name"] @@ -5663,6 +5676,29 @@ export type VcsStatusOutput = { data: Array } +export type VcsBranchesInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly search?: string | undefined + readonly limit?: number | undefined + }["location"] + readonly search?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly search?: string | undefined + readonly limit?: number | undefined + }["search"] + readonly limit?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly search?: string | undefined + readonly limit?: number | undefined + }["limit"] +} + +export type VcsBranchesOutput = { + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } + data: VcsBranchList +} + export type VcsDiffInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index d1df007910c..c710ff97579 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -27,7 +27,6 @@ test("exposes every standard HTTP API group", () => { "event", "pty", "shell", - "question", "reference", "worktree", "workspace", @@ -47,7 +46,7 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"]) expect(Object.keys(client.websearch)).toEqual(["providers", "query"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) - expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"]) + expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"]) expect(Object.keys(client.pty.connect)).toEqual(["token"]) expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"]) diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 3c2f5fc80fd..40950bfc6f3 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -109,6 +109,7 @@ export interface Interface { readonly create: (input: { repository: Repository directory: AbsolutePath + ref?: string }) => Effect.Effect readonly remove: (input: { repository: Repository @@ -644,11 +645,12 @@ const layer = Layer.effect( const worktreeCreate = Effect.fn("Git.worktree.create")(function* (input: { repository: Repository directory: AbsolutePath + ref?: string }) { yield* worktreeRun( "create", input.repository, - ["worktree", "add", "--detach", input.directory, "HEAD"], + ["worktree", "add", "--detach", "--", input.directory, input.ref ?? "HEAD"], input.directory, ) const repository = yield* discover(input.directory) diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts index 4f5603313ba..6cd851500fc 100644 --- a/packages/core/src/vcs.ts +++ b/packages/core/src/vcs.ts @@ -4,7 +4,7 @@ import path from "path" import { Context, Effect, Layer, Stream } from "effect" import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileSystem } from "@opencode-ai/schema/filesystem" -import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" +import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" import { VcsEvent } from "@opencode-ai/schema/vcs-event" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -14,14 +14,20 @@ import { Bus } from "./bus.js" import { VcsGit } from "./vcs/git.js" import { VcsHg } from "./vcs/hg.js" -export { FileStatus, Info, Mode } +export { BranchList, FileStatus, Info, Mode } export interface DiffOptions { readonly context?: number } +export interface BranchOptions { + readonly search?: string + readonly limit?: number +} + export interface Interface { readonly info: () => Effect.Effect + readonly branches: (options?: BranchOptions) => Effect.Effect readonly status: () => Effect.Effect readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect } @@ -73,6 +79,10 @@ const layer = Layer.effect( info: Effect.fn("Vcs.info")(function* () { return state.info }), + branches: Effect.fn("Vcs.branches")(function* (options?: BranchOptions) { + if (!impl) return [] + return yield* impl.branches(options) + }), status: Effect.fn("Vcs.status")(function* () { if (!impl) return [] return yield* impl.status() diff --git a/packages/core/src/vcs/git.ts b/packages/core/src/vcs/git.ts index e09fff14a4f..038a887c22a 100644 --- a/packages/core/src/vcs/git.ts +++ b/packages/core/src/vcs/git.ts @@ -3,9 +3,9 @@ export * as VcsGit from "./git.js" import { Effect } from "effect" import { ChildProcess } from "effect/unstable/process" import { FileDiff } from "@opencode-ai/schema/file-diff" -import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" +import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" import { AppProcess } from "@opencode-ai/util/process" -import type { DiffOptions, Interface } from "../vcs.js" +import type { BranchOptions, DiffOptions, Interface } from "../vcs.js" import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch.js" import type { Patch } from "./patch.js" @@ -26,6 +26,9 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor }) return { branch: { current, default: root?.name } } satisfies Info }), + branches: Effect.fn("VcsGit.branches")(function* (options?: BranchOptions) { + return yield* ctx.git.branches(ctx.directory, options) + }), status: Effect.fn("VcsGit.status")(function* () { const git = ctx.git const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined @@ -176,6 +179,24 @@ function makeGit(proc: AppProcess.Interface) { return result.text().trim() || undefined }) + const branches = Effect.fn("VcsGit.branches")(function* (cwd: string, options?: BranchOptions) { + const search = options?.search?.trim().replace(/[*?[\]\\]/g, "\\$&") + return ( + yield* lines( + [ + "for-each-ref", + "--ignore-case", + "--sort=refname", + "--sort=-committerdate", + "--format=%(refname:short)", + ...(options?.limit ? [`--count=${options.limit}`] : []), + ...(search ? [`refs/heads/*${search}*`, `refs/remotes/*${search}*`] : ["refs/heads", "refs/remotes"]), + ], + { cwd }, + ) + ).filter((item) => !item.endsWith("/HEAD")) satisfies BranchList + }) + const defaultBranch = Effect.fn("VcsGit.defaultBranch")(function* (cwd: string) { const remote = yield* primary(cwd) if (remote) { @@ -313,6 +334,7 @@ function makeGit(proc: AppProcess.Interface) { return { branch, + branches, defaultBranch, hasHead, mergeBase, diff --git a/packages/core/src/vcs/hg.ts b/packages/core/src/vcs/hg.ts index 755288bdd1c..25367172555 100644 --- a/packages/core/src/vcs/hg.ts +++ b/packages/core/src/vcs/hg.ts @@ -76,6 +76,9 @@ export function make( info: Effect.fn("VcsHg.info")(function* () { return { branch: { current: yield* hg.branch(), default: "default" } } satisfies Info }), + branches: Effect.fn("VcsHg.branches")(function* () { + return [] + }), status: Effect.fn("VcsHg.status")(function* () { const [items, batch] = yield* Effect.all( // Zero-context patches are enough to count changed lines. diff --git a/packages/core/src/worktree.ts b/packages/core/src/worktree.ts index 3489f1e9fdd..f86370533cf 100644 --- a/packages/core/src/worktree.ts +++ b/packages/core/src/worktree.ts @@ -94,6 +94,7 @@ export interface Strategy { readonly create: (input: { sourceDirectory: AbsolutePath directory: AbsolutePath + branch?: string }) => Effect.Effect readonly remove: (input: { directory: AbsolutePath @@ -251,6 +252,7 @@ const layer = Layer.effect( const result = yield* selected.create({ directory: worktreeDirectory, sourceDirectory, + branch: input.branch, }) yield* changed( input.projectID, diff --git a/packages/core/src/worktree/git.ts b/packages/core/src/worktree/git.ts index 10b27086a10..6889d5b167c 100644 --- a/packages/core/src/worktree/git.ts +++ b/packages/core/src/worktree/git.ts @@ -16,7 +16,7 @@ export const make = Effect.gen(function* () { create: Effect.fn("Worktree.Git.create")(function* (input) { const repository = yield* git.repo.discover(input.sourceDirectory) if (!repository) return yield* new DirectoryUnavailableError({ directory: input.sourceDirectory }) - yield* git.worktree.create({ repository, directory: input.directory }) + yield* git.worktree.create({ repository, directory: input.directory, ref: input.branch }) return { directory: yield* canonical(fs, input.directory) } }), remove: Effect.fn("Worktree.Git.remove")(function* (input) { diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts index 677fb4f156a..d0326bc6e49 100644 --- a/packages/core/test/vcs.test.ts +++ b/packages/core/test/vcs.test.ts @@ -64,6 +64,7 @@ describe("Vcs", () => { Effect.gen(function* () { const vcs = yield* Vcs.Service expect(yield* vcs.info()).toEqual({ branch: {} }) + expect(yield* vcs.branches()).toEqual([]) expect(yield* vcs.status()).toEqual([]) expect(yield* vcs.diff("working")).toEqual([]) expect(yield* vcs.diff("branch")).toEqual([]) @@ -71,6 +72,29 @@ describe("Vcs", () => { ), ) + it.live("lists local branches by recent activity", () => + withGit((directory) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.writeFile(path.join(directory, "file.txt"), "one\n") + await commitAll(directory, "initial") + await $`git checkout -b z-recent`.cwd(directory).quiet() + await fs.writeFile(path.join(directory, "file.txt"), "two\n") + await $`git add -A`.cwd(directory).quiet() + await $`git commit -m recent` + .cwd(directory) + .env({ ...process.env, GIT_AUTHOR_DATE: "2030-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2030-01-01T00:00:00Z" }) + .quiet() + }) + const vcs = yield* Vcs.Service + expect(yield* vcs.branches()).toEqual(["z-recent", "main"]) + expect(yield* vcs.branches({ limit: 1 })).toEqual(["z-recent"]) + expect(yield* vcs.branches({ search: "MAIN", limit: 1 })).toEqual(["main"]) + expect(yield* vcs.branches({ search: "*" })).toEqual([]) + }), + ), + ) + it.live("reports modified, deleted, and untracked files", () => withGit((directory) => Effect.gen(function* () { diff --git a/packages/core/test/worktree.test.ts b/packages/core/test/worktree.test.ts index c8ac96f9f44..d6a6476a0ec 100644 --- a/packages/core/test/worktree.test.ts +++ b/packages/core/test/worktree.test.ts @@ -192,6 +192,58 @@ describe("Worktree", () => { }), ) + it.live("creates a git worktree from a selected branch", () => + Effect.gen(function* () { + const input = yield* setup() + const worktree = yield* Worktree.Service + const parent = abs(`${input.root.path}-branch-worktree`) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore), + ) + yield* Effect.promise(async () => { + await $`git branch feature-base`.cwd(input.sourceDirectory).quiet() + }) + + const created = yield* worktree.create({ + projectID: input.projectID, + strategy: gitWorktree, + branch: "feature-base", + directory: parent, + name: "worktree", + }) + + const head = (yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(created.directory).quiet().text())).trim() + const branch = (yield* Effect.promise(() => + $`git rev-parse feature-base`.cwd(input.sourceDirectory).quiet().text(), + )).trim() + expect(head).toBe(branch) + }), + ) + + it.live("does not interpret a branch as a git option", () => + Effect.gen(function* () { + const input = yield* setup() + const worktree = yield* Worktree.Service + const parent = abs(`${input.root.path}-option-worktree`) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore), + ) + + const error = yield* worktree + .create({ + projectID: input.projectID, + strategy: gitWorktree, + branch: "--no-checkout", + directory: parent, + name: "worktree", + }) + .pipe(Effect.flip) + + expect(error).toBeInstanceOf(Git.WorktreeError) + expect(yield* Effect.promise(() => Bun.file(path.join(parent, "worktree")).exists())).toBe(false) + }), + ) + it.live("rejects a missing source directory", () => Effect.gen(function* () { const input = yield* setup() diff --git a/packages/protocol/src/groups/vcs.ts b/packages/protocol/src/groups/vcs.ts index b4b3c3d10a1..575a5251719 100644 --- a/packages/protocol/src/groups/vcs.ts +++ b/packages/protocol/src/groups/vcs.ts @@ -1,11 +1,17 @@ import { FileDiff } from "@opencode-ai/schema/file-diff" import { Location } from "@opencode-ai/schema/location" -import { NonNegativeInt } from "@opencode-ai/schema/schema" +import { NonNegativeInt, PositiveInt } from "@opencode-ai/schema/schema" import { Vcs } from "@opencode-ai/schema/vcs" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { LocationQuery, locationQueryOpenApi } from "./location.js" +const BranchesQuery = Schema.Struct({ + ...LocationQuery.fields, + search: Schema.optional(Schema.String), + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional), +}) + const DiffQuery = Schema.Struct({ ...LocationQuery.fields, mode: Vcs.Mode, @@ -41,6 +47,20 @@ export const VcsGroup = HttpApiGroup.make("server.vcs") }), ), ) + .add( + HttpApiEndpoint.get("vcs.branches", "/api/vcs/branches", { + query: BranchesQuery, + success: Location.response(Vcs.BranchList), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.vcs.branches", + summary: "VCS branches", + description: "List local and remote branches available at the requested location.", + }), + ), + ) .add( HttpApiEndpoint.get("vcs.diff", "/api/vcs/diff", { query: DiffQuery, diff --git a/packages/schema/src/vcs.ts b/packages/schema/src/vcs.ts index db753ffad89..71ed6005422 100644 --- a/packages/schema/src/vcs.ts +++ b/packages/schema/src/vcs.ts @@ -14,6 +14,9 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Vcs.Info" }) export interface Info extends Schema.Schema.Type {} +export const BranchList = Schema.Array(Schema.String).annotate({ identifier: "Vcs.BranchList" }) +export type BranchList = typeof BranchList.Type + export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" }) export type Mode = typeof Mode.Type diff --git a/packages/schema/src/worktree.ts b/packages/schema/src/worktree.ts index ae436952c96..0f203b62a91 100644 --- a/packages/schema/src/worktree.ts +++ b/packages/schema/src/worktree.ts @@ -13,6 +13,7 @@ export const CreateInput = Schema.Struct({ projectID: ProjectID, strategy: StrategyID, from: optional(AbsolutePath), + branch: optional(Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()))), directory: AbsolutePath, name: optional(Schema.String), }).annotate({ identifier: "Worktree.CreateInput" }) diff --git a/packages/server/src/handlers/vcs.ts b/packages/server/src/handlers/vcs.ts index 70a9aa6b444..4a014e90b44 100644 --- a/packages/server/src/handlers/vcs.ts +++ b/packages/server/src/handlers/vcs.ts @@ -23,6 +23,14 @@ export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) => }), ), ) + .handle("vcs.branches", (ctx) => + response( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.branches({ search: ctx.query.search, limit: Math.min(ctx.query.limit ?? 50, 100) }) + }), + ), + ) .handle("vcs.diff", (ctx) => response( Effect.gen(function* () { diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts index fa628f31d4f..4f22f203a8c 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -96,6 +96,7 @@ const vcsLayer = Layer.succeed( Vcs.Service, Vcs.Service.of({ info: () => Effect.succeed({ branch: {} }), + branches: () => Effect.succeed([]), status: () => Effect.succeed([]), diff: () => Effect.succeed([]), }),