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.
This commit is contained in:
Shirone
2026-01-14 18:25:31 +01:00
parent 3f8a8db7a5
commit 07593f8704
4 changed files with 102 additions and 14 deletions

View File

@@ -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<void> => {
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;

View File

@@ -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<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Branch fetching state
const [branches, setBranches] = useState<string[]>([]);
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({
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<Input
id="base-branch"
placeholder="main"
<BranchAutocomplete
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
className="font-mono text-sm"
onChange={setBaseBranch}
branches={branches}
placeholder="Select base branch..."
disabled={isLoadingBranches}
data-testid="base-branch-autocomplete"
/>
</div>
<div className="flex items-end">

View File

@@ -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) =>

View File

@@ -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;