diff --git a/packages/ui/src/components/multirun/BranchSelector.tsx b/packages/ui/src/components/multirun/BranchSelector.tsx index c740923b..6aefb86d 100644 --- a/packages/ui/src/components/multirun/BranchSelector.tsx +++ b/packages/ui/src/components/multirun/BranchSelector.tsx @@ -10,6 +10,7 @@ import { SelectValue, } from '@/components/ui/select'; import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi'; +import { resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate'; export type WorktreeBaseOption = { value: string; @@ -38,6 +39,18 @@ export interface BranchSelectorState { isGitRepository: boolean | null; } +const parseTrackingRemote = (tracking: string | null | undefined): string | null => { + const value = String(tracking || '').trim().replace(/^remotes\//, ''); + if (!value) { + return null; + } + const slashIndex = value.indexOf('/'); + if (slashIndex <= 0) { + return null; + } + return value.slice(0, slashIndex); +}; + /** * Hook to load available git branches for a directory. */ @@ -77,6 +90,9 @@ export function useBranchOptions(directory: string | null): BranchSelectorState const branchData = await getGitBranches(directory).catch(() => null); if (cancelled) return; + const rootTrackingRemote = await resolveRootTrackingRemote(directory).catch(() => null); + if (cancelled) return; + const worktreeBaseOptions: WorktreeBaseOption[] = []; const headLabel = branchData?.current ? `Current (HEAD: ${branchData.current})` : 'Current (HEAD)'; worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' }); @@ -84,6 +100,17 @@ export function useBranchOptions(directory: string | null): BranchSelectorState if (branchData) { const localBranches = branchData.all .filter((branchName) => !branchName.startsWith('remotes/')) + .filter((branchName) => { + if (!rootTrackingRemote) { + return true; + } + const tracking = branchData.branches?.[branchName]?.tracking; + const trackingRemote = parseTrackingRemote(tracking); + if (!trackingRemote) { + return true; + } + return trackingRemote === rootTrackingRemote; + }) .sort((a, b) => a.localeCompare(b)); localBranches.forEach((branchName) => { worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'local' }); @@ -92,6 +119,16 @@ export function useBranchOptions(directory: string | null): BranchSelectorState const remoteBranches = branchData.all .filter((branchName) => branchName.startsWith('remotes/')) .map((branchName) => branchName.replace(/^remotes\//, '')) + .filter((branchName) => { + if (!rootTrackingRemote) { + return true; + } + const slashIndex = branchName.indexOf('/'); + if (slashIndex <= 0) { + return false; + } + return branchName.slice(0, slashIndex) === rootTrackingRemote; + }) .sort((a, b) => a.localeCompare(b)); remoteBranches.forEach((branchName) => { worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'remote' }); diff --git a/packages/ui/src/components/session/BranchPickerDialog.tsx b/packages/ui/src/components/session/BranchPickerDialog.tsx index cadbaf4a..2cf45404 100644 --- a/packages/ui/src/components/session/BranchPickerDialog.tsx +++ b/packages/ui/src/components/session/BranchPickerDialog.tsx @@ -19,7 +19,7 @@ import { RiSearchLine, } from '@remixicon/react'; import { cn } from '@/lib/utils'; -import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi'; +import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi'; import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types'; export interface BranchPickerProject { @@ -59,7 +59,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker try { const [b, w] = await Promise.all([ getGitBranches(project.path), - listGitWorktrees(project.path), + git.worktree.list(project.path), ]); setBranches(b); setWorktrees(w); diff --git a/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx b/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx index f18779b5..a4c81821 100644 --- a/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx @@ -28,9 +28,15 @@ import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { opencodeClient } from '@/lib/opencode/client'; import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator'; -import { gitFetch } from '@/lib/gitApi'; -import { execCommand, execCommands } from '@/lib/execCommands'; -import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types'; +import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager'; +import { getRemotes } from '@/lib/gitApi'; +import type { + GitHubPullRequestContextResult, + GitHubPullRequestHeadRepo, + GitHubPullRequestSummary, + GitHubPullRequestsListResult, + GitRemote, +} from '@/lib/api/types'; const parsePullRequestNumber = (value: string): number | null => { const trimmed = value.trim(); @@ -64,6 +70,35 @@ const sanitizeGitRemoteName = (value: string): string => { .slice(0, 64); }; +const looksLikeSshUrl = (value: string): boolean => { + const trimmed = value.trim(); + return /^git@/i.test(trimmed) || /^ssh:\/\//i.test(trimmed); +}; + +const resolvePreferredPushTransport = (remotes: GitRemote[]): 'ssh' | 'https' => { + const candidates = remotes.length > 0 + ? remotes + : []; + const preferredByName = candidates.find((remote) => remote.name === 'origin') + || candidates.find((remote) => remote.name === 'upstream') + || candidates[0]; + + const sample = preferredByName?.pushUrl || preferredByName?.fetchUrl || ''; + return looksLikeSshUrl(sample) ? 'ssh' : 'https'; +}; + +const resolveForkRemoteUrl = (headRepo: GitHubPullRequestHeadRepo | null | undefined, preferredTransport: 'ssh' | 'https'): string => { + if (!headRepo) { + return ''; + } + + if (preferredTransport === 'ssh') { + return headRepo.sshUrl || headRepo.cloneUrl || headRepo.url || ''; + } + + return headRepo.cloneUrl || headRepo.sshUrl || headRepo.url || ''; +}; + export function GitHubPullRequestPickerDialog({ open, onOpenChange, @@ -79,6 +114,15 @@ export function GitHubPullRequestPickerDialog({ const activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; + const projectRef = React.useMemo(() => { + if (!projectDirectory) { + return null; + } + return { + id: activeProject?.id ?? `path:${projectDirectory}`, + path: projectDirectory, + }; + }, [activeProject?.id, projectDirectory]); const [query, setQuery] = React.useState(''); const [createInWorktree, setCreateInWorktree] = React.useState(false); @@ -91,8 +135,14 @@ export function GitHubPullRequestPickerDialog({ const [isLoading, setIsLoading] = React.useState(false); const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [existingBranchHeads, setExistingBranchHeads] = React.useState>(new Map()); + const [projectRemotes, setProjectRemotes] = React.useState([]); const [error, setError] = React.useState(null); + const preferredPushTransport = React.useMemo( + () => resolvePreferredPushTransport(projectRemotes), + [projectRemotes] + ); + const refresh = React.useCallback(async () => { if (!projectDirectory) { setResult(null); @@ -166,13 +216,37 @@ export function GitHubPullRequestPickerDialog({ setIsLoading(false); setError(null); setExistingBranchHeads(new Map()); + setProjectRemotes([]); return; } void refresh(); }, [open, refresh]); + React.useEffect(() => { + if (!open || !projectDirectory) { + return; + } + + let cancelled = false; + void getRemotes(projectDirectory) + .then((remotes) => { + if (!cancelled) { + setProjectRemotes(Array.isArray(remotes) ? remotes : []); + } + }) + .catch(() => { + if (!cancelled) { + setProjectRemotes([]); + } + }); + + return () => { + cancelled = true; + }; + }, [open, projectDirectory]); + const checkLocalBranchExists = React.useCallback(async (heads: string[]) => { - if (!projectDirectory) return; + if (!projectRef) return; const unique = Array.from(new Set(heads.map((h) => (h || '').trim()).filter(Boolean))); if (unique.length === 0) return; @@ -180,28 +254,36 @@ export function GitHubPullRequestPickerDialog({ const unknown = unique.filter((h) => !existingBranchHeads.has(h)); if (unknown.length === 0) return; - // optimistic UI: no spinner; disable once results arrive - { - // Avoid shell wrappers; rely on exit code only. - const commands = unknown.map((h) => `git show-ref --verify --quiet ${JSON.stringify(`refs/heads/${h}`)}`); - const res = await execCommands(commands, projectDirectory); - setExistingBranchHeads((prev) => { - const next = new Map(prev); - for (let i = 0; i < unknown.length; i += 1) { - const head = unknown[i]; - next.set(head, Boolean(res.results[i]?.success)); - } - return next; - }); - } - }, [projectDirectory, existingBranchHeads]); + const results = await Promise.all( + unknown.map(async (head) => { + const validation = await validateWorktreeCreate(projectRef, { + mode: 'new', + branchName: head, + worktreeName: head, + }).catch(() => ({ ok: false, errors: [{ code: 'validation_failed', message: 'Validation failed' }] })); + + const blockedByBranch = validation.errors.some((entry) => + entry.code === 'branch_in_use' || entry.code === 'branch_exists' + ); + return { head, blocked: blockedByBranch }; + }) + ); + + setExistingBranchHeads((prev) => { + const next = new Map(prev); + for (const item of results) { + next.set(item.head, item.blocked); + } + return next; + }); + }, [projectRef, existingBranchHeads]); React.useEffect(() => { if (!open) return; - if (!projectDirectory) return; + if (!projectRef) return; if (!createInWorktree) return; void checkLocalBranchExists(prs.map((pr) => pr.head)); - }, [open, projectDirectory, createInWorktree, prs, checkLocalBranchExists]); + }, [open, projectRef, createInWorktree, prs, checkLocalBranchExists]); React.useEffect(() => { if (!open) return; @@ -290,7 +372,7 @@ export function GitHubPullRequestPickerDialog({ baseRepo: GitHubPullRequestsListResult['repo'] | undefined, pr: GitHubPullRequestSummary, ): Promise<{ id: string } | null> => { - if (!projectDirectory) return null; + if (!projectDirectory || !projectRef) return null; const headRef = pr.head; const headRepo = pr.headRepo; if (!headRef) { @@ -303,33 +385,53 @@ export function GitHubPullRequestPickerDialog({ (headRepo.owner !== baseRepo.owner || headRepo.repo !== baseRepo.repo) ); - const fetchRemote = isFork - ? (headRepo?.cloneUrl || headRepo?.url || '') - : 'origin'; - if (!fetchRemote) { - throw new Error('PR head remote URL missing'); - } - - const fetchRef = `refs/heads/${headRef}`; - const fetchResult = await gitFetch(projectDirectory, { remote: fetchRemote, branch: fetchRef }); - if (!fetchResult?.success) { - throw new Error('Failed to fetch PR head'); - } - - const headCommitish = pr.headSha?.trim() || (await execCommand('git rev-parse FETCH_HEAD', projectDirectory)).stdout?.trim() || ''; - if (!headCommitish) { - throw new Error('PR head commit not resolvable'); - } - const preferredBranch = pr.head; + const remoteName = isFork + ? (sanitizeGitRemoteName(`pr-${headRepo?.owner || 'fork'}-${headRepo?.repo || ''}`) || `pr-${pr.number}`) + : 'origin'; + const remoteUrl = isFork ? resolveForkRemoteUrl(headRepo, preferredPushTransport) : ''; + + if (isFork && !remoteUrl) { + throw new Error('PR fork remote URL missing'); + } + + const startRef = `${remoteName}/${preferredBranch}`; + const validation = await validateWorktreeCreate(projectRef, { + mode: 'new', + branchName: preferredBranch, + worktreeName: preferredBranch, + startRef, + setUpstream: true, + upstreamRemote: remoteName, + upstreamBranch: preferredBranch, + ensureRemoteName: isFork ? remoteName : undefined, + ensureRemoteUrl: isFork ? remoteUrl : undefined, + }); + + if (!validation.ok) { + const branchError = validation.errors.find((entry) => + entry.code === 'branch_in_use' || entry.code === 'branch_exists' + ); + if (branchError) { + throw new Error(branchError.message); + } + throw new Error(validation.errors[0]?.message || 'PR worktree validation failed'); + } // Prevent clobbering/removing an existing local branch when using PR worktree mode. if (existingBranchHeads.get(preferredBranch) === true) { throw new Error(`Local branch already exists: ${preferredBranch}`); } - const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, headCommitish, { + const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, startRef, { kind: 'pr', + worktreeName: preferredBranch, + setUpstream: true, + upstreamRemote: remoteName, + upstreamBranch: preferredBranch, + ensureRemoteName: isFork ? remoteName : undefined, + ensureRemoteUrl: isFork ? remoteUrl : undefined, + createdFromBranch: pr.base, }); if (!session?.id) { throw new Error('Failed to create PR worktree session'); @@ -341,53 +443,6 @@ export function GitHubPullRequestPickerDialog({ throw new Error('Worktree directory not found'); } - // Switch the new worktree to the PR branch and delete the SDK-created opencode/* branch immediately. - // This makes the worktree directly operate on the PR branch. - const commands: string[] = [ - // Create local branch from the fetched PR head commit. - `git -C ${JSON.stringify(worktreeDir)} switch -c ${JSON.stringify(preferredBranch)} ${JSON.stringify(headCommitish)}`, - ]; - const originalBranch = (meta?.branch || session.branch || '').replace(/^refs\/heads\//, '').trim(); - if (meta?.kind === 'pr' && originalBranch && originalBranch.startsWith('opencode/')) { - commands.push(`git -C ${JSON.stringify(projectDirectory)} branch -D ${JSON.stringify(originalBranch)}`); - } - - const result = await execCommands(commands, projectDirectory); - if (!result.success) { - const failed = result.results.find((r) => !r.success); - throw new Error(failed?.stderr || failed?.stdout || 'Failed to switch worktree to PR branch'); - } - - // Best-effort: set upstream for PR branch (without pushing). - try { - const remoteName = isFork - ? sanitizeGitRemoteName(`pr-${headRepo?.owner || 'fork'}-${headRepo?.repo || ''}`) - : 'origin'; - const remoteUrl = isFork ? (headRepo?.cloneUrl || headRepo?.url || '') : ''; - const fetchRefspec = `+refs/heads/${preferredBranch}:refs/remotes/${remoteName}/${preferredBranch}`; - - const upstreamCommands: string[] = []; - if (isFork && remoteUrl) { - upstreamCommands.push( - `git -C ${JSON.stringify(projectDirectory)} remote add ${JSON.stringify(remoteName)} ${JSON.stringify(remoteUrl)} 2>/dev/null || git -C ${JSON.stringify(projectDirectory)} remote set-url ${JSON.stringify(remoteName)} ${JSON.stringify(remoteUrl)}` - ); - } - upstreamCommands.push( - `git -C ${JSON.stringify(projectDirectory)} fetch ${JSON.stringify(remoteName)} ${JSON.stringify(fetchRefspec)}` - ); - upstreamCommands.push( - `git -C ${JSON.stringify(worktreeDir)} branch --set-upstream-to=${JSON.stringify(`${remoteName}/${preferredBranch}`)} ${JSON.stringify(preferredBranch)}` - ); - - const upstreamResult = await execCommands(upstreamCommands, projectDirectory); - if (!upstreamResult.success) { - const failed = upstreamResult.results.find((r) => !r.success); - toast.message('PR upstream not set', { description: failed?.stderr || failed?.stdout || 'Configure remote manually if needed.' }); - } - } catch { - toast.message('PR upstream not set', { description: 'Configure remote manually if needed.' }); - } - // Update stored metadata for better UX + reintegration target. useSessionStore.getState().setWorktreeMetadata(session.id, { ...(meta || { path: worktreeDir, projectDirectory, branch: preferredBranch, label: preferredBranch }), @@ -400,7 +455,7 @@ export function GitHubPullRequestPickerDialog({ }); return { id: session.id }; - }, [projectDirectory, existingBranchHeads]); + }, [projectDirectory, projectRef, existingBranchHeads, preferredPushTransport]); const startSession = React.useCallback(async (number: number) => { if (!projectDirectory) { @@ -433,14 +488,8 @@ export function GitHubPullRequestPickerDialog({ const sessionId = await (async () => { if (createInWorktree) { - try { - const worktreeSession = await createPrWorktreeSession(prContext.repo, pr); - return worktreeSession?.id || null; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - toast.error('PR worktree failed', { description: msg }); - // fall back to normal session - } + const worktreeSession = await createPrWorktreeSession(prContext.repo, pr); + return worktreeSession?.id || null; } const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null); return session?.id || null; @@ -572,7 +621,7 @@ Nice-to-have: toast.success('Session created from PR'); } catch (e) { const message = e instanceof Error ? e.message : String(e); - toast.error('Failed to start session', { description: message }); + toast.error(createInWorktree ? 'PR worktree failed' : 'Failed to start session', { description: message }); } finally { setStartingNumber(null); } @@ -690,7 +739,7 @@ Nice-to-have:

{pr.title}

{createInWorktree && disabledByWorktree ? (

- PR worktree disabled: local branch exists ({pr.head}) + PR worktree disabled: branch already exists or is in use ({pr.head})

) : null} diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index e55ac05f..59db83d5 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -53,6 +53,7 @@ export const SessionDialogs: React.FC = () => { const [deleteDialog, setDeleteDialog] = React.useState(null); const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState>([]); const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false); + const [deleteDialogShouldDeleteLocalBranch, setDeleteDialogShouldDeleteLocalBranch] = React.useState(false); const [isProcessingDelete, setIsProcessingDelete] = React.useState(false); const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false); const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState>(new Set()); @@ -102,6 +103,7 @@ export const SessionDialogs: React.FC = () => { const shouldArchiveWorktree = isWorktreeDelete; const removeRemoteOptionDisabled = isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches; + const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete; React.useEffect(() => { loadSessions(); @@ -186,6 +188,7 @@ export const SessionDialogs: React.FC = () => { setDeleteDialog(null); setDeleteDialogSummaries([]); setDeleteDialogShouldRemoveRemote(false); + setDeleteDialogShouldDeleteLocalBranch(false); setIsProcessingDelete(false); setHasCompletedDirtyCheck(false); setDirtyWorktreePaths(new Set()); @@ -207,6 +210,7 @@ export const SessionDialogs: React.FC = () => { if (!deleteDialog) { setDeleteDialogSummaries([]); setDeleteDialogShouldRemoveRemote(false); + setDeleteDialogShouldDeleteLocalBranch(false); setHasCompletedDirtyCheck(false); setDirtyWorktreePaths(new Set()); return; @@ -326,6 +330,26 @@ export const SessionDialogs: React.FC = () => { } }, [canRemoveRemoteBranches]); + const removeSelectedWorktree = React.useCallback(async ( + worktree: WorktreeMetadata, + deleteLocalBranch: boolean + ): Promise => { + const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; + try { + await removeProjectWorktree( + getProjectRefForWorktree(worktree), + worktree, + { deleteRemoteBranch: shouldRemoveRemote, deleteLocalBranch } + ); + return true; + } catch (error) { + toast.error('Failed to remove worktree', { + description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'), + }); + return false; + } + }, [canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, getProjectRefForWorktree]); + const handleConfirmDelete = React.useCallback(async () => { if (!deleteDialog) { return; @@ -335,22 +359,15 @@ export const SessionDialogs: React.FC = () => { try { const shouldArchive = shouldArchiveWorktree; const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote; + const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch; if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { - const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; - try { - await removeProjectWorktree( - getProjectRefForWorktree(deleteDialog.worktree), - deleteDialog.worktree, - { deleteRemoteBranch: shouldRemoveRemote, force: true } - ); - } catch (error) { - toast.error('Failed to remove worktree', { - description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'), - }); + const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); + if (!removed) { closeDeleteDialog(); return; } + const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.'; toast.success('Worktree removed', { description: renderToastDescription(archiveNote), @@ -367,6 +384,7 @@ export const SessionDialogs: React.FC = () => { // Don't try to derive worktree removal from per-session metadata (may be missing). archiveWorktree: isWorktreeDelete ? false : shouldArchive, deleteRemoteBranch: removeRemoteBranch, + deleteLocalBranch, }); if (!success) { toast.error('Failed to delete session'); @@ -390,23 +408,15 @@ export const SessionDialogs: React.FC = () => { const { deletedIds, failedIds } = await deleteSessions(ids, { archiveWorktree: isWorktreeDelete ? false : shouldArchive, deleteRemoteBranch: removeRemoteBranch, + deleteLocalBranch, }); if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { // Remove selected worktree even if per-session metadata is missing. // Use same projectRef logic as the no-sessions path. - const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; - try { - await removeProjectWorktree( - getProjectRefForWorktree(deleteDialog.worktree), - deleteDialog.worktree, - { deleteRemoteBranch: shouldRemoveRemote, force: true } - ); + const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); + if (removed) { await loadSessions(); - } catch (error) { - toast.error('Failed to remove worktree', { - description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'), - }); } } @@ -444,18 +454,9 @@ export const SessionDialogs: React.FC = () => { } if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { - const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; - try { - await removeProjectWorktree( - getProjectRefForWorktree(deleteDialog.worktree), - deleteDialog.worktree, - { deleteRemoteBranch: shouldRemoveRemote, force: true } - ); + const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); + if (removed) { await loadSessions(); - } catch (error) { - toast.error('Failed to remove worktree', { - description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'), - }); } } @@ -466,13 +467,14 @@ export const SessionDialogs: React.FC = () => { }, [ deleteDialog, deleteDialogShouldRemoveRemote, + deleteDialogShouldDeleteLocalBranch, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, - getProjectRefForWorktree, + removeSelectedWorktree, loadSessions, ]); @@ -590,9 +592,36 @@ export const SessionDialogs: React.FC = () => { ) ) : null; + const deleteLocalBranchAction = isWorktreeDelete ? ( + + ) : null; + const deleteDialogActions = isWorktreeDelete ? (
+ {deleteLocalBranchAction} {deleteRemoteBranchAction}
diff --git a/packages/ui/src/components/ui/CodeMirrorEditor.tsx b/packages/ui/src/components/ui/CodeMirrorEditor.tsx index b2e198c3..ef7e98a4 100644 --- a/packages/ui/src/components/ui/CodeMirrorEditor.tsx +++ b/packages/ui/src/components/ui/CodeMirrorEditor.tsx @@ -43,6 +43,7 @@ const toViewKeyBindings = (bindings: readonly unknown[]): readonly KeyBinding[] return bindings as readonly KeyBinding[]; }; +const forceParsingCompat = forceParsing as unknown as (view: EditorView, upto?: number, timeout?: number) => boolean; const openSearchPanelCompat = openSearchPanel as unknown as (view: EditorView) => void; const closeSearchPanelCompat = closeSearchPanel as unknown as (view: EditorView) => void; @@ -223,7 +224,7 @@ export function CodeMirrorEditor({ parent: hostRef.current, }); - forceParsing(viewRef.current, viewRef.current.state.doc.length, 200); + forceParsingCompat(viewRef.current, viewRef.current.state.doc.length, 200); viewRef.current.requestMeasure(); if (viewRef.current) { @@ -255,7 +256,7 @@ export function CodeMirrorEditor({ ], }); - forceParsing(view, view.state.doc.length, 200); + forceParsingCompat(view, view.state.doc.length, 200); view.requestMeasure(); // Force a re-render to ensure Portals can find the new widget containers in the DOM diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 03ae5444..b907423c 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -409,8 +409,6 @@ export const GitView: React.FC = ({ mode = 'full' }) => { const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false); const [conflictFiles, setConflictFiles] = React.useState([]); const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge'); - const [pushRemoteDialogOpen, setPushRemoteDialogOpen] = React.useState(false); - const [pendingPushAction, setPendingPushAction] = React.useState<'commitAndPush' | null>(null); // Conflict state persistence key const conflictStorageKey = React.useMemo(() => { @@ -727,22 +725,28 @@ export const GitView: React.FC = ({ mode = 'full' }) => { }); }, [status, changeEntries, hasUserAdjustedSelection]); - const handleSyncAction = async (action: Exclude, remote: GitRemote) => { + const handleSyncAction = async (action: Exclude, remote?: GitRemote) => { if (!currentDirectory) return; setSyncAction(action); try { if (action === 'fetch') { + if (!remote) { + throw new Error('No remote available for fetch'); + } await git.gitFetch(currentDirectory, { remote: remote.name }); toast.success(`Fetched from ${remote.name}`); } else if (action === 'pull') { + if (!remote) { + throw new Error('No remote available for pull'); + } const result = await git.gitPull(currentDirectory, { remote: remote.name }); toast.success( `Pulled ${result.files.length} file${result.files.length === 1 ? '' : 's'} from ${remote.name}` ); } else if (action === 'push') { - await git.gitPush(currentDirectory, { remote: remote.name }); - toast.success(`Pushed to ${remote.name}`); + await git.gitPush(currentDirectory); + toast.success('Pushed to upstream'); } await refreshStatusAndBranches(false); @@ -758,7 +762,7 @@ export const GitView: React.FC = ({ mode = 'full' }) => { } }; - const handleCommit = async (options: { pushAfter?: boolean; remote?: GitRemote } = {}) => { + const handleCommit = async (options: { pushAfter?: boolean } = {}) => { if (!currentDirectory) return; if (!commitMessage.trim()) { toast.error('Please enter a commit message'); @@ -771,17 +775,6 @@ export const GitView: React.FC = ({ mode = 'full' }) => { return; } - // If pushing with multiple remotes and no remote specified, this shouldn't happen anymore - // since CommitSection now uses a dropdown. But keep as fallback for safety. - if (options.pushAfter && remotes.length > 1 && !options.remote) { - setPendingPushAction('commitAndPush'); - setPushRemoteDialogOpen(true); - return; - } - - // If there's only one remote, use it automatically when no remote is specified - const targetRemote = options.remote ?? (remotes.length === 1 ? remotes[0] : undefined); - const action: CommitAction = options.pushAfter ? 'commitAndPush' : 'commit'; setCommitAction(action); @@ -798,9 +791,8 @@ export const GitView: React.FC = ({ mode = 'full' }) => { await refreshStatusAndBranches(); if (options.pushAfter) { - const remoteName = targetRemote?.name; - await git.gitPush(currentDirectory, remoteName ? { remote: remoteName } : undefined); - toast.success(remoteName ? `Pushed to ${remoteName}` : 'Pushed to remote'); + await git.gitPush(currentDirectory); + toast.success('Pushed to upstream'); triggerFireworks(); await refreshStatusAndBranches(false); } else { @@ -817,19 +809,6 @@ export const GitView: React.FC = ({ mode = 'full' }) => { } }; - const handlePushRemoteSelect = (remote: GitRemote) => { - setPushRemoteDialogOpen(false); - if (pendingPushAction === 'commitAndPush') { - handleCommit({ pushAfter: true, remote }); - } - setPendingPushAction(null); - }; - - const handlePushRemoteDialogClose = () => { - setPushRemoteDialogOpen(false); - setPendingPushAction(null); - }; - const handleGenerateCommitMessage = React.useCallback(async () => { if (!currentDirectory) return; if (selectedPaths.size === 0) { @@ -1611,7 +1590,7 @@ export const GitView: React.FC = ({ mode = 'full' }) => { remotes={effectiveRemotes} onFetch={(remote) => handleSyncAction('fetch', remote)} onPull={(remote) => handleSyncAction('pull', remote)} - onPush={(remote) => handleSyncAction('push', remote)} + onPush={() => handleSyncAction('push')} onCheckoutBranch={handleCheckoutBranch} onCreateBranch={handleCreateBranch} onRenameBranch={handleRenameBranch} @@ -1697,12 +1676,11 @@ export const GitView: React.FC = ({ mode = 'full' }) => { onGenerateMessage={handleGenerateCommitMessage} isGeneratingMessage={isGeneratingMessage} onCommit={() => handleCommit({ pushAfter: false })} - onCommitAndPush={(remote) => handleCommit({ pushAfter: true, remote })} + onCommitAndPush={() => handleCommit({ pushAfter: true })} commitAction={commitAction} isBusy={isBusy} gitmojiEnabled={settingsGitmojiEnabled} onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} - remotes={remotes} /> ) : ( @@ -1885,34 +1863,6 @@ export const GitView: React.FC = ({ mode = 'full' }) => { project={branchPickerProject} /> - - - - Select remote - - Choose which remote to push to - - -
- {remotes.map((remote) => ( - - ))} -
-
-
-
); }; diff --git a/packages/ui/src/components/views/git/CommitSection.tsx b/packages/ui/src/components/views/git/CommitSection.tsx index b3da1e1a..5680df2d 100644 --- a/packages/ui/src/components/views/git/CommitSection.tsx +++ b/packages/ui/src/components/views/git/CommitSection.tsx @@ -4,7 +4,6 @@ import { RiAiGenerate2, RiLoader4Line, RiEmotionHappyLine, - RiArrowDownSLine, } from '@remixicon/react'; import { Collapsible, @@ -16,13 +15,6 @@ import { CommitInput } from './CommitInput'; import { AIHighlightsBox } from './AIHighlightsBox'; import { useDeviceInfo } from '@/lib/device'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import type { GitRemote } from '@/lib/api/types'; type CommitAction = 'commit' | 'commitAndPush' | null; @@ -36,12 +28,11 @@ interface CommitSectionProps { onGenerateMessage: () => void; isGeneratingMessage: boolean; onCommit: () => void; - onCommitAndPush: (remote?: GitRemote) => void; + onCommitAndPush: () => void; commitAction: CommitAction; isBusy: boolean; gitmojiEnabled: boolean; onOpenGitmojiPicker: () => void; - remotes?: GitRemote[]; variant?: 'framed' | 'plain'; } @@ -60,13 +51,11 @@ export const CommitSection: React.FC = ({ isBusy, gitmojiEnabled, onOpenGitmojiPicker, - remotes = [], variant = 'framed', }) => { const hasSelectedFiles = selectedCount > 0; const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null; const { isMobile, hasTouchInput } = useDeviceInfo(); - const hasMultipleRemotes = remotes.length > 1; const containerClassName = variant === 'framed' @@ -177,109 +166,27 @@ export const CommitSection: React.FC = ({ {isMobile ? ( - hasMultipleRemotes ? ( - - - - - - - - -

Commit & Push

-
-
- - {remotes.map((remote) => ( - onCommitAndPush(remote)}> -
- - {remote.name} - - - {remote.pushUrl} - -
-
- ))} -
-
- ) : ( - - - - - -

Commit & Push

-
-
- ) - ) : hasMultipleRemotes ? ( - - - + + + + +

Commit & Push

+
+ ) : ( void; onPull: (remote: GitRemote) => void; - onPush: (remote: GitRemote) => void; + onPush: () => void; onCheckoutBranch: (branch: string) => void; onCreateBranch: (name: string, remote?: GitRemote) => Promise; onRenameBranch?: (oldName: string, newName: string) => Promise; diff --git a/packages/ui/src/components/views/git/SyncActions.tsx b/packages/ui/src/components/views/git/SyncActions.tsx index 1ab83119..64aa78c0 100644 --- a/packages/ui/src/components/views/git/SyncActions.tsx +++ b/packages/ui/src/components/views/git/SyncActions.tsx @@ -22,7 +22,7 @@ interface SyncActionsProps { remotes: GitRemote[]; onFetch: (remote: GitRemote) => void; onPull: (remote: GitRemote) => void; - onPush: (remote: GitRemote) => void; + onPush: () => void; disabled: boolean; iconOnly?: boolean; tooltipDelayMs?: number; @@ -61,9 +61,8 @@ export const SyncActions: React.FC = ({ }; const handlePush = () => { - const remote = remotes[0]; - if (remotes.length === 1 && remote) { - onPush(remote); + if (remotes.length >= 1) { + onPush(); } }; @@ -202,25 +201,15 @@ export const SyncActions: React.FC = ({ behindCount )} - {hasMultipleRemotes - ? renderDropdownButton( - 'push', - , - , - 'Push', - onPush, - aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes', - aheadCount - ) - : renderButton( - 'push', - , - , - 'Push', - handlePush, - aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes', - aheadCount - )} + {renderButton( + 'push', + , + , + 'Push', + handlePush, + aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes', + aheadCount + )}
); }; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index df86fe2a..52ab5de4 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -285,9 +285,59 @@ export interface GitCommitFilesResponse { } export interface GitWorktreeInfo { - worktree: string; - head?: string; - branch?: string; + head: string; + name: string; + branch: string; + path: string; +} + +export interface GitWorktreeValidationError { + code: string; + message: string; +} + +export interface GitWorktreeValidationResult { + ok: boolean; + errors: GitWorktreeValidationError[]; + resolved?: { + mode?: 'new' | 'existing'; + localBranch?: string | null; + }; +} + +export interface CreateGitWorktreePayload { + mode?: 'new' | 'existing'; + /** Worktree folder name (falls back to OpenCode name generation when omitted). */ + worktreeName?: string; + /** Backward-compatible alias for worktreeName. */ + name?: string; + /** New local branch name for mode=new. */ + branchName?: string; + /** Existing local/remote branch for mode=existing. */ + existingBranch?: string; + /** Start ref for mode=new (local/remote branch or commit SHA). */ + startRef?: string; + /** Additional startup script to run after project startup script. */ + startCommand?: string; + /** Configure upstream tracking for the created/attached local branch. */ + setUpstream?: boolean; + upstreamRemote?: string; + upstreamBranch?: string; + /** Optional remote provisioning (used for fork PR workflows). */ + ensureRemoteName?: string; + ensureRemoteUrl?: string; +} + +export interface GitWorktreeCreateResult { + head: string; + name: string; + branch: string; + path: string; +} + +export interface RemoveGitWorktreePayload { + directory: string; + deleteLocalBranch?: boolean; } export interface GitDeleteBranchPayload { @@ -322,6 +372,13 @@ export interface GeneratedPullRequestDescription { body: string; } +export interface GitWorktreeAPI { + list(directory: string): Promise; + validate?(directory: string, payload: CreateGitWorktreePayload): Promise; + create?(directory: string, payload: CreateGitWorktreePayload): Promise; + remove?(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }>; +} + export interface GitAPI { checkIsGitRepository(directory: string): Promise; getGitStatus(directory: string): Promise; @@ -338,6 +395,9 @@ export interface GitAPI { payload: { base: string; head: string; context?: string; zenModel?: string } ): Promise; listGitWorktrees(directory: string): Promise; + validateGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise; + createGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise; + deleteGitWorktree?(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }>; createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise; gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record }): Promise; gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise; @@ -367,6 +427,7 @@ export interface GitAPI { stash(directory: string, options?: { message?: string; includeUntracked?: boolean }): Promise<{ success: boolean }>; stashPop(directory: string): Promise<{ success: boolean }>; getConflictDetails(directory: string): Promise; + worktree?: GitWorktreeAPI; } export interface FileListEntry { @@ -633,6 +694,7 @@ export type GitHubPullRequestHeadRepo = { repo: string; url: string; cloneUrl?: string; + sshUrl?: string; }; export type GitHubPullRequestSummary = GitHubPullRequest & { diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index f174c559..24302e57 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -18,6 +18,11 @@ export type { GitLogEntry, GitLogResponse, GitWorktreeInfo, + CreateGitWorktreePayload, + GitWorktreeCreateResult, + RemoveGitWorktreePayload, + GitWorktreeValidationError, + GitWorktreeValidationResult, GitDeleteBranchPayload, GitDeleteRemoteBranchPayload, DiscoveredGitCredential, @@ -120,10 +125,64 @@ export async function generatePullRequestDescription( export async function listGitWorktrees(directory: string): Promise { const runtime = getRuntimeGit(); + if (runtime?.worktree?.list) { + return runtime.worktree.list(directory); + } if (runtime) return runtime.listGitWorktrees(directory); return gitHttp.listGitWorktrees(directory); } +export async function validateGitWorktree( + directory: string, + payload: import('./api/types').CreateGitWorktreePayload +): Promise { + const runtime = getRuntimeGit(); + if (runtime?.worktree?.validate) { + return runtime.worktree.validate(directory, payload); + } + if (runtime?.validateGitWorktree) { + return runtime.validateGitWorktree(directory, payload); + } + return gitHttp.validateGitWorktree(directory, payload); +} + +export async function createGitWorktree( + directory: string, + payload: import('./api/types').CreateGitWorktreePayload +): Promise { + const runtime = getRuntimeGit(); + if (runtime?.worktree?.create) { + return runtime.worktree.create(directory, payload); + } + if (runtime?.createGitWorktree) { + return runtime.createGitWorktree(directory, payload); + } + return gitHttp.createGitWorktree(directory, payload); +} + +export async function deleteGitWorktree( + directory: string, + payload: import('./api/types').RemoveGitWorktreePayload +): Promise<{ success: boolean }> { + const runtime = getRuntimeGit(); + if (runtime?.worktree?.remove) { + return runtime.worktree.remove(directory, payload); + } + if (runtime?.deleteGitWorktree) { + return runtime.deleteGitWorktree(directory, payload); + } + return gitHttp.deleteGitWorktree(directory, payload); +} + +export const git = { + worktree: { + list: listGitWorktrees, + validate: validateGitWorktree, + create: createGitWorktree, + remove: deleteGitWorktree, + }, +}; + export async function createGitCommit( directory: string, message: string, diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 50186775..2876aba4 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -11,6 +11,10 @@ import type { GitDeleteRemoteBranchPayload, GeneratedCommitMessage, GitWorktreeInfo, + CreateGitWorktreePayload, + GitWorktreeCreateResult, + RemoveGitWorktreePayload, + GitWorktreeValidationResult, CreateGitCommitOptions, GitCommitResult, GitPushResult, @@ -299,6 +303,51 @@ export async function listGitWorktrees(directory: string): Promise { + const response = await fetch(buildUrl(`${API_BASE}/worktrees/validate`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload ?? {}), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to validate worktree'); + } + + return response.json(); +} + +export async function createGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise { + const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload ?? {}), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to create worktree'); + } + + return response.json(); +} + +export async function deleteGitWorktree(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> { + const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload ?? {}), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to delete worktree'); + } + + return response.json(); +} + export async function createGitCommit( directory: string, message: string, diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index bf707d2b..466ba7cd 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -15,10 +15,10 @@ import { generateBranchName } from '@/lib/git/branchNameGenerator'; import { getRootBranch, getWorktreeStatus } from '@/lib/worktrees/worktreeStatus'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { - createSdkWorktree, removeProjectWorktree, type ProjectRef, } from '@/lib/worktrees/worktreeManager'; +import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate'; import { startConfigUpdate, finishConfigUpdate } from '@/lib/configUpdate'; const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value; @@ -98,8 +98,11 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> { const setupCommands = await getWorktreeSetupCommands(projectRef); const rootBranch = await getRootBranch(projectRef.path); - const metadata = await createSdkWorktree(projectRef, { + const metadata = await createWorktreeWithDefaults(projectRef, { preferredName, + mode: 'new', + branchName: preferredName, + worktreeName: preferredName, setupCommands, }); @@ -118,7 +121,7 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> { const session = await sessionStore.createSession(undefined, metadata.path); if (!session) { // Clean up the worktree if session creation failed - await removeProjectWorktree(projectRef, metadata).catch(() => undefined); + await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined); toast.error('Failed to create session', { description: 'Could not create a session for the worktree.', }); @@ -264,8 +267,11 @@ export async function createWorktreeOnly(): Promise { const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory }; const preferredName = generateBranchName(); const setupCommands = await getWorktreeSetupCommands(projectRef); - const metadata = await createSdkWorktree(projectRef, { + const metadata = await createWorktreeWithDefaults(projectRef, { preferredName, + mode: 'new', + branchName: preferredName, + worktreeName: preferredName, setupCommands, }); @@ -303,7 +309,18 @@ export async function createWorktreeOnly(): Promise { */ export async function createWorktreeSessionForBranch( projectDirectory: string, - branchName: string + branchName: string, + options?: { + kind?: 'pr' | 'standard'; + existingBranch?: string; + worktreeName?: string; + setUpstream?: boolean; + upstreamRemote?: string; + upstreamBranch?: string; + ensureRemoteName?: string; + ensureRemoteUrl?: string; + createdFromBranch?: string; + } ): Promise<{ id: string } | null> { if (isCreatingWorktreeSession) { return null; @@ -335,15 +352,25 @@ export async function createWorktreeSessionForBranch( const setupCommands = await getWorktreeSetupCommands(projectRef); const rootBranch = await getRootBranch(projectRef.path); - const metadata = await createSdkWorktree(projectRef, { + const metadata = await createWorktreeWithDefaults(projectRef, { preferredName: branchName, + mode: 'existing', + existingBranch: options?.existingBranch || branchName, + branchName, + worktreeName: options?.worktreeName || branchName, + setUpstream: options?.setUpstream, + upstreamRemote: options?.upstreamRemote, + upstreamBranch: options?.upstreamBranch, + ensureRemoteName: options?.ensureRemoteName, + ensureRemoteUrl: options?.ensureRemoteUrl, setupCommands, }); + const kind = options?.kind ?? 'standard'; const createdMetadata = { ...metadata, - createdFromBranch: rootBranch, - kind: 'standard' as const, + createdFromBranch: options?.createdFromBranch || rootBranch, + kind, }; // Get worktree status @@ -355,7 +382,7 @@ export async function createWorktreeSessionForBranch( const session = await sessionStore.createSession(undefined, metadata.path); if (!session) { // Clean up the worktree if session creation failed - await removeProjectWorktree(projectRef, metadata).catch(() => undefined); + await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined); toast.error('Failed to create session', { description: 'Could not create a session for the worktree.', }); @@ -467,7 +494,16 @@ export async function createWorktreeSessionForNewBranch( projectDirectory: string, preferredBranchName: string, startPoint?: string, - options?: { kind?: 'pr' | 'standard' } + options?: { + kind?: 'pr' | 'standard'; + worktreeName?: string; + setUpstream?: boolean; + upstreamRemote?: string; + upstreamBranch?: string; + ensureRemoteName?: string; + ensureRemoteUrl?: string; + createdFromBranch?: string; + } ): Promise<{ id: string; branch: string } | null> { if (isCreatingWorktreeSession) { return null; @@ -506,15 +542,23 @@ export async function createWorktreeSessionForNewBranch( const setupCommands = await getWorktreeSetupCommands(projectRef); const rootBranch = await getRootBranch(projectRef.path); - try { - const metadata = await createSdkWorktree(projectRef, { + const metadata = await createWorktreeWithDefaults(projectRef, { preferredName: base, + mode: 'new', + branchName: base, + worktreeName: options?.worktreeName || base, + startRef: start, + setUpstream: options?.setUpstream, + upstreamRemote: options?.upstreamRemote, + upstreamBranch: options?.upstreamBranch, + ensureRemoteName: options?.ensureRemoteName, + ensureRemoteUrl: options?.ensureRemoteUrl, setupCommands, }); const createdMetadata = { ...metadata, - createdFromBranch: rootBranch || start, + createdFromBranch: options?.createdFromBranch || rootBranch || start, kind, }; @@ -524,7 +568,7 @@ export async function createWorktreeSessionForNewBranch( const sessionStore = useSessionStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); if (!session) { - await removeProjectWorktree(projectRef, metadata).catch(() => undefined); + await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined); throw new Error('Could not create a session for the worktree.'); } @@ -616,9 +660,25 @@ export async function createWorktreeSessionForNewBranchExact( projectDirectory: string, branchName: string, startPoint: string, - options?: { kind?: 'pr' | 'standard' } + options?: { + kind?: 'pr' | 'standard'; + worktreeName?: string; + setUpstream?: boolean; + upstreamRemote?: string; + upstreamBranch?: string; + ensureRemoteName?: string; + ensureRemoteUrl?: string; + createdFromBranch?: string; + } ): Promise<{ id: string; branch: string } | null> { return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { kind: options?.kind, + worktreeName: options?.worktreeName, + setUpstream: options?.setUpstream, + upstreamRemote: options?.upstreamRemote, + upstreamBranch: options?.upstreamBranch, + ensureRemoteName: options?.ensureRemoteName, + ensureRemoteUrl: options?.ensureRemoteUrl, + createdFromBranch: options?.createdFromBranch, }); } diff --git a/packages/ui/src/lib/worktrees/worktreeCreate.ts b/packages/ui/src/lib/worktrees/worktreeCreate.ts new file mode 100644 index 00000000..2ed77db6 --- /dev/null +++ b/packages/ui/src/lib/worktrees/worktreeCreate.ts @@ -0,0 +1,120 @@ +import { getGitBranches, getGitStatus } from '@/lib/gitApi'; +import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { createWorktree } from '@/lib/worktrees/worktreeManager'; +import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; + +const parseTrackingRef = (tracking: string | null | undefined): { remote: string; branch: string } | null => { + const value = String(tracking || '').trim().replace(/^remotes\//, ''); + if (!value) { + return null; + } + + const separatorIndex = value.indexOf('/'); + if (separatorIndex <= 0 || separatorIndex >= value.length - 1) { + return null; + } + + return { + remote: value.slice(0, separatorIndex), + branch: value.slice(separatorIndex + 1), + }; +}; + +const normalizeBranchName = (value: string): string => { + return String(value || '') + .trim() + .replace(/^refs\/heads\//, '') + .replace(/^heads\//, '') + .replace(/^remotes\//, ''); +}; + +const resolveLocalBranchName = (args: CreateWorktreeArgs): string => { + if (args.branchName) { + return normalizeBranchName(args.branchName); + } + if (args.mode === 'existing') { + return normalizeBranchName(args.existingBranch || args.preferredName || ''); + } + return normalizeBranchName(args.preferredName || ''); +}; + +export const resolveRootTrackingRemote = async (projectDirectory: string): Promise => { + const rootBranch = await getRootBranch(projectDirectory); + + try { + const branchState = await getGitBranches(projectDirectory); + const tracking = branchState.branches?.[rootBranch]?.tracking || null; + const parsed = parseTrackingRef(tracking); + if (parsed?.remote) { + return parsed.remote; + } + } catch { + // ignore and fallback to status tracking + } + + try { + const status = await getGitStatus(projectDirectory); + const parsed = parseTrackingRef(status.tracking); + if (parsed?.remote) { + return parsed.remote; + } + } catch { + // ignore + } + + return null; +}; + +export const resolveWorktreeUpstreamDefaults = async ( + projectDirectory: string, + localBranch: string +): Promise<{ setUpstream: true; upstreamRemote: string; upstreamBranch: string } | null> => { + const remote = await resolveRootTrackingRemote(projectDirectory); + const normalizedBranch = normalizeBranchName(localBranch); + if (!remote || !normalizedBranch) { + return null; + } + + return { + setUpstream: true, + upstreamRemote: remote, + upstreamBranch: normalizedBranch, + }; +}; + +export const withWorktreeUpstreamDefaults = async ( + projectDirectory: string, + args: CreateWorktreeArgs, + options?: { resolvedRootTrackingRemote?: string | null } +): Promise => { + const localBranch = resolveLocalBranchName(args); + const resolvedRemote = options?.resolvedRootTrackingRemote; + const defaults = resolvedRemote === undefined + ? await resolveWorktreeUpstreamDefaults(projectDirectory, localBranch) + : (resolvedRemote && normalizeBranchName(localBranch) + ? { + setUpstream: true as const, + upstreamRemote: resolvedRemote, + upstreamBranch: normalizeBranchName(localBranch), + } + : null); + if (!defaults) { + return args; + } + + return { + ...args, + setUpstream: args.setUpstream ?? defaults.setUpstream, + upstreamRemote: args.upstreamRemote || defaults.upstreamRemote, + upstreamBranch: args.upstreamBranch || defaults.upstreamBranch, + }; +}; + +export const createWorktreeWithDefaults = async ( + project: ProjectRef, + args: CreateWorktreeArgs, + options?: { resolvedRootTrackingRemote?: string | null } +) => { + const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, args, options); + return createWorktree(project, resolvedArgs); +}; diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 80b217be..f67e4c5c 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -1,7 +1,13 @@ -import { opencodeClient } from '@/lib/opencode/client'; import { substituteCommandVariables } from '@/lib/openchamberConfig'; import type { WorktreeMetadata } from '@/types/worktree'; -import { deleteRemoteBranch, getGitStatus } from '@/lib/gitApi'; +import { + deleteRemoteBranch, + git, +} from '@/lib/gitApi'; +import type { + CreateGitWorktreePayload, + GitWorktreeValidationResult, +} from '@/lib/api/types'; export type ProjectRef = { id: string; path: string }; @@ -16,21 +22,24 @@ const normalizePath = (value: string): string => { const slugifyWorktreeName = (value: string): string => { return value .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') + .replace(/^refs\/heads\//, '') + .replace(/^heads\//, '') + .replace(/\s+/g, '-') + .replace(/^\/+|\/+$/g, '') + .split('/').join('-') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/-+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80); }; -const unwrapSdkData = (value: unknown): unknown => { - if (!value || typeof value !== 'object') { - return value; - } - const record = value as Record; - if ('data' in record) { - return record.data; - } - return value; +const normalizeBranchName = (value: string): string => { + return value + .trim() + .replace(/^refs\/heads\//, '') + .replace(/^heads\//, '') + .replace(/\s+/g, '-') + .replace(/^\/+|\/+$/g, ''); }; const deriveSdkWorktreeNameFromDirectory = (directory: string): string => { @@ -57,106 +66,73 @@ export const buildSdkStartCommand = (args: { return joined.trim().length > 0 ? joined : undefined; }; -const waitForSdkWorktreeReady = async (directory: string, timeoutMs = 60_000): Promise => { - const target = normalizePath(directory); - if (!target) { - return; - } +const toCreatePayload = (args: { + preferredName?: string; + setupCommands?: string[]; + mode?: 'new' | 'existing'; + worktreeName?: string; + branchName?: string; + existingBranch?: string; + startRef?: string; + setUpstream?: boolean; + upstreamRemote?: string; + upstreamBranch?: string; + ensureRemoteName?: string; + ensureRemoteUrl?: string; +}, projectDirectory: string): CreateGitWorktreePayload => { + const mode = args.mode === 'existing' ? 'existing' : 'new'; - await new Promise((resolve, reject) => { - let done = false; - let unsubscribe = () => {}; - let timeout: ReturnType | null = null; - const cleanup = () => { - if (timeout) { - clearTimeout(timeout); - } - try { - unsubscribe(); - } catch { - // ignore - } - }; - const finish = (result?: { error?: string }) => { - if (done) return; - done = true; - cleanup(); - if (result?.error) { - reject(new Error(result.error)); - } else { - resolve(); - } - }; + const worktreeNameSeed = args.worktreeName ?? args.preferredName ?? ''; + const worktreeName = slugifyWorktreeName(worktreeNameSeed); - timeout = setTimeout(() => { - finish({ error: 'Worktree startup timed out' }); - }, timeoutMs); + const branchNameSeed = args.branchName ?? (mode === 'new' ? args.preferredName : undefined) ?? ''; + const branchName = normalizeBranchName(branchNameSeed); - unsubscribe = opencodeClient.subscribeToGlobalEvents( - (event) => { - const payload = event.payload as { type?: string; properties?: Record }; - if (payload?.type === 'worktree.ready') { - finish(); - return; - } - if (payload?.type === 'worktree.failed') { - const message = typeof payload.properties?.message === 'string' - ? payload.properties.message - : 'Worktree failed to start'; - finish({ error: message }); - } - }, - undefined, - undefined, - { directory: target } - ); + const existingBranch = normalizeBranchName(args.existingBranch ?? args.branchName ?? ''); + const startRef = (args.startRef || '').trim(); + + const commands = Array.isArray(args.setupCommands) ? args.setupCommands : []; + const startCommand = buildSdkStartCommand({ + projectDirectory, + setupCommands: commands, }); + + return { + mode, + ...(worktreeName ? { worktreeName } : {}), + ...(branchName ? { branchName } : {}), + ...(existingBranch ? { existingBranch } : {}), + ...(startRef ? { startRef } : {}), + ...(startCommand ? { startCommand } : {}), + ...(args.setUpstream ? { setUpstream: true } : {}), + ...(args.upstreamRemote ? { upstreamRemote: args.upstreamRemote } : {}), + ...(args.upstreamBranch ? { upstreamBranch: args.upstreamBranch } : {}), + ...(args.ensureRemoteName ? { ensureRemoteName: args.ensureRemoteName } : {}), + ...(args.ensureRemoteUrl ? { ensureRemoteUrl: args.ensureRemoteUrl } : {}), + }; }; export async function listProjectWorktrees(project: ProjectRef): Promise { const projectDirectory = project.path; - const scoped = opencodeClient.getScopedApiClient(projectDirectory); + const normalizedProjectDirectory = normalizePath(projectDirectory); - const results: WorktreeMetadata[] = []; - - // SDK worktrees - try { - const raw = await scoped.worktree.list(); - const data = unwrapSdkData(raw); - const directories = Array.isArray(data) ? data : []; - - for (const entry of directories) { - if (typeof entry !== 'string' || entry.trim().length === 0) { - continue; - } - const directory = normalizePath(entry); - const name = deriveSdkWorktreeNameFromDirectory(directory); - results.push({ - source: 'sdk', - name, - path: directory, + const worktrees = await git.worktree.list(projectDirectory).catch(() => []); + const results: WorktreeMetadata[] = worktrees + .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0) + .map((entry) => { + const worktreePath = normalizePath(entry.path); + const branch = (entry.branch || '').replace(/^refs\/heads\//, '').trim(); + const name = (entry.name || '').trim(); + return { + source: 'sdk' as const, + name: name || deriveSdkWorktreeNameFromDirectory(worktreePath), + path: worktreePath, projectDirectory, - branch: '', - label: name, - }); - } - } catch { - // ignore - } - - // Enrich worktrees with branch information from git status - await Promise.all( - results.map(async (worktree) => { - try { - const status = await getGitStatus(worktree.path); - if (status?.current) { - worktree.branch = status.current; - } - } catch { - // ignore - branch will remain empty - } + branch, + label: branch || name || deriveSdkWorktreeNameFromDirectory(worktreePath), + }; }) - ); + .filter((entry) => normalizePath(entry.path) !== normalizedProjectDirectory); return results.sort((a, b) => { const aLabel = (a.label || a.branch || a.path).toLowerCase(); @@ -165,71 +141,67 @@ export async function listProjectWorktrees(project: ProjectRef): Promise { + mode?: 'new' | 'existing'; + worktreeName?: string; + branchName?: string; + existingBranch?: string; + startRef?: string; + setUpstream?: boolean; + upstreamRemote?: string; + upstreamBranch?: string; + ensureRemoteName?: string; + ensureRemoteUrl?: string; +}; + +export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise { const projectDirectory = project.path; - const scoped = opencodeClient.getScopedApiClient(projectDirectory); + const payload = toCreatePayload(args, projectDirectory); - const baseName = typeof args.preferredName === 'string' ? slugifyWorktreeName(args.preferredName) : ''; - const seed = baseName || undefined; + const created = await git.worktree.create(projectDirectory, payload); + const returnedName = typeof created?.name === 'string' ? created.name : ''; + const returnedBranch = typeof created?.branch === 'string' ? created.branch : ''; + const returnedPath = typeof created?.path === 'string' ? created.path : ''; - const commands = Array.isArray(args.setupCommands) ? args.setupCommands : []; - const startCommand = buildSdkStartCommand({ - projectDirectory, - setupCommands: commands, - }); - - const name = seed || undefined; - const raw = await scoped.worktree.create({ - worktreeCreateInput: { - ...(name ? { name } : {}), - ...(startCommand ? { startCommand } : {}), - }, - }); - - const data = unwrapSdkData(raw); - if (!data || typeof data !== 'object') { - throw new Error('Invalid worktree.create response'); - } - - const record = data as Record; - const returnedName = typeof record.name === 'string' ? record.name : name; - const returnedBranch = typeof record.branch === 'string' ? record.branch : (returnedName ? `opencode/${returnedName}` : ''); - const returnedDirectory = typeof record.directory === 'string' ? record.directory : ''; - - if (!returnedName || !returnedDirectory) { - throw new Error('Worktree create missing name/directory'); + if (!returnedName || !returnedPath) { + throw new Error('Worktree create missing name/path'); } const metadata: WorktreeMetadata = { source: 'sdk', name: returnedName, - path: normalizePath(returnedDirectory), + path: normalizePath(returnedPath), projectDirectory, branch: returnedBranch, - label: returnedName, + label: returnedBranch || returnedName, }; - await waitForSdkWorktreeReady(metadata.path); - return metadata; } +export async function validateWorktreeCreate(project: ProjectRef, args: CreateWorktreeArgs): Promise { + const projectDirectory = project.path; + const payload = toCreatePayload(args, projectDirectory); + return git.worktree.validate(projectDirectory, payload); +} + export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: { deleteRemoteBranch?: boolean; + deleteLocalBranch?: boolean; remoteName?: string; - force?: boolean; }): Promise { const projectDirectory = project.path; const deleteRemote = Boolean(options?.deleteRemoteBranch); + const deleteLocalBranch = options?.deleteLocalBranch === true; const remoteName = options?.remoteName; - const scoped = opencodeClient.getScopedApiClient(projectDirectory); - const raw = await scoped.worktree.remove({ worktreeRemoveInput: { directory: worktree.path } }); - const ok = unwrapSdkData(raw); - if (ok !== true) { + const raw = await git.worktree.remove(projectDirectory, { + directory: worktree.path, + deleteLocalBranch, + }); + if (!raw?.success) { throw new Error('Worktree removal failed'); } diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts index f34bca07..4809e2d0 100644 --- a/packages/ui/src/stores/sessionStore.ts +++ b/packages/ui/src/stores/sessionStore.ts @@ -29,8 +29,8 @@ interface SessionState { interface SessionActions { loadSessions: () => Promise; createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; - deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise; - deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; + deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise; + deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; updateSessionTitle: (id: string, title: string) => Promise; shareSession: (id: string) => Promise; unshareSession: (id: string) => Promise; @@ -132,7 +132,7 @@ const clearInvalidSessionSelection = (directory: string | null | undefined, vali const archiveSessionWorktree = async ( metadata: WorktreeMetadata, - options?: { deleteRemoteBranch?: boolean; remoteName?: string } + options?: { deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string } ) => { const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined)); @@ -150,8 +150,8 @@ const archiveSessionWorktree = async ( status ? ({ ...metadata, status } as WorktreeMetadata) : metadata, { deleteRemoteBranch: options?.deleteRemoteBranch, + deleteLocalBranch: options?.deleteLocalBranch, remoteName: options?.remoteName, - force: Boolean(status?.isDirty), } ); }; @@ -955,6 +955,7 @@ export const useSessionStore = create()( try { await archiveSessionWorktree(metadata, { deleteRemoteBranch: options?.deleteRemoteBranch, + deleteLocalBranch: options?.deleteLocalBranch, remoteName: options?.remoteName, }); archiveSucceeded = true; @@ -1012,7 +1013,7 @@ export const useSessionStore = create()( deleteSessions: async ( ids: string[], - options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean } + options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean } ) => { const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0))); if (uniqueIds.length === 0) { @@ -1064,6 +1065,7 @@ export const useSessionStore = create()( try { await archiveSessionWorktree(metadata, { deleteRemoteBranch: options?.deleteRemoteBranch, + deleteLocalBranch: options?.deleteLocalBranch, remoteName: options?.remoteName, }); archivedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory }); diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 89a7a01b..6097700a 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -215,8 +215,8 @@ export interface SessionStore { createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; createSessionFromAssistantMessage: (sourceMessageId: string) => Promise; - deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise; - deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; + deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise; + deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; updateSessionTitle: (id: string, title: string) => Promise; shareSession: (id: string) => Promise; unshareSession: (id: string) => Promise; diff --git a/packages/ui/src/stores/useAgentGroupsStore.ts b/packages/ui/src/stores/useAgentGroupsStore.ts index 03f91354..eeca5eea 100644 --- a/packages/ui/src/stores/useAgentGroupsStore.ts +++ b/packages/ui/src/stores/useAgentGroupsStore.ts @@ -272,6 +272,39 @@ const collectDeleteCandidates = async (params: { return results; }; +const deleteGroupWorktreeSessions = async (params: { + group: AgentGroup; + projectDirectory: string; + worktreePaths: string[]; +}) => { + const apiClient = opencodeClient.getApiClient(); + const candidates = await collectDeleteCandidates({ + apiClient, + group: params.group, + projectDirectory: params.projectDirectory, + worktreePaths: params.worktreePaths, + }); + + const sessionStore = useSessionStore.getState(); + const ids = new Set(); + + candidates.forEach(({ worktreePath, sessionIds, metadata }) => { + sessionIds.forEach((id) => { + ids.add(id); + if (metadata) { + sessionStore.setWorktreeMetadata(id, metadata); + sessionStore.setSessionDirectory(id, worktreePath); + } + }); + }); + + if (ids.size === 0) { + return { failedIds: [] as string[] }; + } + + return sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true }); +}; + /** * Parse a session title to extract group, provider, model, and index. * Title format: groupSlug/provider/model[/index] @@ -555,27 +588,11 @@ export const useAgentGroupsStore = create()( set({ isLoading: true, error: null }); try { - const apiClient = opencodeClient.getApiClient(); - const candidates = await collectDeleteCandidates({ - apiClient, + const { failedIds } = await deleteGroupWorktreeSessions({ group, projectDirectory: normalize(projectDirectory), worktreePaths: group.sessions.map((s) => s.path), }); - - const sessionStore = useSessionStore.getState(); - const ids = new Set(); - candidates.forEach(({ worktreePath, sessionIds, metadata }) => { - sessionIds.forEach((id) => { - ids.add(id); - if (metadata) { - sessionStore.setWorktreeMetadata(id, metadata); - sessionStore.setSessionDirectory(id, worktreePath); - } - }); - }); - - const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true }); if (failedIds.length > 0) { set({ error: 'Failed to delete some sessions' }); } @@ -613,27 +630,11 @@ export const useAgentGroupsStore = create()( set({ isLoading: true, error: null }); try { - const apiClient = opencodeClient.getApiClient(); - const candidates = await collectDeleteCandidates({ - apiClient, + const { failedIds } = await deleteGroupWorktreeSessions({ group, projectDirectory: normalize(projectDirectory), worktreePaths: [normalizedWorktreePath], }); - - const sessionStore = useSessionStore.getState(); - const ids = new Set(); - candidates.forEach(({ worktreePath: resolvedPath, sessionIds, metadata }) => { - sessionIds.forEach((id) => { - ids.add(id); - if (metadata) { - sessionStore.setWorktreeMetadata(id, metadata); - sessionStore.setSessionDirectory(id, resolvedPath); - } - }); - }); - - const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true }); if (failedIds.length > 0) { set({ error: 'Failed to delete some sessions' }); } @@ -690,27 +691,11 @@ export const useAgentGroupsStore = create()( set({ isLoading: true, error: null }); try { - const apiClient = opencodeClient.getApiClient(); - const candidates = await collectDeleteCandidates({ - apiClient, + const { failedIds } = await deleteGroupWorktreeSessions({ group, projectDirectory: normalize(projectDirectory), worktreePaths: toDelete, }); - - const sessionStore = useSessionStore.getState(); - const ids = new Set(); - candidates.forEach(({ worktreePath, sessionIds, metadata }) => { - sessionIds.forEach((id) => { - ids.add(id); - if (metadata) { - sessionStore.setWorktreeMetadata(id, metadata); - sessionStore.setSessionDirectory(id, worktreePath); - } - }); - }); - - const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true }); if (failedIds.length > 0) { set({ error: 'Failed to delete some sessions' }); } diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts index 24f9326e..77ecf9bb 100644 --- a/packages/ui/src/stores/useMultiRunStore.ts +++ b/packages/ui/src/stores/useMultiRunStore.ts @@ -3,7 +3,8 @@ import { devtools } from 'zustand/middleware'; import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun'; import { opencodeClient } from '@/lib/opencode/client'; import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; -import { createSdkWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { checkIsGitRepository } from '@/lib/gitApi'; import { useSessionStore } from './sessionStore'; @@ -31,8 +32,8 @@ const toModelSlug = (providerID: string, modelID: string): string => { }; /** - * Seed name for SDK worktree creation. - * Uses slashes for readability; SDK will slugify. + * Seed name for worktree creation. + * Uses slashes for readability; create payload will slugify. */ const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => { return `${groupSlug}/${modelSlug}`; @@ -123,6 +124,7 @@ export const useMultiRunStore = create()( const groupSlug = toGitSafeSlug(groupName); const rootBranch = await getRootBranch(directory); + const rootTrackingRemote = await resolveRootTrackingRemote(directory); const createdRuns: Array<{ sessionId: string; @@ -156,11 +158,16 @@ export const useMultiRunStore = create()( const preferredName = count > 1 ? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`) : generateWorktreeNameSeed(groupSlug, modelSlug); - try { - const worktreeMetadata = await createSdkWorktree(project, { + const worktreeMetadata = await createWorktreeWithDefaults(project, { preferredName, + mode: 'new', + branchName: preferredName, + worktreeName: preferredName, + startRef: params.worktreeBaseBranch || 'HEAD', setupCommands: commandsToRun, + }, { + resolvedRootTrackingRemote: rootTrackingRemote, }); const enrichedMetadata = { diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index f359cc7b..8e3e6881 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -2282,12 +2282,61 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } case 'api:git/worktrees': { + const { directory, method } = (payload || {}) as { + directory?: string; + method?: string; + body?: unknown; + directoryPath?: string; + deleteLocalBranch?: boolean; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const worktrees = await gitService.listGitWorktrees(directory); + return { id, type, success: true, data: worktrees }; + } + + if (normalizedMethod === 'POST') { + const created = await gitService.createWorktree(directory, (payload || {}) as gitService.CreateGitWorktreePayload); + return { id, type, success: true, data: created }; + } + + if (normalizedMethod === 'DELETE') { + const removePayload = payload as { + body?: { directory?: string; deleteLocalBranch?: boolean }; + directory?: string; + deleteLocalBranch?: boolean; + }; + const bodyDirectory = typeof removePayload?.body?.directory === 'string' + ? removePayload.body.directory + : ''; + const legacyDirectory = typeof removePayload?.directory === 'string' ? removePayload.directory : ''; + const worktreeDirectory = bodyDirectory || legacyDirectory || ''; + + if (!worktreeDirectory) { + return { id, type, success: false, error: 'Worktree directory is required' }; + } + const removed = await gitService.removeWorktree(directory, { + directory: worktreeDirectory, + deleteLocalBranch: removePayload?.body?.deleteLocalBranch === true || removePayload?.deleteLocalBranch === true, + }); + return { id, type, success: true, data: { success: Boolean(removed) } }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/worktrees/validate': { const { directory } = (payload || {}) as { directory?: string }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } - const worktrees = await gitService.listGitWorktrees(directory); - return { id, type, success: true, data: worktrees }; + const result = await gitService.validateWorktreeCreate(directory, (payload || {}) as gitService.CreateGitWorktreePayload); + return { id, type, success: true, data: result }; } case 'api:git/diff': { diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 54730d1f..c509b022 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -170,6 +170,43 @@ function normalizePath(p: string): string { return normalized; } +function normalizeDirectoryPath(value: string): string { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + if (trimmed === '~') { + return os.homedir(); + } + + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return path.join(os.homedir(), trimmed.slice(2)); + } + + return trimmed; +} + +function cleanBranchName(branch: string): string { + if (!branch) { + return branch; + } + if (branch.startsWith('refs/heads/')) { + return branch.substring('refs/heads/'.length); + } + if (branch.startsWith('heads/')) { + return branch.substring('heads/'.length); + } + if (branch.startsWith('refs/')) { + return branch.substring('refs/'.length); + } + return branch; +} + /** * Execute a raw git command and return the output */ @@ -703,45 +740,1026 @@ export async function deleteRemoteBranch(directory: string, branch: string, remo // ============== Worktree Operations ============== export interface GitWorktreeInfo { + head: string; + name: string; + branch: string; + path: string; +} + +type WorktreeListEntry = { worktree: string; head?: string; + branchRef?: string; branch?: string; +}; + +export interface GitWorktreeValidationError { + code: string; + message: string; } +export interface GitWorktreeValidationResult { + ok: boolean; + errors: GitWorktreeValidationError[]; + resolved?: { + mode?: 'new' | 'existing'; + localBranch?: string | null; + }; +} + +export interface CreateGitWorktreePayload { + mode?: 'new' | 'existing'; + worktreeName?: string; + name?: string; + branchName?: string; + existingBranch?: string; + startRef?: string; + startCommand?: string; + setUpstream?: boolean; + upstreamRemote?: string; + upstreamBranch?: string; + ensureRemoteName?: string; + ensureRemoteUrl?: string; +} + +export interface RemoveGitWorktreePayload { + directory: string; + deleteLocalBranch?: boolean; +} + +const OPENCODE_ADJECTIVES = [ + 'brave', 'calm', 'clever', 'cosmic', 'crisp', 'curious', 'eager', 'gentle', 'glowing', 'happy', + 'hidden', 'jolly', 'kind', 'lucky', 'mighty', 'misty', 'neon', 'nimble', 'playful', 'proud', + 'quick', 'quiet', 'shiny', 'silent', 'stellar', 'sunny', 'swift', 'tidy', 'witty', +]; + +const OPENCODE_NOUNS = [ + 'cabin', 'cactus', 'canyon', 'circuit', 'comet', 'eagle', 'engine', 'falcon', 'forest', 'garden', + 'harbor', 'island', 'knight', 'lagoon', 'meadow', 'moon', 'mountain', 'nebula', 'orchid', 'otter', + 'panda', 'pixel', 'planet', 'river', 'rocket', 'sailor', 'squid', 'star', 'tiger', 'wizard', 'wolf', +]; + +const OPENCODE_WORKTREE_ATTEMPTS = 26; + +const getOpenCodeDataPath = () => { + const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(xdgDataHome, 'opencode'); +}; + +const pickRandom = (values: string[]) => values[Math.floor(Math.random() * values.length)]; + +const generateOpenCodeRandomName = () => `${pickRandom(OPENCODE_ADJECTIVES)}-${pickRandom(OPENCODE_NOUNS)}`; + +const slugWorktreeName = (value: string) => { + return String(value || '') + .trim() + .replace(/^refs\/heads\//, '') + .replace(/^heads\//, '') + .replace(/\s+/g, '-') + .replace(/^\/+|\/+$/g, '') + .split('/').join('-') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') + .slice(0, 80); +}; + +const parseWorktreePorcelain = (raw: string): WorktreeListEntry[] => { + const lines = String(raw || '').split('\n').map((line) => line.trim()); + const entries: WorktreeListEntry[] = []; + let current: WorktreeListEntry | null = null; + + for (const line of lines) { + if (!line) { + if (current?.worktree) { + entries.push(current); + } + current = null; + continue; + } + + if (line.startsWith('worktree ')) { + if (current?.worktree) { + entries.push(current); + } + current = { worktree: line.substring('worktree '.length).trim() }; + continue; + } + + if (!current) { + continue; + } + + if (line.startsWith('HEAD ')) { + current.head = line.substring('HEAD '.length).trim(); + continue; + } + + if (line.startsWith('branch ')) { + const branchRef = line.substring('branch '.length).trim(); + current.branchRef = branchRef; + current.branch = cleanBranchName(branchRef); + } + } + + if (current?.worktree) { + entries.push(current); + } + + return entries; +}; + +const canonicalPath = async (input: string): Promise => { + const absolutePath = path.resolve(input); + const realPath = await fs.promises.realpath(absolutePath).catch(() => absolutePath); + const normalized = path.normalize(realPath); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +}; + +const checkPathExists = async (targetPath: string): Promise => { + try { + await fs.promises.stat(targetPath); + return true; + } catch { + return false; + } +}; + +const normalizeStartRef = (value: string | undefined): string => { + const trimmed = String(value || '').trim(); + return trimmed || 'HEAD'; +}; + +const parseRemoteBranchRef = (value: string) => { + const trimmed = String(value || '').trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith('refs/remotes/')) { + const rest = trimmed.substring('refs/remotes/'.length); + const slashIndex = rest.indexOf('/'); + if (slashIndex <= 0 || slashIndex === rest.length - 1) { + return null; + } + return { + remote: rest.slice(0, slashIndex), + branch: rest.slice(slashIndex + 1), + remoteRef: rest, + fullRef: `refs/remotes/${rest}`, + }; + } + + if (trimmed.startsWith('remotes/')) { + return parseRemoteBranchRef(`refs/${trimmed}`); + } + + const slashIndex = trimmed.indexOf('/'); + if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { + return null; + } + + return { + remote: trimmed.slice(0, slashIndex), + branch: trimmed.slice(slashIndex + 1), + remoteRef: trimmed, + fullRef: `refs/remotes/${trimmed}`, + }; +}; + +const normalizeUpstreamTarget = (remote: string | undefined, branch: string | undefined) => { + const remoteName = String(remote || '').trim(); + const branchName = String(branch || '').trim(); + if (!remoteName || !branchName) { + return null; + } + return { + remote: remoteName, + branch: branchName, + full: `${remoteName}/${branchName}`, + }; +}; + +type GitCommandResult = { + success: boolean; + exitCode: number; + stdout: string; + stderr: string; + message?: string; +}; + +const runGitCommand = async (cwd: string, args: string[]): Promise => { + const result = await execGit(args, cwd); + const message = [result.stderr, result.stdout].map((value) => String(value || '').trim()).filter(Boolean).join('\n').trim(); + return { + success: result.exitCode === 0, + exitCode: result.exitCode, + stdout: String(result.stdout || ''), + stderr: String(result.stderr || ''), + message, + }; +}; + +const runGitCommandOrThrow = async (cwd: string, args: string[], fallbackMessage: string) => { + const result = await runGitCommand(cwd, args); + if (!result.success) { + throw new Error(result.message || fallbackMessage || 'Git command failed'); + } + return result; +}; + +const ensureOpenCodeProjectId = async (primaryWorktree: string): Promise => { + const gitDir = path.join(primaryWorktree, '.git'); + const idFile = path.join(gitDir, 'opencode'); + const existing = await fs.promises.readFile(idFile, 'utf8').then((value) => value.trim()).catch(() => ''); + if (existing) { + return existing; + } + + const rootsResult = await runGitCommandOrThrow( + primaryWorktree, + ['rev-list', '--max-parents=0', '--all'], + 'Failed to resolve repository roots' + ); + + const roots = rootsResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + + const projectId = roots[0] || ''; + if (!projectId) { + throw new Error('Failed to derive OpenCode project ID'); + } + + await fs.promises.mkdir(gitDir, { recursive: true }).catch(() => undefined); + await fs.promises.writeFile(idFile, projectId, 'utf8').catch(() => undefined); + return projectId; +}; + +const resolveWorktreeProjectContext = async (directory: string) => { + const directoryPath = normalizeDirectoryPath(directory); + if (!directoryPath) { + throw new Error('Directory is required'); + } + + const topResult = await runGitCommandOrThrow( + directoryPath, + ['rev-parse', '--show-toplevel'], + 'Failed to resolve git top-level directory' + ); + const sandbox = path.resolve(directoryPath, topResult.stdout.trim()); + + const commonResult = await runGitCommandOrThrow( + sandbox, + ['rev-parse', '--git-common-dir'], + 'Failed to resolve git common directory' + ); + const commonDir = path.resolve(sandbox, commonResult.stdout.trim()); + const primaryWorktree = path.dirname(commonDir); + const projectID = await ensureOpenCodeProjectId(primaryWorktree); + const worktreeRoot = path.join(getOpenCodeDataPath(), 'worktree', projectID); + + return { projectID, sandbox, primaryWorktree, worktreeRoot }; +}; + +const listWorktreeEntries = async (directory: string): Promise => { + const rawResult = await runGitCommandOrThrow(directory, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees'); + return parseWorktreePorcelain(rawResult.stdout); +}; + +const resolveWorktreeNameCandidates = (baseName: string): string[] => { + const normalizedBase = slugWorktreeName(baseName || ''); + if (!normalizedBase) { + return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, () => generateOpenCodeRandomName()); + } + return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, (_, index) => { + if (index === 0) { + return normalizedBase; + } + return `${normalizedBase}-${generateOpenCodeRandomName()}`; + }); +}; + +const resolveCandidateDirectory = async ( + worktreeRoot: string, + preferredName: string, + explicitBranchName: string, + primaryWorktree: string +) => { + const candidates = resolveWorktreeNameCandidates(preferredName); + + for (const name of candidates) { + const directory = path.join(worktreeRoot, name); + if (await checkPathExists(directory)) { + continue; + } + + if (explicitBranchName) { + return { name, directory, branch: explicitBranchName }; + } + + const branch = `openchamber/${name}`; + const branchRef = `refs/heads/${branch}`; + const branchExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', branchRef]); + if (branchExists.success) { + continue; + } + + return { name, directory, branch }; + } + + throw new Error('Failed to generate a unique worktree name'); +}; + +const fetchRemoteBranchRef = async (primaryWorktree: string, remoteName: string, branchName: string) => { + const remote = String(remoteName || '').trim(); + const branch = String(branchName || '').trim(); + if (!remote || !branch) { + return; + } + + const refspec = `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`; + await runGitCommandOrThrow(primaryWorktree, ['fetch', remote, refspec], `Failed to fetch ${remote}/${branch}`); +}; + +const resolveBranchForExistingMode = async (primaryWorktree: string, existingBranch: string, preferredBranchName: string) => { + const requested = String(existingBranch || '').trim(); + if (!requested) { + throw new Error('existingBranch is required in existing mode'); + } + + const normalizedLocal = cleanBranchName(requested); + const localRef = `refs/heads/${normalizedLocal}`; + const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]); + if (localExists.success) { + return { + localBranch: normalizedLocal, + checkoutRef: normalizedLocal, + createLocalBranch: false, + remoteRef: null as ReturnType, + }; + } + + const remoteRef = parseRemoteBranchRef(requested); + if (!remoteRef) { + throw new Error(`Branch not found: ${requested}`); + } + + const remoteExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]); + if (!remoteExists.success) { + await fetchRemoteBranchRef(primaryWorktree, remoteRef.remote, remoteRef.branch).catch(() => undefined); + const recheck = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]); + if (!recheck.success) { + throw new Error(`Remote branch not found: ${requested}`); + } + } + + const localBranch = cleanBranchName(preferredBranchName || remoteRef.branch || requested); + if (!localBranch) { + throw new Error('Failed to resolve local branch name for existing branch worktree'); + } + + return { + localBranch, + checkoutRef: remoteRef.remoteRef, + createLocalBranch: true, + remoteRef, + }; +}; + +const findBranchInUse = async (primaryWorktree: string, localBranchName: string) => { + if (!localBranchName) { + return null; + } + const entries = await listWorktreeEntries(primaryWorktree); + const targetRef = `refs/heads/${localBranchName}`; + const targetClean = cleanBranchName(targetRef); + return entries.find((entry) => { + const entryRef = String(entry.branchRef || '').trim(); + const entryClean = cleanBranchName(entryRef || entry.branch || ''); + return entryRef === targetRef || entryClean === targetClean; + }) || null; +}; + +const runWorktreeStartCommand = async (directory: string, command: string): Promise<{ success: boolean; message?: string; stdout?: string; stderr?: string }> => { + const text = String(command || '').trim(); + if (!text) { + return { success: true }; + } + + const env = await buildGitEnv(); + if (process.platform === 'win32') { + try { + const { stdout, stderr } = await execFileAsync('cmd', ['/c', text], { + cwd: directory, + env, + maxBuffer: 20 * 1024 * 1024, + }); + return { success: true, stdout: String(stdout || ''), stderr: String(stderr || '') }; + } catch (error) { + const err = error as { stdout?: string; stderr?: string; message?: string }; + return { + success: false, + stdout: err.stdout, + stderr: err.stderr, + message: String(err.message || err.stderr || err.stdout || 'Failed to run start command').trim(), + }; + } + } + + try { + const { stdout, stderr } = await execFileAsync('bash', ['-lc', text], { + cwd: directory, + env, + maxBuffer: 20 * 1024 * 1024, + }); + return { success: true, stdout: String(stdout || ''), stderr: String(stderr || '') }; + } catch (error) { + const err = error as { stdout?: string; stderr?: string; message?: string }; + return { + success: false, + stdout: err.stdout, + stderr: err.stderr, + message: String(err.message || err.stderr || err.stdout || 'Failed to run start command').trim(), + }; + } +}; + +const loadProjectStartCommand = async (projectID: string): Promise => { + const storagePath = path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); + try { + const raw = await fs.promises.readFile(storagePath, 'utf8'); + const parsed = JSON.parse(raw) as { commands?: { start?: string } }; + const start = typeof parsed?.commands?.start === 'string' ? parsed.commands.start.trim() : ''; + return start || ''; + } catch { + return ''; + } +}; + +const getProjectStoragePath = (projectID: string) => { + return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); +}; + +const updateProjectSandboxes = async ( + projectID: string, + primaryWorktree: string, + updater: (project: { + id: string; + worktree: string; + vcs: string; + sandboxes: string[]; + time: { created: number; updated: number }; + }) => void +) => { + const storagePath = getProjectStoragePath(projectID); + await fs.promises.mkdir(path.dirname(storagePath), { recursive: true }); + + const now = Date.now(); + const base = { + id: projectID, + worktree: primaryWorktree, + vcs: 'git', + sandboxes: [] as string[], + time: { created: now, updated: now }, + }; + + const parsed = await fs.promises.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw) as typeof base).catch(() => null); + const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base; + current.id = String(current.id || projectID); + current.worktree = String(current.worktree || primaryWorktree); + current.vcs = current.vcs || 'git'; + current.sandboxes = Array.isArray(current.sandboxes) + ? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean) + : []; + const createdAt = Number(current?.time?.created); + current.time = { + created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now, + updated: now, + }; + + updater(current); + + current.sandboxes = [...new Set(current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean))]; + await fs.promises.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8'); +}; + +const syncProjectSandboxAdd = async (projectID: string, primaryWorktree: string, sandboxPath: string) => { + const sandbox = String(sandboxPath || '').trim(); + if (!sandbox) { + return; + } + await updateProjectSandboxes(projectID, primaryWorktree, (project) => { + if (!project.sandboxes.includes(sandbox)) { + project.sandboxes.push(sandbox); + } + }); +}; + +const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: string, sandboxPath: string) => { + const sandbox = String(sandboxPath || '').trim(); + if (!sandbox) { + return; + } + await updateProjectSandboxes(projectID, primaryWorktree, (project) => { + project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox); + }); +}; + +const queueWorktreeStartScripts = (directory: string, projectID: string, startCommand: string | undefined) => { + setTimeout(() => { + const run = async () => { + const projectStart = await loadProjectStartCommand(projectID); + if (projectStart) { + const projectResult = await runWorktreeStartCommand(directory, projectStart); + if (!projectResult.success) { + console.warn('[GitService] Worktree project start command failed:', projectResult.message || projectResult.stderr || projectResult.stdout); + return; + } + } + + const extraCommand = String(startCommand || '').trim(); + if (!extraCommand) { + return; + } + const extraResult = await runWorktreeStartCommand(directory, extraCommand); + if (!extraResult.success) { + console.warn('[GitService] Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout); + } + }; + + void run().catch((error) => { + console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error)); + }); + }, 0); +}; + +const ensureRemoteWithUrl = async (primaryWorktree: string, remoteName: string, remoteUrl: string) => { + const name = String(remoteName || '').trim(); + const url = String(remoteUrl || '').trim(); + if (!name || !url) { + return; + } + + const getUrl = await runGitCommand(primaryWorktree, ['remote', 'get-url', name]); + if (getUrl.success) { + const currentUrl = String(getUrl.stdout || '').trim(); + if (currentUrl !== url) { + await runGitCommandOrThrow(primaryWorktree, ['remote', 'set-url', name, url], 'Failed to update git remote URL'); + } + return; + } + + await runGitCommandOrThrow(primaryWorktree, ['remote', 'add', name, url], 'Failed to add git remote'); +}; + +const checkRemoteBranchExists = async (primaryWorktree: string, remoteName: string, branchName: string, remoteUrl = '') => { + const remote = String(remoteName || '').trim(); + const branch = String(branchName || '').trim(); + const url = String(remoteUrl || '').trim(); + if (!remote || !branch) { + return { success: false, found: false }; + } + + const target = url || remote; + const lsRemote = await runGitCommand(primaryWorktree, ['ls-remote', '--heads', target, `refs/heads/${branch}`]); + if (!lsRemote.success) { + return { success: false, found: false }; + } + + return { + success: true, + found: Boolean(String(lsRemote.stdout || '').trim()), + }; +}; + +const setBranchTrackingFallback = async (worktreeDirectory: string, localBranch: string, upstream: { remote: string; branch: string }) => { + await runGitCommandOrThrow( + worktreeDirectory, + ['config', `branch.${localBranch}.remote`, upstream.remote], + `Failed to set branch.${localBranch}.remote` + ); + await runGitCommandOrThrow( + worktreeDirectory, + ['config', `branch.${localBranch}.merge`, `refs/heads/${upstream.branch}`], + `Failed to set branch.${localBranch}.merge` + ); +}; + +const applyUpstreamConfiguration = async (args: { + primaryWorktree: string; + worktreeDirectory: string; + localBranch: string; + setUpstream: boolean; + upstreamRemote?: string; + upstreamBranch?: string; + ensureRemoteName?: string; + ensureRemoteUrl?: string; +}) => { + const { + primaryWorktree, + worktreeDirectory, + localBranch, + setUpstream, + upstreamRemote, + upstreamBranch, + ensureRemoteName, + ensureRemoteUrl, + } = args; + + if (!setUpstream) { + return; + } + + if (ensureRemoteName && ensureRemoteUrl) { + await ensureRemoteWithUrl(primaryWorktree, ensureRemoteName, ensureRemoteUrl); + } + + const upstream = normalizeUpstreamTarget(upstreamRemote, upstreamBranch); + if (!upstream || !localBranch) { + return; + } + + let fetched = true; + try { + await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch); + } catch { + fetched = false; + } + + if (fetched) { + await runGitCommandOrThrow( + worktreeDirectory, + ['branch', `--set-upstream-to=${upstream.full}`, localBranch], + `Failed to set upstream to ${upstream.full}` + ); + return; + } + + await setBranchTrackingFallback(worktreeDirectory, localBranch, upstream); +}; + /** * List all worktrees for a repository */ export async function listGitWorktrees(directory: string): Promise { - const result = await execGit(['worktree', 'list', '--porcelain'], directory); - - if (result.exitCode !== 0) { + const directoryPath = normalizeDirectoryPath(directory); + if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) { return []; } - const worktrees: GitWorktreeInfo[] = []; - let current: Partial = {}; + try { + const result = await runGitCommandOrThrow(directoryPath, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees'); + return parseWorktreePorcelain(result.stdout).map((entry) => ({ + head: entry.head || '', + name: path.basename(entry.worktree || ''), + branch: entry.branch || '', + path: entry.worktree, + })); + } catch (error) { + console.warn('[GitService] Failed to list worktrees, returning empty list:', error instanceof Error ? error.message : String(error)); + return []; + } +} - for (const line of result.stdout.split('\n')) { - if (line.startsWith('worktree ')) { - if (current.worktree) { - worktrees.push(current as GitWorktreeInfo); +export async function validateWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise { + const mode = input?.mode === 'existing' ? 'existing' : 'new'; + const errors: GitWorktreeValidationError[] = []; + + try { + const context = await resolveWorktreeProjectContext(directory); + const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + const startRef = normalizeStartRef(input?.startRef); + const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); + const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); + + let localBranch = ''; + let inferredUpstream: { remote: string; branch: string } | null = null; + + if (mode === 'existing') { + try { + const requestedExistingBranch = String(input?.existingBranch || '').trim(); + const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch); + if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) { + const lsRemote = await runGitCommand( + context.primaryWorktree, + ['ls-remote', '--heads', ensureRemoteUrl, `refs/heads/${parsedExistingRemote.branch}`] + ); + if (!lsRemote.success) { + throw new Error(`Unable to query remote ${ensureRemoteName}`); + } + if (!String(lsRemote.stdout || '').trim()) { + throw new Error(`Remote branch not found: ${parsedExistingRemote.remoteRef}`); + } + localBranch = cleanBranchName(preferredBranchName || parsedExistingRemote.branch); + inferredUpstream = { + remote: parsedExistingRemote.remote, + branch: parsedExistingRemote.branch, + }; + } else { + const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName); + localBranch = resolved.localBranch || ''; + if (resolved.remoteRef) { + inferredUpstream = { + remote: resolved.remoteRef.remote, + branch: resolved.remoteRef.branch, + }; + } + } + } catch (error) { + errors.push({ + code: 'branch_not_found', + message: error instanceof Error ? error.message : 'Existing branch not found', + }); } - current = { worktree: line.slice(9).trim() }; - } else if (line.startsWith('HEAD ')) { - current.head = line.slice(5).trim(); - } else if (line.startsWith('branch ')) { - current.branch = line.slice(7).trim(); - } else if (line === '' && current.worktree) { - worktrees.push(current as GitWorktreeInfo); - current = {}; + } else { + if (preferredBranchName) { + const exists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${preferredBranchName}`]); + if (exists.success) { + errors.push({ code: 'branch_exists', message: `Branch already exists: ${preferredBranchName}` }); + } + localBranch = preferredBranchName; + } + + const parsedRemoteRef = parseRemoteBranchRef(startRef); + if (startRef && startRef !== 'HEAD') { + if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) { + const remoteCheck = await checkRemoteBranchExists( + context.primaryWorktree, + parsedRemoteRef.remote, + parsedRemoteRef.branch, + ensureRemoteUrl + ); + if (!remoteCheck.success) { + errors.push({ code: 'remote_unreachable', message: `Unable to query remote ${ensureRemoteName}` }); + } else if (!remoteCheck.found) { + errors.push({ code: 'start_ref_not_found', message: `Remote branch not found: ${parsedRemoteRef.remoteRef}` }); + } + } else if (parsedRemoteRef) { + const remoteCheck = await checkRemoteBranchExists(context.primaryWorktree, parsedRemoteRef.remote, parsedRemoteRef.branch); + if (!remoteCheck.success) { + errors.push({ code: 'remote_unreachable', message: `Unable to query remote ${parsedRemoteRef.remote}` }); + } else if (!remoteCheck.found) { + errors.push({ code: 'start_ref_not_found', message: `Remote branch not found: ${parsedRemoteRef.remoteRef}` }); + } + } else { + const startRefExists = await runGitCommand(context.primaryWorktree, ['rev-parse', '--verify', '--quiet', startRef]); + if (!startRefExists.success) { + errors.push({ code: 'start_ref_not_found', message: `Start ref not found: ${startRef}` }); + } + } + } + + if (parsedRemoteRef) { + inferredUpstream = { remote: parsedRemoteRef.remote, branch: parsedRemoteRef.branch }; + } + } + + if (localBranch) { + const inUse = await findBranchInUse(context.primaryWorktree, localBranch); + if (inUse) { + errors.push({ code: 'branch_in_use', message: `Branch is already checked out in ${inUse.worktree}` }); + } + } + + if ((ensureRemoteName && !ensureRemoteUrl) || (!ensureRemoteName && ensureRemoteUrl)) { + errors.push({ code: 'invalid_remote_config', message: 'Both ensureRemoteName and ensureRemoteUrl are required together' }); + } + + const shouldSetUpstream = Boolean(input?.setUpstream); + if (shouldSetUpstream) { + const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); + const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); + + if (!upstreamRemote || !upstreamBranch) { + errors.push({ code: 'upstream_incomplete', message: 'upstreamRemote and upstreamBranch are required when setUpstream is true' }); + } else { + const remoteExists = await runGitCommand(context.primaryWorktree, ['remote', 'get-url', upstreamRemote]); + if (!remoteExists.success && (!ensureRemoteName || ensureRemoteName !== upstreamRemote)) { + errors.push({ code: 'remote_not_found', message: `Remote not found: ${upstreamRemote}` }); + } + } + } + + return { + ok: errors.length === 0, + errors, + resolved: { + mode, + localBranch: localBranch || null, + }, + }; + } catch (error) { + return { + ok: false, + errors: [{ + code: 'validation_failed', + message: error instanceof Error ? error.message : 'Failed to validate worktree creation', + }], + }; + } +} + +export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise { + const mode = input?.mode === 'existing' ? 'existing' : 'new'; + const context = await resolveWorktreeProjectContext(directory); + await fs.promises.mkdir(context.worktreeRoot, { recursive: true }); + + const preferredName = String(input?.worktreeName || input?.name || '').trim(); + const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + const startRef = normalizeStartRef(input?.startRef); + const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); + const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); + + const candidate = await resolveCandidateDirectory( + context.worktreeRoot, + preferredName, + mode === 'new' && preferredBranchName ? preferredBranchName : '', + context.primaryWorktree + ); + + let localBranch = ''; + let inferredUpstream: { remote: string; branch: string } | null = null; + const worktreeAddArgs = ['worktree', 'add', '--no-checkout']; + + if (mode === 'existing') { + const requestedExistingBranch = String(input?.existingBranch || '').trim(); + const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch); + if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) { + await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); + await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch); + } + + const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName); + localBranch = resolved.localBranch; + + const inUse = await findBranchInUse(context.primaryWorktree, localBranch); + if (inUse) { + throw new Error(`Branch is already checked out in ${inUse.worktree}`); + } + + if (resolved.createLocalBranch) { + worktreeAddArgs.push('-b', localBranch); + } + worktreeAddArgs.push(candidate.directory, resolved.checkoutRef); + + if (resolved.remoteRef) { + inferredUpstream = { + remote: resolved.remoteRef.remote, + branch: resolved.remoteRef.branch, + }; + } + } else { + localBranch = candidate.branch; + if (!localBranch) { + throw new Error('Failed to resolve branch name for new worktree'); + } + + const branchExists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${localBranch}`]); + if (branchExists.success) { + throw new Error(`Branch already exists: ${localBranch}`); + } + + const inUse = await findBranchInUse(context.primaryWorktree, localBranch); + if (inUse) { + throw new Error(`Branch is already checked out in ${inUse.worktree}`); + } + + worktreeAddArgs.push('-b', localBranch, candidate.directory); + if (startRef && startRef !== 'HEAD') { + worktreeAddArgs.push(startRef); + } + + const parsedRemoteStartRef = parseRemoteBranchRef(startRef); + if (parsedRemoteStartRef) { + inferredUpstream = { + remote: parsedRemoteStartRef.remote, + branch: parsedRemoteStartRef.branch, + }; } } - if (current.worktree) { - worktrees.push(current as GitWorktreeInfo); + if (ensureRemoteName && ensureRemoteUrl) { + await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); } - return worktrees; + if (mode === 'new') { + const parsedRemoteStartRef = parseRemoteBranchRef(startRef); + if (parsedRemoteStartRef) { + await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); + } + } + + await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); + await runGitCommandOrThrow(candidate.directory, ['reset', '--hard'], 'Failed to populate worktree'); + + try { + await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory); + } catch (error) { + console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); + } + + const shouldSetUpstream = Boolean(input?.setUpstream); + const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); + const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); + + if (shouldSetUpstream) { + await applyUpstreamConfiguration({ + primaryWorktree: context.primaryWorktree, + worktreeDirectory: candidate.directory, + localBranch, + setUpstream: shouldSetUpstream, + upstreamRemote, + upstreamBranch, + ensureRemoteName, + ensureRemoteUrl, + }); + } + + queueWorktreeStartScripts(candidate.directory, context.projectID, input?.startCommand); + + const headResult = await runGitCommand(candidate.directory, ['rev-parse', 'HEAD']); + const head = String(headResult.stdout || '').trim(); + + return { + head, + name: candidate.name, + branch: localBranch, + path: candidate.directory, + }; +} + +export async function removeWorktree(directory: string, input: RemoveGitWorktreePayload): Promise { + const targetDirectory = normalizeDirectoryPath(input?.directory); + if (!targetDirectory) { + throw new Error('Worktree directory is required'); + } + + const context = await resolveWorktreeProjectContext(directory); + const deleteLocalBranch = input?.deleteLocalBranch === true; + + const targetCanonical = await canonicalPath(targetDirectory); + const primaryCanonical = await canonicalPath(context.primaryWorktree); + if (targetCanonical === primaryCanonical) { + throw new Error('Cannot remove the primary workspace'); + } + + const entries = await listWorktreeEntries(context.primaryWorktree); + const matchedEntry = await (async () => { + for (const entry of entries) { + if (!entry?.worktree) { + continue; + } + const entryCanonical = await canonicalPath(entry.worktree); + if (entryCanonical === targetCanonical) { + return entry; + } + } + return null; + })(); + + if (!matchedEntry?.worktree) { + const targetExists = await checkPathExists(targetDirectory); + if (targetExists) { + await fs.promises.rm(targetDirectory, { recursive: true, force: true }); + } + + try { + await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory); + } catch (error) { + console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error)); + } + + return true; + } + + await runGitCommandOrThrow( + context.primaryWorktree, + ['worktree', 'remove', '--force', matchedEntry.worktree], + 'Failed to remove git worktree' + ); + + if (deleteLocalBranch) { + const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim()); + if (branchName) { + await runGitCommandOrThrow( + context.primaryWorktree, + ['branch', '-D', branchName], + `Failed to delete local branch ${branchName}` + ); + } + } + + try { + await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree); + } catch (error) { + console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error)); + } + + return true; } /** @@ -758,9 +1776,7 @@ export async function getAvailableBranchesForWorktree(directory: string): Promis const checkedOutBranches = new Set(); for (const wt of worktrees) { if (wt.branch) { - // Normalize branch name (remove refs/heads/ prefix) - const branchName = wt.branch.replace(/^refs\/heads\//, ''); - checkedOutBranches.add(branchName); + checkedOutBranches.add(wt.branch.replace(/^refs\/heads\//, '')); } } @@ -1049,56 +2065,147 @@ export async function gitPush( directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record } ): Promise<{ success: boolean; pushed: Array<{ local: string; remote: string }>; repo: string; ref: unknown }> { - const repo = await getRepository(directory); - const remote = options?.remote || 'origin'; + const remote = options?.remote?.trim(); const branch = options?.branch; const gitOptions = options?.options; - - // Determine if we should set upstream (default true if no options specified) - const setUpstream = gitOptions - ? hasOption(gitOptions, '--set-upstream') || hasOption(gitOptions, '-u') - : true; - - if (repo) { + + const describePushFailure = (value: unknown): string => { + const message = String( + (value as { message?: string } | undefined)?.message || + (value as { stderr?: string } | undefined)?.stderr || + (value as { stdout?: string } | undefined)?.stdout || + '' + ).trim(); + return message || 'Failed to push to remote'; + }; + + const buildUpstreamOptions = (raw?: string[] | Record): string[] => { + const normalized = normalizeGitOptions(raw); + if (hasOption(normalized, '--set-upstream') || hasOption(normalized, '-u')) { + return normalized; + } + return [...normalized, '--set-upstream']; + }; + + const looksLikeMissingUpstream = (value: unknown): boolean => { + const message = String( + (value as { message?: string } | undefined)?.message || + (value as { stderr?: string } | undefined)?.stderr || + '' + ).toLowerCase(); + return ( + message.includes('has no upstream') || + message.includes('no upstream') || + message.includes('set-upstream') || + message.includes('set upstream') || + (message.includes('upstream') && message.includes('push') && message.includes('-u')) + ); + }; + + const getCurrentBranch = async (): Promise => { + const result = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], directory); + return String(result.stdout || '').trim(); + }; + + const hasTrackingBranch = async (): Promise => { + const result = await execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], directory); + return result.exitCode === 0 && Boolean(String(result.stdout || '').trim()); + }; + + const getRemotes = async (): Promise => { + const result = await execGit(['remote'], directory); + if (result.exitCode !== 0) { + return []; + } + return String(result.stdout || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + }; + + const pushRaw = async (args: string[]) => { + const result = await execGit(args, directory); + if (result.exitCode !== 0) { + throw new Error(describePushFailure(result)); + } + }; + + const normalizePushResult = (local: string, remoteName: string) => ({ + success: true, + pushed: [{ local, remote: remoteName }], + repo: directory, + ref: null, + }); + + if (!remote && !branch) { try { - await repo.push(remote, branch, setUpstream); - + const args = ['push']; + const normalizedOptions = normalizeGitOptions(gitOptions); + if (normalizedOptions.length > 0) { + args.push(...normalizedOptions); + } + await pushRaw(args); return { success: true, - pushed: [{ local: branch || '', remote }], + pushed: [], repo: directory, ref: null, }; } catch (error) { - console.error('[GitService] Failed to push via VS Code API:', error); + if (!looksLikeMissingUpstream(error)) { + throw new Error(describePushFailure(error)); + } + + const currentBranch = await getCurrentBranch(); + const remotes = await getRemotes(); + const fallbackRemote = remotes.includes('origin') ? 'origin' : remotes[0]; + if (!currentBranch || !fallbackRemote) { + throw new Error(describePushFailure(error)); + } + + const args = ['push', ...buildUpstreamOptions(gitOptions), fallbackRemote, currentBranch]; + await pushRaw(args); + return normalizePushResult(currentBranch, fallbackRemote); } } - // Fallback to raw git - use full options here - const args = ['push']; - - // Add normalized options - const normalizedOptions = normalizeGitOptions(gitOptions); - - // If no options provided, default to -u for upstream - if (normalizedOptions.length === 0) { - args.push('-u'); - } else { - args.push(...normalizedOptions); - } - - // Add remote and branch - args.push(remote); - if (branch) args.push(branch); + const remoteName = remote || 'origin'; - const result = await execGit(args, directory); - - return { - success: result.exitCode === 0, - pushed: result.exitCode === 0 ? [{ local: branch || '', remote }] : [], - repo: directory, - ref: null, - }; + if (!branch) { + try { + const currentBranch = await getCurrentBranch(); + const tracking = await hasTrackingBranch(); + if (currentBranch && !tracking) { + const args = ['push', ...buildUpstreamOptions(gitOptions), remoteName, currentBranch]; + await pushRaw(args); + return normalizePushResult(currentBranch, remoteName); + } + } catch (error) { + console.warn('[GitService] Failed to determine upstream state before push:', error); + } + } + + try { + const args = ['push', ...normalizeGitOptions(gitOptions), remoteName]; + if (branch) { + args.push(branch); + } + await pushRaw(args); + return normalizePushResult(branch || '', remoteName); + } catch (error) { + if (!looksLikeMissingUpstream(error)) { + throw new Error(describePushFailure(error)); + } + + const fallbackBranch = branch || await getCurrentBranch(); + if (!fallbackBranch) { + throw new Error(describePushFailure(error)); + } + + const args = ['push', ...buildUpstreamOptions(gitOptions), remoteName, fallbackBranch]; + await pushRaw(args); + return normalizePushResult(fallbackBranch, remoteName); + } } /** diff --git a/packages/vscode/src/githubPulls.ts b/packages/vscode/src/githubPulls.ts index bff24038..c771800d 100644 --- a/packages/vscode/src/githubPulls.ts +++ b/packages/vscode/src/githubPulls.ts @@ -57,7 +57,7 @@ type GitHubCheckRun = { }>; }; -type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string }; +type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string; sshUrl?: string }; type GitHubPullRequestSummary = { number: number; @@ -188,6 +188,7 @@ const mapHeadRepo = (raw: unknown): GitHubPullRequestHeadRepo | null => { repo, url, cloneUrl: readString(rec?.clone_url) || undefined, + sshUrl: readString(rec?.ssh_url) || undefined, }; }; diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 709a10eb..70551f64 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -17,6 +17,10 @@ import type { GeneratedCommitMessage, GeneratedPullRequestDescription, GitWorktreeInfo, + CreateGitWorktreePayload, + GitWorktreeValidationResult, + GitWorktreeCreateResult, + RemoveGitWorktreePayload, GitCommitResult, CreateGitCommitOptions, GitPushResult, @@ -113,6 +117,32 @@ export const createVSCodeGitAPI = (): GitAPI => ({ return sendBridgeMessage('api:git/worktrees', { directory, method: 'GET' }); }, + validateGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise => { + return sendBridgeMessage('api:git/worktrees/validate', { + directory, + ...(payload || {}), + }); + }, + + createGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise => { + return sendBridgeMessage('api:git/worktrees', { + directory, + method: 'POST', + ...(payload || {}), + }); + }, + + deleteGitWorktree: async (directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> => { + return sendBridgeMessage<{ success: boolean }>('api:git/worktrees', { + directory, + method: 'DELETE', + body: { + directory: payload.directory, + deleteLocalBranch: payload.deleteLocalBranch === true, + }, + }); + }, + createGitCommit: async (directory: string, message: string, options?: CreateGitCommitOptions): Promise => { return sendBridgeMessage('api:git/commit', { directory, @@ -281,4 +311,33 @@ export const createVSCodeGitAPI = (): GitAPI => ({ operation: 'merge' | 'rebase'; }>('api:git/conflict-details', { directory }); }, + + worktree: { + list: async (directory: string): Promise => { + return sendBridgeMessage('api:git/worktrees', { directory, method: 'GET' }); + }, + validate: async (directory: string, payload: CreateGitWorktreePayload): Promise => { + return sendBridgeMessage('api:git/worktrees/validate', { + directory, + ...(payload || {}), + }); + }, + create: async (directory: string, payload: CreateGitWorktreePayload): Promise => { + return sendBridgeMessage('api:git/worktrees', { + directory, + method: 'POST', + ...(payload || {}), + }); + }, + remove: async (directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> => { + return sendBridgeMessage<{ success: boolean }>('api:git/worktrees', { + directory, + method: 'DELETE', + body: { + directory: payload.directory, + deleteLocalBranch: payload.deleteLocalBranch === true, + }, + }); + }, + }, }); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index d51e4cd9..bb57cf60 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -281,7 +281,10 @@ const resolveWorkspacePathFromWorktrees = async (targetPath, baseDirectory) => { const worktrees = await getWorktrees(resolvedBase); for (const worktree of worktrees) { - const candidate = typeof worktree?.worktree === 'string' ? normalizeDirectoryPath(worktree.worktree) : ''; + const candidatePath = typeof worktree?.path === 'string' + ? worktree.path + : (typeof worktree?.worktree === 'string' ? worktree.worktree : ''); + const candidate = normalizeDirectoryPath(candidatePath); if (!candidate) { continue; } @@ -8093,6 +8096,7 @@ async function main(options = {}) { repo: pr.head.repo.name, url: pr.head.repo.html_url, cloneUrl: pr.head.repo.clone_url, + sshUrl: pr.head.repo.ssh_url, } : null; return { @@ -8160,6 +8164,7 @@ async function main(options = {}) { repo: prData.head.repo.name, url: prData.head.repo.html_url, cloneUrl: prData.head.repo.clone_url, + sshUrl: prData.head.repo.ssh_url, } : null; @@ -9485,6 +9490,74 @@ Context: } }); + app.post('/api/git/worktrees/validate', async (req, res) => { + const { validateWorktreeCreate } = await getGitLibraries(); + if (typeof validateWorktreeCreate !== 'function') { + return res.status(501).json({ error: 'Worktree validation is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await validateWorktreeCreate(directory, req.body || {}); + res.json(result); + } catch (error) { + console.error('Failed to validate worktree creation:', error); + res.status(500).json({ error: error.message || 'Failed to validate worktree creation' }); + } + }); + + app.post('/api/git/worktrees', async (req, res) => { + const { createWorktree } = await getGitLibraries(); + if (typeof createWorktree !== 'function') { + return res.status(501).json({ error: 'Worktree creation is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const created = await createWorktree(directory, req.body || {}); + res.json(created); + } catch (error) { + console.error('Failed to create worktree:', error); + res.status(500).json({ error: error.message || 'Failed to create worktree' }); + } + }); + + app.delete('/api/git/worktrees', async (req, res) => { + const { removeWorktree } = await getGitLibraries(); + if (typeof removeWorktree !== 'function') { + return res.status(501).json({ error: 'Worktree removal is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const worktreeDirectory = typeof req.body?.directory === 'string' ? req.body.directory : ''; + if (!worktreeDirectory) { + return res.status(400).json({ error: 'worktree directory is required' }); + } + + const result = await removeWorktree(directory, { + directory: worktreeDirectory, + deleteLocalBranch: req.body?.deleteLocalBranch === true, + }); + res.json({ success: Boolean(result) }); + } catch (error) { + console.error('Failed to remove worktree:', error); + res.status(500).json({ error: error.message || 'Failed to remove worktree' }); + } + }); + app.get('/api/git/worktree-type', async (req, res) => { const { isLinkedWorktree } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git-service.js b/packages/web/server/lib/git-service.js index 07cf776e..e34591e6 100644 --- a/packages/web/server/lib/git-service.js +++ b/packages/web/server/lib/git-service.js @@ -168,6 +168,683 @@ const cleanBranchName = (branch) => { return branch; }; +const OPENCODE_ADJECTIVES = [ + 'brave', + 'calm', + 'clever', + 'cosmic', + 'crisp', + 'curious', + 'eager', + 'gentle', + 'glowing', + 'happy', + 'hidden', + 'jolly', + 'kind', + 'lucky', + 'mighty', + 'misty', + 'neon', + 'nimble', + 'playful', + 'proud', + 'quick', + 'quiet', + 'shiny', + 'silent', + 'stellar', + 'sunny', + 'swift', + 'tidy', + 'witty', +]; + +const OPENCODE_NOUNS = [ + 'cabin', + 'cactus', + 'canyon', + 'circuit', + 'comet', + 'eagle', + 'engine', + 'falcon', + 'forest', + 'garden', + 'harbor', + 'island', + 'knight', + 'lagoon', + 'meadow', + 'moon', + 'mountain', + 'nebula', + 'orchid', + 'otter', + 'panda', + 'pixel', + 'planet', + 'river', + 'rocket', + 'sailor', + 'squid', + 'star', + 'tiger', + 'wizard', + 'wolf', +]; + +const OPENCODE_WORKTREE_ATTEMPTS = 26; + +const getOpenCodeDataPath = () => { + const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(xdgDataHome, 'opencode'); +}; + +const pickRandom = (values) => values[Math.floor(Math.random() * values.length)]; + +const generateOpenCodeRandomName = () => `${pickRandom(OPENCODE_ADJECTIVES)}-${pickRandom(OPENCODE_NOUNS)}`; + +const slugWorktreeName = (value) => { + return String(value || '') + .trim() + .replace(/^refs\/heads\//, '') + .replace(/^heads\//, '') + .replace(/\s+/g, '-') + .replace(/^\/+|\/+$/g, '') + .split('/').join('-') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') + .slice(0, 80); +}; + +const parseWorktreePorcelain = (raw) => { + const lines = String(raw || '').split('\n').map((line) => line.trim()); + const entries = []; + let current = null; + + for (const line of lines) { + if (!line) { + if (current?.worktree) { + entries.push(current); + } + current = null; + continue; + } + + if (line.startsWith('worktree ')) { + if (current?.worktree) { + entries.push(current); + } + current = { worktree: line.substring('worktree '.length).trim() }; + continue; + } + + if (!current) { + continue; + } + + if (line.startsWith('HEAD ')) { + current.head = line.substring('HEAD '.length).trim(); + continue; + } + + if (line.startsWith('branch ')) { + const branchRef = line.substring('branch '.length).trim(); + current.branchRef = branchRef; + current.branch = cleanBranchName(branchRef); + } + } + + if (current?.worktree) { + entries.push(current); + } + + return entries; +}; + +const canonicalPath = async (input) => { + const absolutePath = path.resolve(input); + const realPath = await fsp.realpath(absolutePath).catch(() => absolutePath); + const normalized = path.normalize(realPath); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +}; + +const checkPathExists = async (targetPath) => { + try { + await fsp.stat(targetPath); + return true; + } catch { + return false; + } +}; + +const normalizeStartRef = (value) => { + const trimmed = String(value || '').trim(); + if (!trimmed) { + return 'HEAD'; + } + return trimmed; +}; + +const parseRemoteBranchRef = (value) => { + const trimmed = String(value || '').trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith('refs/remotes/')) { + const rest = trimmed.substring('refs/remotes/'.length); + const slashIndex = rest.indexOf('/'); + if (slashIndex <= 0 || slashIndex === rest.length - 1) { + return null; + } + return { + remote: rest.slice(0, slashIndex), + branch: rest.slice(slashIndex + 1), + remoteRef: rest, + fullRef: `refs/remotes/${rest}`, + }; + } + + if (trimmed.startsWith('remotes/')) { + return parseRemoteBranchRef(`refs/${trimmed}`); + } + + const slashIndex = trimmed.indexOf('/'); + if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { + return null; + } + + return { + remote: trimmed.slice(0, slashIndex), + branch: trimmed.slice(slashIndex + 1), + remoteRef: trimmed, + fullRef: `refs/remotes/${trimmed}`, + }; +}; + +const normalizeUpstreamTarget = (remote, branch) => { + const remoteName = String(remote || '').trim(); + const branchName = String(branch || '').trim(); + if (!remoteName || !branchName) { + return null; + } + return { + remote: remoteName, + branch: branchName, + full: `${remoteName}/${branchName}`, + }; +}; + +const parseGitErrorText = (error) => { + const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; + const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; + const message = typeof error?.message === 'string' ? error.message : ''; + return [stderr, stdout, message] + .map((chunk) => String(chunk || '').trim()) + .filter(Boolean) + .join('\n') + .trim(); +}; + +const runGitCommand = async (cwd, args) => { + try { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + env: await buildGitEnv(), + maxBuffer: 20 * 1024 * 1024, + }); + return { + success: true, + exitCode: 0, + stdout: String(stdout || ''), + stderr: String(stderr || ''), + }; + } catch (error) { + return { + success: false, + exitCode: typeof error?.code === 'number' ? error.code : 1, + stdout: String(error?.stdout || ''), + stderr: String(error?.stderr || ''), + message: parseGitErrorText(error), + }; + } +}; + +const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => { + const result = await runGitCommand(cwd, args); + if (!result.success) { + throw new Error(result.message || fallbackMessage || 'Git command failed'); + } + return result; +}; + +const ensureOpenCodeProjectId = async (primaryWorktree) => { + const gitDir = path.join(primaryWorktree, '.git'); + const idFile = path.join(gitDir, 'opencode'); + const existing = await fsp.readFile(idFile, 'utf8').then((value) => value.trim()).catch(() => ''); + if (existing) { + return existing; + } + + const rootsResult = await runGitCommandOrThrow( + primaryWorktree, + ['rev-list', '--max-parents=0', '--all'], + 'Failed to resolve repository roots' + ); + + const roots = rootsResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + + const projectId = roots[0] || ''; + if (!projectId) { + throw new Error('Failed to derive OpenCode project ID'); + } + + await fsp.mkdir(gitDir, { recursive: true }).catch(() => undefined); + await fsp.writeFile(idFile, projectId, 'utf8').catch(() => undefined); + + return projectId; +}; + +const resolveWorktreeProjectContext = async (directory) => { + const directoryPath = normalizeDirectoryPath(directory); + if (!directoryPath) { + throw new Error('Directory is required'); + } + + const topResult = await runGitCommandOrThrow( + directoryPath, + ['rev-parse', '--show-toplevel'], + 'Failed to resolve git top-level directory' + ); + const sandbox = path.resolve(directoryPath, topResult.stdout.trim()); + + const commonResult = await runGitCommandOrThrow( + sandbox, + ['rev-parse', '--git-common-dir'], + 'Failed to resolve git common directory' + ); + const commonDir = path.resolve(sandbox, commonResult.stdout.trim()); + const primaryWorktree = path.dirname(commonDir); + const projectID = await ensureOpenCodeProjectId(primaryWorktree); + const worktreeRoot = path.join(getOpenCodeDataPath(), 'worktree', projectID); + + return { + projectID, + sandbox, + primaryWorktree, + worktreeRoot, + }; +}; + +const listWorktreeEntries = async (directory) => { + const rawResult = await runGitCommandOrThrow( + directory, + ['worktree', 'list', '--porcelain'], + 'Failed to list git worktrees' + ); + return parseWorktreePorcelain(rawResult.stdout); +}; + +const resolveWorktreeNameCandidates = (baseName) => { + const normalizedBase = slugWorktreeName(baseName || ''); + if (!normalizedBase) { + return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, () => generateOpenCodeRandomName()); + } + return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, (_, index) => { + if (index === 0) { + return normalizedBase; + } + return `${normalizedBase}-${generateOpenCodeRandomName()}`; + }); +}; + +const resolveCandidateDirectory = async (worktreeRoot, preferredName, explicitBranchName, primaryWorktree) => { + const candidates = resolveWorktreeNameCandidates(preferredName); + + for (const name of candidates) { + const directory = path.join(worktreeRoot, name); + if (await checkPathExists(directory)) { + continue; + } + + if (explicitBranchName) { + return { name, directory, branch: explicitBranchName }; + } + + const branch = `openchamber/${name}`; + const branchRef = `refs/heads/${branch}`; + const branchExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', branchRef]); + if (branchExists.success) { + continue; + } + + return { name, directory, branch }; + } + + throw new Error('Failed to generate a unique worktree name'); +}; + +const resolveBranchForExistingMode = async (primaryWorktree, existingBranch, preferredBranchName) => { + const requested = String(existingBranch || '').trim(); + if (!requested) { + throw new Error('existingBranch is required in existing mode'); + } + + const normalizedLocal = cleanBranchName(requested); + const localRef = `refs/heads/${normalizedLocal}`; + const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]); + if (localExists.success) { + return { + localBranch: normalizedLocal, + checkoutRef: normalizedLocal, + createLocalBranch: false, + remoteRef: null, + }; + } + + const remoteRef = parseRemoteBranchRef(requested); + if (!remoteRef) { + throw new Error(`Branch not found: ${requested}`); + } + + const remoteExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]); + if (!remoteExists.success) { + await fetchRemoteBranchRef(primaryWorktree, remoteRef.remote, remoteRef.branch).catch(() => undefined); + const recheck = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]); + if (!recheck.success) { + throw new Error(`Remote branch not found: ${requested}`); + } + } + + const localBranch = cleanBranchName(preferredBranchName || remoteRef.branch || requested); + if (!localBranch) { + throw new Error('Failed to resolve local branch name for existing branch worktree'); + } + + return { + localBranch, + checkoutRef: remoteRef.remoteRef, + createLocalBranch: true, + remoteRef, + }; +}; + +const findBranchInUse = async (primaryWorktree, localBranchName) => { + if (!localBranchName) { + return null; + } + const entries = await listWorktreeEntries(primaryWorktree); + const targetRef = `refs/heads/${localBranchName}`; + const targetClean = cleanBranchName(targetRef); + return entries.find((entry) => { + const entryRef = String(entry.branchRef || '').trim(); + const entryClean = cleanBranchName(entryRef || entry.branch || ''); + return entryRef === targetRef || entryClean === targetClean; + }) || null; +}; + +const runWorktreeStartCommand = async (directory, command) => { + const text = String(command || '').trim(); + if (!text) { + return { success: true }; + } + + if (process.platform === 'win32') { + const result = await execFileAsync('cmd', ['/c', text], { + cwd: directory, + env: await buildGitEnv(), + maxBuffer: 20 * 1024 * 1024, + }).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({ + success: false, + stdout: error?.stdout, + stderr: error?.stderr, + message: parseGitErrorText(error), + })); + return result; + } + + const result = await execFileAsync('bash', ['-lc', text], { + cwd: directory, + env: await buildGitEnv(), + maxBuffer: 20 * 1024 * 1024, + }).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({ + success: false, + stdout: error?.stdout, + stderr: error?.stderr, + message: parseGitErrorText(error), + })); + return result; +}; + +const loadProjectStartCommand = async (projectID) => { + const storagePath = path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); + try { + const raw = await fsp.readFile(storagePath, 'utf8'); + const parsed = JSON.parse(raw); + const start = typeof parsed?.commands?.start === 'string' ? parsed.commands.start.trim() : ''; + return start || ''; + } catch { + return ''; + } +}; + +const getProjectStoragePath = (projectID) => { + return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); +}; + +const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => { + const storagePath = getProjectStoragePath(projectID); + await fsp.mkdir(path.dirname(storagePath), { recursive: true }); + + const now = Date.now(); + const base = { + id: projectID, + worktree: primaryWorktree, + vcs: 'git', + sandboxes: [], + time: { + created: now, + updated: now, + }, + }; + + const parsed = await fsp.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null); + const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base; + current.id = String(current.id || projectID); + current.worktree = String(current.worktree || primaryWorktree); + current.vcs = current.vcs || 'git'; + current.sandboxes = Array.isArray(current.sandboxes) + ? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean) + : []; + const createdAt = Number(current?.time?.created); + current.time = { + created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now, + updated: now, + }; + + updater(current); + + current.sandboxes = [...new Set( + (Array.isArray(current.sandboxes) ? current.sandboxes : []) + .map((entry) => String(entry || '').trim()) + .filter(Boolean) + )]; + + await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8'); +}; + +const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => { + const sandbox = String(sandboxPath || '').trim(); + if (!sandbox) { + return; + } + await updateProjectSandboxes(projectID, primaryWorktree, (project) => { + if (!project.sandboxes.includes(sandbox)) { + project.sandboxes.push(sandbox); + } + }); +}; + +const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath) => { + const sandbox = String(sandboxPath || '').trim(); + if (!sandbox) { + return; + } + await updateProjectSandboxes(projectID, primaryWorktree, (project) => { + project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox); + }); +}; + +const queueWorktreeStartScripts = (directory, projectID, startCommand) => { + setTimeout(() => { + const run = async () => { + const projectStart = await loadProjectStartCommand(projectID); + if (projectStart) { + const projectResult = await runWorktreeStartCommand(directory, projectStart); + if (!projectResult.success) { + console.warn('Worktree project start command failed:', projectResult.message || projectResult.stderr || projectResult.stdout); + return; + } + } + + const extraCommand = String(startCommand || '').trim(); + if (!extraCommand) { + return; + } + const extraResult = await runWorktreeStartCommand(directory, extraCommand); + if (!extraResult.success) { + console.warn('Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout); + } + }; + + void run().catch((error) => { + console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error)); + }); + }, 0); +}; + +const ensureRemoteWithUrl = async (primaryWorktree, remoteName, remoteUrl) => { + const name = String(remoteName || '').trim(); + const url = String(remoteUrl || '').trim(); + if (!name || !url) { + return; + } + + const getUrl = await runGitCommand(primaryWorktree, ['remote', 'get-url', name]); + if (getUrl.success) { + const currentUrl = String(getUrl.stdout || '').trim(); + if (currentUrl !== url) { + await runGitCommandOrThrow(primaryWorktree, ['remote', 'set-url', name, url], 'Failed to update git remote URL'); + } + return; + } + + await runGitCommandOrThrow(primaryWorktree, ['remote', 'add', name, url], 'Failed to add git remote'); +}; + +const fetchRemoteBranchRef = async (primaryWorktree, remoteName, branchName) => { + const remote = String(remoteName || '').trim(); + const branch = String(branchName || '').trim(); + if (!remote || !branch) { + return; + } + + const refspec = `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`; + await runGitCommandOrThrow( + primaryWorktree, + ['fetch', remote, refspec], + `Failed to fetch ${remote}/${branch}` + ); +}; + +const checkRemoteBranchExists = async (primaryWorktree, remoteName, branchName, remoteUrl = '') => { + const remote = String(remoteName || '').trim(); + const branch = String(branchName || '').trim(); + const url = String(remoteUrl || '').trim(); + if (!remote || !branch) { + return { success: false, found: false }; + } + + const target = url || remote; + const lsRemote = await runGitCommand( + primaryWorktree, + ['ls-remote', '--heads', target, `refs/heads/${branch}`] + ); + if (!lsRemote.success) { + return { success: false, found: false }; + } + + return { + success: true, + found: Boolean(String(lsRemote.stdout || '').trim()), + }; +}; + +const setBranchTrackingFallback = async (worktreeDirectory, localBranch, upstream) => { + await runGitCommandOrThrow( + worktreeDirectory, + ['config', `branch.${localBranch}.remote`, upstream.remote], + `Failed to set branch.${localBranch}.remote` + ); + await runGitCommandOrThrow( + worktreeDirectory, + ['config', `branch.${localBranch}.merge`, `refs/heads/${upstream.branch}`], + `Failed to set branch.${localBranch}.merge` + ); +}; + +const applyUpstreamConfiguration = async (args) => { + const { + primaryWorktree, + worktreeDirectory, + localBranch, + setUpstream, + upstreamRemote, + upstreamBranch, + ensureRemoteName, + ensureRemoteUrl, + } = args; + + if (!setUpstream) { + return; + } + + if (ensureRemoteName && ensureRemoteUrl) { + await ensureRemoteWithUrl(primaryWorktree, ensureRemoteName, ensureRemoteUrl); + } + + const upstream = normalizeUpstreamTarget(upstreamRemote, upstreamBranch); + if (!upstream || !localBranch) { + return; + } + + let fetched = true; + try { + await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch); + } catch { + fetched = false; + } + + if (fetched) { + await runGitCommandOrThrow( + worktreeDirectory, + ['branch', `--set-upstream-to=${upstream.full}`, localBranch], + `Failed to set upstream to ${upstream.full}` + ); + return; + } + + await setBranchTrackingFallback(worktreeDirectory, localBranch, upstream); +}; + export async function isGitRepository(directory) { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath || !fs.existsSync(directoryPath)) { @@ -814,6 +1491,22 @@ export async function pull(directory, options = {}) { export async function push(directory, options = {}) { const git = await createGit(directory); + const describePushError = (error) => { + const fromNestedGit = error?.git && typeof error.git === 'object' + ? [error.git.message, error.git.stderr, error.git.stdout] + : []; + const candidates = [ + error?.message, + error?.stderr, + error?.stdout, + ...fromNestedGit, + ] + .map((value) => String(value || '').trim()) + .filter(Boolean); + + return candidates[0] || 'Failed to push to remote'; + }; + const buildUpstreamOptions = (raw) => { if (Array.isArray(raw)) { return raw.includes('--set-upstream') ? raw : [...raw, '--set-upstream']; @@ -846,7 +1539,45 @@ export async function push(directory, options = {}) { }; }; - const remote = options.remote || 'origin'; + const remote = String(options.remote || '').trim(); + + if (!remote && !options.branch) { + try { + await git.push(); + return { + success: true, + pushed: [], + repo: directory, + ref: null, + }; + } catch (error) { + if (!looksLikeMissingUpstream(error)) { + const message = describePushError(error); + console.error('Failed to push:', error); + throw new Error(message); + } + + try { + const status = await git.status(); + const branch = status.current; + const remotes = await git.getRemotes(true); + const fallbackRemote = remotes.find((entry) => entry.name === 'origin')?.name || remotes[0]?.name; + if (!branch || !fallbackRemote) { + const message = describePushError(error); + throw new Error(message); + } + + const result = await git.push(fallbackRemote, branch, buildUpstreamOptions(options.options)); + return normalizePushResult(result); + } catch (fallbackError) { + const message = describePushError(fallbackError); + console.error('Failed to push (including upstream fallback):', fallbackError); + throw new Error(message); + } + } + } + + const remoteName = remote || 'origin'; // If caller didn't specify a branch, this is the common "Push"/"Commit & Push" path. // When there's no upstream yet (typical for freshly-created worktree branches), publish it on first push. @@ -854,7 +1585,7 @@ export async function push(directory, options = {}) { try { const status = await git.status(); if (status.current && !status.tracking) { - const result = await git.push(remote, status.current, buildUpstreamOptions(options.options)); + const result = await git.push(remoteName, status.current, buildUpstreamOptions(options.options)); return normalizePushResult(result); } } catch (error) { @@ -864,13 +1595,14 @@ export async function push(directory, options = {}) { } try { - const result = await git.push(remote, options.branch, options.options || {}); + const result = await git.push(remoteName, options.branch, options.options || {}); return normalizePushResult(result); } catch (error) { // Last-resort fallback: retry with upstream if the error suggests it's missing. if (!looksLikeMissingUpstream(error)) { + const message = describePushError(error); console.error('Failed to push:', error); - throw error; + throw new Error(message); } try { @@ -881,11 +1613,12 @@ export async function push(directory, options = {}) { throw error; } - const result = await git.push(remote, branch, buildUpstreamOptions(options.options)); + const result = await git.push(remoteName, branch, buildUpstreamOptions(options.options)); return normalizePushResult(result); } catch (fallbackError) { + const message = describePushError(fallbackError); console.error('Failed to push (including upstream fallback):', fallbackError); - throw fallbackError; + throw new Error(message); } } } @@ -1041,45 +1774,398 @@ export async function getWorktrees(directory) { if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) { return []; } - - const git = await createGit(directoryPath); - try { - const result = await git.raw(['worktree', 'list', '--porcelain']); - - const worktrees = []; - const lines = result.split('\n'); - let current = {}; - - for (const line of lines) { - if (line.startsWith('worktree ')) { - if (current.worktree) { - worktrees.push(current); - } - current = { worktree: line.substring(9) }; - } else if (line.startsWith('HEAD ')) { - current.head = line.substring(5); - } else if (line.startsWith('branch ')) { - current.branch = cleanBranchName(line.substring(7)); - } else if (line === '') { - if (current.worktree) { - worktrees.push(current); - current = {}; - } - } - } - - if (current.worktree) { - worktrees.push(current); - } - - return worktrees; + const result = await runGitCommandOrThrow( + directoryPath, + ['worktree', 'list', '--porcelain'], + 'Failed to list git worktrees' + ); + return parseWorktreePorcelain(result.stdout).map((entry) => ({ + head: entry.head || '', + name: path.basename(entry.worktree || ''), + branch: entry.branch || '', + path: entry.worktree, + })); } catch (error) { console.warn('Failed to list worktrees, returning empty list:', error?.message || error); return []; } } +export async function validateWorktreeCreate(directory, input = {}) { + const mode = input?.mode === 'existing' ? 'existing' : 'new'; + const errors = []; + + try { + const context = await resolveWorktreeProjectContext(directory); + const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + const startRef = normalizeStartRef(input?.startRef); + const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); + const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); + + let localBranch = ''; + let inferredUpstream = null; + + if (mode === 'existing') { + try { + const requestedExistingBranch = String(input?.existingBranch || '').trim(); + const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch); + if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) { + const lsRemote = await runGitCommand( + context.primaryWorktree, + ['ls-remote', '--heads', ensureRemoteUrl, `refs/heads/${parsedExistingRemote.branch}`] + ); + if (!lsRemote.success) { + throw new Error(`Unable to query remote ${ensureRemoteName}`); + } + if (!String(lsRemote.stdout || '').trim()) { + throw new Error(`Remote branch not found: ${parsedExistingRemote.remoteRef}`); + } + localBranch = cleanBranchName(preferredBranchName || parsedExistingRemote.branch); + inferredUpstream = { + remote: parsedExistingRemote.remote, + branch: parsedExistingRemote.branch, + }; + } else { + const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName); + localBranch = resolved.localBranch || ''; + if (resolved.remoteRef) { + inferredUpstream = { + remote: resolved.remoteRef.remote, + branch: resolved.remoteRef.branch, + }; + } + } + } catch (error) { + errors.push({ + code: 'branch_not_found', + message: error instanceof Error ? error.message : 'Existing branch not found', + }); + } + } else { + if (preferredBranchName) { + const exists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${preferredBranchName}`]); + if (exists.success) { + errors.push({ + code: 'branch_exists', + message: `Branch already exists: ${preferredBranchName}`, + }); + } + localBranch = preferredBranchName; + } + + const parsedRemoteRef = parseRemoteBranchRef(startRef); + if (startRef && startRef !== 'HEAD') { + if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) { + const remoteCheck = await checkRemoteBranchExists( + context.primaryWorktree, + parsedRemoteRef.remote, + parsedRemoteRef.branch, + ensureRemoteUrl + ); + if (!remoteCheck.success) { + errors.push({ + code: 'remote_unreachable', + message: `Unable to query remote ${ensureRemoteName}`, + }); + } else if (!remoteCheck.found) { + errors.push({ + code: 'start_ref_not_found', + message: `Remote branch not found: ${parsedRemoteRef.remoteRef}`, + }); + } + } else if (parsedRemoteRef) { + const remoteCheck = await checkRemoteBranchExists( + context.primaryWorktree, + parsedRemoteRef.remote, + parsedRemoteRef.branch + ); + if (!remoteCheck.success) { + errors.push({ + code: 'remote_unreachable', + message: `Unable to query remote ${parsedRemoteRef.remote}`, + }); + } else if (!remoteCheck.found) { + errors.push({ + code: 'start_ref_not_found', + message: `Remote branch not found: ${parsedRemoteRef.remoteRef}`, + }); + } + } else { + const startRefExists = await runGitCommand(context.primaryWorktree, ['rev-parse', '--verify', '--quiet', startRef]); + if (!startRefExists.success) { + errors.push({ + code: 'start_ref_not_found', + message: `Start ref not found: ${startRef}`, + }); + } + } + } + + if (parsedRemoteRef) { + inferredUpstream = { + remote: parsedRemoteRef.remote, + branch: parsedRemoteRef.branch, + }; + } + } + + if (localBranch) { + const inUse = await findBranchInUse(context.primaryWorktree, localBranch); + if (inUse) { + errors.push({ + code: 'branch_in_use', + message: `Branch is already checked out in ${inUse.worktree}`, + }); + } + } + + if ((ensureRemoteName && !ensureRemoteUrl) || (!ensureRemoteName && ensureRemoteUrl)) { + errors.push({ + code: 'invalid_remote_config', + message: 'Both ensureRemoteName and ensureRemoteUrl are required together', + }); + } + + const shouldSetUpstream = Boolean(input?.setUpstream); + if (shouldSetUpstream) { + const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); + const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); + + if (!upstreamRemote || !upstreamBranch) { + errors.push({ + code: 'upstream_incomplete', + message: 'upstreamRemote and upstreamBranch are required when setUpstream is true', + }); + } else { + const remoteExists = await runGitCommand(context.primaryWorktree, ['remote', 'get-url', upstreamRemote]); + if (!remoteExists.success && (!ensureRemoteName || ensureRemoteName !== upstreamRemote)) { + errors.push({ + code: 'remote_not_found', + message: `Remote not found: ${upstreamRemote}`, + }); + } + } + } + + return { + ok: errors.length === 0, + errors, + resolved: { + mode, + localBranch: localBranch || null, + }, + }; + } catch (error) { + return { + ok: false, + errors: [{ + code: 'validation_failed', + message: error instanceof Error ? error.message : 'Failed to validate worktree creation', + }], + }; + } +} + +export async function createWorktree(directory, input = {}) { + const mode = input?.mode === 'existing' ? 'existing' : 'new'; + const context = await resolveWorktreeProjectContext(directory); + await fsp.mkdir(context.worktreeRoot, { recursive: true }); + + const preferredName = String(input?.worktreeName || input?.name || '').trim(); + const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + const startRef = normalizeStartRef(input?.startRef); + const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); + const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); + + const candidate = await resolveCandidateDirectory( + context.worktreeRoot, + preferredName, + mode === 'new' && preferredBranchName ? preferredBranchName : '', + context.primaryWorktree + ); + + let localBranch = ''; + let inferredUpstream = null; + const worktreeAddArgs = ['worktree', 'add', '--no-checkout']; + + if (mode === 'existing') { + const requestedExistingBranch = String(input?.existingBranch || '').trim(); + const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch); + if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) { + await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); + await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch); + } + + const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName); + localBranch = resolved.localBranch; + + const inUse = await findBranchInUse(context.primaryWorktree, localBranch); + if (inUse) { + throw new Error(`Branch is already checked out in ${inUse.worktree}`); + } + + if (resolved.createLocalBranch) { + worktreeAddArgs.push('-b', localBranch); + } + worktreeAddArgs.push(candidate.directory, resolved.checkoutRef); + + if (resolved.remoteRef) { + inferredUpstream = { + remote: resolved.remoteRef.remote, + branch: resolved.remoteRef.branch, + }; + } + } else { + localBranch = candidate.branch; + if (!localBranch) { + throw new Error('Failed to resolve branch name for new worktree'); + } + + const branchExists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${localBranch}`]); + if (branchExists.success) { + throw new Error(`Branch already exists: ${localBranch}`); + } + + const inUse = await findBranchInUse(context.primaryWorktree, localBranch); + if (inUse) { + throw new Error(`Branch is already checked out in ${inUse.worktree}`); + } + + worktreeAddArgs.push('-b', localBranch, candidate.directory); + if (startRef && startRef !== 'HEAD') { + worktreeAddArgs.push(startRef); + } + + const parsedRemoteStartRef = parseRemoteBranchRef(startRef); + if (parsedRemoteStartRef) { + inferredUpstream = { + remote: parsedRemoteStartRef.remote, + branch: parsedRemoteStartRef.branch, + }; + } + } + + if (ensureRemoteName && ensureRemoteUrl) { + await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); + } + + if (mode === 'new') { + const parsedRemoteStartRef = parseRemoteBranchRef(startRef); + if (parsedRemoteStartRef) { + await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); + } + } + + await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); + await runGitCommandOrThrow(candidate.directory, ['reset', '--hard'], 'Failed to populate worktree'); + + try { + await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory); + } catch (error) { + console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); + } + + const shouldSetUpstream = Boolean(input?.setUpstream); + const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); + const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); + + if (shouldSetUpstream) { + await applyUpstreamConfiguration({ + primaryWorktree: context.primaryWorktree, + worktreeDirectory: candidate.directory, + localBranch, + setUpstream: shouldSetUpstream, + upstreamRemote, + upstreamBranch, + ensureRemoteName, + ensureRemoteUrl, + }); + } + + queueWorktreeStartScripts(candidate.directory, context.projectID, input?.startCommand); + + const headResult = await runGitCommand(candidate.directory, ['rev-parse', 'HEAD']); + const head = String(headResult.stdout || '').trim(); + + return { + head, + name: candidate.name, + branch: localBranch, + path: candidate.directory, + }; +} + +export async function removeWorktree(directory, input = {}) { + const targetDirectory = normalizeDirectoryPath(input?.directory); + if (!targetDirectory) { + throw new Error('Worktree directory is required'); + } + + const context = await resolveWorktreeProjectContext(directory); + const deleteLocalBranch = input?.deleteLocalBranch === true; + + const targetCanonical = await canonicalPath(targetDirectory); + const primaryCanonical = await canonicalPath(context.primaryWorktree); + if (targetCanonical === primaryCanonical) { + throw new Error('Cannot remove the primary workspace'); + } + + const entries = await listWorktreeEntries(context.primaryWorktree); + const matchedEntry = await (async () => { + for (const entry of entries) { + if (!entry?.worktree) { + continue; + } + const entryCanonical = await canonicalPath(entry.worktree); + if (entryCanonical === targetCanonical) { + return entry; + } + } + return null; + })(); + + if (!matchedEntry?.worktree) { + const targetExists = await checkPathExists(targetDirectory); + if (targetExists) { + await fsp.rm(targetDirectory, { recursive: true, force: true }); + } + + try { + await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory); + } catch (error) { + console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error)); + } + + return true; + } + + await runGitCommandOrThrow( + context.primaryWorktree, + ['worktree', 'remove', '--force', matchedEntry.worktree], + 'Failed to remove git worktree' + ); + + if (deleteLocalBranch) { + const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim()); + if (branchName) { + await runGitCommandOrThrow( + context.primaryWorktree, + ['branch', '-D', branchName], + `Failed to delete local branch ${branchName}` + ); + } + } + + try { + await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree); + } catch (error) { + console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error)); + } + + return true; +} + export async function deleteBranch(directory, branch, options = {}) { const git = await createGit(directory); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 0cef1865..5ca6526a 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -18,6 +18,9 @@ export const createWebGitAPI = (): GitAPI => ({ generateCommitMessage: gitApiHttp.generateCommitMessage, generatePullRequestDescription: gitApiHttp.generatePullRequestDescription, listGitWorktrees: gitApiHttp.listGitWorktrees, + validateGitWorktree: gitApiHttp.validateGitWorktree, + createGitWorktree: gitApiHttp.createGitWorktree, + deleteGitWorktree: gitApiHttp.deleteGitWorktree, createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) { return gitApiHttp.createGitCommit(directory, message, options); }, @@ -48,4 +51,10 @@ export const createWebGitAPI = (): GitAPI => ({ stash: gitApiHttp.stash, stashPop: gitApiHttp.stashPop, getConflictDetails: gitApiHttp.getConflictDetails, + worktree: { + list: gitApiHttp.listGitWorktrees, + validate: gitApiHttp.validateGitWorktree, + create: gitApiHttp.createGitWorktree, + remove: gitApiHttp.deleteGitWorktree, + }, });