diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 07eb90b921..2a86f3dcab 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -393,7 +393,9 @@ The IPC layer is the boundary between backend and frontend. Follow these rules t ```typescript // ✅ GOOD - Frontend combines backend data with context it already has - const result = await window.api.workspace.create(projectPath, branchName); + const { recommendedTrunk } = await window.api.projects.listBranches(projectPath); + const trunkBranch = recommendedTrunk ?? "main"; + const result = await window.api.workspace.create(projectPath, branchName, trunkBranch); if (result.success) { setSelectedWorkspace({ ...result.metadata, @@ -404,7 +406,9 @@ The IPC layer is the boundary between backend and frontend. Follow these rules t } // ❌ BAD - Backend returns frontend-specific data - const result = await window.api.workspace.create(projectPath, branchName); + const { recommendedTrunk } = await window.api.projects.listBranches(projectPath); + const trunkBranch = recommendedTrunk ?? "main"; + const result = await window.api.workspace.create(projectPath, branchName, trunkBranch); if (result.success) { setSelectedWorkspace(result.workspace); // Backend shouldn't know about WorkspaceSelection } diff --git a/src/App.tsx b/src/App.tsx index c1099aebe8..b4cc67a218 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { GitStatusProvider } from "./contexts/GitStatusContext"; import type { ThinkingLevel } from "./types/thinking"; import { CUSTOM_EVENTS } from "./constants/events"; import { getThinkingLevelKey } from "./constants/storage"; +import type { BranchListResult } from "./types/ipc"; const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; @@ -206,10 +207,15 @@ function AppInner() { setWorkspaceModalOpen(true); }, []); - const handleCreateWorkspace = async (branchName: string) => { + const handleCreateWorkspace = async (branchName: string, trunkBranch: string) => { if (!workspaceModalProject) return; - const newWorkspace = await createWorkspace(workspaceModalProject, branchName); + console.assert( + typeof trunkBranch === "string" && trunkBranch.trim().length > 0, + "Expected trunk branch to be provided by the workspace modal" + ); + + const newWorkspace = await createWorkspace(workspaceModalProject, branchName, trunkBranch); if (newWorkspace) { setSelectedWorkspace(newWorkspace); } @@ -329,13 +335,38 @@ function AppInner() { ); const createWorkspaceFromPalette = useCallback( - async (projectPath: string, branchName: string) => { - const newWs = await createWorkspace(projectPath, branchName); + async (projectPath: string, branchName: string, trunkBranch: string) => { + console.assert( + typeof trunkBranch === "string" && trunkBranch.trim().length > 0, + "Expected trunk branch to be provided by the command palette" + ); + const newWs = await createWorkspace(projectPath, branchName, trunkBranch); if (newWs) setSelectedWorkspace(newWs); }, [createWorkspace, setSelectedWorkspace] ); + const getBranchesForProject = useCallback( + async (projectPath: string): Promise => { + const branchResult = await window.api.projects.listBranches(projectPath); + const sanitizedBranches = Array.isArray(branchResult?.branches) + ? branchResult.branches.filter((branch): branch is string => typeof branch === "string") + : []; + + const recommended = + typeof branchResult?.recommendedTrunk === "string" && + sanitizedBranches.includes(branchResult.recommendedTrunk) + ? branchResult.recommendedTrunk + : (sanitizedBranches[0] ?? ""); + + return { + branches: sanitizedBranches, + recommendedTrunk: recommended, + }; + }, + [] + ); + const selectWorkspaceFromPalette = useCallback( (selection: { projectPath: string; @@ -389,6 +420,7 @@ function AppInner() { onSetThinkingLevel: setThinkingLevelFromPalette, onOpenNewWorkspaceModal: openNewWorkspaceFromPalette, onCreateWorkspace: createWorkspaceFromPalette, + getBranchesForProject, onSelectWorkspace: selectWorkspaceFromPalette, onRemoveWorkspace: removeWorkspaceFromPalette, onRenameWorkspace: renameWorkspaceFromPalette, diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index f126378398..7e29da502a 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -306,9 +306,65 @@ export const CommandPalette: React.FC = ({ getSlashContext } }, [activePrompt]); + const [selectOptions, setSelectOptions] = useState< + Array<{ id: string; label: string; keywords?: string[] }> + >([]); + const [isLoadingOptions, setIsLoadingOptions] = useState(false); + const currentField: PromptField | null = activePrompt ? (activePrompt.fields[activePrompt.idx] ?? null) : null; + + useEffect(() => { + // Select prompts can return options synchronously or as a promise. This effect normalizes + // both flows, keeps the loading state in sync, and bails out early if the prompt switches + // while a request is in flight. + let cancelled = false; + + const resetState = () => { + if (cancelled) return; + setSelectOptions([]); + setIsLoadingOptions(false); + }; + + const hydrateSelectOptions = async () => { + if (!currentField || currentField.type !== "select") { + resetState(); + return; + } + + setIsLoadingOptions(true); + try { + const rawOptions = await Promise.resolve( + currentField.getOptions(activePrompt?.values ?? {}) + ); + + if (!Array.isArray(rawOptions)) { + throw new Error("Prompt select options must resolve to an array"); + } + + if (!cancelled) { + setSelectOptions(rawOptions); + } + } catch (error) { + if (!cancelled) { + console.error("Failed to resolve prompt select options", error); + setSelectOptions([]); + } + } finally { + if (!cancelled) { + setIsLoadingOptions(false); + } + } + }; + + void hydrateSelectOptions(); + + return () => { + cancelled = true; + }; + }, [currentField, activePrompt]); + const isSlashQuery = !currentField && query.trim().startsWith("/"); const shouldUseCmdkFilter = currentField ? currentField.type === "select" : !isSlashQuery; @@ -318,7 +374,7 @@ export const CommandPalette: React.FC = ({ getSlashContext if (currentField) { const promptTitle = activePrompt?.title ?? currentField.label ?? "Provide details"; if (currentField.type === "select") { - const options = currentField.getOptions(activePrompt?.values ?? {}); + const options = selectOptions; groups = [ { name: promptTitle, @@ -331,7 +387,11 @@ export const CommandPalette: React.FC = ({ getSlashContext })), }, ]; - emptyText = options.length ? undefined : "No options"; + emptyText = isLoadingOptions + ? "Loading options..." + : options.length + ? undefined + : "No options"; } else { const typed = query.trim(); const fallbackHint = currentField.placeholder ?? "Type value and press Enter"; diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx index 6c659b6ebc..88cdaf84e9 100644 --- a/src/components/NewWorkspaceModal.tsx +++ b/src/components/NewWorkspaceModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useId } from "react"; +import React, { useEffect, useId, useState } from "react"; import styled from "@emotion/styled"; import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal"; @@ -12,7 +12,8 @@ const FormGroup = styled.div` font-size: 14px; } - input { + input, + select { width: 100%; padding: 8px 12px; background: #2d2d2d; @@ -31,6 +32,15 @@ const FormGroup = styled.div` cursor: not-allowed; } } + + select { + cursor: pointer; + + option { + background: #2d2d2d; + color: #fff; + } + } `; const ErrorMessage = styled.div` @@ -48,7 +58,7 @@ interface NewWorkspaceModalProps { isOpen: boolean; projectPath: string; onClose: () => void; - onAdd: (branchName: string) => Promise; + onAdd: (branchName: string, trunkBranch: string) => Promise; } const NewWorkspaceModal: React.FC = ({ @@ -58,13 +68,20 @@ const NewWorkspaceModal: React.FC = ({ onAdd, }) => { const [branchName, setBranchName] = useState(""); + const [trunkBranch, setTrunkBranch] = useState(""); + const [defaultTrunkBranch, setDefaultTrunkBranch] = useState(""); + const [branches, setBranches] = useState([]); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [branchesError, setBranchesError] = useState(null); const infoId = useId(); const handleCancel = () => { setBranchName(""); + setTrunkBranch(defaultTrunkBranch); setError(null); + setBranchesError(null); onClose(); }; @@ -76,12 +93,22 @@ const NewWorkspaceModal: React.FC = ({ return; } + const normalizedTrunkBranch = trunkBranch.trim(); + if (normalizedTrunkBranch.length === 0) { + setError("Trunk branch is required"); + return; + } + + console.assert(normalizedTrunkBranch.length > 0, "Expected trunk branch name to be validated"); + console.assert(trimmedBranchName.length > 0, "Expected branch name to be validated"); + setIsLoading(true); setError(null); try { - await onAdd(trimmedBranchName); + await onAdd(trimmedBranchName, normalizedTrunkBranch); setBranchName(""); + setTrunkBranch(defaultTrunkBranch); onClose(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create workspace"; @@ -91,6 +118,60 @@ const NewWorkspaceModal: React.FC = ({ } }; + // Load branches when modal opens + useEffect(() => { + if (!isOpen) { + return; + } + + const loadBranches = async () => { + setIsLoadingBranches(true); + setBranchesError(null); + try { + const branchList = await window.api.projects.listBranches(projectPath); + const rawBranches = Array.isArray(branchList?.branches) ? branchList.branches : []; + const sanitizedBranches = rawBranches.filter( + (branch): branch is string => typeof branch === "string" + ); + + if (!Array.isArray(branchList?.branches)) { + console.warn("Expected listBranches to return BranchListResult", branchList); + } + + setBranches(sanitizedBranches); + + if (sanitizedBranches.length === 0) { + setTrunkBranch(""); + setDefaultTrunkBranch(""); + setBranchesError("No branches available in this project"); + return; + } + + const recommended = + typeof branchList?.recommendedTrunk === "string" && + sanitizedBranches.includes(branchList.recommendedTrunk) + ? branchList.recommendedTrunk + : sanitizedBranches[0]; + + setBranchesError(null); + setDefaultTrunkBranch(recommended); + setTrunkBranch((current) => + current && sanitizedBranches.includes(current) ? current : recommended + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load branches"; + setBranches([]); + setTrunkBranch(""); + setDefaultTrunkBranch(""); + setBranchesError(message); + } finally { + setIsLoadingBranches(false); + } + }; + + void loadBranches(); + }, [isOpen, projectPath]); + const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project"; return ( @@ -104,7 +185,7 @@ const NewWorkspaceModal: React.FC = ({ >
void handleSubmit(event)}> - + = ({ {error && {error}} + + + + {branchesError && {branchesError}} + +

This will create a git worktree at:

@@ -133,7 +239,16 @@ const NewWorkspaceModal: React.FC = ({ Cancel - + {isLoading ? "Creating..." : "Create Workspace"} diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index dca286d11a..30f62ca2d4 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -15,6 +15,7 @@ export const IPC_CHANNELS = { PROJECT_CREATE: "project:create", PROJECT_REMOVE: "project:remove", PROJECT_LIST: "project:list", + PROJECT_LIST_BRANCHES: "project:listBranches", PROJECT_SECRETS_GET: "project:secrets:get", PROJECT_SECRETS_UPDATE: "project:secrets:update", diff --git a/src/contexts/CommandRegistryContext.tsx b/src/contexts/CommandRegistryContext.tsx index a2c2a8844a..0bf31227e6 100644 --- a/src/contexts/CommandRegistryContext.tsx +++ b/src/contexts/CommandRegistryContext.tsx @@ -28,11 +28,19 @@ export interface CommandAction { name: string; label?: string; placeholder?: string; - getOptions: (values: Record) => Array<{ - id: string; - label: string; - keywords?: string[]; - }>; + getOptions: (values: Record) => + | Array<{ + id: string; + label: string; + keywords?: string[]; + }> + | Promise< + Array<{ + id: string; + label: string; + keywords?: string[]; + }> + >; } >; onSubmit: (values: Record) => void | Promise; diff --git a/src/git.test.ts b/src/git.test.ts index c41db13a44..522cddbd72 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeAll, afterAll } from "@jest/globals"; -import { createWorktree } from "./git"; +import { createWorktree, listLocalBranches, detectDefaultTrunkBranch } from "./git"; import { Config } from "./config"; import * as path from "path"; import * as os from "os"; @@ -12,6 +12,7 @@ const execAsync = promisify(exec); describe("createWorktree", () => { let tempGitRepo: string; let config: Config; + let defaultTrunk: string; beforeAll(async () => { // Create a temporary git repository for testing @@ -29,6 +30,8 @@ describe("createWorktree", () => { // Create a config instance for testing const testConfigPath = path.join(tempGitRepo, "test-config.json"); config = new Config(testConfigPath); + + defaultTrunk = await detectDefaultTrunkBranch(tempGitRepo); }); afterAll(async () => { @@ -49,7 +52,9 @@ describe("createWorktree", () => { // The fixed code correctly detects "docs" doesn't exist and tries: git worktree add -b "docs" // However, Git itself won't allow creating "docs" when "docs/bash-timeout-ux" exists // due to ref namespace conflicts, so this will fail with a different, more informative error. - const result = await createWorktree(config, tempGitRepo, "docs"); + const result = await createWorktree(config, tempGitRepo, "docs", { + trunkBranch: defaultTrunk, + }); // Should fail, but with a ref lock error (not "invalid reference") expect(result.success).toBe(false); @@ -64,7 +69,9 @@ describe("createWorktree", () => { // Create a branch first await execAsync(`git branch existing-branch`, { cwd: tempGitRepo }); - const result = await createWorktree(config, tempGitRepo, "existing-branch"); + const result = await createWorktree(config, tempGitRepo, "existing-branch", { + trunkBranch: defaultTrunk, + }); // Should succeed by using the existing branch expect(result.success).toBe(true); @@ -74,4 +81,23 @@ describe("createWorktree", () => { const { stdout } = await execAsync(`git worktree list`, { cwd: tempGitRepo }); expect(stdout).toContain("existing-branch"); }); + + test("listLocalBranches should return sorted branch names", async () => { + const uniqueSuffix = Date.now().toString(36); + const newBranches = [`zz-${uniqueSuffix}`, `aa-${uniqueSuffix}`, `mid/${uniqueSuffix}`]; + + for (const branch of newBranches) { + await execAsync(`git branch ${branch}`, { cwd: tempGitRepo }); + } + + const branches = await listLocalBranches(tempGitRepo); + + for (const branch of newBranches) { + expect(branches).toContain(branch); + } + + for (let i = 1; i < branches.length; i += 1) { + expect(branches[i - 1].localeCompare(branches[i])).toBeLessThanOrEqual(0); + } + }); }); diff --git a/src/git.ts b/src/git.ts index 4b634b48f9..a1b3e48075 100644 --- a/src/git.ts +++ b/src/git.ts @@ -12,13 +12,84 @@ export interface WorktreeResult { error?: string; } +export interface CreateWorktreeOptions { + trunkBranch: string; +} + +export async function listLocalBranches(projectPath: string): Promise { + const { stdout } = await execAsync( + `git -C "${projectPath}" for-each-ref --format="%(refname:short)" refs/heads` + ); + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .sort((a, b) => a.localeCompare(b)); +} + +async function getCurrentBranch(projectPath: string): Promise { + try { + const { stdout } = await execAsync(`git -C "${projectPath}" rev-parse --abbrev-ref HEAD`); + const branch = stdout.trim(); + if (!branch || branch === "HEAD") { + return null; + } + return branch; + } catch { + return null; + } +} + +const FALLBACK_TRUNK_CANDIDATES = ["main", "master", "trunk", "develop", "default"]; + +export async function detectDefaultTrunkBranch( + projectPath: string, + branches?: string[] +): Promise { + const branchList = branches ?? (await listLocalBranches(projectPath)); + + if (branchList.length === 0) { + throw new Error(`No branches available in repository ${projectPath}`); + } + + const branchSet = new Set(branchList); + const currentBranch = await getCurrentBranch(projectPath); + + if (currentBranch && branchSet.has(currentBranch)) { + return currentBranch; + } + + for (const candidate of FALLBACK_TRUNK_CANDIDATES) { + if (branchSet.has(candidate)) { + return candidate; + } + } + + return branchList[0]; +} + export async function createWorktree( config: Config, projectPath: string, - branchName: string + branchName: string, + options: CreateWorktreeOptions ): Promise { try { const workspacePath = config.getWorkspacePath(projectPath, branchName); + const { trunkBranch } = options; + const normalizedTrunkBranch = typeof trunkBranch === "string" ? trunkBranch.trim() : ""; + + if (!normalizedTrunkBranch) { + return { + success: false, + error: "Trunk branch is required to create a workspace", + }; + } + + console.assert( + normalizedTrunkBranch.length > 0, + "Expected trunk branch to be validated before calling createWorktree" + ); // Create workspace directory if it doesn't exist if (!fs.existsSync(path.dirname(workspacePath))) { @@ -33,25 +104,37 @@ export async function createWorktree( }; } - // Check if branch exists - const { stdout: branches } = await execAsync(`git -C "${projectPath}" branch -a`); - const branchExists = branches + const localBranches = await listLocalBranches(projectPath); + + // If branch already exists locally, reuse it instead of creating a new one + if (localBranches.includes(branchName)) { + await execAsync(`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`); + return { success: true, path: workspacePath }; + } + + // Check if branch exists remotely (origin/) + const { stdout: remoteBranchesRaw } = await execAsync(`git -C "${projectPath}" branch -a`); + const branchExists = remoteBranchesRaw .split("\n") - .some( - (b) => - b.trim() === branchName || - b.trim() === `* ${branchName}` || - b.trim() === `remotes/origin/${branchName}` - ); + .map((b) => b.trim().replace(/^(\*)\s+/, "")) + .some((b) => b === branchName || b === `remotes/origin/${branchName}`); if (branchExists) { - // Branch exists, create worktree with existing branch await execAsync(`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`); - } else { - // Branch doesn't exist, create new branch with worktree - await execAsync(`git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}"`); + return { success: true, path: workspacePath }; + } + + if (!localBranches.includes(normalizedTrunkBranch)) { + return { + success: false, + error: `Trunk branch "${normalizedTrunkBranch}" does not exist locally`, + }; } + await execAsync( + `git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${normalizedTrunkBranch}"` + ); + return { success: true, path: workspacePath }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/hooks/useWorkspaceManagement.ts b/src/hooks/useWorkspaceManagement.ts index bf4ae18db3..caec196dac 100644 --- a/src/hooks/useWorkspaceManagement.ts +++ b/src/hooks/useWorkspaceManagement.ts @@ -38,8 +38,12 @@ export function useWorkspaceManagement({ } }; - const createWorkspace = async (projectPath: string, branchName: string) => { - const result = await window.api.workspace.create(projectPath, branchName); + const createWorkspace = async (projectPath: string, branchName: string, trunkBranch: string) => { + console.assert( + typeof trunkBranch === "string" && trunkBranch.trim().length > 0, + "Expected trunk branch to be provided when creating a workspace" + ); + const result = await window.api.workspace.create(projectPath, branchName, trunkBranch); if (result.success) { // Backend has already updated the config - reload projects to get updated state const projectsList = await window.api.projects.list(); diff --git a/src/preload.ts b/src/preload.ts index 14a73cf9ae..7f3e8f73fc 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -37,6 +37,8 @@ const api: IPCApi = { create: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, projectPath), remove: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_REMOVE, projectPath), list: () => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST), + listBranches: (projectPath: string) => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath), secrets: { get: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_SECRETS_GET, projectPath), update: (projectPath, secrets) => @@ -45,8 +47,8 @@ const api: IPCApi = { }, workspace: { list: () => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST), - create: (projectPath, branchName) => - ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName), + create: (projectPath, branchName, trunkBranch: string) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch), remove: (workspaceId: string) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId), rename: (workspaceId: string, newName: string) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, newName), diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index e023bd20f3..271fbca6ff 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -9,6 +9,8 @@ import { moveWorktree, pruneWorktrees, getMainWorktreeFromWorktree, + listLocalBranches, + detectDefaultTrunkBranch, } from "@/git"; import { AIService } from "@/services/aiService"; import { HistoryService } from "@/services/historyService"; @@ -179,15 +181,23 @@ export class IpcMain { private registerWorkspaceHandlers(ipcMain: ElectronIpcMain): void { ipcMain.handle( IPC_CHANNELS.WORKSPACE_CREATE, - async (_event, projectPath: string, branchName: string) => { + async (_event, projectPath: string, branchName: string, trunkBranch: string) => { // Validate workspace name const validation = validateWorkspaceName(branchName); if (!validation.valid) { return { success: false, error: validation.error }; } + if (typeof trunkBranch !== "string" || trunkBranch.trim().length === 0) { + return { success: false, error: "Trunk branch is required" }; + } + + const normalizedTrunkBranch = trunkBranch.trim(); + // First create the git worktree - const result = await createWorktree(this.config, projectPath, branchName); + const result = await createWorktree(this.config, projectPath, branchName, { + trunkBranch: normalizedTrunkBranch, + }); if (result.success && result.path) { const projectName = @@ -982,6 +992,21 @@ export class IpcMain { } }); + ipcMain.handle(IPC_CHANNELS.PROJECT_LIST_BRANCHES, async (_event, projectPath: string) => { + if (typeof projectPath !== "string" || projectPath.trim().length === 0) { + throw new Error("Project path is required to list branches"); + } + + try { + const branches = await listLocalBranches(projectPath); + const recommendedTrunk = await detectDefaultTrunkBranch(projectPath, branches); + return { branches, recommendedTrunk }; + } catch (error) { + log.error("Failed to list branches:", error); + throw error instanceof Error ? error : new Error(String(error)); + } + }); + ipcMain.handle(IPC_CHANNELS.PROJECT_SECRETS_GET, (_event, projectPath: string) => { try { return this.config.getProjectSecrets(projectPath); diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 6935536e15..50c7e1052a 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -29,6 +29,11 @@ export { IPC_CHANNELS, getChatChannel }; // Type for all channel names export type IPCChannel = string; +export interface BranchListResult { + branches: string[]; + recommendedTrunk: string; +} + // Caught up message type export interface CaughtUpMessage { type: "caught-up"; @@ -158,6 +163,7 @@ export interface IPCApi { create(projectPath: string): Promise>; remove(projectPath: string): Promise>; list(): Promise; + listBranches(projectPath: string): Promise; secrets: { get(projectPath: string): Promise; update(projectPath: string, secrets: Secret[]): Promise>; @@ -167,7 +173,8 @@ export interface IPCApi { list(): Promise; create( projectPath: string, - branchName: string + branchName: string, + trunkBranch: string ): Promise<{ success: true; metadata: WorkspaceMetadata } | { success: false; error: string }>; remove(workspaceId: string): Promise<{ success: boolean; error?: string }>; rename( diff --git a/src/utils/commands/sources.test.ts b/src/utils/commands/sources.test.ts index 0df9ef296b..3f7f1eaa1b 100644 --- a/src/utils/commands/sources.test.ts +++ b/src/utils/commands/sources.test.ts @@ -23,7 +23,7 @@ const mk = (over: Partial[0]> = {}) => { streamingModels: new Map(), getThinkingLevel: () => "off", onSetThinkingLevel: () => undefined, - onCreateWorkspace: async () => { + onCreateWorkspace: async (_projectPath, _branchName, _trunkBranch) => { await Promise.resolve(); }, onOpenNewWorkspaceModal: () => undefined, @@ -35,6 +35,11 @@ const mk = (over: Partial[0]> = {}) => { onToggleSidebar: () => undefined, onNavigateWorkspace: () => undefined, onOpenWorkspaceInTerminal: () => undefined, + getBranchesForProject: () => + Promise.resolve({ + branches: ["main"], + recommendedTrunk: "main", + }), ...over, }; return buildCoreSources(params); diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 209993d7fe..f2a38a992c 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -5,6 +5,7 @@ import { CUSTOM_EVENTS } from "@/constants/events"; import type { ProjectConfig } from "@/config"; import type { WorkspaceMetadata } from "@/types/workspace"; +import type { BranchListResult } from "@/types/ipc"; export interface BuildSourcesParams { projects: Map; @@ -21,7 +22,12 @@ export interface BuildSourcesParams { onSetThinkingLevel: (workspaceId: string, level: ThinkingLevel) => void; onOpenNewWorkspaceModal: (projectPath: string) => void; - onCreateWorkspace: (projectPath: string, branchName: string) => Promise; + onCreateWorkspace: ( + projectPath: string, + branchName: string, + trunkBranch: string + ) => Promise; + getBranchesForProject: (projectPath: string) => Promise; onSelectWorkspace: (sel: { projectPath: string; projectName: string; @@ -54,35 +60,75 @@ const section = { export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandAction[]> { const actions: Array<() => CommandAction[]> = []; + const createWorkspaceForSelectedProjectAction = ( + selected: NonNullable + ): CommandAction => { + let cachedBranchInfo: BranchListResult | null = null; + const getBranchInfo = async () => { + cachedBranchInfo ??= await p.getBranchesForProject(selected.projectPath); + return cachedBranchInfo; + }; + + return { + id: "ws:new", + title: "Create New Workspace…", + subtitle: `for ${selected.projectName}`, + section: section.workspaces, + shortcutHint: formatKeybind(KEYBINDS.NEW_WORKSPACE), + run: () => undefined, + prompt: { + title: "New Workspace", + fields: [ + { + type: "text", + name: "branchName", + label: "Workspace branch name", + placeholder: "Enter branch name", + validate: (v) => (!v.trim() ? "Branch name is required" : null), + }, + { + type: "select", + name: "trunkBranch", + label: "Trunk branch", + placeholder: "Search branches…", + getOptions: async () => { + const info = await getBranchInfo(); + return info.branches.map((branch) => ({ + id: branch, + label: branch, + keywords: [branch], + })); + }, + }, + ], + onSubmit: async (vals) => { + const trimmedBranchName = vals.branchName.trim(); + const info = await getBranchInfo(); + const providedTrunk = vals.trunkBranch?.trim(); + const resolvedTrunk = + providedTrunk && info.branches.includes(providedTrunk) + ? providedTrunk + : info.branches.includes(info.recommendedTrunk) + ? info.recommendedTrunk + : info.branches[0]; + + if (!resolvedTrunk) { + throw new Error("Unable to determine trunk branch for workspace creation"); + } + + await p.onCreateWorkspace(selected.projectPath, trimmedBranchName, resolvedTrunk); + }, + }, + }; + }; + // Workspaces actions.push(() => { const list: CommandAction[] = []; const selected = p.selectedWorkspace; if (selected) { - list.push({ - id: "ws:new", - title: "Create New Workspace…", - subtitle: `for ${selected.projectName}`, - section: section.workspaces, - shortcutHint: formatKeybind(KEYBINDS.NEW_WORKSPACE), - run: () => undefined, - prompt: { - title: "New Workspace", - fields: [ - { - type: "text", - name: "branchName", - label: "Branch name", - placeholder: "Enter branch name", - validate: (v) => (!v.trim() ? "Branch name is required" : null), - }, - ], - onSubmit: async (vals) => { - await p.onCreateWorkspace(selected.projectPath, vals.branchName.trim()); - }, - }, - }); + list.push(createWorkspaceForSelectedProjectAction(selected)); } // Switch to workspace @@ -442,6 +488,15 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // Projects actions.push(() => { + const branchCache = new Map(); + const getBranchInfoForProject = async (projectPath: string) => { + const cached = branchCache.get(projectPath); + if (cached) return cached; + const info = await p.getBranchesForProject(projectPath); + branchCache.set(projectPath, info); + return info; + }; + const list: CommandAction[] = [ { id: "project:add", @@ -472,13 +527,43 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi { type: "text", name: "branchName", - label: "Branch name", + label: "Workspace branch name", placeholder: "Enter branch name", validate: (v) => (!v.trim() ? "Branch name is required" : null), }, + { + type: "select", + name: "trunkBranch", + label: "Trunk branch", + placeholder: "Search branches…", + getOptions: async (values) => { + if (!values.projectPath) return []; + const info = await getBranchInfoForProject(values.projectPath); + return info.branches.map((branch) => ({ + id: branch, + label: branch, + keywords: [branch], + })); + }, + }, ], onSubmit: async (vals) => { - await p.onCreateWorkspace(vals.projectPath, vals.branchName.trim()); + const projectPath = vals.projectPath; + const trimmedBranchName = vals.branchName.trim(); + const info = await getBranchInfoForProject(projectPath); + const providedTrunk = vals.trunkBranch?.trim(); + const resolvedTrunk = + providedTrunk && info.branches.includes(providedTrunk) + ? providedTrunk + : info.branches.includes(info.recommendedTrunk) + ? info.recommendedTrunk + : info.branches[0]; + + if (!resolvedTrunk) { + throw new Error("Unable to determine trunk branch for workspace creation"); + } + + await p.onCreateWorkspace(projectPath, trimmedBranchName, resolvedTrunk); }, }, }, diff --git a/tests/ipcMain/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts index 04ab02377d..d2797c081a 100644 --- a/tests/ipcMain/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -1,6 +1,7 @@ import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; import { createTempGitRepo, cleanupTempGitRepo } from "./helpers"; +import { detectDefaultTrunkBranch } from "../../src/git"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -25,11 +26,14 @@ describeIntegration("IpcMain create workspace integration tests", () => { { name: "a".repeat(65), expectedError: "64 characters" }, ]; + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + for (const { name, expectedError } of invalidNames) { const createResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_CREATE, tempGitRepo, - name + name, + trunkBranch ); expect(createResult.success).toBe(false); expect(createResult.error.toLowerCase()).toContain(expectedError.toLowerCase()); @@ -59,11 +63,14 @@ describeIntegration("IpcMain create workspace integration tests", () => { "b".repeat(64), // Max length ]; + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + for (const name of validNames) { const createResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_CREATE, tempGitRepo, - name + name, + trunkBranch ); if (!createResult.success) { console.error(`Failed to create workspace "${name}":`, createResult.error); diff --git a/tests/ipcMain/executeBash.test.ts b/tests/ipcMain/executeBash.test.ts index 1c7c239264..d4a28e06aa 100644 --- a/tests/ipcMain/executeBash.test.ts +++ b/tests/ipcMain/executeBash.test.ts @@ -1,7 +1,18 @@ import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; -import { createTempGitRepo, cleanupTempGitRepo } from "./helpers"; +import { createTempGitRepo, cleanupTempGitRepo, createWorkspace } from "./helpers"; import { BASH_HARD_MAX_LINES } from "../../src/constants/toolLimits"; +import type { WorkspaceMetadata } from "../../src/types/workspace"; + +type WorkspaceCreationResult = Awaited>; + +function expectWorkspaceCreationSuccess(result: WorkspaceCreationResult): WorkspaceMetadata { + expect(result.success).toBe(true); + if (!result.success) { + throw new Error(`Expected workspace creation to succeed, but it failed: ${result.error}`); + } + return result.metadata; +} // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -15,13 +26,8 @@ describeIntegration("IpcMain executeBash integration tests", () => { try { // Create a workspace - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, - tempGitRepo, - "test-bash" - ); - expect(createResult.success).toBe(true); - const workspaceId = createResult.metadata.id; + const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, "test-bash"); + const workspaceId = expectWorkspaceCreationSuccess(createResult).id; // Execute a simple bash command (pwd should return workspace path) const pwdResult = await env.mockIpcRenderer.invoke( @@ -53,13 +59,12 @@ describeIntegration("IpcMain executeBash integration tests", () => { try { // Create a workspace - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, + const createResult = await createWorkspace( + env.mockIpcRenderer, tempGitRepo, "test-git-status" ); - expect(createResult.success).toBe(true); - const workspaceId = createResult.metadata.id; + const workspaceId = expectWorkspaceCreationSuccess(createResult).id; // Execute git status const gitStatusResult = await env.mockIpcRenderer.invoke( @@ -91,13 +96,12 @@ describeIntegration("IpcMain executeBash integration tests", () => { try { // Create a workspace - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, + const createResult = await createWorkspace( + env.mockIpcRenderer, tempGitRepo, "test-failure" ); - expect(createResult.success).toBe(true); - const workspaceId = createResult.metadata.id; + const workspaceId = expectWorkspaceCreationSuccess(createResult).id; // Execute a command that will fail const failResult = await env.mockIpcRenderer.invoke( @@ -129,13 +133,12 @@ describeIntegration("IpcMain executeBash integration tests", () => { try { // Create a workspace - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, + const createResult = await createWorkspace( + env.mockIpcRenderer, tempGitRepo, "test-timeout" ); - expect(createResult.success).toBe(true); - const workspaceId = createResult.metadata.id; + const workspaceId = expectWorkspaceCreationSuccess(createResult).id; // Execute a command that takes longer than the timeout const timeoutResult = await env.mockIpcRenderer.invoke( @@ -167,13 +170,12 @@ describeIntegration("IpcMain executeBash integration tests", () => { try { // Create a workspace - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, + const createResult = await createWorkspace( + env.mockIpcRenderer, tempGitRepo, "test-maxlines" ); - expect(createResult.success).toBe(true); - const workspaceId = createResult.metadata.id; + const workspaceId = expectWorkspaceCreationSuccess(createResult).id; // Execute a command that produces many lines const maxLinesResult = await env.mockIpcRenderer.invoke( @@ -205,13 +207,12 @@ describeIntegration("IpcMain executeBash integration tests", () => { const tempGitRepo = await createTempGitRepo(); try { - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, + const createResult = await createWorkspace( + env.mockIpcRenderer, tempGitRepo, "test-maxlines-hardcap" ); - expect(createResult.success).toBe(true); - const workspaceId = createResult.metadata.id; + const workspaceId = expectWorkspaceCreationSuccess(createResult).id; const oversizedResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, @@ -264,13 +265,12 @@ describeIntegration("IpcMain executeBash integration tests", () => { try { // Create a workspace - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, + const createResult = await createWorkspace( + env.mockIpcRenderer, tempGitRepo, "test-secrets" ); - expect(createResult.success).toBe(true); - const workspaceId = createResult.metadata.id; + const workspaceId = expectWorkspaceCreationSuccess(createResult).id; // Set secrets for the project await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_SECRETS_UPDATE, tempGitRepo, [ @@ -309,13 +309,12 @@ describeIntegration("IpcMain executeBash integration tests", () => { try { // Create a workspace - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, + const createResult = await createWorkspace( + env.mockIpcRenderer, tempGitRepo, "test-git-env" ); - expect(createResult.success).toBe(true); - const workspaceId = createResult.metadata.id; + const workspaceId = expectWorkspaceCreationSuccess(createResult).id; // Verify GIT_TERMINAL_PROMPT is set to 0 const gitEnvResult = await env.mockIpcRenderer.invoke( diff --git a/tests/ipcMain/helpers.ts b/tests/ipcMain/helpers.ts index 6027aa4ad1..bba26f372d 100644 --- a/tests/ipcMain/helpers.ts +++ b/tests/ipcMain/helpers.ts @@ -6,6 +6,7 @@ import type { SendMessageError } from "../../src/types/errors"; import type { WorkspaceMetadata } from "../../src/types/workspace"; import * as path from "path"; import * as os from "os"; +import { detectDefaultTrunkBranch } from "../../src/git"; /** * Generate a unique branch name @@ -64,11 +65,20 @@ export async function sendMessageWithModel( export async function createWorkspace( mockIpcRenderer: IpcRenderer, projectPath: string, - branchName: string + branchName: string, + trunkBranch?: string ): Promise<{ success: true; metadata: WorkspaceMetadata } | { success: false; error: string }> { - return (await mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName)) as - | { success: true; metadata: WorkspaceMetadata } - | { success: false; error: string }; + const resolvedTrunk = + typeof trunkBranch === "string" && trunkBranch.trim().length > 0 + ? trunkBranch.trim() + : await detectDefaultTrunkBranch(projectPath); + + return (await mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + projectPath, + branchName, + resolvedTrunk + )) as { success: true; metadata: WorkspaceMetadata } | { success: false; error: string }; } /** diff --git a/tests/ipcMain/renameWorkspace.test.ts b/tests/ipcMain/renameWorkspace.test.ts index e0bff95299..4b978f009c 100644 --- a/tests/ipcMain/renameWorkspace.test.ts +++ b/tests/ipcMain/renameWorkspace.test.ts @@ -4,6 +4,7 @@ import { createEventCollector, waitForFileExists, waitForFileNotExists, + createWorkspace, } from "./helpers"; import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; import type { CmuxMessage } from "../../src/types/message"; @@ -137,8 +138,8 @@ describeIntegration("IpcMain rename workspace integration tests", () => { try { // Create a second workspace with a different branch const secondBranchName = "conflict-branch"; - const createResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_CREATE, + const createResult = await createWorkspace( + env.mockIpcRenderer, tempGitRepo, secondBranchName );