feat(app): select worktree base branch (#44906)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
opencode-agent[bot] 2026-08-25 21:05:52 +08:00 committed by GitHub
parent 5c25c38961
commit 4fb8a6038a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 477 additions and 45 deletions

View file

@ -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()
})

View file

@ -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(

View file

@ -21,6 +21,7 @@ export interface MockServerConfig {
cursor?: string
}
vcsDiff?: unknown[]
vcsBranches?: string[]
messageDelay?: number
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
@ -296,6 +297,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
vcs: () =>
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(

View file

@ -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 <T,>(run: () => Promise<T>) => {
const afterCreation = async <T>(run: () => Promise<T>) => {
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<typeof useData>
serverSDK: ReturnType<typeof useServerSDK>
language: ReturnType<typeof useLanguage>
@ -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,
),

View file

@ -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) => {

View file

@ -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)

View file

@ -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}
/>

View file

@ -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)

View file

@ -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: {

View file

@ -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: {
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<Tooltip
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
@ -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: {
</Menu.Portal>
</Menu>
</Tooltip>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
<Show
when={selected() === "create" && props.branch}
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
>
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu
placement="bottom"
gutter={4}
onOpenChange={(open) => {
onOpenChange(open)
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
}}
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
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()
}}
/>
<Show when={search.branches.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setSearch("branches", "")
props.onSearch("")
}}
aria-label={language.t("common.clear")}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<div class="max-h-[224px] overflow-y-auto">
<Menu.RadioGroup value={props.branch}>
<For each={props.branches}>
{(branch) => (
<Menu.RadioItem
value={branch}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
closeOnSelect
onSelect={() => (pending = { type: "create", branch })}
>
<span class="min-w-0 truncate leading-5">{branch}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</div>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</Show>
</>
)
}

View file

@ -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",

View file

@ -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 []
})

View file

@ -29,6 +29,7 @@ export type DraftTab = {
server: ServerConnection.Key
directory: string
worktree?: string
branch?: string
}
export type Tab = SessionTab | DraftTab

View file

@ -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<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: VcsStatusInput) => Effect.Effect<VcsStatusOutput, E>
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<E = never> = (input?: VcsBranchesInput) => Effect.Effect<VcsBranchesOutput, E>
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<E = never> = (input: VcsDiffInput) => Effect.Effect
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly status: VcsStatusOperation<E>
readonly branches: VcsBranchesOperation<E>
readonly diff: VcsDiffOperation<E>
}

View file

@ -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<WorktreeCreateOutput>()(
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<VcsBranchesOutput>()(
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<VcsDiffOutput>()(
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),
})

View file

@ -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<VcsBranchesOutput>(
{
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<VcsDiffOutput>(
{

View file

@ -393,6 +393,8 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified"
}
export type VcsBranchList = Array<string>
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<VcsFileStatus>
}
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

View file

@ -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"])

View file

@ -109,6 +109,7 @@ export interface Interface {
readonly create: (input: {
repository: Repository
directory: AbsolutePath
ref?: string
}) => Effect.Effect<Repository, WorktreeError>
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)

View file

@ -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<Info>
readonly branches: (options?: BranchOptions) => Effect.Effect<BranchList>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
}
@ -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()

View file

@ -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,

View file

@ -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.

View file

@ -94,6 +94,7 @@ export interface Strategy {
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
branch?: string
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
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,

View file

@ -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) {

View file

@ -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* () {

View file

@ -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()

View file

@ -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,

View file

@ -14,6 +14,9 @@ export const Info = Schema.Struct({
}).annotate({ identifier: "Vcs.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
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

View file

@ -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" })

View file

@ -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* () {

View file

@ -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([]),
}),