mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 18:02:09 +00:00
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:
parent
5c25c38961
commit
4fb8a6038a
31 changed files with 477 additions and 45 deletions
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export type DraftTab = {
|
|||
server: ServerConnection.Key
|
||||
directory: string
|
||||
worktree?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export type Tab = SessionTab | DraftTab
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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([]),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue