From 07593f8704302d491610e0e70c36fa3e9586447a Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 14 Jan 2026 18:25:31 +0100 Subject: [PATCH 1/2] feat: enhance list-branches endpoint to support fetching remote branches - Updated the list-branches endpoint to accept an optional parameter for including remote branches. - Implemented logic to fetch and deduplicate remote branches alongside local branches. - Modified the CreatePRDialog component to utilize the updated API for branch selection, allowing users to select from both local and remote branches. --- .../routes/worktree/routes/list-branches.ts | 54 ++++++++++++++++++- .../board-view/dialogs/create-pr-dialog.tsx | 51 +++++++++++++++--- apps/ui/src/lib/http-api-client.ts | 4 +- apps/ui/src/types/electron.d.ts | 7 ++- 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index dc7d7d6c..84beee40 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -1,5 +1,5 @@ /** - * POST /list-branches endpoint - List all local branches + * POST /list-branches endpoint - List all local branches and optionally remote branches * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidWorktree middleware in index.ts @@ -21,8 +21,9 @@ interface BranchInfo { export function createListBranchesHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, includeRemote = false } = req.body as { worktreePath: string; + includeRemote?: boolean; }; if (!worktreePath) { @@ -60,6 +61,55 @@ export function createListBranchesHandler() { }; }); + // Fetch remote branches if requested + if (includeRemote) { + try { + // Fetch latest remote refs (silently, don't fail if offline) + try { + await execAsync('git fetch --all --quiet', { + cwd: worktreePath, + timeout: 10000, // 10 second timeout + }); + } catch { + // Ignore fetch errors - we'll use cached remote refs + } + + // List remote branches + const { stdout: remoteBranchesOutput } = await execAsync( + 'git branch -r --format="%(refname:short)"', + { cwd: worktreePath } + ); + + const localBranchNames = new Set(branches.map((b) => b.name)); + + remoteBranchesOutput + .trim() + .split('\n') + .filter((b) => b.trim()) + .forEach((name) => { + // Remove any surrounding quotes + const cleanName = name.trim().replace(/^['"]|['"]$/g, ''); + // Skip HEAD pointers like "origin/HEAD" + if (cleanName.includes('/HEAD')) return; + + // Extract the branch name without the remote prefix for deduplication + // e.g., "origin/main" -> "main" + const branchNameWithoutRemote = cleanName.replace(/^[^/]+\//, ''); + + // Only add remote branches that don't exist locally (to avoid duplicates) + if (!localBranchNames.has(branchNameWithoutRemote)) { + branches.push({ + name: cleanName, // Keep full name like "origin/main" + isCurrent: false, + isRemote: true, + }); + } + }); + } catch { + // Ignore errors fetching remote branches - return local branches only + } + } + // Get ahead/behind count for current branch let aheadCount = 0; let behindCount = 0; diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx index a4a7dbed..59906dee 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Dialog, DialogContent, @@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; +import { BranchAutocomplete } from '@/components/ui/branch-autocomplete'; import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; @@ -52,9 +53,38 @@ export function CreatePRDialog({ const [prUrl, setPrUrl] = useState(null); const [browserUrl, setBrowserUrl] = useState(null); const [showBrowserFallback, setShowBrowserFallback] = useState(false); + // Branch fetching state + const [branches, setBranches] = useState([]); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); // Track whether an operation completed that warrants a refresh const operationCompletedRef = useRef(false); + // Fetch branches for autocomplete + const fetchBranches = useCallback(async () => { + if (!worktree?.path) return; + + setIsLoadingBranches(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.listBranches) { + return; + } + // Fetch both local and remote branches for PR base branch selection + const result = await api.worktree.listBranches(worktree.path, true); + if (result.success && result.result) { + // Extract branch names, filtering out the current worktree branch + const branchNames = result.result.branches + .map((b) => b.name) + .filter((name) => name !== worktree.branch); + setBranches(branchNames); + } + } catch { + // Silently fail - branches will default to main only + } finally { + setIsLoadingBranches(false); + } + }, [worktree?.path, worktree?.branch]); + // Reset state when dialog opens or worktree changes useEffect(() => { if (open) { @@ -72,6 +102,9 @@ export function CreatePRDialog({ setShowBrowserFallback(false); // Reset operation tracking operationCompletedRef.current = false; + // Reset branches and fetch fresh ones + setBranches([]); + fetchBranches(); } else { // Reset everything when dialog closes setTitle(''); @@ -84,8 +117,9 @@ export function CreatePRDialog({ setBrowserUrl(null); setShowBrowserFallback(false); operationCompletedRef.current = false; + setBranches([]); } - }, [open, worktree?.path, defaultBaseBranch]); + }, [open, worktree?.path, defaultBaseBranch, fetchBranches]); const handleCreate = async () => { if (!worktree) return; @@ -346,15 +380,16 @@ export function CreatePRDialog({ /> -
+
- setBaseBranch(e.target.value)} - className="font-mono text-sm" + onChange={setBaseBranch} + branches={branches} + placeholder="Select base branch..." + disabled={isLoadingBranches} + data-testid="base-branch-autocomplete" />
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 2ce7f6a7..f08e7620 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1746,8 +1746,8 @@ export class HttpApiClient implements ElectronAPI { pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }), checkoutBranch: (worktreePath: string, branchName: string) => this.post('/api/worktree/checkout-branch', { worktreePath, branchName }), - listBranches: (worktreePath: string) => - this.post('/api/worktree/list-branches', { worktreePath }), + listBranches: (worktreePath: string, includeRemote?: boolean) => + this.post('/api/worktree/list-branches', { worktreePath, includeRemote }), switchBranch: (worktreePath: string, branchName: string) => this.post('/api/worktree/switch-branch', { worktreePath, branchName }), openInEditor: (worktreePath: string, editorCommand?: string) => diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 23814c18..94a033f5 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -858,8 +858,11 @@ export interface WorktreeAPI { code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; }>; - // List all local branches - listBranches: (worktreePath: string) => Promise<{ + // List branches (local and optionally remote) + listBranches: ( + worktreePath: string, + includeRemote?: boolean + ) => Promise<{ success: boolean; result?: { currentBranch: string; From 0898578c11ce1d37b51fd4cc4dd5373e269410ad Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 14 Jan 2026 18:36:14 +0100 Subject: [PATCH 2/2] fix: Include remote branches in PR base selection even when local branch exists The branch listing logic now correctly shows remote branches (e.g., "origin/main") even if a local branch with the same base name exists, since users need remote branches as PR base targets. Also extracted duplicate state reset logic in create-pr-dialog into a reusable function. --- .../routes/worktree/routes/list-branches.ts | 12 ++--- .../board-view/dialogs/create-pr-dialog.tsx | 49 +++++++------------ 2 files changed, 25 insertions(+), 36 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index 84beee40..c6db10fc 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -92,12 +92,12 @@ export function createListBranchesHandler() { // Skip HEAD pointers like "origin/HEAD" if (cleanName.includes('/HEAD')) return; - // Extract the branch name without the remote prefix for deduplication - // e.g., "origin/main" -> "main" - const branchNameWithoutRemote = cleanName.replace(/^[^/]+\//, ''); - - // Only add remote branches that don't exist locally (to avoid duplicates) - if (!localBranchNames.has(branchNameWithoutRemote)) { + // Only add remote branches if a branch with the exact same name isn't already + // in the list. This avoids duplicates if a local branch is named like a remote one. + // Note: We intentionally include remote branches even when a local branch with the + // same base name exists (e.g., show "origin/main" even if local "main" exists), + // since users need to select remote branches as PR base targets. + if (!localBranchNames.has(cleanName)) { branches.push({ name: cleanName, // Keep full name like "origin/main" isCurrent: false, diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx index 59906dee..125e8416 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -59,6 +59,21 @@ export function CreatePRDialog({ // Track whether an operation completed that warrants a refresh const operationCompletedRef = useRef(false); + // Common state reset function to avoid duplication + const resetState = useCallback(() => { + setTitle(''); + setBody(''); + setCommitMessage(''); + setBaseBranch(defaultBaseBranch); + setIsDraft(false); + setError(null); + setPrUrl(null); + setBrowserUrl(null); + setShowBrowserFallback(false); + operationCompletedRef.current = false; + setBranches([]); + }, [defaultBaseBranch]); + // Fetch branches for autocomplete const fetchBranches = useCallback(async () => { if (!worktree?.path) return; @@ -87,39 +102,13 @@ export function CreatePRDialog({ // Reset state when dialog opens or worktree changes useEffect(() => { + // Reset all state on both open and close + resetState(); if (open) { - // Reset form fields - setTitle(''); - setBody(''); - setCommitMessage(''); - setBaseBranch(defaultBaseBranch); - setIsDraft(false); - setError(null); - // Also reset result states when opening for a new worktree - // This prevents showing stale PR URLs from previous worktrees - setPrUrl(null); - setBrowserUrl(null); - setShowBrowserFallback(false); - // Reset operation tracking - operationCompletedRef.current = false; - // Reset branches and fetch fresh ones - setBranches([]); + // Fetch fresh branches when dialog opens fetchBranches(); - } else { - // Reset everything when dialog closes - setTitle(''); - setBody(''); - setCommitMessage(''); - setBaseBranch(defaultBaseBranch); - setIsDraft(false); - setError(null); - setPrUrl(null); - setBrowserUrl(null); - setShowBrowserFallback(false); - operationCompletedRef.current = false; - setBranches([]); } - }, [open, worktree?.path, defaultBaseBranch, fetchBranches]); + }, [open, worktree?.path, resetState, fetchBranches]); const handleCreate = async () => { if (!worktree) return;