From 07593f8704302d491610e0e70c36fa3e9586447a Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 14 Jan 2026 18:25:31 +0100 Subject: [PATCH] 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;