From e22d6fd53c432da0a11e22df1406e5edde9e18c9 Mon Sep 17 00:00:00 2001 From: Luke Abbott Date: Tue, 25 Aug 2026 17:26:39 -0700 Subject: [PATCH 1/2] Discover user-managed Git worktrees Discover Git worktrees registered outside bb and expose them through the daemon, project API, SDK, CLI, and new-thread picker. Use canonical host/path identity so reusable environments take precedence while locked, stale, detached, and unavailable worktrees remain explicit. Preserve user-owned worktrees after their last thread is archived or deleted. Bump the host-daemon protocol for the wire-contract change and cover discovery and lifecycle behavior end to end. --- apps/app/.ladle/story-fixtures.ts | 38 +- .../components/pickers/EnvironmentPicker.tsx | 12 +- .../src/components/pickers/WorktreePicker.tsx | 192 +++++- .../pickers/environment-picker-value.test.ts | 47 ++ .../pickers/environment-picker-value.ts | 35 + .../plugin/PluginNewThreadComposer.test.tsx | 38 ++ .../new-thread-environment-seed.test.ts | 21 +- .../plugin/new-thread-environment-seed.ts | 10 + .../promptbox/NewThreadComposer.tsx | 89 ++- .../NewThreadEnvironmentOptions.stories.tsx | 3 +- .../promptbox/NewThreadPromptBox.stories.tsx | 1 + .../promptbox/NewThreadPromptBox.test.tsx | 2 + .../promptbox/NewThreadPromptBox.tsx | 17 +- apps/app/src/hooks/queries/project-queries.ts | 27 + apps/app/src/hooks/queries/query-keys.ts | 11 + .../app/src/hooks/useThreadCreationOptions.ts | 4 +- apps/app/src/views/RootComposeView.test.ts | 190 +++++- .../root-compose-environment-selection.ts | 173 +++-- .../root-compose-thread-environment.test.ts | 19 + .../views/root-compose-thread-environment.ts | 11 + .../__tests__/command-output/project.test.ts | 157 +++++ apps/cli/src/commands/project.ts | 111 +++ apps/host-daemon/src/command-dispatch.ts | 2 + .../src/command-handlers/host-worktrees.ts | 42 ++ .../command/host-worktrees-dispatch.test.ts | 111 +++ apps/server/src/routes/projects.ts | 9 + .../services/projects/project-worktrees.ts | 357 ++++++++++ .../bb-cli/references/command-index.md | 1 + .../bb-cli/references/thread-creation.md | 5 + .../public/public-project-worktrees.test.ts | 634 ++++++++++++++++++ docs/worktrees.md | 56 +- packages/db/src/data/environments.ts | 23 +- packages/db/src/data/index.ts | 2 + packages/domain/src/git-worktrees.ts | 62 ++ packages/domain/src/host-list-limits.ts | 1 + packages/domain/src/index.ts | 1 + packages/host-daemon-contract/src/commands.ts | 28 + packages/host-daemon-contract/src/protocol.ts | 2 +- packages/host-daemon-contract/src/session.ts | 1 + .../test/contract.test.ts | 88 ++- packages/host-workspace/src/index.ts | 3 + packages/host-workspace/src/worktree-list.ts | 252 +++++++ .../host-workspace/test/worktree-list.test.ts | 308 +++++++++ packages/sdk/src/areas/projects.ts | 24 + packages/sdk/test/public-types.test.ts | 3 +- packages/sdk/test/sdk.test.ts | 29 + packages/server-contract/src/api/projects.ts | 60 ++ packages/server-contract/src/public-api.ts | 7 + .../src/templates/bb-guide-projects.md | 1 + 49 files changed, 3148 insertions(+), 172 deletions(-) create mode 100644 apps/app/src/components/pickers/environment-picker-value.test.ts create mode 100644 apps/host-daemon/src/command-handlers/host-worktrees.ts create mode 100644 apps/host-daemon/test/command/host-worktrees-dispatch.test.ts create mode 100644 apps/server/src/services/projects/project-worktrees.ts create mode 100644 apps/server/test/public/public-project-worktrees.test.ts create mode 100644 packages/domain/src/git-worktrees.ts create mode 100644 packages/host-workspace/src/worktree-list.ts create mode 100644 packages/host-workspace/test/worktree-list.test.ts diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 11840408fe..63020bd675 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -16,7 +16,7 @@ import { getProviderIconInfo } from "../src/lib/provider-icon"; import type { PickerOption } from "../src/components/pickers/OptionPicker"; import type { ModelPickerOption } from "../src/components/pickers/model-picker-option"; import type { ProjectSelectorOption } from "../src/components/pickers/ProjectSelector"; -import type { ReuseThreadOption } from "../src/components/pickers/WorktreePicker"; +import type { WorktreeOption } from "../src/components/pickers/WorktreePicker"; import type { ExecutionControlsProps } from "../src/components/promptbox/ExecutionControls"; import { INERT_TYPEAHEAD_COMMAND_CONFIG, @@ -225,22 +225,52 @@ export const STORY_BRANCH_OPTIONS: readonly string[] = [ "bb/refactor-project-creation-thr_jj65bdsiwa", ]; -export const STORY_WORKTREE_OPTIONS: readonly ReuseThreadOption[] = [ +export const STORY_WORKTREE_OPTIONS: readonly WorktreeOption[] = [ { + value: "reuse:env_review_flow", environmentId: "env_review_flow", - branchName: "bb/review-flow-thr_4hge9xn14m", + hostId: HOST_IDS.local, + hostName: null, name: null, + checkout: { kind: "branch", branchName: "bb/review-flow-thr_4hge9xn14m" }, + displayPath: "/Users/dev/bb-worktrees/review-flow", + availability: "selectable", + lock: null, + ownership: "bb-managed", threads: [ { id: "thr_review", title: "Review flow cleanup" }, { id: "thr_tests", title: "Backfill promptbox tests" }, ], }, { + value: "reuse:env_timeline", environmentId: "env_timeline", - branchName: "bb/timeline-pagination-thr_qfk8ksbxkk", + hostId: HOST_IDS.local, + hostName: null, name: "Timeline workspace", + checkout: { + kind: "branch", + branchName: "bb/timeline-pagination-thr_qfk8ksbxkk", + }, + displayPath: "/Users/dev/bb-worktrees/timeline-pagination", + availability: "selectable", + lock: null, + ownership: "bb-managed", threads: [{ id: "thr_timeline", title: "Timeline pagination" }], }, + { + value: "path:host_local:%2FUsers%2Fdev%2Fworktrees%2Fspike", + environmentId: null, + hostId: HOST_IDS.local, + hostName: null, + name: null, + checkout: { kind: "branch", branchName: "spike/manual-worktree" }, + displayPath: "/Users/dev/worktrees/spike", + availability: "selectable", + lock: null, + ownership: "user-managed", + threads: [], + }, ]; export const STORY_PROJECTS: readonly ProjectSelectorOption[] = [ diff --git a/apps/app/src/components/pickers/EnvironmentPicker.tsx b/apps/app/src/components/pickers/EnvironmentPicker.tsx index fe2a51503f..5dd84d60d3 100644 --- a/apps/app/src/components/pickers/EnvironmentPicker.tsx +++ b/apps/app/src/components/pickers/EnvironmentPicker.tsx @@ -103,7 +103,7 @@ export function EnvironmentPickerUI({ const newWorktreeDisabledReason = workspaceDisabledReason ?? worktreeDisabledReason ?? null; const reuseDisabledReason = reuseDisabled - ? "No worktrees in this project yet" + ? "No existing worktrees found." : null; const parsed = useMemo(() => parseEnvironmentValue(value), [value]); @@ -133,7 +133,7 @@ export function EnvironmentPickerUI({ icon: "Laptop" as const, }; } - if (parsed.type === "reuse") { + if (parsed.type === "reuse" || parsed.type === "worktree-path") { return { modeLabel: "Reuse worktree", compactModeLabel: "Reuse", @@ -318,7 +318,9 @@ function EnvironmentOptionsSection({ label="Existing worktree" description={reuseDisabledReason ?? undefined} icon={getEnvironmentWorkspaceLabelIconName("managed-worktree")} - selected={selectedType === "reuse"} + selected={ + selectedType === "reuse" || selectedType === "worktree-path" + } disabled={reuseDisabledReason !== null} onSelect={() => onChange(REUSE_VALUE_WITHOUT_ENVIRONMENT)} /> @@ -387,7 +389,9 @@ function MachineGroupedEnvironmentOptions({ label="Existing worktree" description={reuseDisabledReason ?? undefined} icon={getEnvironmentWorkspaceLabelIconName("managed-worktree")} - selected={selectedType === "reuse"} + selected={ + selectedType === "reuse" || selectedType === "worktree-path" + } disabled={reuseDisabledReason !== null} onSelect={() => onChange(REUSE_VALUE_WITHOUT_ENVIRONMENT)} /> diff --git a/apps/app/src/components/pickers/WorktreePicker.tsx b/apps/app/src/components/pickers/WorktreePicker.tsx index 52ad787beb..5f68052080 100644 --- a/apps/app/src/components/pickers/WorktreePicker.tsx +++ b/apps/app/src/components/pickers/WorktreePicker.tsx @@ -15,6 +15,7 @@ import { COARSE_POINTER_COMPACT_ICON_SIZE_SHRINK_CLASS, COARSE_POINTER_ICON_SIZE_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; +import type { ProjectWorktreeCheckout } from "@bb/server-contract"; import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display"; import { OPTION_BASE_CLASS_NAME, @@ -26,18 +27,45 @@ import { const REUSE_THREAD_PREVIEW_LIMIT = 2; -export interface ReuseThreadOption { - environmentId: string; - branchName: string | null; +export interface WorktreeOption { + value: string | null; + environmentId: string | null; + hostId: string; + hostName: string | null; name: string | null; - hostName?: string | null; + checkout: ProjectWorktreeCheckout; + displayPath: string; + availability: "selectable" | "missing" | "prunable"; + lock: { reason: string | null } | null; + ownership: "bb-managed" | "user-managed"; threads: ReadonlyArray<{ id: string; title: string }>; } +export interface WorktreeDiscoveryFailure { + hostId: string; + hostName: string | null; + message: string; +} + +export function worktreeOptionLabel(option: WorktreeOption): string { + if (option.name !== null) { + return option.name; + } + return option.checkout.kind === "branch" + ? option.checkout.branchName + : `Detached at ${option.checkout.headSha.slice(0, 7)}`; +} + +const UNAVAILABLE_REMEDIATION = + "Inspect with `git worktree list`; clean up with `git worktree prune`."; + interface WorktreePickerProps { - options: readonly ReuseThreadOption[]; + options: readonly WorktreeOption[]; + failures: readonly WorktreeDiscoveryFailure[]; value: string | null; - onChange: (environmentId: string) => void; + onChange: (value: string) => void; + onRetry?: () => void; + loading?: boolean; muted?: boolean; disabled?: boolean; defaultOpen?: boolean; @@ -46,8 +74,11 @@ interface WorktreePickerProps { export function WorktreePicker({ options, + failures, value, onChange, + onRetry, + loading = false, muted, disabled = false, defaultOpen, @@ -55,11 +86,31 @@ export function WorktreePicker({ }: WorktreePickerProps) { const branchIcon = getEnvironmentWorkspaceLabelIconName("managed-worktree"); const activeOption = useMemo( - () => options.find((option) => option.environmentId === value) ?? null, + () => + value === null + ? null + : (options.find((option) => option.value === value) ?? null), [options, value], ); - const triggerLabel = - activeOption?.name ?? activeOption?.branchName ?? "Pick a worktree"; + const triggerLabel = activeOption + ? worktreeOptionLabel(activeOption) + : "Pick a worktree"; + // Machine grouping only exists in multi-machine projects: the option + // builder sets hostName exactly then. + const groups = useMemo(() => { + const grouped = new Map(); + for (const option of options) { + const bucket = grouped.get(option.hostName); + if (bucket) { + bucket.push(option); + } else { + grouped.set(option.hostName, [option]); + } + } + return [...grouped.entries()]; + }, [options]); + const showMachineHeaders = groups.some(([hostName]) => hostName !== null); + const isEmpty = options.length === 0 && failures.length === 0; return ( @@ -100,23 +151,43 @@ export function WorktreePicker({ - Reuse existing worktree - {options.length === 0 ? ( + Existing worktrees + {isEmpty ? (
- No worktrees in this project yet. + {loading ? "Discovering worktrees…" : "No existing worktrees found."}
) : ( - options.map((option) => ( - - )) + <> + {groups.map(([hostName, groupOptions]) => ( +
+ {showMachineHeaders && hostName !== null ? ( + + {hostName} + + ) : null} + {groupOptions.map((option) => ( + + ))} +
+ ))} + {failures.map((failure) => ( + + ))} + )}
@@ -124,9 +195,9 @@ export function WorktreePicker({ } interface WorktreeMenuItemProps { - option: ReuseThreadOption; + option: WorktreeOption; isSelected: boolean; - onSelect: (environmentId: string) => void; + onSelect: (value: string) => void; } function WorktreeMenuItem({ @@ -137,11 +208,21 @@ function WorktreeMenuItem({ const previewThreads = option.threads.slice(0, REUSE_THREAD_PREVIEW_LIMIT); const additionalCount = option.threads.length - previewThreads.length; const branchIcon = getEnvironmentWorkspaceLabelIconName("managed-worktree"); - const label = option.name ?? option.branchName ?? "Worktree"; - const branchDetail = option.name ? option.branchName : null; + const label = worktreeOptionLabel(option); + const branchDetail = + option.name !== null && option.checkout.kind === "branch" + ? option.checkout.branchName + : null; + const unavailable = option.availability !== "selectable"; + const optionValue = option.value; return ( onSelect(option.environmentId)} + disabled={unavailable || optionValue === null} + onSelect={() => { + if (optionValue !== null) { + onSelect(optionValue); + } + }} className={cn( "flex flex-col items-stretch gap-1 py-2", LIST_HOVER_TRANSITION, @@ -163,9 +244,9 @@ function WorktreeMenuItem({ ) : null} - {option.hostName ? ( - - {option.hostName} + {option.ownership === "user-managed" ? ( + + User-managed ) : null} + + {option.displayPath} + + {option.lock !== null && !unavailable ? ( + + {option.lock.reason === null + ? "Locked" + : `Locked: ${option.lock.reason}`} + + ) : null} + {unavailable ? ( + + {option.availability === "missing" + ? "Directory is missing. " + : "Registration is prunable. "} + {UNAVAILABLE_REMEDIATION} + + ) : null} {previewThreads.length > 0 ? ( {previewThreads.map((thread) => ( @@ -193,3 +292,38 @@ function WorktreeMenuItem({ ); } + +interface WorktreeFailureRowProps { + failure: WorktreeDiscoveryFailure; + onRetry?: () => void; +} + +function WorktreeFailureRow({ failure, onRetry }: WorktreeFailureRowProps) { + return ( +
+ + + {failure.hostName !== null + ? `${failure.hostName}: ${failure.message}` + : failure.message} + + {onRetry ? ( + + ) : null} +
+ ); +} diff --git a/apps/app/src/components/pickers/environment-picker-value.test.ts b/apps/app/src/components/pickers/environment-picker-value.test.ts new file mode 100644 index 0000000000..4515ef82f3 --- /dev/null +++ b/apps/app/src/components/pickers/environment-picker-value.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + encodeWorktreePathValue, + parseEnvironmentValue, +} from "./environment-picker-value"; + +describe("worktree-path picker values", () => { + it.each([ + ["/Users/dev/worktrees/feature"], + ["/tmp/path with spaces"], + ["/tmp/colon:separated:path"], + ["/tmp/unicode/wörk trée/日本語"], + ["/tmp/percent%20literal%3A"], + ["C:\\Users\\dev\\worktree"], + ])("round-trips %s", (canonicalPath) => { + const value = encodeWorktreePathValue("host:with:colons", canonicalPath); + expect(parseEnvironmentValue(value)).toEqual({ + type: "worktree-path", + hostId: "host:with:colons", + canonicalPath, + }); + }); + + it("rejects malformed path values", () => { + expect(parseEnvironmentValue("path:")).toBeNull(); + expect(parseEnvironmentValue("path:onlyhost")).toBeNull(); + expect(parseEnvironmentValue("path:host:one:extra")).toBeNull(); + expect(parseEnvironmentValue("path::")).toBeNull(); + expect(parseEnvironmentValue("path:%:X")).toBeNull(); + }); + + it("keeps existing host and reuse values parsing unchanged", () => { + expect(parseEnvironmentValue("host:h1:local")).toEqual({ + type: "host", + hostId: "h1", + mode: "local", + }); + expect(parseEnvironmentValue("reuse:env-1")).toEqual({ + type: "reuse", + environmentId: "env-1", + }); + expect(parseEnvironmentValue("reuse")).toEqual({ + type: "reuse", + environmentId: null, + }); + }); +}); diff --git a/apps/app/src/components/pickers/environment-picker-value.ts b/apps/app/src/components/pickers/environment-picker-value.ts index 3c745fee2c..e9a54c060f 100644 --- a/apps/app/src/components/pickers/environment-picker-value.ts +++ b/apps/app/src/components/pickers/environment-picker-value.ts @@ -11,11 +11,17 @@ interface ParsedReuseEnvironmentValue { environmentId: string | null; } +interface ParsedWorktreePathEnvironmentValue { + type: "worktree-path"; + hostId: string; + canonicalPath: string; +} export const REUSE_VALUE_WITHOUT_ENVIRONMENT = "reuse"; export type ParsedEnvironmentValue = | ParsedHostEnvironmentValue | ParsedReuseEnvironmentValue + | ParsedWorktreePathEnvironmentValue | null; export function encodeHostValue( @@ -29,6 +35,32 @@ export function encodeReuseValue(environmentId: string): string { return `reuse:${environmentId}`; } +export function encodeWorktreePathValue( + hostId: string, + canonicalPath: string, +): string { + return `path:${encodeURIComponent(hostId)}:${encodeURIComponent(canonicalPath)}`; +} + +function parseWorktreePathValue( + value: string, +): ParsedWorktreePathEnvironmentValue | null { + const segments = value.slice("path:".length).split(":"); + if (segments.length !== 2) { + return null; + } + try { + const hostId = decodeURIComponent(segments[0]); + const canonicalPath = decodeURIComponent(segments[1]); + if (hostId.length === 0 || canonicalPath.length === 0) { + return null; + } + return { type: "worktree-path", hostId, canonicalPath }; + } catch { + return null; + } +} + export function parseEnvironmentValue(value: string): ParsedEnvironmentValue { if (value === REUSE_VALUE_WITHOUT_ENVIRONMENT) { return { type: "reuse", environmentId: null }; @@ -47,5 +79,8 @@ export function parseEnvironmentValue(value: string): ParsedEnvironmentValue { return { type: "reuse", environmentId }; } } + if (value.startsWith("path:")) { + return parseWorktreePathValue(value); + } return null; } diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index c0952cb43d..bbb23e2628 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({ copyAttachments: vi.fn(), uploadAttachment: vi.fn(), projectThreads: [] as ThreadListEntry[], + projectWorktrees: [] as unknown[], sidebarNavigationSettled: true, sidebarNavigationReplayed: false, extraProjects: [] as Array>, @@ -228,6 +229,12 @@ vi.mock("@/hooks/queries/project-queries", () => ({ isFetching: false, refetch: vi.fn(), }), + useProjectWorktrees: () => ({ + data: { worktrees: mocks.projectWorktrees, failures: [] }, + isLoading: false, + isFetching: false, + refetch: vi.fn(), + }), })); vi.mock("@/hooks/queries/project-default-execution-options-query", () => ({ @@ -385,6 +392,7 @@ describe("PluginNewThreadComposer seeding", () => { mocks.copyAttachments.mockReset(); mocks.uploadAttachment.mockReset(); mocks.projectThreads = []; + mocks.projectWorktrees = []; mocks.sidebarNavigationSettled = true; mocks.sidebarNavigationReplayed = false; mocks.extraProjects = []; @@ -731,6 +739,21 @@ describe("PluginNewThreadComposer seeding", () => { environmentWorkspaceDisplayKind: "managed-worktree", }), ]; + mocks.projectWorktrees = [ + { + hostId: "host_1", + path: "/worktrees/source", + checkout: { kind: "branch", branchName: "feature/source" }, + lock: null, + availability: { + kind: "selectable", + canonicalPath: "/worktrees/source", + }, + ownership: "bb-managed", + environmentId: "env-source", + environmentName: "source", + }, + ]; const submitted: NewThreadRequest[] = []; render( @@ -846,6 +869,21 @@ describe("PluginNewThreadComposer seeding", () => { environmentWorkspaceDisplayKind: "managed-worktree", }), ]; + mocks.projectWorktrees = [ + { + hostId: "host_1", + path: "/worktrees/source", + checkout: { kind: "branch", branchName: "feature/source" }, + lock: null, + availability: { + kind: "selectable", + canonicalPath: "/worktrees/source", + }, + ownership: "bb-managed", + environmentId: "env-source", + environmentName: "source", + }, + ]; rerender(element()); await waitFor(() => { diff --git a/apps/app/src/components/plugin/new-thread-environment-seed.test.ts b/apps/app/src/components/plugin/new-thread-environment-seed.test.ts index 5ad4fd6742..ffe844dafd 100644 --- a/apps/app/src/components/plugin/new-thread-environment-seed.test.ts +++ b/apps/app/src/components/plugin/new-thread-environment-seed.test.ts @@ -110,12 +110,29 @@ describe("newThreadEnvironmentArgsToSeed round trip", () => { ).toBeNull(); }); - it("documented limit: an unmanaged path is not representable", () => { + it("seeds an unmanaged path as a discovered-worktree selection", () => { const seed = newThreadEnvironmentArgsToSeed({ type: "host", hostId: "host_1", workspace: { type: "unmanaged", path: "/somewhere/else" }, }); - expect(seed).toEqual({ selectionValue: "host:host_1:local", branch: null }); + expect(seed).toEqual({ + selectionValue: `path:host_1:${encodeURIComponent("/somewhere/else")}`, + branch: null, + }); + }); + + it("does not seed an unmanaged path with a branch", () => { + expect( + newThreadEnvironmentArgsToSeed({ + type: "host", + hostId: "host_1", + workspace: { + type: "unmanaged", + path: "/somewhere/else", + branch: { kind: "existing", name: "main" }, + }, + }), + ).toBeNull(); }); }); diff --git a/apps/app/src/components/plugin/new-thread-environment-seed.ts b/apps/app/src/components/plugin/new-thread-environment-seed.ts index f13387c8c1..2575868799 100644 --- a/apps/app/src/components/plugin/new-thread-environment-seed.ts +++ b/apps/app/src/components/plugin/new-thread-environment-seed.ts @@ -2,6 +2,7 @@ import type { CreateThreadEnvironmentArgs } from "@bb/server-contract"; import { encodeHostValue, encodeReuseValue, + encodeWorktreePathValue, } from "@/components/pickers/environment-picker-value"; import type { RootComposeSelectedBranch } from "@/views/root-compose-thread-environment"; @@ -38,6 +39,15 @@ export function newThreadEnvironmentArgsToSeed( : null, }; } + if (workspace.path !== null) { + if (workspace.branch !== undefined) { + return null; + } + return { + selectionValue: encodeWorktreePathValue(hostId, workspace.path), + branch: null, + }; + } return { selectionValue: encodeHostValue(hostId, "local"), branch: diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index 57d095d1da..074f2c7d16 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -24,7 +24,6 @@ import type { import type { ProjectSelectorCreateProjectConfig } from "@/components/pickers/ProjectSelector"; import { encodeHostValue, - encodeReuseValue, parseEnvironmentValue, } from "@/components/pickers/environment-picker-value"; import { formatModelLoadErrorText } from "@/components/pickers/model-load-error-message"; @@ -45,6 +44,7 @@ import { stripProjectThreads, useProjectPromptHistory, useProjectSourceBranches, + useProjectWorktrees, type SidebarProject, } from "@/hooks/queries/project-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; @@ -80,12 +80,13 @@ import { } from "@/views/root-compose-branch-ui"; import { useScopedBranchSelection } from "@/views/root-compose-branch-selection"; import { - buildReuseThreadOptions, + buildWorktreeOptions, resolveProjectSourceWorktreeDisabledReason, resolveRootComposeEffectiveEnvironmentValue, resolveRootComposeProjectRouting, resolveRootComposeProviderRouting, } from "@/views/root-compose-environment-selection"; +import type { WorktreeOption } from "@/components/pickers/WorktreePicker"; import { resolveRootComposeThreadEnvironment, type RootComposeSelectedBranch, @@ -348,11 +349,11 @@ export function hasPromptBranchSelectionChanged( function resolvePanelThreadId( environmentId: string | null, - reuseThreadOptions: ReturnType, + worktreeOptions: readonly WorktreeOption[], ): string | null { if (environmentId === null) return null; return ( - reuseThreadOptions.find((option) => option.environmentId === environmentId) + worktreeOptions.find((option) => option.environmentId === environmentId) ?.threads[0]?.id ?? null ); } @@ -441,11 +442,25 @@ export function NewThreadComposer({ return navigation.projects.find((project) => project.id === projectId) ?.threads; }, [isProjectless, projectId, sidebarNavigationQuery.data]); - const reuseThreadOptionsLoading = - projectThreads === undefined && !sidebarNavigationSettled; - const reuseThreadOptions = useMemo( - () => buildReuseThreadOptions(projectThreads ?? [], worktreeHostNameById), - [projectThreads, worktreeHostNameById], + const worktreeDiscoveryEnabled = + !isProjectless && + Boolean(projectId) && + sidebarNavigationQuery.data !== undefined; + const worktreesQuery = useProjectWorktrees(projectId, { + enabled: worktreeDiscoveryEnabled, + }); + const worktreeOptionsLoading = worktreeDiscoveryEnabled + ? worktreesQuery.data === undefined + : !isProjectless && !sidebarNavigationSettled; + const { options: worktreeOptions, failures: worktreeFailures } = useMemo( + () => + buildWorktreeOptions({ + worktrees: worktreesQuery.data?.worktrees ?? [], + failures: worktreesQuery.data?.failures ?? [], + threads: projectThreads ?? [], + hostNameById: worktreeHostNameById, + }), + [worktreesQuery.data, projectThreads, worktreeHostNameById], ); const seedSignature = JSON.stringify([ @@ -479,16 +494,18 @@ export function NewThreadComposer({ knownHostIds, primaryHostId, projectSources, - reuseThreadOptions, - reuseThreadOptionsLoading, + worktreeOptions, + worktreeOptionsLoading, + hasWorktreeDiscoveryFailures: worktreeFailures.length > 0, }), [ isProjectless, knownHostIds, primaryHostId, projectSources, - reuseThreadOptions, - reuseThreadOptionsLoading, + worktreeOptions, + worktreeOptionsLoading, + worktreeFailures, ], ); const projectDefaultsQuery = useProjectDefaultExecutionOptions( @@ -611,8 +628,9 @@ export function NewThreadComposer({ knownHostIds, primaryHostId, projectSources, - reuseThreadOptions, - reuseThreadOptionsLoading, + worktreeOptions, + worktreeOptionsLoading, + hasWorktreeDiscoveryFailures: worktreeFailures.length > 0, }), [ environmentSelectionValue, @@ -620,8 +638,9 @@ export function NewThreadComposer({ knownHostIds, primaryHostId, projectSources, - reuseThreadOptions, - reuseThreadOptionsLoading, + worktreeOptions, + worktreeOptionsLoading, + worktreeFailures, ], ); const parsedEnvironment = useMemo( @@ -906,6 +925,12 @@ export function NewThreadComposer({ parsedEnvironment?.type === "reuse" ? parsedEnvironment.environmentId : null; + const selectedWorktreeValue = + (parsedEnvironment?.type === "reuse" && + parsedEnvironment.environmentId !== null) || + parsedEnvironment?.type === "worktree-path" + ? effectiveEnvironmentValue + : null; const projectRouting = resolveRootComposeProjectRouting( parsedEnvironment, primaryHostId, @@ -913,7 +938,7 @@ export function NewThreadComposer({ const projectHostId = projectRouting.hostId ?? null; const panelThreadId = resolvePanelThreadId( reuseEnvironmentId, - reuseThreadOptions, + worktreeOptions, ); const promptMentions = usePromptMentions( isProjectless ? undefined : projectId, @@ -1176,9 +1201,15 @@ export function NewThreadComposer({ }, [refetchBranches], ); + const refetchWorktrees = worktreesQuery.refetch; + const handleWorktreeRetry = useCallback(() => { + void refetchWorktrees(); + }, [refetchWorktrees]); const handleWorktreeChange = useCallback( - (environmentId: string) => { - changeEnvironment(encodeReuseValue(environmentId)); + (value: string) => { + // Rows already carry their encoded picker value: reuse: for + // environment-backed rows, a worktree-path value for discovered ones. + changeEnvironment(value); }, [changeEnvironment], ); @@ -1244,7 +1275,10 @@ export function NewThreadComposer({ value: effectiveEnvironmentValue, onChange: changeEnvironment, sources: projectSources, - reuseDisabled: reuseThreadOptions.length === 0, + reuseDisabled: + !worktreeOptionsLoading && + worktreeOptions.length === 0 && + worktreeFailures.length === 0, worktreeDisabledReason, disabled: locks.environment, ...(!isProjectless && options.onRequestMachineSetup @@ -1287,9 +1321,12 @@ export function NewThreadComposer({ onSearchQueryChange: setBranchSearchQuery, }, worktree: { - options: reuseThreadOptions, - value: reuseEnvironmentId, + options: worktreeOptions, + failures: worktreeFailures, + value: selectedWorktreeValue, onChange: handleWorktreeChange, + onRetry: handleWorktreeRetry, + loading: worktreeOptionsLoading, disabled: locks.environment, }, permission: { @@ -1377,6 +1414,7 @@ export function NewThreadComposer({ handleServiceTierChange, handleSubmit, handleWorktreeChange, + handleWorktreeRetry, hasMultipleProviders, isCopyingAttachments, isLoadingModels, @@ -1402,8 +1440,11 @@ export function NewThreadComposer({ reasoningOptions, remoteBranchOptions, reuseEnvironmentId, - reuseThreadOptions, selectedBranch, + selectedWorktreeValue, + worktreeFailures, + worktreeOptions, + worktreeOptionsLoading, selectedModel, selectedProviderId, serviceTier, diff --git a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx index 5fd51c94b0..a2b4fc160a 100644 --- a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx +++ b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx @@ -84,6 +84,7 @@ function EnvironmentOptionsStrip({ {showWorktreePicker ? ( { }} worktree={{ options: [], + failures: [], value: null, onChange: vi.fn(), }} @@ -94,6 +95,7 @@ describe("ThreadEnvSlot", () => { }} worktree={{ options: [], + failures: [], value: null, onChange: vi.fn(), }} diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx index ccaa58b778..36efe67583 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx @@ -59,7 +59,8 @@ import { } from "@/components/pickers/ProjectSelector"; import { WorktreePicker, - type ReuseThreadOption, + type WorktreeDiscoveryFailure, + type WorktreeOption, } from "@/components/pickers/WorktreePicker"; import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; import { useSystemConfig } from "@/hooks/queries/system-queries"; @@ -115,9 +116,12 @@ export interface NewThreadBranchConfig { } export interface NewThreadWorktreeConfig { - options: readonly ReuseThreadOption[]; + options: readonly WorktreeOption[]; + failures: readonly WorktreeDiscoveryFailure[]; value: string | null; - onChange: (environmentId: string) => void; + onChange: (value: string) => void; + onRetry?: () => void; + loading?: boolean; disabled?: boolean; } @@ -444,7 +448,9 @@ export function ThreadEnvSlot({ const branchMenuKind = getBranchPickerMenuKind({ parsedEnvironment }); const showBranchPicker = parsedEnvironment?.type === "host" && branch.hidden !== true; - const showWorktreePicker = parsedEnvironment?.type === "reuse"; + const showWorktreePicker = + parsedEnvironment?.type === "reuse" || + parsedEnvironment?.type === "worktree-path"; return ( <> ) : null} diff --git a/apps/app/src/hooks/queries/project-queries.ts b/apps/app/src/hooks/queries/project-queries.ts index 2cad2f463a..d6a8b9a998 100644 --- a/apps/app/src/hooks/queries/project-queries.ts +++ b/apps/app/src/hooks/queries/project-queries.ts @@ -3,6 +3,7 @@ import type { CommandListResponse, ProjectBranchesResponse, ProjectWithThreadsResponse, + ProjectWorktreesResponse, PromptHistoryResponse, WorkspacePathListResponse, } from "@bb/server-contract"; @@ -21,6 +22,7 @@ import { projectPathsQueryKey, projectPromptHistoryQueryKey, projectSourceBranchesQueryKey, + projectWorktreesQueryKey, } from "./query-keys"; import { resolveProjectSourceBranchesPlaceholder } from "./query-placeholders"; import { @@ -31,6 +33,7 @@ import { } from "./query-helpers"; import { EXPENSIVE_MANUAL_QUERY_POLICY, + FOCUS_OWNED_LIVE_QUERY_POLICY, HEAVY_PAYLOAD_QUERY_POLICY, REALTIME_OWNED_NO_FOCUS_QUERY_POLICY, TYPEAHEAD_QUERY_POLICY, @@ -127,6 +130,30 @@ export function useProjectSourceBranches( }); } +/** + * Focus-owned worktree discovery for the new-thread composer. The policy's + * 30-second stale time is load-bearing: discovery spawns a git process per + * host, so focus changes and composer re-renders must not refetch eagerly. + * Expose `refetch` as the explicit retry affordance. + */ +export function useProjectWorktrees( + projectId: string | undefined, + options?: QueryOptions, +) { + const enabled = (options?.enabled ?? true) && Boolean(projectId); + + return useQuery({ + queryKey: projectWorktreesQueryKey(projectId ?? ""), + queryFn: ({ signal }) => + sdk.projects.worktrees({ + projectId: requireProjectId(projectId, "useProjectWorktrees"), + signal, + }), + enabled, + ...FOCUS_OWNED_LIVE_QUERY_POLICY, + }); +} + export function useProjectPromptHistory( projectId: string | undefined, options?: QueryOptions, diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 860f632256..9c49de28b4 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -18,6 +18,7 @@ const PROJECTS_QUERY_KEY = "projects"; const PROJECT_PATHS_QUERY_KEY = "projectPaths"; const PROJECT_FILE_PREVIEW_QUERY_KEY = "projectFilePreview"; export const PROJECT_SOURCE_BRANCHES_QUERY_KEY = "projectSourceBranches"; +const PROJECT_WORKTREES_QUERY_KEY = "projectWorktrees"; const PROJECT_DEFAULT_EXECUTION_OPTIONS_QUERY_KEY = "projectDefaultExecutionOptions"; const PROJECT_PROMPT_HISTORY_QUERY_KEY = "projectPromptHistory"; @@ -162,6 +163,10 @@ type ProjectSourceBranchesQueryKey = readonly [ number, string, ]; +type ProjectWorktreesQueryKey = readonly [ + typeof PROJECT_WORKTREES_QUERY_KEY, + string, +]; export type SidebarNavigationQueryKey = readonly [ typeof SIDEBAR_NAVIGATION_QUERY_KEY, ]; @@ -628,6 +633,12 @@ export function projectSourceBranchesQueryKeyPrefix( return [PROJECT_SOURCE_BRANCHES_QUERY_KEY, projectId]; } +export function projectWorktreesQueryKey( + projectId: string, +): ProjectWorktreesQueryKey { + return [PROJECT_WORKTREES_QUERY_KEY, projectId]; +} + export function sidebarNavigationQueryKey(): SidebarNavigationQueryKey { return [SIDEBAR_NAVIGATION_QUERY_KEY]; } diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts index 37b3a1434b..4b390b1b06 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.ts +++ b/apps/app/src/hooks/useThreadCreationOptions.ts @@ -178,7 +178,7 @@ type InitialReadyProviderResolution = function sanitizeStoredEnvironmentValue(stored: string): string { if (!stored) return ""; const parsed = parseEnvironmentValue(stored); - if (parsed?.type === "reuse") return ""; + if (parsed?.type === "reuse" || parsed?.type === "worktree-path") return ""; return stored; } @@ -832,7 +832,7 @@ export function useThreadCreationOptions( (value: string) => { if (scope === "new-thread") { const parsed = parseEnvironmentValue(value); - if (parsed?.type === "reuse") { + if (parsed?.type === "reuse" || parsed?.type === "worktree-path") { setRootComposeReuseValue(value); return; } diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts index 9e76e33706..19eaf4889c 100644 --- a/apps/app/src/views/RootComposeView.test.ts +++ b/apps/app/src/views/RootComposeView.test.ts @@ -11,7 +11,7 @@ import type { } from "@bb/server-contract"; import { describe, expect, it } from "vitest"; import { parseEnvironmentValue } from "@/components/pickers/environment-picker-value"; -import type { ReuseThreadOption } from "@/components/pickers/WorktreePicker"; +import type { WorktreeOption } from "@/components/pickers/WorktreePicker"; import { hasPromptBranchSelectionChanged, hasPromptOptionValueChanged, @@ -267,15 +267,41 @@ function makeProjectSource(hostId = "host_1"): ProjectSource { }; } -function makeReuseThreadOption(environmentId: string): ReuseThreadOption { +function makeWorktreeOption(environmentId: string): WorktreeOption { return { + value: `reuse:${environmentId}`, environmentId, - branchName: "feature", + hostId: "host_1", + hostName: null, name: null, + checkout: { kind: "branch", branchName: "feature" }, + displayPath: `/worktrees/${environmentId}`, + availability: "selectable", + lock: null, + ownership: "bb-managed", threads: [{ id: "thr_1", title: "Thread" }], }; } +function makeDiscoveredWorktreeOption( + hostId: string, + canonicalPath: string, +): WorktreeOption { + return { + value: `path:${encodeURIComponent(hostId)}:${encodeURIComponent(canonicalPath)}`, + environmentId: null, + hostId, + hostName: null, + name: null, + checkout: { kind: "branch", branchName: "feature" }, + displayPath: canonicalPath, + availability: "selectable", + lock: null, + ownership: "user-managed", + threads: [], + }; +} + function makeThread(args: MakeThreadArgs): ThreadListEntry { return { id: args.id, @@ -907,8 +933,9 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: false, primaryHostId: "host_1", projectSources: [makeProjectSource("host_1")], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toBe("host:host_1:worktree"); }); @@ -921,8 +948,9 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: false, primaryHostId: "host_1", projectSources: [], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toBe(""); }); @@ -935,8 +963,9 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: false, primaryHostId: "host_1", projectSources: [makeProjectSource("host_1")], - reuseThreadOptions: [makeReuseThreadOption("env_current")], - reuseThreadOptionsLoading: false, + worktreeOptions: [makeWorktreeOption("env_current")], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toBe("reuse:env_current"); @@ -947,10 +976,11 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: false, primaryHostId: "host_1", projectSources: [makeProjectSource("host_1")], - reuseThreadOptions: [makeReuseThreadOption("env_current")], - reuseThreadOptionsLoading: false, + worktreeOptions: [makeWorktreeOption("env_current")], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), - ).toBe("host:host_1:local"); + ).toBe("reuse"); }); it("holds specific reuse values as incomplete while project worktrees load", () => { @@ -961,8 +991,9 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: false, primaryHostId: "host_1", projectSources: [makeProjectSource("host_1")], - reuseThreadOptions: [], - reuseThreadOptionsLoading: true, + worktreeOptions: [], + worktreeOptionsLoading: true, + hasWorktreeDiscoveryFailures: false, }), ).toBe("reuse"); }); @@ -975,8 +1006,9 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: true, primaryHostId: "host_1", projectSources: [], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toBe("host:host_1:local"); }); @@ -992,8 +1024,9 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { makeProjectSource("host_1"), makeProjectSource("host_2"), ], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toBe("host:host_2:worktree"); }); @@ -1006,8 +1039,9 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: false, primaryHostId: "host_1", projectSources: [makeProjectSource("host_1")], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toBe("host:host_1:worktree"); }); @@ -1020,8 +1054,9 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: true, primaryHostId: "host_1", projectSources: [], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toBe("host:host_2:local"); }); @@ -1034,8 +1069,9 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: true, primaryHostId: "host_1", projectSources: [], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toBe("host:host_1:local"); }); @@ -1048,14 +1084,101 @@ describe("resolveRootComposeEffectiveEnvironmentValue", () => { isProjectless: false, primaryHostId: "host_1", projectSources: [makeProjectSource("host_1")], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toBe("host:host_1:local"); }); + + it("keeps a discovered worktree selection while it stays listed", () => { + const option = makeDiscoveredWorktreeOption("host_1", "/worktrees/manual"); + const value = option.value; + if (value === null) throw new Error("expected selectable option"); + expect( + resolveRootComposeEffectiveEnvironmentValue({ + knownHostIds: new Set(["host_1"]), + environmentSelectionValue: value, + isProjectless: false, + primaryHostId: "host_1", + projectSources: [makeProjectSource("host_1")], + worktreeOptions: [option], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, + }), + ).toBe(value); + }); + + it("keeps a discovered worktree selection while discovery loads", () => { + const option = makeDiscoveredWorktreeOption("host_1", "/worktrees/manual"); + const value = option.value; + if (value === null) throw new Error("expected selectable option"); + expect( + resolveRootComposeEffectiveEnvironmentValue({ + knownHostIds: new Set(["host_1"]), + environmentSelectionValue: value, + isProjectless: false, + primaryHostId: "host_1", + projectSources: [makeProjectSource("host_1")], + worktreeOptions: [], + worktreeOptionsLoading: true, + hasWorktreeDiscoveryFailures: false, + }), + ).toBe(value); + }); + + it("clears a discovered selection that disappeared instead of retargeting", () => { + const option = makeDiscoveredWorktreeOption("host_1", "/worktrees/manual"); + const value = option.value; + if (value === null) throw new Error("expected selectable option"); + expect( + resolveRootComposeEffectiveEnvironmentValue({ + knownHostIds: new Set(["host_1"]), + environmentSelectionValue: value, + isProjectless: false, + primaryHostId: "host_1", + projectSources: [makeProjectSource("host_1")], + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, + }), + ).toBe("reuse"); + }); + + it("keeps bare reuse mode open when a machine's discovery failed", () => { + expect( + resolveRootComposeEffectiveEnvironmentValue({ + knownHostIds: new Set(["host_1"]), + environmentSelectionValue: "reuse", + isProjectless: false, + primaryHostId: "host_1", + projectSources: [makeProjectSource("host_1")], + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: true, + }), + ).toBe("reuse"); + }); }); describe("resolveRootComposeProviderRouting", () => { + it("routes a discovered worktree selection by its host", () => { + const option = makeDiscoveredWorktreeOption("host_2", "/worktrees/manual"); + const value = option.value; + if (value === null) throw new Error("expected selectable option"); + expect( + resolveRootComposeProviderRouting({ + knownHostIds: new Set(["host_1", "host_2"]), + environmentSelectionValue: value, + isProjectless: false, + primaryHostId: "host_1", + projectSources: [makeProjectSource("host_1")], + worktreeOptions: [option], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, + }), + ).toEqual({ hostId: "host_2" }); + }); it("routes discovery through the effective selected host", () => { expect( resolveRootComposeProviderRouting({ @@ -1067,8 +1190,9 @@ describe("resolveRootComposeProviderRouting", () => { makeProjectSource("host_1"), makeProjectSource("host_2"), ], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toEqual({ hostId: "host_2" }); }); @@ -1081,8 +1205,9 @@ describe("resolveRootComposeProviderRouting", () => { isProjectless: false, primaryHostId: "host_1", projectSources: [makeProjectSource("host_1")], - reuseThreadOptions: [], - reuseThreadOptionsLoading: false, + worktreeOptions: [], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toEqual({ hostId: "host_1" }); }); @@ -1095,8 +1220,9 @@ describe("resolveRootComposeProviderRouting", () => { isProjectless: false, primaryHostId: "host_1", projectSources: [makeProjectSource("host_1")], - reuseThreadOptions: [makeReuseThreadOption("env_remote")], - reuseThreadOptionsLoading: false, + worktreeOptions: [makeWorktreeOption("env_remote")], + worktreeOptionsLoading: false, + hasWorktreeDiscoveryFailures: false, }), ).toEqual({ environmentId: "env_remote" }); }); diff --git a/apps/app/src/views/root-compose-environment-selection.ts b/apps/app/src/views/root-compose-environment-selection.ts index 3f27713758..17dce4de29 100644 --- a/apps/app/src/views/root-compose-environment-selection.ts +++ b/apps/app/src/views/root-compose-environment-selection.ts @@ -5,14 +5,21 @@ import { } from "@bb/domain"; import type { ProjectBranchesResponse, + ProjectWorktree, + ProjectWorktreeFailure, SystemProvidersQuery, } from "@bb/server-contract"; import { encodeHostValue, + encodeReuseValue, + encodeWorktreePathValue, parseEnvironmentValue, REUSE_VALUE_WITHOUT_ENVIRONMENT, } from "@/components/pickers/environment-picker-value"; -import type { ReuseThreadOption } from "@/components/pickers/WorktreePicker"; +import type { + WorktreeDiscoveryFailure, + WorktreeOption, +} from "@/components/pickers/WorktreePicker"; import { getThreadDisplayTitle } from "@/lib/thread-title"; interface ResolveRootComposeEffectiveEnvironmentValueArgs { @@ -21,8 +28,10 @@ interface ResolveRootComposeEffectiveEnvironmentValueArgs { knownHostIds: ReadonlySet; primaryHostId: string | null; projectSources: readonly ProjectSource[]; - reuseThreadOptions: readonly ReuseThreadOption[]; - reuseThreadOptionsLoading: boolean; + worktreeOptions: readonly WorktreeOption[]; + worktreeOptionsLoading: boolean; + /** At least one machine's discovery failed; keep reuse mode inspectable. */ + hasWorktreeDiscoveryFailures: boolean; } const PROJECT_SOURCE_NOT_GIT_WORKTREE_DISABLED_REASON = @@ -30,67 +39,83 @@ const PROJECT_SOURCE_NOT_GIT_WORKTREE_DISABLED_REASON = const PROJECT_SOURCE_NO_COMMITS_WORKTREE_DISABLED_REASON = "Project source has no commits. Create an initial commit before creating a worktree"; -function isWorktreeWithEnv(thread: ThreadListEntry): boolean { - if (thread.environmentId === null) return false; - return ( - thread.environmentWorkspaceDisplayKind === "managed-worktree" || - thread.environmentWorkspaceDisplayKind === "unmanaged-worktree" - ); +export interface WorktreeOptionsModel { + options: WorktreeOption[]; + failures: WorktreeDiscoveryFailure[]; } -export function buildReuseThreadOptions( - threads: readonly ThreadListEntry[], - hostNameById: ReadonlyMap | null = null, -): ReuseThreadOption[] { +interface BuildWorktreeOptionsArgs { + worktrees: readonly ProjectWorktree[]; + failures: readonly ProjectWorktreeFailure[]; + threads: readonly ThreadListEntry[]; + hostNameById: ReadonlyMap | null; +} + +export function buildWorktreeOptions( + args: BuildWorktreeOptionsArgs, +): WorktreeOptionsModel { const threadsByEnvironmentId = new Map(); - const branchByEnvironmentId = new Map(); - const nameByEnvironmentId = new Map(); - const hostIdByEnvironmentId = new Map(); - for (const thread of threads) { - if (!isWorktreeWithEnv(thread)) continue; + for (const thread of args.threads) { if (thread.environmentId === null) continue; - let bucket = threadsByEnvironmentId.get(thread.environmentId); - if (!bucket) { - bucket = []; - threadsByEnvironmentId.set(thread.environmentId, bucket); - branchByEnvironmentId.set( - thread.environmentId, - thread.environmentBranchName, - ); - nameByEnvironmentId.set(thread.environmentId, thread.environmentName); - hostIdByEnvironmentId.set(thread.environmentId, thread.environmentHostId); + const bucket = threadsByEnvironmentId.get(thread.environmentId); + if (bucket) { + bucket.push(thread); + } else { + threadsByEnvironmentId.set(thread.environmentId, [thread]); } - bucket.push(thread); } - const options: ReuseThreadOption[] = []; - for (const [environmentId, bucket] of threadsByEnvironmentId) { + for (const bucket of threadsByEnvironmentId.values()) { bucket.sort( (left, right) => right.latestAttentionAt - left.latestAttentionAt, ); - const hostId = hostIdByEnvironmentId.get(environmentId) ?? null; - options.push({ - environmentId, - branchName: branchByEnvironmentId.get(environmentId) ?? null, - name: nameByEnvironmentId.get(environmentId) ?? null, - hostName: - hostNameById !== null && hostId !== null - ? (hostNameById.get(hostId) ?? null) - : null, - threads: bucket.map((thread) => ({ + } + + const hostName = (hostId: string): string | null => + args.hostNameById === null ? null : (args.hostNameById.get(hostId) ?? null); + + const options = args.worktrees.map((worktree): WorktreeOption => { + const availability = worktree.availability; + const selectable = availability.kind === "selectable"; + const value = + worktree.environmentId !== null + ? encodeReuseValue(worktree.environmentId) + : availability.kind === "selectable" + ? encodeWorktreePathValue(worktree.hostId, availability.canonicalPath) + : null; + const threads = + worktree.environmentId !== null + ? (threadsByEnvironmentId.get(worktree.environmentId) ?? []) + : []; + return { + value: selectable ? value : null, + environmentId: worktree.environmentId, + hostId: worktree.hostId, + hostName: hostName(worktree.hostId), + name: worktree.environmentName, + checkout: worktree.checkout, + displayPath: worktree.path, + availability: + availability.kind === "selectable" ? "selectable" : availability.reason, + lock: worktree.lock, + ownership: worktree.ownership, + threads: threads.map((thread) => ({ id: thread.id, title: getThreadDisplayTitle(thread), })), - }); - } - options.sort((left, right) => { - const leftLabel = left.name ?? left.branchName; - const rightLabel = right.name ?? right.branchName; - if (leftLabel && rightLabel) { - return leftLabel.localeCompare(rightLabel); - } - return left.environmentId.localeCompare(right.environmentId); + }; }); - return options; + + return { + options, + failures: args.failures.map((failure) => ({ + hostId: failure.hostId, + hostName: hostName(failure.hostId), + message: + failure.code === "host_offline" + ? "Machine is offline" + : failure.message, + })), + }; } export function resolveProjectSourceWorktreeDisabledReason( @@ -114,8 +139,9 @@ export function resolveRootComposeEffectiveEnvironmentValue({ knownHostIds, primaryHostId, projectSources, - reuseThreadOptions, - reuseThreadOptionsLoading, + worktreeOptions, + worktreeOptionsLoading, + hasWorktreeDiscoveryFailures, }: ResolveRootComposeEffectiveEnvironmentValueArgs): string { if (!primaryHostId) { return ""; @@ -153,20 +179,39 @@ export function resolveRootComposeEffectiveEnvironmentValue({ if (parsedSelection?.type === "reuse") { if (parsedSelection.environmentId === null) { - return reuseThreadOptionsLoading || reuseThreadOptions.length > 0 + return worktreeOptionsLoading || + worktreeOptions.length > 0 || + hasWorktreeDiscoveryFailures ? environmentSelectionValue : fallbackHostValue; } - if (reuseThreadOptionsLoading) { + if (worktreeOptionsLoading) { return REUSE_VALUE_WITHOUT_ENVIRONMENT; } - return reuseThreadOptions.some( - (option) => option.environmentId === parsedSelection.environmentId, + // A refresh that removed or disabled the selected row clears the choice + // and keeps the picker in reuse mode: silently retargeting the thread at + // "Work locally" would run it somewhere the user never picked. + return worktreeOptions.some( + (option) => + option.environmentId === parsedSelection.environmentId && + option.value !== null, ) ? environmentSelectionValue - : fallbackHostValue; + : REUSE_VALUE_WITHOUT_ENVIRONMENT; + } + + if (parsedSelection?.type === "worktree-path") { + if (worktreeOptionsLoading) { + return environmentSelectionValue; + } + return worktreeOptions.some( + (option) => + option.value !== null && option.value === environmentSelectionValue, + ) + ? environmentSelectionValue + : REUSE_VALUE_WITHOUT_ENVIRONMENT; } if (!canUseHostWorkspace) { @@ -184,9 +229,13 @@ export function resolveComposeHostId( parsedEnvironment: ReturnType, primaryHostId: string | null, ): string | null { - return parsedEnvironment?.type === "host" - ? parsedEnvironment.hostId - : primaryHostId; + if ( + parsedEnvironment?.type === "host" || + parsedEnvironment?.type === "worktree-path" + ) { + return parsedEnvironment.hostId; + } + return primaryHostId; } export function resolveRootComposeProjectRouting( @@ -208,7 +257,7 @@ export function resolveRootComposeProviderRouting( const parsed = parseEnvironmentValue( resolveRootComposeEffectiveEnvironmentValue(args), ); - if (parsed?.type === "host") { + if (parsed?.type === "host" || parsed?.type === "worktree-path") { return { hostId: parsed.hostId }; } if (parsed?.type === "reuse" && parsed.environmentId !== null) { diff --git a/apps/app/src/views/root-compose-thread-environment.test.ts b/apps/app/src/views/root-compose-thread-environment.test.ts index c78fed84bb..ddb5d30edb 100644 --- a/apps/app/src/views/root-compose-thread-environment.test.ts +++ b/apps/app/src/views/root-compose-thread-environment.test.ts @@ -138,6 +138,25 @@ describe("resolveRootComposeThreadEnvironment", () => { }); }); + it("attaches a discovered worktree as an unmanaged path with no branch intent", () => { + const canonicalPath = "/Users/dev/worktrees/spike branch:odd"; + expect( + resolveRootComposeThreadEnvironment({ + defaultBranch: "main", + defaultWorktreeBaseBranch: null, + environmentValue: `path:${encodeURIComponent("host_123")}:${encodeURIComponent(canonicalPath)}`, + projectId, + // A branch pick from an earlier host-mode state must not leak into + // the discovered-worktree attachment. + selectedBranch: selectedBranch("develop"), + }), + ).toEqual({ + type: "host", + hostId: "host_123", + workspace: { type: "unmanaged", path: canonicalPath }, + }); + }); + it("uses personal workspaces for the personal project", () => { expect( resolveRootComposeThreadEnvironment({ diff --git a/apps/app/src/views/root-compose-thread-environment.ts b/apps/app/src/views/root-compose-thread-environment.ts index a37c8f8811..c5f5c2945c 100644 --- a/apps/app/src/views/root-compose-thread-environment.ts +++ b/apps/app/src/views/root-compose-thread-environment.ts @@ -50,6 +50,17 @@ export function resolveRootComposeThreadEnvironment( return { type: "reuse", environmentId: parsed.environmentId }; } + if (parsed.type === "worktree-path") { + // A discovered user-managed worktree attaches through the existing + // unmanaged request at its canonical path. No branch intent: the branch + // or detached HEAD stays exactly as the user left it. + return { + type: "host", + hostId: parsed.hostId, + workspace: { type: "unmanaged", path: parsed.canonicalPath }, + }; + } + if (parsed.type === "host") { if (args.projectId === PERSONAL_PROJECT_ID) { return { diff --git a/apps/cli/src/__tests__/command-output/project.test.ts b/apps/cli/src/__tests__/command-output/project.test.ts index eda3364553..f7a1c933bb 100644 --- a/apps/cli/src/__tests__/command-output/project.test.ts +++ b/apps/cli/src/__tests__/command-output/project.test.ts @@ -194,6 +194,163 @@ describe("bb project command output", () => { } }); + it("bb project worktrees --json prints the raw discovery response", async () => { + const response = { + worktrees: [ + { + hostId: "host-1", + path: "/work/feature", + checkout: { kind: "branch", branchName: "feature" }, + lock: null, + availability: { kind: "selectable", canonicalPath: "/work/feature" }, + ownership: "user-managed", + environmentId: null, + environmentName: null, + }, + ], + failures: [ + { + hostId: "host-2", + code: "host_offline", + message: "Machine is offline", + }, + ], + }; + const getWorktrees = vi.fn(async () => response); + stubServerApi({ "v1.projects.:id.worktrees.$get": getWorktrees }); + + await runCommand(["project", "worktrees", "proj-1", "--json"], register); + + expect( + JSON.parse(String(vi.mocked(console.log).mock.calls[0]?.[0])), + ).toEqual(response); + expect(getWorktrees).toHaveBeenCalledWith({ param: { id: "proj-1" } }); + }); + + it("bb project worktrees groups by machine and prints failures as rows", async () => { + const response = { + worktrees: [ + { + hostId: "host-1", + path: "/work/feature", + checkout: { kind: "branch", branchName: "feature" }, + lock: null, + availability: { kind: "selectable", canonicalPath: "/work/feature" }, + ownership: "user-managed", + environmentId: null, + environmentName: null, + }, + { + hostId: "host-1", + path: "/work/named", + checkout: { kind: "branch", branchName: "bb/thread" }, + lock: { reason: "on external drive" }, + availability: { kind: "selectable", canonicalPath: "/work/named" }, + ownership: "bb-managed", + environmentId: "env-1", + environmentName: "Named env", + }, + { + hostId: "host-1", + path: "/work/stale", + checkout: { kind: "detached", headSha: "abc1234def5678" }, + lock: null, + availability: { kind: "unavailable", reason: "missing" }, + ownership: "user-managed", + environmentId: null, + environmentName: null, + }, + ], + failures: [ + { + hostId: "host-2", + code: "host_offline", + message: "Machine is offline", + }, + ], + }; + stubServerApi({ + "v1.projects.:id.worktrees.$get": vi.fn(async () => response), + "v1.hosts.$get": vi.fn(async () => [ + { id: "host-1", name: "MacBook" }, + { id: "host-2", name: "Studio" }, + ]), + }); + + await runCommand(["project", "worktrees", "proj-1"], register); + + const output = collectLogPayloads(vi.mocked(console.log)).join("\n"); + expect(output).toContain("MacBook · feature"); + expect(output).toContain("/work/feature"); + expect(output).toContain("available"); + expect(output).toContain("MacBook · Named env"); + expect(output).toContain("locked: on external drive"); + expect(output).toContain("bb-managed"); + expect(output).toContain("env-1"); + expect(output).toContain("MacBook · Detached at abc1234"); + expect(output).toContain("missing"); + expect(output).toContain("Studio"); + expect(output).toContain("offline"); + }); + + it("bb project worktrees renders a host's failure without dropping its rows", async () => { + const response = { + worktrees: [ + { + hostId: "host-1", + path: "/work/feature", + checkout: { kind: "branch", branchName: "feature" }, + lock: null, + availability: { kind: "selectable", canonicalPath: "/work/feature" }, + ownership: "user-managed", + environmentId: null, + environmentName: null, + }, + ], + failures: [ + { hostId: "host-1", code: "discovery_failed", message: "Partial scan" }, + ], + }; + stubServerApi({ + "v1.projects.:id.worktrees.$get": vi.fn(async () => response), + "v1.hosts.$get": vi.fn(async () => [{ id: "host-1", name: "MacBook" }]), + }); + + await runCommand(["project", "worktrees", "proj-1"], register); + + const output = collectLogPayloads(vi.mocked(console.log)).join("\n"); + expect(output).toContain("Partial scan"); + expect(output).toContain("/work/feature"); + }); + + it("bb project worktrees omits the machine prefix for a single machine", async () => { + const response = { + worktrees: [ + { + hostId: "host-1", + path: "/work/feature", + checkout: { kind: "branch", branchName: "feature" }, + lock: null, + availability: { kind: "selectable", canonicalPath: "/work/feature" }, + ownership: "user-managed", + environmentId: null, + environmentName: null, + }, + ], + failures: [], + }; + stubServerApi({ + "v1.projects.:id.worktrees.$get": vi.fn(async () => response), + "v1.hosts.$get": vi.fn(async () => [{ id: "host-1", name: "MacBook" }]), + }); + + await runCommand(["project", "worktrees", "proj-1"], register); + + const output = collectLogPayloads(vi.mocked(console.log)).join("\n"); + expect(output).toContain("feature"); + expect(output).not.toContain("MacBook ·"); + }); + it("bb project list --json prints raw projects", async () => { const projects = [ { diff --git a/apps/cli/src/commands/project.ts b/apps/cli/src/commands/project.ts index 3363268caf..28f53f1f05 100644 --- a/apps/cli/src/commands/project.ts +++ b/apps/cli/src/commands/project.ts @@ -4,6 +4,8 @@ import { Command } from "commander"; import type { CreateProjectSourceRequest, ProjectResponse, + ProjectWorktree, + ProjectWorktreesResponse, UpdateProjectSourceRequest, } from "@bb/server-contract"; import { action } from "../action.js"; @@ -34,6 +36,10 @@ interface ProjectShowCommandOptions { json?: boolean; } +interface ProjectWorktreesCommandOptions { + json?: boolean; +} + interface ProjectHistoryCommandOptions { json?: boolean; limit?: string; @@ -526,6 +532,23 @@ export function registerProjectCommands( }), ); + project + .command("worktrees ") + .description("List git worktrees discovered for a project") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (id: string, opts: ProjectWorktreesCommandOptions) => { + const sdk = createCliBbSdk(getUrl()); + const result = await sdk.projects.worktrees({ projectId: id }); + if (outputJson(opts, result)) return; + const hosts = await sdk.hosts.list(); + printProjectWorktrees( + result, + new Map(hosts.map((host) => [host.id, host.name])), + ); + }), + ); + project .command("update ") .description("Update a project") @@ -696,6 +719,94 @@ function printProject(project: ProjectResponse): void { console.log(""); } +function formatWorktreeLabel(worktree: ProjectWorktree): string { + if (worktree.environmentName !== null) { + return worktree.environmentName; + } + return worktree.checkout.kind === "branch" + ? worktree.checkout.branchName + : `Detached at ${worktree.checkout.headSha.slice(0, 7)}`; +} + +function formatWorktreeState(worktree: ProjectWorktree): string { + if (worktree.availability.kind === "unavailable") { + return worktree.availability.reason; + } + if (worktree.lock !== null) { + return worktree.lock.reason === null + ? "locked" + : `locked: ${worktree.lock.reason}`; + } + return "available"; +} + +function printProjectWorktrees( + response: ProjectWorktreesResponse, + hostNameById: ReadonlyMap, +): void { + const hostIds = [ + ...new Set([ + ...response.worktrees.map((worktree) => worktree.hostId), + ...response.failures.map((failure) => failure.hostId), + ]), + ]; + if (hostIds.length === 0) { + console.log("No worktrees found"); + return; + } + const multiMachine = hostIds.length > 1; + const hostLabel = (hostId: string) => hostNameById.get(hostId) ?? hostId; + + const rows: string[][] = []; + for (const hostId of hostIds) { + // The response shape permits a host to report both a failure and rows; + // render everything rather than letting a warning hide returned data. + const failure = response.failures.find((entry) => entry.hostId === hostId); + if (failure) { + rows.push([ + hostLabel(hostId), + "-", + failure.code === "host_offline" ? "offline" : failure.message, + "-", + "-", + ]); + } + for (const worktree of response.worktrees) { + if (worktree.hostId !== hostId) { + continue; + } + const label = formatWorktreeLabel(worktree); + rows.push([ + multiMachine ? `${hostLabel(hostId)} · ${label}` : label, + worktree.path, + formatWorktreeState(worktree), + worktree.ownership, + worktree.environmentId ?? "-", + ]); + } + } + + const widths = [ + Math.max(8, ...rows.map((row) => row[0].length)), + Math.max(4, ...rows.map((row) => row[1].length)), + Math.max(5, ...rows.map((row) => row[2].length)), + Math.max(9, ...rows.map((row) => row[3].length)), + Math.max(11, ...rows.map((row) => row[4].length)), + ]; + console.log(""); + console.log( + renderBorderlessTable( + { + head: ["Worktree", "Path", "State", "Ownership", "Environment"], + colWidths: widths, + trimTrailingWhitespace: true, + }, + rows, + ), + ); + console.log(""); +} + function printProjectTable(projects: ProjectResponse[]): void { const rows = projects.map((project) => { const source = getProjectDisplaySource(project); diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts index 78f3f1fc1f..01bb67d34a 100644 --- a/apps/host-daemon/src/command-dispatch.ts +++ b/apps/host-daemon/src/command-dispatch.ts @@ -23,6 +23,7 @@ import { listHostBranchOptions, listHostBranches, } from "./command-handlers/host-branches.js"; +import { listHostWorktrees } from "./command-handlers/host-worktrees.js"; import { installGlobalSkills, readGlobalSkillsStatus, @@ -597,6 +598,7 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { readGlobalSkillsStatus(command, {}), "host.list_branch_options": listHostBranchOptions, "host.list_branches": listHostBranches, + "host.list_worktrees": listHostWorktrees, "host.file_metadata": readHostFileMetadata, "host.read_file": readHostFile, "host.read_file_relative": readHostRelativeFile, diff --git a/apps/host-daemon/src/command-handlers/host-worktrees.ts b/apps/host-daemon/src/command-handlers/host-worktrees.ts new file mode 100644 index 0000000000..48b63dd93a --- /dev/null +++ b/apps/host-daemon/src/command-handlers/host-worktrees.ts @@ -0,0 +1,42 @@ +import path from "node:path"; +import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract"; +import { listGitWorktrees, resolveHostPaths } from "@bb/host-workspace"; +import { CommandDispatchError } from "../command-dispatch-support.js"; +import type { + CommandDispatchOptions, + CommandOf, +} from "../command-dispatch-support.js"; +import { userExecutableProcessOptions } from "../user-executable-env.js"; + +/** + * Raw host-local worktree facts for project discovery. The daemon reports + * what git and the filesystem say — including stale and locked registrations — + * and canonicalizes paths here because the server cannot realpath a remote + * host's paths. Availability, ownership, and environment merging are server + * policy. + */ +export async function listHostWorktrees( + command: CommandOf<"host.list_worktrees">, + options?: Pick, +): Promise> { + if (!path.isAbsolute(command.path)) { + throw new CommandDispatchError("invalid_path", "Path must be absolute"); + } + for (const comparisonPath of command.comparisonPaths) { + if (!path.isAbsolute(comparisonPath)) { + throw new CommandDispatchError( + "invalid_path", + "Comparison paths must be absolute", + ); + } + } + + const gitProcessOptions = userExecutableProcessOptions( + options?.runtimeManager.getShellEnv() ?? {}, + ); + const [worktrees, resolvedPaths] = await Promise.all([ + listGitWorktrees(command.path, gitProcessOptions), + resolveHostPaths(command.comparisonPaths), + ]); + return { worktrees, resolvedPaths }; +} diff --git a/apps/host-daemon/test/command/host-worktrees-dispatch.test.ts b/apps/host-daemon/test/command/host-worktrees-dispatch.test.ts new file mode 100644 index 0000000000..6394b6b86f --- /dev/null +++ b/apps/host-daemon/test/command/host-worktrees-dispatch.test.ts @@ -0,0 +1,111 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { dispatchOnlineRpcCommand } from "../../src/command-dispatch.js"; +import { + cleanupTempDirs, + createHarness, + makeTempDir, + runGitCommand, +} from "./dispatch-helpers.js"; + +afterEach(cleanupTempDirs); + +async function initWorktreeRepo(): Promise { + const repoPath = await makeTempDir("bb-host-worktrees-repo-"); + await runGitCommand(["init", "-b", "main"], { cwd: repoPath }); + await runGitCommand(["config", "user.name", "BB Tests"], { cwd: repoPath }); + await runGitCommand(["config", "user.email", "bb@example.com"], { + cwd: repoPath, + }); + await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8"); + await runGitCommand(["add", "."], { cwd: repoPath }); + await runGitCommand(["commit", "-m", "Initial commit"], { cwd: repoPath }); + return repoPath; +} + +describe("host.list_worktrees dispatch", () => { + it("returns worktree facts and resolved comparison paths", async () => { + const repoPath = await initWorktreeRepo(); + await runGitCommand(["branch", "feature"], { cwd: repoPath }); + const worktreeParent = await makeTempDir("bb-host-worktrees-target-"); + const worktreePath = path.join(worktreeParent, "feature"); + await runGitCommand(["worktree", "add", worktreePath, "feature"], { + cwd: repoPath, + }); + const missingComparisonPath = path.join(worktreeParent, "gone"); + const harness = createHarness(); + + const result = await dispatchOnlineRpcCommand( + { + type: "host.list_worktrees", + path: repoPath, + comparisonPaths: [worktreePath, missingComparisonPath], + }, + harness.dispatchOptions(), + ); + + expect(result.worktrees).toHaveLength(2); + expect(result.worktrees[0].checkout).toEqual({ + kind: "branch", + branchName: "main", + }); + expect(result.worktrees[1]).toMatchObject({ + canonicalPath: await fs.realpath(worktreePath), + checkout: { kind: "branch", branchName: "feature" }, + lock: null, + prunable: null, + }); + expect(result.resolvedPaths).toEqual([ + { path: worktreePath, canonicalPath: await fs.realpath(worktreePath) }, + { path: missingComparisonPath, canonicalPath: null }, + ]); + }); + + it("rejects a relative source path with invalid_path", async () => { + const harness = createHarness(); + + await expect( + dispatchOnlineRpcCommand( + { + type: "host.list_worktrees", + path: "relative/repo", + comparisonPaths: [], + }, + harness.dispatchOptions(), + ), + ).rejects.toMatchObject({ code: "invalid_path" }); + }); + + it("rejects a relative comparison path with invalid_path", async () => { + const repoPath = await initWorktreeRepo(); + const harness = createHarness(); + + await expect( + dispatchOnlineRpcCommand( + { + type: "host.list_worktrees", + path: repoPath, + comparisonPaths: ["relative/env"], + }, + harness.dispatchOptions(), + ), + ).rejects.toMatchObject({ code: "invalid_path" }); + }); + + it("fails with a structured code for a non-repository path", async () => { + const plainDir = await makeTempDir("bb-host-worktrees-plain-"); + const harness = createHarness(); + + await expect( + dispatchOnlineRpcCommand( + { + type: "host.list_worktrees", + path: plainDir, + comparisonPaths: [], + }, + harness.dispatchOptions(), + ), + ).rejects.toMatchObject({ code: "not_git_repo" }); + }); +}); diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 8520adc0cb..1040f603d9 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -73,6 +73,7 @@ import { requestProjectDeletionAdvance, } from "../services/projects/project-deletion.js"; import { resolveDefaultWorktreeBaseBranch } from "../services/projects/worktree-base-branch.js"; +import { discoverProjectWorktrees } from "../services/projects/project-worktrees.js"; import { listProjectPromptHistory } from "../services/prompt-history.js"; import { parsePathKindInclusion } from "./path-list-inclusion.js"; import { @@ -851,6 +852,14 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void { }); }); + get(routes.worktrees, async (context) => { + const projectId = context.req.param("id"); + requirePublicStandardProject(deps.db, projectId); + + const result = await discoverProjectWorktrees(deps, { projectId }); + return context.json(result); + }); + post(routes.uploadAttachment, async (context) => { requirePublicProject(deps.db, context.req.param("id")); const formData = await context.req.formData(); diff --git a/apps/server/src/services/projects/project-worktrees.ts b/apps/server/src/services/projects/project-worktrees.ts new file mode 100644 index 0000000000..01214df1e7 --- /dev/null +++ b/apps/server/src/services/projects/project-worktrees.ts @@ -0,0 +1,357 @@ +import nodePath from "node:path"; +import { listProjectEnvironmentsWithPaths, listProjectSources } from "@bb/db"; +import { + WORKTREE_COMPARISON_PATHS_MAX, + type HostWorktreeListResult, +} from "@bb/domain"; +import type { + ProjectWorktree, + ProjectWorktreeFailure, + ProjectWorktreesResponse, +} from "@bb/server-contract"; +import { ApiError } from "../../errors.js"; +import type { WorkSessionDeps } from "../../types.js"; +import { + callHostOnlineRpc, + isHostUnavailableApiError, +} from "../hosts/online-rpc.js"; + +/** + * Discovery serves an open composer, so a host gets one short budget instead + * of the normal command timeout: the daemon bounds its git call to ~10s and a + * machine that cannot answer within that window becomes a failure row rather + * than a delay on every healthy machine's rows. + */ +const WORKTREE_DISCOVERY_TIMEOUT_MS = 15_000; + +type ProjectEnvironmentRow = ReturnType< + typeof listProjectEnvironmentsWithPaths +>[number]; + +interface DiscoverProjectWorktreesArgs { + projectId: string; +} + +interface SourceDiscovery { + failure: ProjectWorktreeFailure | null; + worktrees: ProjectWorktree[]; +} + +function isReusableEnvironment(environment: ProjectEnvironmentRow): boolean { + return environment.status === "ready" || environment.status === "retiring"; +} + +/** + * The daemon canonicalizes stored paths for us, so the source path rides along + * with the environment paths. Canonical identity is ownership identity — + * dropping a stored path could reclassify a bb workspace as user-managed — so + * a project that exceeds the wire bound fails this host's discovery clearly + * instead of returning a truncated, misclassifying comparison set. + */ +function buildComparisonPaths( + sourcePath: string, + environments: readonly ProjectEnvironmentRow[], +): string[] { + const paths = new Set([sourcePath]); + for (const environment of environments) { + if (environment.path !== null) { + paths.add(environment.path); + } + } + if (paths.size > WORKTREE_COMPARISON_PATHS_MAX) { + throw new ApiError( + 400, + "comparison_paths_exceeded", + `Discovery compares at most ${WORKTREE_COMPARISON_PATHS_MAX} stored paths per machine; this project has ${paths.size}`, + ); + } + return [...paths]; +} + +function worktreeSortLabel(row: ProjectWorktree): string { + if (row.environmentName !== null) { + return row.environmentName; + } + if (row.checkout.kind === "branch") { + return row.checkout.branchName; + } + return `Detached at ${row.checkout.headSha.slice(0, 7)}`; +} + +interface MergeHostWorktreesArgs { + hostId: string; + sourcePath: string; + environments: readonly ProjectEnvironmentRow[]; + result: HostWorktreeListResult; +} + +function mergeHostWorktrees(args: MergeHostWorktreesArgs): ProjectWorktree[] { + const canonicalByStoredPath = new Map( + args.result.resolvedPaths.map((resolved) => [ + resolved.path, + resolved.canonicalPath, + ]), + ); + const sourceCanonicalPath = + canonicalByStoredPath.get(args.sourcePath) ?? null; + + // Reusable environments win alias collisions: suppressing a discovered row + // for a terminal environment must never shadow a live one at the same + // canonical directory. + const environmentByCanonicalPath = new Map(); + const orderedEnvironments = [...args.environments].sort( + (left, right) => + Number(isReusableEnvironment(right)) - + Number(isReusableEnvironment(left)), + ); + for (const environment of orderedEnvironments) { + if (environment.path === null) { + continue; + } + const canonicalPath = canonicalByStoredPath.get(environment.path) ?? null; + if ( + canonicalPath !== null && + !environmentByCanonicalPath.has(canonicalPath) + ) { + environmentByCanonicalPath.set(canonicalPath, environment); + } + } + + const rows: ProjectWorktree[] = []; + const seenIdentities = new Set(); + const matchedEnvironmentIds = new Set(); + + for (const entry of args.result.worktrees) { + const checkout = entry.checkout; + // A bare record is the repository itself, never a workable checkout; its + // linked worktrees stay. The configured source checkout is represented by + // "Work locally" instead of a row. + if (checkout.kind === "bare") { + continue; + } + if ( + entry.canonicalPath !== null && + entry.canonicalPath === sourceCanonicalPath + ) { + continue; + } + // Canonical identity when the path exists; a stale registration can only + // coalesce with an identically-reported stale record on this host. + const identity = + entry.canonicalPath ?? `reported:${nodePath.normalize(entry.path)}`; + if (seenIdentities.has(identity)) { + continue; + } + seenIdentities.add(identity); + + const entryCanonicalPath = entry.canonicalPath; + const environment = + entryCanonicalPath !== null + ? environmentByCanonicalPath.get(entryCanonicalPath) + : undefined; + if (environment !== undefined && entryCanonicalPath !== null) { + matchedEnvironmentIds.add(environment.id); + if (isReusableEnvironment(environment)) { + rows.push({ + hostId: args.hostId, + path: entry.path, + checkout, + lock: entry.lock, + // Environment metadata wins, but git's prunable verdict does not: + // a registration git wants pruned stays unavailable even when a + // reusable environment still points at it. + availability: + entry.prunable !== null + ? { kind: "unavailable", reason: "prunable" } + : { kind: "selectable", canonicalPath: entryCanonicalPath }, + ownership: environment.managed ? "bb-managed" : "user-managed", + environmentId: environment.id, + environmentName: environment.name, + }); + continue; + } + if (environment.managed) { + // Still bb's workspace mid-lifecycle (provisioning, destroying, …): + // offering it as user-managed would misclassify cleanup ownership. + continue; + } + // A non-reusable unmanaged environment leaves the directory a plain + // user worktree; submit-time validation owns any conflict. + } + + rows.push({ + hostId: args.hostId, + path: entry.path, + checkout, + lock: entry.lock, + availability: + entry.canonicalPath === null + ? { kind: "unavailable", reason: "missing" } + : entry.prunable !== null + ? { kind: "unavailable", reason: "prunable" } + : { kind: "selectable", canonicalPath: entry.canonicalPath }, + ownership: "user-managed", + environmentId: null, + environmentName: null, + }); + } + + // Reusable environments that git did not report (directories outside the + // source's worktree registry) stay listed, keyed by their stored path. + for (const environment of orderedEnvironments) { + if ( + !isReusableEnvironment(environment) || + matchedEnvironmentIds.has(environment.id) || + environment.path === null + ) { + continue; + } + const canonicalPath = canonicalByStoredPath.get(environment.path) ?? null; + if (canonicalPath !== null && canonicalPath === sourceCanonicalPath) { + continue; + } + if (canonicalPath !== null && seenIdentities.has(canonicalPath)) { + continue; + } + const branchName = environment.branchName ?? environment.defaultBranch; + if (branchName === null) { + // Cannot render a checkout for a directory git never reported and whose + // environment recorded no branch; the row would be unlabelable. + continue; + } + if (canonicalPath !== null) { + seenIdentities.add(canonicalPath); + } + rows.push({ + hostId: args.hostId, + path: environment.path, + checkout: { kind: "branch", branchName }, + lock: null, + availability: + canonicalPath !== null + ? { kind: "selectable", canonicalPath } + : { kind: "unavailable", reason: "missing" }, + ownership: environment.managed ? "bb-managed" : "user-managed", + environmentId: environment.id, + environmentName: environment.name, + }); + } + + rows.sort((left, right) => { + const environmentRank = + Number(right.environmentId !== null) - + Number(left.environmentId !== null); + if (environmentRank !== 0) { + return environmentRank; + } + const labelCompare = worktreeSortLabel(left).localeCompare( + worktreeSortLabel(right), + ); + if (labelCompare !== 0) { + return labelCompare; + } + return left.path.localeCompare(right.path); + }); + return rows; +} + +function normalizeDiscoveryFailure( + hostId: string, + error: unknown, +): ProjectWorktreeFailure { + if (isHostUnavailableApiError(error)) { + return { hostId, code: "host_offline", message: "Machine is offline" }; + } + const code = error instanceof ApiError ? error.body.code : null; + const message = + code === "not_git_repo" + ? "Project source is not a git repository" + : code === "path_not_found" + ? "Project source path was not found" + : code === "command_timeout" + ? "Worktree discovery timed out" + : code === "comparison_paths_exceeded" + ? "Too many environment paths on this machine to discover safely" + : "Worktree discovery failed"; + return { hostId, code: "discovery_failed", message }; +} + +/** + * Discovers every configured source's worktrees and merges them with the + * project's environments by canonical `(hostId, canonicalPath)` identity. + * Hosts run in parallel; a host with no connected daemon fast-fails without + * spending the reconnect budget, and each failed host becomes a typed failure + * row while healthy hosts keep their results. + */ +export async function discoverProjectWorktrees( + deps: WorkSessionDeps, + args: DiscoverProjectWorktreesArgs, +): Promise { + // Every project source is a local_path row by schema check constraint, so + // the targeted per-project query needs no further narrowing. + const sources = listProjectSources(deps.db, args.projectId); + const environments = listProjectEnvironmentsWithPaths( + deps.db, + args.projectId, + ); + const environmentsByHost = new Map(); + for (const environment of environments) { + const bucket = environmentsByHost.get(environment.hostId); + if (bucket) { + bucket.push(environment); + } else { + environmentsByHost.set(environment.hostId, [environment]); + } + } + + const perSource = await Promise.all( + sources.map(async (source): Promise => { + const hostEnvironments = environmentsByHost.get(source.hostId) ?? []; + if (!deps.hub.hasDaemonForHost(source.hostId)) { + return { + failure: { + hostId: source.hostId, + code: "host_offline", + message: "Machine is offline", + }, + worktrees: [], + }; + } + try { + const result = await callHostOnlineRpc(deps, { + hostId: source.hostId, + timeoutMs: WORKTREE_DISCOVERY_TIMEOUT_MS, + command: { + type: "host.list_worktrees", + path: source.path, + comparisonPaths: buildComparisonPaths( + source.path, + hostEnvironments, + ), + }, + }); + return { + failure: null, + worktrees: mergeHostWorktrees({ + hostId: source.hostId, + sourcePath: source.path, + environments: hostEnvironments, + result, + }), + }; + } catch (error) { + return { + failure: normalizeDiscoveryFailure(source.hostId, error), + worktrees: [], + }; + } + }), + ); + + return { + worktrees: perSource.flatMap((entry) => entry.worktrees), + failures: perSource.flatMap((entry) => + entry.failure ? [entry.failure] : [], + ), + }; +} diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/command-index.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/command-index.md index 12d3116a04..c2241a9570 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/command-index.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/command-index.md @@ -36,6 +36,7 @@ This index lists every command path that the core CLI registers. Read the task-s - `bb project history` - `bb project reorder` - `bb project branches` +- `bb project worktrees` - `bb project paths` - `bb project commands` - `bb project files` diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/thread-creation.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/thread-creation.md index 34c14003b5..79f100a2a9 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/thread-creation.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/thread-creation.md @@ -97,6 +97,11 @@ status|install` to inspect or install provider CLIs on a selected machine. (convert them to JPEG or PNG first). `bb project attachment download --client-file ` writes existing attachment bytes on the CLI machine. There is no project-attachment list or per-file remove API. +- `bb project worktrees ` discovers registered Git worktrees across + configured project sources and merges them with reusable BB environments. + Partial discovery returns successful rows alongside per-machine failures. + Start a thread in a discovered user-managed worktree by passing its path as + an unmanaged workspace; BB never removes that worktree or its branch. - `bb project history|reorder` exposes project prompt recall and sidebar order. - Use `bb project show|update|delete` for one project. Use `bb project source update|delete` for one source. Use `bb project branches` for branch data. diff --git a/apps/server/test/public/public-project-worktrees.test.ts b/apps/server/test/public/public-project-worktrees.test.ts new file mode 100644 index 0000000000..8ef76bbc75 --- /dev/null +++ b/apps/server/test/public/public-project-worktrees.test.ts @@ -0,0 +1,634 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { createProjectSource, getEnvironment, getThread } from "@bb/db"; +import { WORKTREE_COMPARISON_PATHS_MAX } from "@bb/domain"; +import { afterEach, describe, expect, it } from "vitest"; +import type { HostRpcHandlerResult } from "../helpers/host-rpc.js"; +import { registerHostRpcResponder } from "../helpers/host-rpc.js"; +import { listQueuedCommands } from "../helpers/commands.js"; +import { readJson } from "../helpers/json.js"; +import { + seedEnvironment, + seedHost, + seedHostSession, + seedProjectWithSource, + seedThread, +} from "../helpers/seed.js"; +import { withTestHarness } from "../helpers/test-app.js"; + +const execFileAsync = promisify(execFile); +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +async function runGit(args: string[], cwd: string): Promise { + await execFileAsync("git", args, { cwd }); +} + +async function initRepoWithUserWorktree() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "bb-wt-lifecycle-")); + tempDirs.push(root); + const repoPath = path.join(root, "repo"); + await fs.mkdir(repoPath); + await runGit(["init", "-b", "main"], repoPath); + await runGit(["config", "user.name", "BB Tests"], repoPath); + await runGit(["config", "user.email", "bb@example.com"], repoPath); + await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8"); + await runGit(["add", "."], repoPath); + await runGit(["commit", "-m", "Initial commit"], repoPath); + const worktreePath = path.join(root, "user-worktree"); + await runGit( + ["worktree", "add", "-b", "user/branch", worktreePath], + repoPath, + ); + return { repoPath, worktreePath }; +} + +interface WorktreeEntryArgs { + path: string; + canonicalPath?: string | null; + branch?: string; + detachedSha?: string; + lock?: { reason: string | null } | null; + prunable?: { reason: string | null } | null; +} + +function worktreeEntry(args: WorktreeEntryArgs) { + return { + path: args.path, + canonicalPath: + args.canonicalPath === undefined ? args.path : args.canonicalPath, + checkout: args.detachedSha + ? { kind: "detached" as const, headSha: args.detachedSha } + : { kind: "branch" as const, branchName: args.branch ?? "main" }, + lock: args.lock ?? null, + prunable: args.prunable ?? null, + }; +} + +function respondWith(result: { + worktrees: ReturnType[]; + resolvedPaths: { path: string; canonicalPath: string | null }[]; +}): HostRpcHandlerResult { + return { ok: true, result }; +} + +describe("GET /projects/:id/worktrees", () => { + it("merges discovery with environments by canonical identity and excludes the source", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-wt-merge", + }); + // Stored paths are /tmp aliases; git reports /private/tmp. Canonical + // identity must collapse the two spellings. + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/wtproj/main", + }); + const envReady = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/wtproj/feature-env", + status: "ready", + managed: false, + branchName: "feature-env", + }); + const envManagedProvisioning = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/wtproj/bb-managed", + status: "provisioning", + managed: true, + workspaceProvisionType: "managed-worktree", + branchName: "bb/managed", + }); + const envRetiring = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/wtproj/retiring", + status: "retiring", + managed: true, + workspaceProvisionType: "managed-worktree", + branchName: "bb/retiring", + }); + // A provisioning direct-unmanaged environment has no path yet and must + // not reach the daemon's comparison list at all. + seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: null, + status: "provisioning", + managed: false, + }); + const envOffRegistry = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/other/off-registry", + status: "ready", + managed: false, + branchName: "off-branch", + }); + + const responder = registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: () => + respondWith({ + worktrees: [ + worktreeEntry({ + path: "/private/tmp/wtproj/main", + branch: "main", + }), + worktreeEntry({ + path: "/private/tmp/wtproj/feature-env", + branch: "feature-env", + }), + worktreeEntry({ + path: "/private/tmp/wtproj/bb-managed", + branch: "bb/managed", + }), + worktreeEntry({ + path: "/private/tmp/wtproj/retiring", + branch: "bb/retiring", + }), + worktreeEntry({ + path: "/private/tmp/wtproj/user-plain", + branch: "user/plain", + }), + worktreeEntry({ + path: "/private/tmp/wtproj/user-detached", + detachedSha: "abc1234def567890", + }), + worktreeEntry({ + path: "/private/tmp/wtproj/user-locked", + branch: "user/locked", + lock: { reason: "on removable drive" }, + }), + worktreeEntry({ + path: "/private/tmp/wtproj/user-stale", + canonicalPath: null, + branch: "user/stale", + prunable: { reason: "gitdir points nowhere" }, + }), + ], + resolvedPaths: [ + { + path: "/tmp/wtproj/main", + canonicalPath: "/private/tmp/wtproj/main", + }, + { + path: "/tmp/wtproj/feature-env", + canonicalPath: "/private/tmp/wtproj/feature-env", + }, + { + path: "/tmp/wtproj/bb-managed", + canonicalPath: "/private/tmp/wtproj/bb-managed", + }, + { + path: "/tmp/wtproj/retiring", + canonicalPath: "/private/tmp/wtproj/retiring", + }, + { + path: "/tmp/other/off-registry", + canonicalPath: "/private/tmp/other/off-registry", + }, + ], + }), + }); + + const response = await harness.app.request( + `/api/v1/projects/${project.id}/worktrees`, + ); + expect(response.status).toBe(200); + const body = (await readJson(response)) as { + worktrees: Array>; + failures: unknown[]; + }; + + expect(body.failures).toEqual([]); + const request = responder.requests[0]; + if (request?.command.type !== "host.list_worktrees") { + throw new Error("Expected a host.list_worktrees request"); + } + expect(request.command.path).toBe("/tmp/wtproj/main"); + expect([...request.command.comparisonPaths].sort()).toEqual( + [ + "/tmp/wtproj/main", + "/tmp/wtproj/feature-env", + "/tmp/wtproj/bb-managed", + "/tmp/wtproj/retiring", + "/tmp/other/off-registry", + ].sort(), + ); + + const byPath = new Map( + body.worktrees.map((worktree) => [worktree.path, worktree]), + ); + // Source checkout and mid-lifecycle managed environment do not appear. + expect(byPath.has("/private/tmp/wtproj/main")).toBe(false); + expect(byPath.has("/private/tmp/wtproj/bb-managed")).toBe(false); + + expect(byPath.get("/private/tmp/wtproj/feature-env")).toMatchObject({ + environmentId: envReady.id, + ownership: "user-managed", + availability: { + kind: "selectable", + canonicalPath: "/private/tmp/wtproj/feature-env", + }, + checkout: { kind: "branch", branchName: "feature-env" }, + }); + expect(byPath.get("/private/tmp/wtproj/retiring")).toMatchObject({ + environmentId: envRetiring.id, + ownership: "bb-managed", + availability: { kind: "selectable" }, + }); + expect(byPath.get("/tmp/other/off-registry")).toMatchObject({ + environmentId: envOffRegistry.id, + ownership: "user-managed", + availability: { + kind: "selectable", + canonicalPath: "/private/tmp/other/off-registry", + }, + checkout: { kind: "branch", branchName: "off-branch" }, + }); + expect(byPath.get("/private/tmp/wtproj/user-plain")).toMatchObject({ + environmentId: null, + ownership: "user-managed", + availability: { kind: "selectable" }, + }); + expect(byPath.get("/private/tmp/wtproj/user-detached")).toMatchObject({ + checkout: { kind: "detached", headSha: "abc1234def567890" }, + availability: { kind: "selectable" }, + }); + expect(byPath.get("/private/tmp/wtproj/user-locked")).toMatchObject({ + lock: { reason: "on removable drive" }, + availability: { kind: "selectable" }, + }); + expect(byPath.get("/private/tmp/wtproj/user-stale")).toMatchObject({ + environmentId: null, + availability: { kind: "unavailable", reason: "missing" }, + }); + + // Server order: environment-backed rows precede unmatched discoveries. + const environmentRankSwitch = body.worktrees.map( + (worktree) => worktree.environmentId !== null, + ); + expect(environmentRankSwitch.slice(0, 3)).toEqual([true, true, true]); + expect(environmentRankSwitch.slice(3)).toEqual([ + false, + false, + false, + false, + ]); + expect( + [...envManagedProvisioning.id].length > 0 && + body.worktrees.every( + (worktree) => worktree.environmentId !== envManagedProvisioning.id, + ), + ).toBe(true); + }); + }); + + it("fast-fails an offline host while a healthy host returns rows", async () => { + await withTestHarness(async (harness) => { + const { host: healthyHost, session } = seedHostSession(harness.deps, { + id: "host-wt-healthy", + }); + const offlineHost = seedHost(harness.deps, { + id: "host-wt-offline", + name: "Offline Host", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: healthyHost.id, + path: "/work/project", + }); + createProjectSource(harness.db, harness.deps.hub, { + projectId: project.id, + hostId: offlineHost.id, + path: "/work/project", + type: "local_path", + }); + registerHostRpcResponder(harness, { + hostId: healthyHost.id, + sessionId: session.id, + handle: () => + respondWith({ + worktrees: [ + worktreeEntry({ path: "/work/project", branch: "main" }), + worktreeEntry({ path: "/work/feature", branch: "feature" }), + ], + resolvedPaths: [ + { path: "/work/project", canonicalPath: "/work/project" }, + ], + }), + }); + + const startedAt = Date.now(); + const response = await harness.app.request( + `/api/v1/projects/${project.id}/worktrees`, + ); + const elapsedMs = Date.now() - startedAt; + expect(response.status).toBe(200); + const body = (await readJson(response)) as { + worktrees: Array<{ hostId: string; path: string }>; + failures: Array<{ hostId: string; code: string }>; + }; + + expect(body.worktrees).toHaveLength(1); + expect(body.worktrees[0]).toMatchObject({ + hostId: healthyHost.id, + path: "/work/feature", + }); + expect(body.failures).toEqual([ + { + hostId: offlineHost.id, + code: "host_offline", + message: "Machine is offline", + }, + ]); + // Fast-fail: the offline host must not consume a reconnect budget. + expect(elapsedMs).toBeLessThan(2_000); + }); + }); + + it("never deduplicates the same path across different hosts", async () => { + await withTestHarness(async (harness) => { + const first = seedHostSession(harness.deps, { id: "host-wt-a" }); + const second = seedHostSession(harness.deps, { id: "host-wt-b" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: first.host.id, + path: "/work/project", + }); + createProjectSource(harness.db, harness.deps.hub, { + projectId: project.id, + hostId: second.host.id, + path: "/work/project", + type: "local_path", + }); + const answer = () => + respondWith({ + worktrees: [ + worktreeEntry({ path: "/work/project", branch: "main" }), + worktreeEntry({ path: "/work/shared", branch: "shared" }), + ], + resolvedPaths: [ + { path: "/work/project", canonicalPath: "/work/project" }, + ], + }); + registerHostRpcResponder(harness, { + hostId: first.host.id, + sessionId: first.session.id, + handle: answer, + }); + registerHostRpcResponder(harness, { + hostId: second.host.id, + sessionId: second.session.id, + handle: answer, + }); + + const response = await harness.app.request( + `/api/v1/projects/${project.id}/worktrees`, + ); + const body = (await readJson(response)) as { + worktrees: Array<{ hostId: string; path: string }>; + }; + + expect(body.worktrees.map((worktree) => worktree.hostId).sort()).toEqual( + [first.host.id, second.host.id].sort(), + ); + expect( + body.worktrees.every((worktree) => worktree.path === "/work/shared"), + ).toBe(true); + }); + }); + + it("keeps a prunable registration unavailable even when a reusable environment matches", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-wt-prunable-env", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/work/project", + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/work/prunable-env", + status: "ready", + managed: false, + branchName: "user/prunable", + }); + registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: () => + respondWith({ + worktrees: [ + worktreeEntry({ path: "/work/project", branch: "main" }), + worktreeEntry({ + path: "/work/prunable-env", + branch: "user/prunable", + prunable: { reason: "gitdir points nowhere" }, + }), + ], + resolvedPaths: [ + { path: "/work/project", canonicalPath: "/work/project" }, + { + path: "/work/prunable-env", + canonicalPath: "/work/prunable-env", + }, + ], + }), + }); + + const response = await harness.app.request( + `/api/v1/projects/${project.id}/worktrees`, + ); + const body = (await readJson(response)) as { + worktrees: Array>; + }; + + expect(body.worktrees).toHaveLength(1); + expect(body.worktrees[0]).toMatchObject({ + environmentId: environment.id, + environmentName: environment.name, + ownership: "user-managed", + availability: { kind: "unavailable", reason: "prunable" }, + }); + }); + }); + + it("fails a host clearly when comparison paths exceed the wire bound", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-wt-overflow", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/work/project", + }); + // Source path + N environment paths must exceed the bound. + for (let index = 0; index < WORKTREE_COMPARISON_PATHS_MAX; index += 1) { + seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: `/work/env-${index}`, + status: "ready", + managed: false, + branchName: `branch-${index}`, + }); + } + const responder = registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: () => respondWith({ worktrees: [], resolvedPaths: [] }), + }); + + const response = await harness.app.request( + `/api/v1/projects/${project.id}/worktrees`, + ); + expect(response.status).toBe(200); + const body = (await readJson(response)) as { + worktrees: unknown[]; + failures: Array<{ hostId: string; code: string; message: string }>; + }; + + // Truncation would silently misclassify ownership; the host fails + // instead, and no RPC is ever sent. + expect(responder.requests).toEqual([]); + expect(body.worktrees).toEqual([]); + expect(body.failures).toEqual([ + { + hostId: host.id, + code: "discovery_failed", + message: + "Too many environment paths on this machine to discover safely", + }, + ]); + }); + }); + + it("normalizes daemon failures without leaking internals", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-wt-fail", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/work/project", + }); + registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: () => ({ + ok: false, + errorCode: "not_git_repo", + errorMessage: "raw internal detail that must not surface", + }), + }); + + const response = await harness.app.request( + `/api/v1/projects/${project.id}/worktrees`, + ); + expect(response.status).toBe(200); + const body = (await readJson(response)) as { + worktrees: unknown[]; + failures: Array<{ hostId: string; code: string; message: string }>; + }; + + expect(body.worktrees).toEqual([]); + expect(body.failures).toEqual([ + { + hostId: host.id, + code: "discovery_failed", + message: "Project source is not a git repository", + }, + ]); + }); + }); +}); + +describe("user-managed worktree lifecycle", () => { + it("archiving then deleting the last thread leaves a real user worktree and branch intact", async () => { + const { repoPath, worktreePath } = await initRepoWithUserWorktree(); + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host-wt-lifecycle", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: repoPath, + }); + // The environment a discovered-worktree selection produces: unmanaged, + // attached at the user's own path. + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: worktreePath, + status: "ready", + managed: false, + branchName: "user/branch", + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + + const assertWorktreeUntouched = async () => { + // No retirement, no destroy dispatch: bb does not own this directory. + expect(getEnvironment(harness.db, environment.id)).toMatchObject({ + status: "ready", + retireRequestedAt: null, + path: worktreePath, + branchName: "user/branch", + }); + expect(listQueuedCommands(harness, "environment.destroy")).toEqual([]); + await expect( + fs.readFile(path.join(worktreePath, "README.md"), "utf8"), + ).resolves.toBe("hello\n"); + const branches = await execFileAsync( + "git", + ["branch", "--list", "user/branch"], + { cwd: repoPath }, + ); + expect(branches.stdout).toContain("user/branch"); + const registrations = await execFileAsync( + "git", + ["worktree", "list", "--porcelain"], + { cwd: repoPath }, + ); + expect(registrations.stdout).toContain(await fs.realpath(worktreePath)); + }; + + const archiveResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/archive`, + { method: "POST" }, + ); + expect(archiveResponse.status).toBe(200); + expect(getThread(harness.db, thread.id)?.archivedAt).not.toBeNull(); + await assertWorktreeUntouched(); + + const deleteResponse = await harness.app.request( + `/api/v1/threads/${thread.id}`, + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ childThreadsConfirmed: true }), + }, + ); + expect(deleteResponse.status).toBe(200); + await assertWorktreeUntouched(); + }); + }); +}); diff --git a/docs/worktrees.md b/docs/worktrees.md index 825fce925e..9ed6c97cf9 100644 --- a/docs/worktrees.md +++ b/docs/worktrees.md @@ -6,6 +6,11 @@ with its own branch. Worktrees let bb work on multiple things in parallel without touching your main checkout, and they make it easy to throw away whatever the agent does without affecting the rest of your work. +bb also discovers **user-managed worktrees** — worktrees you created yourself +with `git worktree add` — and lets you start threads in them without handing +their lifecycle over to bb. See +[Managed vs user-managed ownership](#managed-vs-user-managed-ownership). + You can pair a worktree with a **`.worktreeinclude` file** that lists the local files each new worktree needs, and with a **setup script** that bb runs the first time the worktree is created — useful for installing dependencies, @@ -48,6 +53,50 @@ main`) behaves like omitting the flag: bb fetches and starts from plain name starts from that local branch as it is; pass `origin/` to fetch and start from the remote branch. +## Managed vs user-managed ownership + +Ownership decides who cleans up: + +- A **bb-managed** worktree was created by bb. When the last thread using its + environment is archived or deleted, bb removes the directory and its branch + and stops processes running inside it (see [Cleanup](#cleanup)). +- A **user-managed** worktree is one you created yourself with + `git worktree add`. bb attaches to it as an unmanaged workspace: it never + removes the directory, never deletes its branch, and never applies managed + cleanup — archiving or deleting the last thread simply detaches bb's + interest. The branch (or detached `HEAD`) stays exactly as you left it. + +### Discovery + +When you open the new-thread composer with a project selected, bb asks each +configured project machine for `git worktree list` of that machine's source +and merges the results with reusable bb environments. In the **Existing +worktree** picker: + +- Every worktree of the project's repos appears — including ones with no bb + thread yet. The project source checkout itself is represented by **Work + locally** instead of a row. +- Rows are labeled by environment name, branch name, or `Detached at `, + with the worktree's path underneath. User-managed rows carry a + `User-managed` hint. +- A locked worktree stays selectable and shows its lock reason as a warning. +- A stale registration (directory deleted, or one git reports as prunable) is + visible but disabled. bb never runs `git worktree prune` for you — inspect + with `git worktree list` and prune manually. +- If a machine is offline or discovery fails there, that machine shows an + explanatory row with a Retry action while other machines' worktrees remain + usable. + +Selecting a discovered worktree has no side effects; the environment is +created or reused only when you submit the thread. + +From the CLI: + +```bash +bb project worktrees # grouped human output +bb project worktrees --json # complete response with failures +``` + ## Copy local files with `.worktreeinclude` A new worktree checks out tracked files only. Your `.env`, your local @@ -111,9 +160,10 @@ Contract: ## Cleanup -You don't need to clean up worktrees by hand — bb removes them once every -thread using the environment is archived or deleted, and the branch goes with -it. If you +You don't need to clean up bb-managed worktrees by hand — bb removes them once +every thread using the environment is archived or deleted, and the branch goes +with it. (User-managed worktrees are never removed; see +[Managed vs user-managed ownership](#managed-vs-user-managed-ownership).) If you want to keep work the agent did, commit and push (or open a PR) from inside the worktree before letting the thread go. diff --git a/packages/db/src/data/environments.ts b/packages/db/src/data/environments.ts index 823f95ee2b..17454a0738 100644 --- a/packages/db/src/data/environments.ts +++ b/packages/db/src/data/environments.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, ne, sql, lt } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, ne, sql, lt } from "drizzle-orm"; import type { DiscoveredWorkspaceProperties, EnvironmentChangeKind, @@ -121,6 +121,27 @@ export function findForeignManagedEnvironmentAtHostPath( ); } +/** + * Environments that can participate in worktree discovery merging. A + * provisioning direct-unmanaged environment has no path yet, so it cannot be + * canonically matched and is deliberately absent here. + */ +export function listProjectEnvironmentsWithPaths( + db: DbConnection, + projectId: string, +) { + return db + .select() + .from(environments) + .where( + and( + eq(environments.projectId, projectId), + isNotNull(environments.path), + ), + ) + .all(); +} + export function listEnvironments(db: DbConnection, projectId?: string) { if (projectId) { return db diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 9c9f1c6d70..8511b9fc03 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -46,6 +46,7 @@ export { createProjectSource, countProjectSources, getProjectSourceForProject, + listProjectSources, listProjectSourcesByProjectIds, getProjectSourceByHost, updateProjectSource, @@ -208,6 +209,7 @@ export { getEnvironment, findProjectEnvironmentByHostPath, listEnvironments, + listProjectEnvironmentsWithPaths, findForeignManagedEnvironmentAtHostPath, listRetiredLoadedEnvironmentIdsOnHost, updateEnvironmentMetadata, diff --git a/packages/domain/src/git-worktrees.ts b/packages/domain/src/git-worktrees.ts new file mode 100644 index 0000000000..ee6e6149ea --- /dev/null +++ b/packages/domain/src/git-worktrees.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +/** + * Host-local facts about the worktrees registered with one git repository, + * parsed from `git worktree list --porcelain -z` and canonicalized on the + * owning host. These are raw facts only: availability, ownership, and merge + * policy against stored environments are the server's job. + */ +export const gitWorktreeCheckoutSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("branch"), + branchName: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("detached"), + headSha: z.string().min(1), + }) + .strict(), + z.object({ kind: z.literal("bare") }).strict(), +]); +export type GitWorktreeCheckout = z.infer; + +export const gitWorktreeEntrySchema = z + .object({ + /** Exact absolute path reported by git, for display. */ + path: z.string().min(1), + /** realpath(path), or null when the registered path does not exist. */ + canonicalPath: z.string().min(1).nullable(), + checkout: gitWorktreeCheckoutSchema, + /** Null when unlocked; a locked worktree may have no recorded reason. */ + lock: z.object({ reason: z.string().nullable() }).strict().nullable(), + /** Null when git considers the registration healthy. */ + prunable: z.object({ reason: z.string().nullable() }).strict().nullable(), + }) + .strict(); +export type GitWorktreeEntry = z.infer; + +/** + * Canonical resolution of a stored environment path, computed on the owning + * host because a stored path may be an alias (macOS `/var` vs `/private/var`) + * and remote paths cannot be resolved on the server machine. + */ +export const resolvedHostPathSchema = z + .object({ + path: z.string().min(1), + canonicalPath: z.string().min(1).nullable(), + }) + .strict(); +export type ResolvedHostPath = z.infer; + +export const hostWorktreeListResultSchema = z + .object({ + worktrees: z.array(gitWorktreeEntrySchema), + resolvedPaths: z.array(resolvedHostPathSchema), + }) + .strict(); +export type HostWorktreeListResult = z.infer< + typeof hostWorktreeListResultSchema +>; diff --git a/packages/domain/src/host-list-limits.ts b/packages/domain/src/host-list-limits.ts index 9b18f7ab74..774cb98f8c 100644 --- a/packages/domain/src/host-list-limits.ts +++ b/packages/domain/src/host-list-limits.ts @@ -2,3 +2,4 @@ export const FILE_LIST_QUERY_MAX_LENGTH = 256; export const FILE_LIST_LIMIT_MAX = 10_000; export const BRANCH_LIST_QUERY_MAX_LENGTH = 256; export const BRANCH_LIST_LIMIT_MAX = 1_000; +export const WORKTREE_COMPARISON_PATHS_MAX = 200; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 93c1441ba0..517966f708 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -13,6 +13,7 @@ export * from "./environment.js"; export * from "./experiments.js"; export * from "./feature-flags.js"; export * from "./git-checkout.js"; +export * from "./git-worktrees.js"; export * from "./host-list-limits.js"; export * from "./host.js"; export * from "./html-escape.js"; diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index 0881c4df6d..7bf6f582a0 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -21,10 +21,12 @@ import { jsonObjectSchema, jsonValueSchema, providerNativeRootSetSchema, + hostWorktreeListResultSchema, BRANCH_LIST_LIMIT_MAX, BRANCH_LIST_QUERY_MAX_LENGTH, FILE_LIST_LIMIT_MAX, FILE_LIST_QUERY_MAX_LENGTH, + WORKTREE_COMPARISON_PATHS_MAX, } from "@bb/domain"; import { z } from "zod"; import { @@ -773,6 +775,23 @@ const hostBranchOptionsResultSchema = projectSourceCheckoutSchema.pick({ selectedBranch: true, }); +/** + * List every git worktree registered at an absolute host path, with canonical + * (realpath) identities computed on that host, plus canonical resolutions of + * the stored environment paths the server wants to compare against. Read-only + * path-only sibling of `host.list_branches`: it never mutates git state, never + * prunes, and fails rather than truncating on timeout or output overflow. + */ +const hostListWorktreesCommandSchema = z + .object({ + type: z.literal("host.list_worktrees"), + path: z.string().min(1), + comparisonPaths: z + .array(z.string().min(1)) + .max(WORKTREE_COMPARISON_PATHS_MAX), + }) + .strict(); + const providerListModelsCommandSchema = z.object({ type: z.literal("provider.list_models"), providerId: z.string().min(1), @@ -1663,6 +1682,15 @@ export const hostDaemonCommandRegistry = { flushEventsBeforeResult: false, envLane: null, }), + "host.list_worktrees": defineHostDaemonCommandDescriptor({ + type: "host.list_worktrees", + schema: hostListWorktreesCommandSchema, + resultSchema: hostWorktreeListResultSchema, + transport: "onlineRpc", + retryable: true, + flushEventsBeforeResult: false, + envLane: null, + }), "host.file_metadata": defineHostDaemonCommandDescriptor({ type: "host.file_metadata", schema: hostFileMetadataCommandSchema, diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index 7f5ad1529b..10377fc2c2 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,3 @@ -export const HOST_DAEMON_PROTOCOL_VERSION = 173 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 174 as const; export const HOST_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024; diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts index eb91c3e92c..1934127820 100644 --- a/packages/host-daemon-contract/src/session.ts +++ b/packages/host-daemon-contract/src/session.ts @@ -425,6 +425,7 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion( onlineRpcResponseSuccessSchemaFor("host.file_metadata"), onlineRpcResponseSuccessSchemaFor("host.list_branch_options"), onlineRpcResponseSuccessSchemaFor("host.list_branches"), + onlineRpcResponseSuccessSchemaFor("host.list_worktrees"), onlineRpcResponseSuccessSchemaFor("host.read_file"), onlineRpcResponseSuccessSchemaFor("host.read_file_relative"), onlineRpcResponseSuccessSchemaFor("host.write_file"), diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index d88ccf12af..2d81ad76ea 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1,5 +1,10 @@ import { collectOptionalFieldPaths } from "@bb/test-helpers"; -import { threadScope, turnScope, type JsonObject } from "@bb/domain"; +import { + threadScope, + turnScope, + WORKTREE_COMPARISON_PATHS_MAX, + type JsonObject, +} from "@bb/domain"; import { describe, expect, it } from "vitest"; import * as contract from "../src/index.js"; import { @@ -305,6 +310,35 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = { kind: "local", }, }, + "host.list_worktrees": { + worktrees: [ + { + path: "/home/user/project", + canonicalPath: "/home/user/project", + checkout: { kind: "branch", branchName: "main" }, + lock: null, + prunable: null, + }, + { + path: "/home/user/project-bare/.bare", + canonicalPath: "/home/user/project-bare/.bare", + checkout: { kind: "bare" }, + lock: null, + prunable: null, + }, + { + path: "/home/user/wt detached", + canonicalPath: null, + checkout: { kind: "detached", headSha: "abc1234def" }, + lock: { reason: null }, + prunable: { reason: "gitdir file points to non-existent location" }, + }, + ], + resolvedPaths: [ + { path: "/home/user/env", canonicalPath: "/home/user/env" }, + { path: "/home/user/gone", canonicalPath: null }, + ], + }, "host.file_metadata": { path: "/tmp/report.html", modifiedAtMs: 1234, @@ -943,7 +977,7 @@ const ACP_BRIDGE_LAUNCH = { describe("host-daemon command schemas", () => { it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(173); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(174); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); @@ -2749,6 +2783,56 @@ describe("host-daemon command schemas", () => { ).toThrow(); }); + it("bounds worktree discovery commands and rejects malformed variants", () => { + const valid = { + type: "host.list_worktrees", + path: "/home/user/project", + comparisonPaths: ["/home/user/env"], + }; + expect(hostDaemonOnlineRpcCommandSchema.safeParse(valid).success).toBe( + true, + ); + + expect( + hostDaemonOnlineRpcCommandSchema.safeParse({ + ...valid, + comparisonPaths: Array.from( + { length: WORKTREE_COMPARISON_PATHS_MAX + 1 }, + (_, index) => `/home/user/env-${index}`, + ), + }).success, + ).toBe(false); + + expect( + hostDaemonOnlineRpcCommandSchema.safeParse({ + type: "host.list_worktrees", + path: "/home/user/project", + }).success, + ).toBe(false); + + expect( + hostDaemonOnlineRpcCommandSchema.safeParse({ + ...valid, + extra: true, + }).success, + ).toBe(false); + + expect( + hostDaemonOnlineRpcResultSchemaByType["host.list_worktrees"].safeParse({ + worktrees: [ + { + path: "/home/user/project", + canonicalPath: "/home/user/project", + checkout: { kind: "branch" }, + lock: null, + prunable: null, + }, + ], + resolvedPaths: [], + }).success, + ).toBe(false); + }); + it("keeps typed per-command result schemas", () => { expect( hostDaemonOnlineRpcResultSchemaByType["host.list_files"].parse({ diff --git a/packages/host-workspace/src/index.ts b/packages/host-workspace/src/index.ts index fe7d904984..47e92be15c 100644 --- a/packages/host-workspace/src/index.ts +++ b/packages/host-workspace/src/index.ts @@ -30,3 +30,6 @@ export { runGit, } from "./git.js"; export type { GitProcessOptions } from "./git.js"; + +export { listGitWorktrees, resolveHostPaths } from "./worktree-list.js"; +export type { ListGitWorktreesOptions } from "./worktree-list.js"; diff --git a/packages/host-workspace/src/worktree-list.ts b/packages/host-workspace/src/worktree-list.ts new file mode 100644 index 0000000000..9b01b92d05 --- /dev/null +++ b/packages/host-workspace/src/worktree-list.ts @@ -0,0 +1,252 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + WORKTREE_COMPARISON_PATHS_MAX, + type GitWorktreeEntry, + type ResolvedHostPath, +} from "@bb/domain"; +import { + WorkspaceError, + detectGitRepoKind, + pathExists, + runGit, + type GitProcessOptions, +} from "./git.js"; + +/** + * Discovery must return quickly and completely: a hung git process or an + * output overflow fails the call instead of producing a truncated list that + * would read as "these are all the worktrees". + */ +const WORKTREE_LIST_TIMEOUT_MS = 10_000; +const WORKTREE_LIST_MAX_BUFFER_BYTES = 8 * 1024 * 1024; + +export interface ListGitWorktreesOptions extends GitProcessOptions { + timeoutMs?: number; + maxBufferBytes?: number; +} + +/** A parsed porcelain record before this host resolves its canonical path. */ +export type ParsedGitWorktreeEntry = Omit; + +interface RawWorktreeRecord { + path: string; + headSha: string | null; + branchRef: string | null; + bare: boolean; + detached: boolean; + lock: { reason: string | null } | null; + prunable: { reason: string | null } | null; +} + +function malformedOutputError(detail: string): WorkspaceError { + return new WorkspaceError( + "worktree_list_malformed", + `Unexpected git worktree list output: ${detail}`, + ); +} + +function finalizeRecord(record: RawWorktreeRecord): ParsedGitWorktreeEntry { + if (record.path.length === 0) { + throw malformedOutputError("worktree record has an empty path"); + } + const checkout = ((): ParsedGitWorktreeEntry["checkout"] => { + if (record.bare) { + return { kind: "bare" }; + } + if (record.detached) { + if (record.headSha === null || record.headSha.length === 0) { + throw malformedOutputError( + `detached worktree record has no HEAD: ${record.path}`, + ); + } + return { kind: "detached", headSha: record.headSha }; + } + if (record.branchRef !== null) { + const branchName = record.branchRef.startsWith("refs/heads/") + ? record.branchRef.slice("refs/heads/".length) + : record.branchRef; + if (branchName.length === 0) { + throw malformedOutputError( + `worktree record has an empty branch ref: ${record.path}`, + ); + } + return { kind: "branch", branchName }; + } + throw malformedOutputError( + `worktree record has no branch, detached, or bare marker: ${record.path}`, + ); + })(); + return { + path: record.path, + checkout, + lock: record.lock, + prunable: record.prunable, + }; +} + +/** + * Parses NUL-delimited `git worktree list --porcelain -z` output. Each + * attribute is NUL-terminated and each record ends with one extra NUL, so + * paths and lock/prunable reasons keep spaces and newlines intact. Unknown + * attribute labels are tolerated for forward compatibility; a structurally + * broken or truncated record is an error, never a silently dropped row. + */ +export function parseGitWorktreeListOutput( + output: string, +): ParsedGitWorktreeEntry[] { + const entries: ParsedGitWorktreeEntry[] = []; + let current: RawWorktreeRecord | null = null; + for (const token of output.split("\0")) { + if (token === "") { + if (current !== null) { + entries.push(finalizeRecord(current)); + current = null; + } + continue; + } + const spaceIndex = token.indexOf(" "); + const label = spaceIndex === -1 ? token : token.slice(0, spaceIndex); + const value = spaceIndex === -1 ? null : token.slice(spaceIndex + 1); + if (label === "worktree") { + if (current !== null) { + throw malformedOutputError( + `worktree attribute before the previous record ended: ${token}`, + ); + } + current = { + path: value ?? "", + headSha: null, + branchRef: null, + bare: false, + detached: false, + lock: null, + prunable: null, + }; + continue; + } + if (current === null) { + throw malformedOutputError( + `attribute before any worktree path: ${token}`, + ); + } + switch (label) { + case "HEAD": + current.headSha = value; + break; + case "branch": + current.branchRef = value; + break; + case "bare": + current.bare = true; + break; + case "detached": + current.detached = true; + break; + case "locked": + current.lock = { reason: value }; + break; + case "prunable": + current.prunable = { reason: value }; + break; + default: + break; + } + } + if (current !== null) { + throw malformedOutputError("output ended inside a worktree record"); + } + return entries; +} + +async function realpathOrNull(targetPath: string): Promise { + try { + return await fs.realpath(targetPath); + } catch { + return null; + } +} + +/** + * Lists every worktree registered with the git repository at `sourcePath`, + * including the source checkout itself, with canonical (realpath) identities + * resolved on this host. A registration whose directory is gone stays in the + * result with `canonicalPath: null`; only a failed git invocation, a timeout, + * an output overflow, or malformed output rejects. + */ +export async function listGitWorktrees( + sourcePath: string, + options: ListGitWorktreesOptions = {}, +): Promise { + if (!path.isAbsolute(sourcePath)) { + throw new WorkspaceError( + "invalid_request", + `Worktree discovery requires an absolute path: ${sourcePath}`, + ); + } + const { + timeoutMs = WORKTREE_LIST_TIMEOUT_MS, + maxBufferBytes = WORKTREE_LIST_MAX_BUFFER_BYTES, + ...processOptions + } = options; + if (!(await pathExists(sourcePath))) { + throw new WorkspaceError( + "path_not_found", + `Path does not exist: ${sourcePath}`, + ); + } + const repoKind = await detectGitRepoKind(sourcePath, { + ...processOptions, + timeoutMs, + }); + if (repoKind === "none") { + throw new WorkspaceError( + "not_git_repo", + `Path is not a git repository: ${sourcePath}`, + ); + } + const result = await runGit(["worktree", "list", "--porcelain", "-z"], { + cwd: sourcePath, + ...processOptions, + timeoutMs, + maxBufferBytes, + }); + const parsed = parseGitWorktreeListOutput(result.stdout); + return Promise.all( + parsed.map(async (entry) => ({ + ...entry, + canonicalPath: await realpathOrNull(entry.path), + })), + ); +} + +/** + * Resolves stored environment paths to canonical identities on this host so + * the server can compare them with discovered worktrees. Canonicalization + * must happen here: for a remote host the server cannot realpath these paths + * itself. A missing path is data (`canonicalPath: null`), not an error. + */ +export async function resolveHostPaths( + paths: readonly string[], +): Promise { + if (paths.length > WORKTREE_COMPARISON_PATHS_MAX) { + throw new WorkspaceError( + "invalid_request", + `Too many comparison paths: ${paths.length} exceeds ${WORKTREE_COMPARISON_PATHS_MAX}`, + ); + } + return Promise.all( + paths.map(async (candidate) => { + if (!path.isAbsolute(candidate)) { + throw new WorkspaceError( + "invalid_request", + `Comparison paths must be absolute: ${candidate}`, + ); + } + return { + path: candidate, + canonicalPath: await realpathOrNull(candidate), + }; + }), + ); +} diff --git a/packages/host-workspace/test/worktree-list.test.ts b/packages/host-workspace/test/worktree-list.test.ts new file mode 100644 index 0000000000..2c4f85b996 --- /dev/null +++ b/packages/host-workspace/test/worktree-list.test.ts @@ -0,0 +1,308 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runGit } from "../src/git.js"; +import { + listGitWorktrees, + parseGitWorktreeListOutput, + resolveHostPaths, +} from "../src/worktree-list.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function initRepo() { + const repoPath = await makeTempDir("bb-worktree-list-"); + await runGit(["init", "-b", "main"], { cwd: repoPath }); + await runGit(["config", "user.name", "BB Tests"], { cwd: repoPath }); + await runGit(["config", "user.email", "bb@example.com"], { cwd: repoPath }); + await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8"); + await runGit(["add", "."], { cwd: repoPath }); + await runGit(["commit", "-m", "Initial commit"], { cwd: repoPath }); + return repoPath; +} + +async function addWorktree( + repoPath: string, + name: string, + target: { branch?: string; detach?: boolean }, +) { + const parent = await makeTempDir("bb-worktree-target-"); + const worktreePath = path.join(parent, name); + await runGit( + [ + "worktree", + "add", + ...(target.detach ? ["--detach"] : []), + worktreePath, + ...(target.branch ? [target.branch] : []), + ], + { cwd: repoPath }, + ); + return worktreePath; +} + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +describe("listGitWorktrees", () => { + it("returns the source checkout and a branch worktree with full facts", async () => { + const repoPath = await initRepo(); + await runGit(["branch", "feature/discovery"], { cwd: repoPath }); + const worktreePath = await addWorktree(repoPath, "feature", { + branch: "feature/discovery", + }); + + const entries = await listGitWorktrees(repoPath); + + expect(entries).toHaveLength(2); + const [source, feature] = entries; + expect(source.canonicalPath).toBe(await fs.realpath(repoPath)); + expect(source.checkout).toEqual({ kind: "branch", branchName: "main" }); + expect(source.lock).toBeNull(); + expect(source.prunable).toBeNull(); + expect(feature.canonicalPath).toBe(await fs.realpath(worktreePath)); + expect(feature.checkout).toEqual({ + kind: "branch", + branchName: "feature/discovery", + }); + }); + + it("returns a detached worktree's SHA without inventing a branch", async () => { + const repoPath = await initRepo(); + const worktreePath = await addWorktree(repoPath, "detached", { + detach: true, + }); + const head = await runGit(["rev-parse", "HEAD"], { cwd: worktreePath }); + + const entries = await listGitWorktrees(repoPath); + const canonicalWorktreePath = await fs.realpath(worktreePath); + const detached = entries.find( + (entry) => entry.canonicalPath === canonicalWorktreePath, + ); + + expect(detached?.checkout).toEqual({ + kind: "detached", + headSha: head.stdout.trim(), + }); + }); + + it("parses a bare source safely and keeps its linked worktrees", async () => { + const origin = await initRepo(); + await runGit(["branch", "feature-a"], { cwd: origin }); + const root = await makeTempDir("bb-bare-layout-"); + await runGit(["clone", "--bare", origin, ".bare"], { cwd: root }); + await runGit( + ["worktree", "add", path.join(root, "feature-a"), "feature-a"], + { + cwd: path.join(root, ".bare"), + }, + ); + + const entries = await listGitWorktrees(path.join(root, ".bare")); + + expect(entries).toHaveLength(2); + expect(entries[0].checkout).toEqual({ kind: "bare" }); + expect(entries[1].checkout).toEqual({ + kind: "branch", + branchName: "feature-a", + }); + expect(entries[1].canonicalPath).toBe( + await fs.realpath(path.join(root, "feature-a")), + ); + }); + + it("keeps a locked worktree available and preserves its reason", async () => { + const repoPath = await initRepo(); + await runGit(["branch", "locked-branch"], { cwd: repoPath }); + const worktreePath = await addWorktree(repoPath, "locked", { + branch: "locked-branch", + }); + const reason = "kept on external drive\nwith a second line"; + await runGit(["worktree", "lock", "--reason", reason, worktreePath], { + cwd: repoPath, + }); + + const entries = await listGitWorktrees(repoPath); + const locked = entries.find((entry) => entry.path.endsWith("locked")); + + expect(locked?.lock).toEqual({ reason }); + expect(locked?.canonicalPath).toBe(await fs.realpath(worktreePath)); + }); + + it("keeps a missing registration with a null canonical path and prunable metadata", async () => { + const repoPath = await initRepo(); + await runGit(["branch", "stale-branch"], { cwd: repoPath }); + const worktreePath = await addWorktree(repoPath, "stale", { + branch: "stale-branch", + }); + await fs.rm(worktreePath, { recursive: true, force: true }); + + const entries = await listGitWorktrees(repoPath); + const stale = entries.find((entry) => entry.path.endsWith("stale")); + + expect(stale).toBeDefined(); + expect(stale?.canonicalPath).toBeNull(); + expect(stale?.prunable).not.toBeNull(); + }); + + it("preserves unusual path characters", async () => { + const repoPath = await initRepo(); + await runGit(["branch", "spaced"], { cwd: repoPath }); + const parent = await makeTempDir("bb-worktree-odd-"); + const worktreePath = path.join(parent, "wt with spaces é"); + await runGit(["worktree", "add", worktreePath, "spaced"], { + cwd: repoPath, + }); + + const entries = await listGitWorktrees(repoPath); + const odd = entries.find((entry) => + entry.path.endsWith("wt with spaces é"), + ); + + expect(odd).toBeDefined(); + expect(odd?.canonicalPath).toBe(await fs.realpath(worktreePath)); + }); + + it("rejects a non-absolute source path", async () => { + await expect(listGitWorktrees("relative/path")).rejects.toMatchObject({ + code: "invalid_request", + }); + }); + + it("rejects a missing source path", async () => { + const parent = await makeTempDir("bb-worktree-missing-"); + await expect( + listGitWorktrees(path.join(parent, "does-not-exist")), + ).rejects.toMatchObject({ code: "path_not_found" }); + }); + + it("rejects a non-repository path", async () => { + const plainDir = await makeTempDir("bb-worktree-plain-"); + await expect(listGitWorktrees(plainDir)).rejects.toMatchObject({ + code: "not_git_repo", + }); + }); + + it("fails on a hung git process instead of waiting forever", async () => { + const repoPath = await initRepo(); + const binDir = await makeTempDir("bb-worktree-slow-git-"); + const fakeGit = path.join(binDir, "git"); + await fs.writeFile(fakeGit, "#!/bin/sh\nexec /bin/sleep 5\n", "utf8"); + await fs.chmod(fakeGit, 0o755); + + await expect( + listGitWorktrees(repoPath, { shellPath: binDir, timeoutMs: 200 }), + ).rejects.toMatchObject({ code: "git_command_timeout" }); + }); + + it("fails on output overflow instead of truncating", async () => { + const repoPath = await initRepo(); + await expect( + listGitWorktrees(repoPath, { maxBufferBytes: 8 }), + ).rejects.toMatchObject({ code: "git_command_failed" }); + }); +}); + +describe("parseGitWorktreeListOutput", () => { + it("preserves NUL-delimited unusual characters in paths and reasons", () => { + const output = [ + "worktree /repo/main", + "HEAD 1111111111111111111111111111111111111111", + "branch refs/heads/main", + "", + "worktree /repo/wt with spaces:and colons", + "HEAD 2222222222222222222222222222222222222222", + "detached", + "locked reason with\nnewline", + "prunable gitdir file points to non-existent location", + "", + ].join("\0"); + + const entries = parseGitWorktreeListOutput(output); + + expect(entries).toEqual([ + { + path: "/repo/main", + checkout: { kind: "branch", branchName: "main" }, + lock: null, + prunable: null, + }, + { + path: "/repo/wt with spaces:and colons", + checkout: { + kind: "detached", + headSha: "2222222222222222222222222222222222222222", + }, + lock: { reason: "reason with\nnewline" }, + prunable: { reason: "gitdir file points to non-existent location" }, + }, + ]); + }); + + it("parses a locked marker without a reason", () => { + const output = [ + "worktree /repo/wt", + "HEAD 3333333333333333333333333333333333333333", + "branch refs/heads/main", + "locked", + "", + ].join("\0"); + + expect(parseGitWorktreeListOutput(output)[0].lock).toEqual({ + reason: null, + }); + }); + + it("rejects a truncated record instead of returning a partial list", () => { + const output = ["worktree /repo/main", "branch refs/heads/main"].join("\0"); + expect(() => parseGitWorktreeListOutput(output)).toThrowError( + /ended inside a worktree record/, + ); + }); + + it("rejects a record with no checkout classification", () => { + const output = ["worktree /repo/mystery", ""].join("\0"); + expect(() => parseGitWorktreeListOutput(output)).toThrowError( + /no branch, detached, or bare marker/, + ); + }); +}); + +describe("resolveHostPaths", () => { + it("resolves existing paths and keeps missing paths as null", async () => { + const existing = await makeTempDir("bb-resolve-path-"); + const missing = path.join(existing, "gone"); + + const resolved = await resolveHostPaths([existing, missing]); + + expect(resolved).toEqual([ + { path: existing, canonicalPath: await fs.realpath(existing) }, + { path: missing, canonicalPath: null }, + ]); + }); + + it("rejects relative comparison paths", async () => { + await expect(resolveHostPaths(["relative"])).rejects.toMatchObject({ + code: "invalid_request", + }); + }); + + it("rejects an oversized batch", async () => { + const paths = Array.from({ length: 201 }, (_, i) => `/tmp/p${i}`); + await expect(resolveHostPaths(paths)).rejects.toMatchObject({ + code: "invalid_request", + }); + }); +}); diff --git a/packages/sdk/src/areas/projects.ts b/packages/sdk/src/areas/projects.ts index 99d9729405..44ab11eee4 100644 --- a/packages/sdk/src/areas/projects.ts +++ b/packages/sdk/src/areas/projects.ts @@ -10,6 +10,7 @@ import type { ProjectFilesQuery, ProjectResponse, ProjectWithThreadsResponse, + ProjectWorktreesResponse, ProjectListQuery, ProjectPathsQuery, PromptHistoryResponse, @@ -95,6 +96,11 @@ export interface ProjectDefaultExecutionOptionsArgs { signal?: AbortSignal; } +export interface ProjectWorktreesArgs { + projectId: string; + signal?: AbortSignal; +} + export interface ProjectSidebarBootstrapArgs { signal?: AbortSignal; } @@ -182,6 +188,7 @@ export type ProjectSourceAddResult = ProjectSource; export type ProjectSourceDeleteResult = { ok: true }; export type ProjectSourceUpdateResult = ProjectSource; export type ProjectUpdateResult = ProjectResponse; +export type ProjectWorktreesResult = ProjectWorktreesResponse; export interface ProjectSourcesArea { add(args: ProjectSourceAddArgs): Promise; @@ -220,6 +227,13 @@ export interface ProjectsArea { ): Promise; sources: ProjectSourcesArea; update(args: ProjectUpdateArgs): Promise; + /** + * Discovers git worktrees for every configured project source and merges + * them with reusable environments. Partial discovery is a success: each + * unreachable machine appears in `failures` while healthy machines keep + * their rows. + */ + worktrees(args: ProjectWorktreesArgs): Promise; } function projectUpdateJson(args: ProjectUpdateArgs): UpdateProjectRequest { @@ -573,5 +587,15 @@ export function createProjectsArea(args: CreateSdkAreaArgs): ProjectsArea { }), ); }, + async worktrees(input) { + return transport.readJson( + transport.api.v1.projects[":id"].worktrees.$get( + { + param: { id: input.projectId }, + }, + ...signalRequestArgs(input.signal), + ), + ); + }, }; } diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index ad470b1b97..354de8e9d8 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -321,7 +321,8 @@ type ExpectedProjectsKey = | "reorder" | "sidebarBootstrap" | "sources" - | "update"; + | "update" + | "worktrees"; type ExpectedProjectSourcesKey = "add" | "delete" | "update"; type ExpectedProjectAttachmentsKey = "copy" | "read" | "upload"; diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index f031fcf1b9..9f32a8d046 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -214,6 +214,35 @@ describe("@bb/sdk", () => { ); }); + it("requests project worktrees while forwarding the abort signal", async () => { + const controller = new AbortController(); + let receivedSignal: AbortSignal | null | undefined; + const response = { worktrees: [], failures: [] }; + const queue = createFetchQueue([{ body: response }]); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: async (input, init) => { + receivedSignal = init?.signal; + return queue.fetch(input, init); + }, + runtime: "node", + }), + }); + + await expect( + sdk.projects.worktrees({ + projectId: "proj-1", + signal: controller.signal, + }), + ).resolves.toEqual(response); + expect(receivedSignal).toBe(controller.signal); + expect(queue.requests[0]?.url).toBe( + "http://bb.test/api/v1/projects/proj-1/worktrees", + ); + expect(queue.requests[0]?.method).toBe("GET"); + }); + it("sends a complete appearance selection through the theme transport", async () => { const appearance = { themeId: "nord", diff --git a/packages/server-contract/src/api/projects.ts b/packages/server-contract/src/api/projects.ts index cb2a957df7..2a0ac48adc 100644 --- a/packages/server-contract/src/api/projects.ts +++ b/packages/server-contract/src/api/projects.ts @@ -223,6 +223,66 @@ export type ProjectBranchesResponse = z.infer< typeof projectBranchesResponseSchema >; +/** + * A worktree the project can start a thread in: either an existing reusable + * environment or a user-managed worktree discovered from the project's git + * sources. Bare checkouts never appear; the configured source checkout itself + * is represented by "Work locally", not by a row here. + */ +export const projectWorktreeCheckoutSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("branch"), branchName: z.string().min(1) }), + z.object({ kind: z.literal("detached"), headSha: z.string().min(1) }), +]); +export type ProjectWorktreeCheckout = z.infer< + typeof projectWorktreeCheckoutSchema +>; + +export const projectWorktreeSchema = z.object({ + hostId: z.string().min(1), + /** Git-reported absolute path, for display. */ + path: z.string().min(1), + checkout: projectWorktreeCheckoutSchema, + /** Null when unlocked; a locked worktree may have no recorded reason. */ + lock: z.object({ reason: z.string().nullable() }).nullable(), + /** + * Selection identity lives only on the selectable arm, so a selectable row + * without a canonical path is unrepresentable. + */ + availability: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("selectable"), + canonicalPath: z.string().min(1), + }), + z.object({ + kind: z.literal("unavailable"), + reason: z.enum(["missing", "prunable"]), + }), + ]), + ownership: z.enum(["bb-managed", "user-managed"]), + /** Null when the row is an unmatched discovered worktree. */ + environmentId: z.string().nullable(), + environmentName: z.string().nullable(), +}); +export type ProjectWorktree = z.infer; + +export const projectWorktreeFailureSchema = z.object({ + hostId: z.string().min(1), + code: z.enum(["host_offline", "discovery_failed"]), + message: z.string().min(1), +}); +export type ProjectWorktreeFailure = z.infer< + typeof projectWorktreeFailureSchema +>; + +/** Partial discovery is a successful response: failures are per-host data. */ +export const projectWorktreesResponseSchema = z.object({ + worktrees: z.array(projectWorktreeSchema), + failures: z.array(projectWorktreeFailureSchema), +}); +export type ProjectWorktreesResponse = z.infer< + typeof projectWorktreesResponseSchema +>; + export const projectAttachmentContentQuerySchema = z.object({ path: z.string().min(1), }); diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 27e8eb99a6..a5f39013da 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -125,6 +125,7 @@ import type { SkillFilesResponse, UpdateSkillRequest, ProjectWithThreadsResponse, + ProjectWorktreesResponse, PromptHistoryQuery, PromptHistoryResponse, ReorderPinnedThreadRequest, @@ -486,6 +487,12 @@ export const publicApiRoutes = { ), response: jsonResponse(), }), + worktrees: defineRoute({ + path: "/projects/:id/worktrees", + method: "get", + request: noRequest(), + response: jsonResponse(), + }), uploadAttachment: defineRoute({ path: "/projects/:id/attachments", method: "post", diff --git a/packages/templates/src/templates/bb-guide-projects.md b/packages/templates/src/templates/bb-guide-projects.md index e8b892a2d9..e224ccfeff 100644 --- a/packages/templates/src/templates/bb-guide-projects.md +++ b/packages/templates/src/templates/bb-guide-projects.md @@ -34,6 +34,7 @@ A project maps to a code repository. All threads belong to a project. Discovery: bb project branches --host List branches for a machine source + bb project worktrees List discovered git worktrees bb project paths Search workspace paths bb project files List workspace files bb project content Read file content (binary is base64) From 749c953d7b0e73ffb087615a8fbb068adf1a77f7 Mon Sep 17 00:00:00 2001 From: Luke Abbott Date: Wed, 26 Aug 2026 08:02:25 -0700 Subject: [PATCH 2/2] Make worktree picker menu scrollable --- .../pickers/WorktreePicker.test.tsx | 35 +++++++++++++++++++ .../src/components/pickers/WorktreePicker.tsx | 18 ++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 apps/app/src/components/pickers/WorktreePicker.test.tsx diff --git a/apps/app/src/components/pickers/WorktreePicker.test.tsx b/apps/app/src/components/pickers/WorktreePicker.test.tsx new file mode 100644 index 0000000000..7b1a0faba4 --- /dev/null +++ b/apps/app/src/components/pickers/WorktreePicker.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WorktreePicker } from "./WorktreePicker"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("WorktreePicker", () => { + it("applies viewport-aware vertical overflow constraints to the menu", () => { + render( + , + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Worktree" }), { + button: 0, + }); + + const menu = screen.getByRole("menu"); + expect(menu.className).toContain( + "max-h-[var(--radix-dropdown-menu-content-available-height)]", + ); + expect(menu.className).toContain("overflow-y-auto"); + expect(menu.className).toContain("overscroll-contain"); + }); +}); diff --git a/apps/app/src/components/pickers/WorktreePicker.tsx b/apps/app/src/components/pickers/WorktreePicker.tsx index 5f68052080..db6f5befdf 100644 --- a/apps/app/src/components/pickers/WorktreePicker.tsx +++ b/apps/app/src/components/pickers/WorktreePicker.tsx @@ -151,13 +151,18 @@ export function WorktreePicker({ Existing worktrees {isEmpty ? (
- {loading ? "Discovering worktrees…" : "No existing worktrees found."} + {loading + ? "Discovering worktrees…" + : "No existing worktrees found."}
) : ( <> @@ -172,9 +177,7 @@ export function WorktreePicker({ ))} @@ -303,7 +306,10 @@ function WorktreeFailureRow({ failure, onRetry }: WorktreeFailureRowProps) {
{failure.hostName !== null