Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions backend/src/routes/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
app.post('/', async (c) => {
try {
const body = await c.req.json()
const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification, provider } = body
const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification, provider, baseBranch } = body

if (!repoUrl && !localPath) {
return c.json({ error: 'Either repoUrl or localPath is required' }, 400)
Expand All @@ -48,9 +48,7 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
database,
gitAuthService,
repoUrl!,
branch,
useWorktree,
skipSSHVerification
{ branch, useWorktree, skipSSHVerification, baseBranch }
)
}

Expand Down
22 changes: 16 additions & 6 deletions backend/src/services/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,14 +576,20 @@ export async function initLocalRepo(
}
}

export interface CloneRepoOptions {
branch?: string
useWorktree?: boolean
skipSSHVerification?: boolean
baseBranch?: string
}

export async function cloneRepo(
database: Database,
gitAuthService: GitAuthService,
repoUrl: string,
branch?: string,
useWorktree: boolean = false,
skipSSHVerification: boolean = false
options: CloneRepoOptions = {}
): Promise<Repo> {
const { branch, useWorktree = false, skipSSHVerification = false, baseBranch } = options
const effectiveUrl = normalizeSSHUrl(repoUrl)
const isSSH = isSSHUrl(effectiveUrl)
const preserveSSH = isSSH
Expand Down Expand Up @@ -639,7 +645,7 @@ export async function cloneRepo(
await executeCommand(['git', '-C', baseRepoPath, 'fetch', '--all'], { cwd: getReposPath(), env })


await createWorktreeSafely(baseRepoPath, worktreePath, branch, env)
await createWorktreeSafely(baseRepoPath, worktreePath, branch, env, baseBranch)

const worktreeVerified = existsSync(worktreePath)

Expand Down Expand Up @@ -986,7 +992,7 @@ function normalizeRepoUrl(url: string, preserveSSH: boolean = false): { url: str
}
}

async function createWorktreeSafely(baseRepoPath: string, worktreePath: string, branch: string, env: Record<string, string>): Promise<void> {
async function createWorktreeSafely(baseRepoPath: string, worktreePath: string, branch: string, env: Record<string, string>, baseBranch?: string): Promise<void> {
const currentBranch = await safeGetCurrentBranch(baseRepoPath, env)
if (currentBranch === branch) {
const defaultBranch = await executeCommand(['git', '-C', baseRepoPath, 'rev-parse', '--abbrev-ref', 'origin/HEAD'], { env })
Expand Down Expand Up @@ -1015,6 +1021,10 @@ async function createWorktreeSafely(baseRepoPath: string, worktreePath: string,
if (branchExists) {
await executeCommand(['git', '-C', baseRepoPath, 'worktree', 'add', worktreePath, branch], { env })
} else {
await executeCommand(['git', '-C', baseRepoPath, 'worktree', 'add', '-b', branch, worktreePath], { env })
const addArgs = ['git', '-C', baseRepoPath, 'worktree', 'add', '-b', branch, worktreePath]
if (baseBranch) {
addArgs.push(baseBranch)
}
await executeCommand(addArgs, { env })
}
}
19 changes: 11 additions & 8 deletions frontend/src/api/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ import { FetchError, fetchWrapper, fetchWrapperVoid, fetchWrapperBlob } from './
import { API_BASE_URL } from '@/config'
import type { DiscoverReposResponse } from '@opencode-manager/shared/types'

export async function createRepo(
repoUrl?: string,
localPath?: string,
branch?: string,
openCodeConfigName?: string,
useWorktree?: boolean,
export interface CreateRepoOptions {
repoUrl?: string
localPath?: string
branch?: string
openCodeConfigName?: string
useWorktree?: boolean
skipSSHVerification?: boolean
): Promise<Repo> {
baseBranch?: string
}

export async function createRepo(options: CreateRepoOptions = {}): Promise<Repo> {
return fetchWrapper(`${API_BASE_URL}/api/repos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification }),
body: JSON.stringify(options),
})
}

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/repo/AddRepoDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
const mutation = useMutation({
mutationFn: async (): Promise<AddRepoResult> => {
if (repoType === 'local') {
const repo = await createRepo(undefined, localPath, branch || undefined, undefined, false)
const repo = await createRepo({ localPath, branch: branch || undefined, useWorktree: false })
return { mode: 'single', repo }
}

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

const repo = await createRepo(repoUrl, undefined, branch || undefined, undefined, false, skipSSHVerification)
const repo = await createRepo({ repoUrl, branch: branch || undefined, useWorktree: false, skipSSHVerification })
return { mode: 'single', repo }
},
onSuccess: (result) => {
Expand Down
204 changes: 204 additions & 0 deletions frontend/src/components/repo/CreateWorktreeDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { AlertCircle, GitBranch, Loader2 } from 'lucide-react'
import { createRepo, listBranches } from '@/api/repos'
import { showToast } from '@/lib/toast'

interface CreateWorktreeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
repoId: number
repoUrl?: string | null
defaultBaseBranch?: string
onCreated?: () => void
}

export function CreateWorktreeDialog({
open,
onOpenChange,
repoId,
repoUrl,
defaultBaseBranch,
onCreated,
}: CreateWorktreeDialogProps) {
const queryClient = useQueryClient()
const [branchName, setBranchName] = useState('')
const [baseBranch, setBaseBranch] = useState<string>('')
const [error, setError] = useState<string | null>(null)

const canCreate = Boolean(repoUrl)

const { data: branchesData, isLoading: branchesLoading } = useQuery({
queryKey: ['branches', repoId],
queryFn: () => listBranches(repoId),
enabled: open && canCreate,
staleTime: 30000,
})

const localBranches = (branchesData?.branches ?? []).filter((b) => b.type === 'local')
const remoteBranches = (branchesData?.branches ?? [])
.filter((b) => b.type === 'remote')
.map((b) => ({ ...b, shortName: b.name.replace(/^remotes\/[^/]+\//, '') }))
.filter((b) => !localBranches.some((lb) => lb.name === b.shortName))

useEffect(() => {
if (!open) {
setBranchName('')
setBaseBranch('')
setError(null)
return
}
if (defaultBaseBranch) {
setBaseBranch(defaultBaseBranch)
}
}, [open, defaultBaseBranch])

const worktreeMutation = useMutation({
mutationFn: (payload: { branch: string; base: string }) =>
createRepo({
repoUrl: repoUrl || undefined,
branch: payload.branch,
useWorktree: true,
baseBranch: payload.base,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos'] })
queryClient.invalidateQueries({ queryKey: ['reposGitStatus'] })
showToast.success('Worktree created')
onCreated?.()
onOpenChange(false)
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Failed to create worktree')
},
})

const handleCreate = () => {
const trimmed = branchName.trim()
if (!trimmed) {
setError('Branch name is required')
return
}
if (!baseBranch) {
setError('Base branch is required')
return
}
setError(null)
worktreeMutation.mutate({ branch: trimmed, base: baseBranch })
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="w-4 h-4" />
Create Worktree
</DialogTitle>
<DialogDescription>
Create a separate workspace for a new branch. The worktree is managed as its own repo entry.
</DialogDescription>
</DialogHeader>

<div className="space-y-4">
{!canCreate ? (
<div className="flex items-start gap-2 bg-yellow-500/10 border border-yellow-500/30 rounded p-3">
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-yellow-700 dark:text-yellow-300">
Worktrees can only be created for repositories with a remote URL.
</p>
</div>
) : (
<>
<div className="space-y-1.5">
<label className="text-sm font-medium">New branch name</label>
<Input
placeholder="feature/my-branch"
value={branchName}
onChange={(e) => setBranchName(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !worktreeMutation.isPending) handleCreate()
}}
/>
</div>

<div className="space-y-1.5">
<label className="text-sm font-medium">Base branch</label>
<Select value={baseBranch} onValueChange={setBaseBranch} disabled={branchesLoading}>
<SelectTrigger className="bg-background border-border text-foreground">
<SelectValue placeholder={branchesLoading ? 'Loading branches...' : 'Select a base branch'} />
</SelectTrigger>
<SelectContent className="bg-popover border-border">
{localBranches.length > 0 && (
<>
{localBranches.map((branch) => (
<SelectItem key={`local-${branch.name}`} value={branch.name}>
<div className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5" />
<span>{branch.name}</span>
{branch.current && (
<span className="text-xs text-muted-foreground">(current)</span>
)}
</div>
</SelectItem>
))}
</>
)}
{remoteBranches.map((branch) => (
<SelectItem key={`remote-${branch.name}`} value={branch.shortName}>
<div className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5 text-blue-500" />
<span>{branch.shortName}</span>
<span className="text-xs text-muted-foreground">(remote)</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The new branch will be created from this branch. Ignored if the branch name already exists locally or on the remote.
</p>
</div>
</>
)}

{error && (
<div className="flex items-start gap-2 bg-red-500/10 border border-red-500/30 rounded p-3">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}

<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border hover:bg-accent"
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!canCreate || !branchName.trim() || !baseBranch || worktreeMutation.isPending}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{worktreeMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
'Create Worktree'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
30 changes: 29 additions & 1 deletion frontend/src/components/repo/RepoRowActions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Loader2, GitBranch, Download, Trash2, MoreVertical } from 'lucide-react'
import { Loader2, GitBranch, GitBranchPlus, Download, Trash2, MoreVertical } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
Expand All @@ -9,6 +9,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { SourceControlPanel } from '@/components/source-control/SourceControlPanel'
import { DownloadDialog } from '@/components/ui/download-dialog'
import { CreateWorktreeDialog } from '@/components/repo/CreateWorktreeDialog'
import { downloadRepo } from '@/api/repos'
import { showToast } from '@/lib/toast'
import { getRepoDisplayName } from '@/lib/utils'
Expand Down Expand Up @@ -47,6 +48,7 @@ export function RepoRowActions({
}: RepoRowActionsProps) {
const [showDownloadDialog, setShowDownloadDialog] = useState(false)
const [showSourceControl, setShowSourceControl] = useState(false)
const [showWorktreeDialog, setShowWorktreeDialog] = useState(false)

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

const handleWorktreeDialogOpen = (open: boolean) => {
setShowWorktreeDialog(open)
onActionsOpenChange?.(open)
}

const canCreateWorktree = isReady && !repo.isWorktree && Boolean(repo.repoUrl)

const handleDownload = async (options: { includeGit?: boolean; includePaths?: string[] }) => {
try {
await downloadRepo(repo.id, repoName, options)
Expand Down Expand Up @@ -162,6 +171,18 @@ export function RepoRowActions({
<GitBranch className="w-4 h-4" />
</Button>

{canCreateWorktree && (
<Button
size="sm"
variant="ghost"
onClick={() => handleWorktreeDialogOpen(true)}
className="h-8 w-8 p-0"
title="Create Worktree"
>
<GitBranchPlus className="w-4 h-4" />
</Button>
)}

<Button
size="sm"
variant="ghost"
Expand Down Expand Up @@ -205,6 +226,13 @@ export function RepoRowActions({
itemName={repoName}
targetPath={repo.fullPath}
/>
<CreateWorktreeDialog
open={showWorktreeDialog}
onOpenChange={handleWorktreeDialogOpen}
repoId={repo.id}
repoUrl={repo.repoUrl}
defaultBaseBranch={branchToDisplay}
/>
</>
)
}
Loading
Loading