Skip to content

Commit 0a2e337

Browse files
feat: add base branch support for worktree creation (#197)
- Add baseBranch parameter to cloneRepo and createWorktreeSafely - Create CreateWorktreeDialog component for dedicated worktree creation - Add Create Worktree action to RepoRowActions and RepoDetail page - Update BranchesTab to use CreateWorktreeDialog - Allow specifying base branch when creating worktrees
1 parent 98e8c90 commit 0a2e337

8 files changed

Lines changed: 361 additions & 106 deletions

File tree

backend/src/routes/repos.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
2727
app.post('/', async (c) => {
2828
try {
2929
const body = await c.req.json()
30-
const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification, provider } = body
30+
const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification, provider, baseBranch } = body
3131

3232
if (!repoUrl && !localPath) {
3333
return c.json({ error: 'Either repoUrl or localPath is required' }, 400)
@@ -48,9 +48,7 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
4848
database,
4949
gitAuthService,
5050
repoUrl!,
51-
branch,
52-
useWorktree,
53-
skipSSHVerification
51+
{ branch, useWorktree, skipSSHVerification, baseBranch }
5452
)
5553
}
5654

backend/src/services/repo.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -576,14 +576,20 @@ export async function initLocalRepo(
576576
}
577577
}
578578

579+
export interface CloneRepoOptions {
580+
branch?: string
581+
useWorktree?: boolean
582+
skipSSHVerification?: boolean
583+
baseBranch?: string
584+
}
585+
579586
export async function cloneRepo(
580587
database: Database,
581588
gitAuthService: GitAuthService,
582589
repoUrl: string,
583-
branch?: string,
584-
useWorktree: boolean = false,
585-
skipSSHVerification: boolean = false
590+
options: CloneRepoOptions = {}
586591
): Promise<Repo> {
592+
const { branch, useWorktree = false, skipSSHVerification = false, baseBranch } = options
587593
const effectiveUrl = normalizeSSHUrl(repoUrl)
588594
const isSSH = isSSHUrl(effectiveUrl)
589595
const preserveSSH = isSSH
@@ -639,7 +645,7 @@ export async function cloneRepo(
639645
await executeCommand(['git', '-C', baseRepoPath, 'fetch', '--all'], { cwd: getReposPath(), env })
640646

641647

642-
await createWorktreeSafely(baseRepoPath, worktreePath, branch, env)
648+
await createWorktreeSafely(baseRepoPath, worktreePath, branch, env, baseBranch)
643649

644650
const worktreeVerified = existsSync(worktreePath)
645651

@@ -986,7 +992,7 @@ function normalizeRepoUrl(url: string, preserveSSH: boolean = false): { url: str
986992
}
987993
}
988994

989-
async function createWorktreeSafely(baseRepoPath: string, worktreePath: string, branch: string, env: Record<string, string>): Promise<void> {
995+
async function createWorktreeSafely(baseRepoPath: string, worktreePath: string, branch: string, env: Record<string, string>, baseBranch?: string): Promise<void> {
990996
const currentBranch = await safeGetCurrentBranch(baseRepoPath, env)
991997
if (currentBranch === branch) {
992998
const defaultBranch = await executeCommand(['git', '-C', baseRepoPath, 'rev-parse', '--abbrev-ref', 'origin/HEAD'], { env })
@@ -1015,6 +1021,10 @@ async function createWorktreeSafely(baseRepoPath: string, worktreePath: string,
10151021
if (branchExists) {
10161022
await executeCommand(['git', '-C', baseRepoPath, 'worktree', 'add', worktreePath, branch], { env })
10171023
} else {
1018-
await executeCommand(['git', '-C', baseRepoPath, 'worktree', 'add', '-b', branch, worktreePath], { env })
1024+
const addArgs = ['git', '-C', baseRepoPath, 'worktree', 'add', '-b', branch, worktreePath]
1025+
if (baseBranch) {
1026+
addArgs.push(baseBranch)
1027+
}
1028+
await executeCommand(addArgs, { env })
10191029
}
10201030
}

frontend/src/api/repos.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@ import { FetchError, fetchWrapper, fetchWrapperVoid, fetchWrapperBlob } from './
33
import { API_BASE_URL } from '@/config'
44
import type { DiscoverReposResponse } from '@opencode-manager/shared/types'
55

6-
export async function createRepo(
7-
repoUrl?: string,
8-
localPath?: string,
9-
branch?: string,
10-
openCodeConfigName?: string,
11-
useWorktree?: boolean,
6+
export interface CreateRepoOptions {
7+
repoUrl?: string
8+
localPath?: string
9+
branch?: string
10+
openCodeConfigName?: string
11+
useWorktree?: boolean
1212
skipSSHVerification?: boolean
13-
): Promise<Repo> {
13+
baseBranch?: string
14+
}
15+
16+
export async function createRepo(options: CreateRepoOptions = {}): Promise<Repo> {
1417
return fetchWrapper(`${API_BASE_URL}/api/repos`, {
1518
method: 'POST',
1619
headers: { 'Content-Type': 'application/json' },
17-
body: JSON.stringify({ repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification }),
20+
body: JSON.stringify(options),
1821
})
1922
}
2023

frontend/src/components/repo/AddRepoDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
3737
const mutation = useMutation({
3838
mutationFn: async (): Promise<AddRepoResult> => {
3939
if (repoType === 'local') {
40-
const repo = await createRepo(undefined, localPath, branch || undefined, undefined, false)
40+
const repo = await createRepo({ localPath, branch: branch || undefined, useWorktree: false })
4141
return { mode: 'single', repo }
4242
}
4343

@@ -46,7 +46,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
4646
return { mode: 'discover', ...result }
4747
}
4848

49-
const repo = await createRepo(repoUrl, undefined, branch || undefined, undefined, false, skipSSHVerification)
49+
const repo = await createRepo({ repoUrl, branch: branch || undefined, useWorktree: false, skipSSHVerification })
5050
return { mode: 'single', repo }
5151
},
5252
onSuccess: (result) => {
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { useState, useEffect } from 'react'
2+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
4+
import { Button } from '@/components/ui/button'
5+
import { Input } from '@/components/ui/input'
6+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
7+
import { AlertCircle, GitBranch, Loader2 } from 'lucide-react'
8+
import { createRepo, listBranches } from '@/api/repos'
9+
import { showToast } from '@/lib/toast'
10+
11+
interface CreateWorktreeDialogProps {
12+
open: boolean
13+
onOpenChange: (open: boolean) => void
14+
repoId: number
15+
repoUrl?: string | null
16+
defaultBaseBranch?: string
17+
onCreated?: () => void
18+
}
19+
20+
export function CreateWorktreeDialog({
21+
open,
22+
onOpenChange,
23+
repoId,
24+
repoUrl,
25+
defaultBaseBranch,
26+
onCreated,
27+
}: CreateWorktreeDialogProps) {
28+
const queryClient = useQueryClient()
29+
const [branchName, setBranchName] = useState('')
30+
const [baseBranch, setBaseBranch] = useState<string>('')
31+
const [error, setError] = useState<string | null>(null)
32+
33+
const canCreate = Boolean(repoUrl)
34+
35+
const { data: branchesData, isLoading: branchesLoading } = useQuery({
36+
queryKey: ['branches', repoId],
37+
queryFn: () => listBranches(repoId),
38+
enabled: open && canCreate,
39+
staleTime: 30000,
40+
})
41+
42+
const localBranches = (branchesData?.branches ?? []).filter((b) => b.type === 'local')
43+
const remoteBranches = (branchesData?.branches ?? [])
44+
.filter((b) => b.type === 'remote')
45+
.map((b) => ({ ...b, shortName: b.name.replace(/^remotes\/[^/]+\//, '') }))
46+
.filter((b) => !localBranches.some((lb) => lb.name === b.shortName))
47+
48+
useEffect(() => {
49+
if (!open) {
50+
setBranchName('')
51+
setBaseBranch('')
52+
setError(null)
53+
return
54+
}
55+
if (defaultBaseBranch) {
56+
setBaseBranch(defaultBaseBranch)
57+
}
58+
}, [open, defaultBaseBranch])
59+
60+
const worktreeMutation = useMutation({
61+
mutationFn: (payload: { branch: string; base: string }) =>
62+
createRepo({
63+
repoUrl: repoUrl || undefined,
64+
branch: payload.branch,
65+
useWorktree: true,
66+
baseBranch: payload.base,
67+
}),
68+
onSuccess: () => {
69+
queryClient.invalidateQueries({ queryKey: ['repos'] })
70+
queryClient.invalidateQueries({ queryKey: ['reposGitStatus'] })
71+
showToast.success('Worktree created')
72+
onCreated?.()
73+
onOpenChange(false)
74+
},
75+
onError: (err) => {
76+
setError(err instanceof Error ? err.message : 'Failed to create worktree')
77+
},
78+
})
79+
80+
const handleCreate = () => {
81+
const trimmed = branchName.trim()
82+
if (!trimmed) {
83+
setError('Branch name is required')
84+
return
85+
}
86+
if (!baseBranch) {
87+
setError('Base branch is required')
88+
return
89+
}
90+
setError(null)
91+
worktreeMutation.mutate({ branch: trimmed, base: baseBranch })
92+
}
93+
94+
return (
95+
<Dialog open={open} onOpenChange={onOpenChange}>
96+
<DialogContent className="sm:max-w-[440px]">
97+
<DialogHeader>
98+
<DialogTitle className="flex items-center gap-2">
99+
<GitBranch className="w-4 h-4" />
100+
Create Worktree
101+
</DialogTitle>
102+
<DialogDescription>
103+
Create a separate workspace for a new branch. The worktree is managed as its own repo entry.
104+
</DialogDescription>
105+
</DialogHeader>
106+
107+
<div className="space-y-4">
108+
{!canCreate ? (
109+
<div className="flex items-start gap-2 bg-yellow-500/10 border border-yellow-500/30 rounded p-3">
110+
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
111+
<p className="text-sm text-yellow-700 dark:text-yellow-300">
112+
Worktrees can only be created for repositories with a remote URL.
113+
</p>
114+
</div>
115+
) : (
116+
<>
117+
<div className="space-y-1.5">
118+
<label className="text-sm font-medium">New branch name</label>
119+
<Input
120+
placeholder="feature/my-branch"
121+
value={branchName}
122+
onChange={(e) => setBranchName(e.target.value)}
123+
autoFocus
124+
onKeyDown={(e) => {
125+
if (e.key === 'Enter' && !worktreeMutation.isPending) handleCreate()
126+
}}
127+
/>
128+
</div>
129+
130+
<div className="space-y-1.5">
131+
<label className="text-sm font-medium">Base branch</label>
132+
<Select value={baseBranch} onValueChange={setBaseBranch} disabled={branchesLoading}>
133+
<SelectTrigger className="bg-background border-border text-foreground">
134+
<SelectValue placeholder={branchesLoading ? 'Loading branches...' : 'Select a base branch'} />
135+
</SelectTrigger>
136+
<SelectContent className="bg-popover border-border">
137+
{localBranches.length > 0 && (
138+
<>
139+
{localBranches.map((branch) => (
140+
<SelectItem key={`local-${branch.name}`} value={branch.name}>
141+
<div className="flex items-center gap-2">
142+
<GitBranch className="w-3.5 h-3.5" />
143+
<span>{branch.name}</span>
144+
{branch.current && (
145+
<span className="text-xs text-muted-foreground">(current)</span>
146+
)}
147+
</div>
148+
</SelectItem>
149+
))}
150+
</>
151+
)}
152+
{remoteBranches.map((branch) => (
153+
<SelectItem key={`remote-${branch.name}`} value={branch.shortName}>
154+
<div className="flex items-center gap-2">
155+
<GitBranch className="w-3.5 h-3.5 text-blue-500" />
156+
<span>{branch.shortName}</span>
157+
<span className="text-xs text-muted-foreground">(remote)</span>
158+
</div>
159+
</SelectItem>
160+
))}
161+
</SelectContent>
162+
</Select>
163+
<p className="text-xs text-muted-foreground">
164+
The new branch will be created from this branch. Ignored if the branch name already exists locally or on the remote.
165+
</p>
166+
</div>
167+
</>
168+
)}
169+
170+
{error && (
171+
<div className="flex items-start gap-2 bg-red-500/10 border border-red-500/30 rounded p-3">
172+
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
173+
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
174+
</div>
175+
)}
176+
177+
<div className="flex gap-2 justify-end">
178+
<Button
179+
variant="outline"
180+
onClick={() => onOpenChange(false)}
181+
className="border-border hover:bg-accent"
182+
>
183+
Cancel
184+
</Button>
185+
<Button
186+
onClick={handleCreate}
187+
disabled={!canCreate || !branchName.trim() || !baseBranch || worktreeMutation.isPending}
188+
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
189+
>
190+
{worktreeMutation.isPending ? (
191+
<>
192+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
193+
Creating...
194+
</>
195+
) : (
196+
'Create Worktree'
197+
)}
198+
</Button>
199+
</div>
200+
</div>
201+
</DialogContent>
202+
</Dialog>
203+
)
204+
}

frontend/src/components/repo/RepoRowActions.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from 'react'
2-
import { Loader2, GitBranch, Download, Trash2, MoreVertical } from 'lucide-react'
2+
import { Loader2, GitBranch, GitBranchPlus, Download, Trash2, MoreVertical } from 'lucide-react'
33
import { Button } from '@/components/ui/button'
44
import {
55
DropdownMenu,
@@ -9,6 +9,7 @@ import {
99
} from '@/components/ui/dropdown-menu'
1010
import { SourceControlPanel } from '@/components/source-control/SourceControlPanel'
1111
import { DownloadDialog } from '@/components/ui/download-dialog'
12+
import { CreateWorktreeDialog } from '@/components/repo/CreateWorktreeDialog'
1213
import { downloadRepo } from '@/api/repos'
1314
import { showToast } from '@/lib/toast'
1415
import { getRepoDisplayName } from '@/lib/utils'
@@ -47,6 +48,7 @@ export function RepoRowActions({
4748
}: RepoRowActionsProps) {
4849
const [showDownloadDialog, setShowDownloadDialog] = useState(false)
4950
const [showSourceControl, setShowSourceControl] = useState(false)
51+
const [showWorktreeDialog, setShowWorktreeDialog] = useState(false)
5052

5153
const repoName = getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath)
5254
const branchToDisplay = gitStatus?.branch || repo.currentBranch || repo.branch
@@ -62,6 +64,13 @@ export function RepoRowActions({
6264
onActionsOpenChange?.(open)
6365
}
6466

67+
const handleWorktreeDialogOpen = (open: boolean) => {
68+
setShowWorktreeDialog(open)
69+
onActionsOpenChange?.(open)
70+
}
71+
72+
const canCreateWorktree = isReady && !repo.isWorktree && Boolean(repo.repoUrl)
73+
6574
const handleDownload = async (options: { includeGit?: boolean; includePaths?: string[] }) => {
6675
try {
6776
await downloadRepo(repo.id, repoName, options)
@@ -162,6 +171,18 @@ export function RepoRowActions({
162171
<GitBranch className="w-4 h-4" />
163172
</Button>
164173

174+
{canCreateWorktree && (
175+
<Button
176+
size="sm"
177+
variant="ghost"
178+
onClick={() => handleWorktreeDialogOpen(true)}
179+
className="h-8 w-8 p-0"
180+
title="Create Worktree"
181+
>
182+
<GitBranchPlus className="w-4 h-4" />
183+
</Button>
184+
)}
185+
165186
<Button
166187
size="sm"
167188
variant="ghost"
@@ -205,6 +226,13 @@ export function RepoRowActions({
205226
itemName={repoName}
206227
targetPath={repo.fullPath}
207228
/>
229+
<CreateWorktreeDialog
230+
open={showWorktreeDialog}
231+
onOpenChange={handleWorktreeDialogOpen}
232+
repoId={repo.id}
233+
repoUrl={repo.repoUrl}
234+
defaultBaseBranch={branchToDisplay}
235+
/>
208236
</>
209237
)
210238
}

0 commit comments

Comments
 (0)