diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index c6db10fc8..adb4afe4b 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -16,6 +16,37 @@ interface BranchInfo { name: string; isCurrent: boolean; isRemote: boolean; + /** Path to worktree if this branch is checked out in a worktree (null if not) */ + checkedOutInWorktree: string | null; +} + +/** + * Get a map of branch names to their worktree paths. + * Git doesn't allow checking out a branch that's already checked out in a worktree. + */ +async function getWorktreeBranches(cwd: string): Promise> { + const branchToWorktree = new Map(); + try { + const { stdout } = await execAsync('git worktree list --porcelain', { cwd }); + const lines = stdout.split('\n'); + let currentWorktreePath: string | null = null; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + currentWorktreePath = line.slice(9); + } else if (line.startsWith('branch ')) { + const branchName = line.slice(7).replace('refs/heads/', ''); + if (currentWorktreePath) { + branchToWorktree.set(branchName, currentWorktreePath); + } + } else if (line === '') { + currentWorktreePath = null; + } + } + } catch { + // If we can't get worktree info, return empty map + } + return branchToWorktree; } export function createListBranchesHandler() { @@ -40,6 +71,10 @@ export function createListBranchesHandler() { }); const currentBranch = currentBranchOutput.trim(); + // Get branches that are already checked out in worktrees + // These cannot be switched to from other locations + const worktreeBranches = await getWorktreeBranches(worktreePath); + // List all local branches // Use double quotes around the format string for cross-platform compatibility // Single quotes are preserved literally on Windows; double quotes work on both @@ -54,10 +89,13 @@ export function createListBranchesHandler() { .map((name) => { // Remove any surrounding quotes (Windows git may preserve them) const cleanName = name.trim().replace(/^['"]|['"]$/g, ''); + const worktreePath = worktreeBranches.get(cleanName); return { name: cleanName, isCurrent: cleanName === currentBranch, isRemote: false, + // Mark if this branch is checked out in a worktree (and it's not the current worktree) + checkedOutInWorktree: worktreePath && cleanName !== currentBranch ? worktreePath : null, }; }); @@ -102,6 +140,7 @@ export function createListBranchesHandler() { name: cleanName, // Keep full name like "origin/main" isCurrent: false, isRemote: true, + checkedOutInWorktree: null, // Remote branches are never checked out in worktrees }); } }); diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts index 45cd816a9..ea73f63a6 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts @@ -12,9 +12,7 @@ interface UseProjectCreationProps { upsertAndSetCurrentProject: (path: string, name: string) => Project; } -export function useProjectCreation({ - upsertAndSetCurrentProject, -}: UseProjectCreationProps) { +export function useProjectCreation({ upsertAndSetCurrentProject }: UseProjectCreationProps) { // Modal state const [showNewProjectModal, setShowNewProjectModal] = useState(false); const [isCreatingProject, setIsCreatingProject] = useState(false); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx index 0f6d2af31..a973a6c5f 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx @@ -90,21 +90,23 @@ export function BranchSwitchDropdown({ {branchFilter ? 'No matching branches' : 'No branches found'} ) : ( - filteredBranches.map((branch) => ( - onSwitchBranch(worktree, branch.name)} - disabled={isSwitching || branch.name === worktree.branch} - className="text-xs font-mono" - > - {branch.name === worktree.branch ? ( - - ) : ( - - )} - {branch.name} - - )) + filteredBranches + .filter((branch) => !branch.checkedOutInWorktree) // Hide branches already in worktrees + .map((branch) => ( + onSwitchBranch(worktree, branch.name)} + disabled={isSwitching || branch.name === worktree.branch} + className="text-xs font-mono" + > + {branch.name === worktree.branch ? ( + + ) : ( + + )} + {branch.name} + + )) )} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index a9cdedca5..bab763c57 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -17,6 +17,8 @@ export interface BranchInfo { name: string; isCurrent: boolean; isRemote: boolean; + /** Path to worktree if this branch is checked out in a worktree (null if not) */ + checkedOutInWorktree: string | null; } export interface GitRepoStatus { diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 1c05eb7b4..549a354be 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -355,8 +355,8 @@ export function WorktreePanel({ )} - {/* Worktrees section - only show if enabled */} - {useWorktreesEnabled && ( + {/* Worktrees section - show if enabled OR if worktrees exist (so users can navigate to them) */} + {(useWorktreesEnabled || nonMainWorktrees.length > 0) && ( <>