From 081be1b7d043a44e8fdea2647a9ff5ba4fc1fb51 Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Fri, 13 Feb 2026 19:18:22 +0200 Subject: [PATCH] feat(worktrees): ship upstream-first worktree flow across web + vscode (#418) * feat: add worktree validation and deleteLocalBranch option Add API to validate and create worktrees with new payload types Allow deleting local branches when removing worktrees via UI and API Introduce OpenCode style random names for worktrees when not provided * feat: enable SSH/HTTPS transport detection for PR picker Load remotes for the current project directory to inform PR picker options. Determine preferred push transport from remotes and apply it. Expose sshUrl in API for frontend to build SSH clone URLs * feat: extend head repo with sshUrl and improve push error messages Add sshUrl field to head repo mapping Enhance push failure handling to display stderr or stdout details Return push details on success * fix: worktree path * feat: worktree set upstream on creation Enable pushing to upstream by default when no remote is specified Remove per-remote dropdown for push actions and auto-use first/upstream remote Update server and VSCode git services to support push without explicit remote and set upstream * fix: worktree-name sanitization * feat: rename worktree path field and branch prefix * feat(worktrees): add git.worktree facade, validation endpoint, upstream/remote-aware creation, and non-blocking setup execution * refactor(git): use git.worktree namespace in branch picker * feat(worktrees): sync OpenCode sandbox metadata on create/remove * fix(worktrees): accept new path key in workspace guard and validate remote startRef * chore(docs): remove temporary worktree testing plan * feat: add git worktree management API (list/create/delete/validate) for vscode * feat: wire root tracking remote and defaults for new worktrees Add resolveRootTrackingRemote to detect upstream remote for root branch Apply upstream defaults when creating new worktrees to auto-set upstream Replace validation and creation flow to use new worktreeCreate APIs * feat(worktrees): enable root tracking remote handling --- .../components/multirun/BranchSelector.tsx | 37 + .../components/session/BranchPickerDialog.tsx | 4 +- .../session/GitHubPullRequestPickerDialog.tsx | 247 ++-- .../src/components/session/SessionDialogs.tsx | 97 +- .../ui/src/components/ui/CodeMirrorEditor.tsx | 5 +- packages/ui/src/components/views/GitView.tsx | 78 +- .../components/views/git/CommitSection.tsx | 123 +- .../ui/src/components/views/git/GitHeader.tsx | 2 +- .../src/components/views/git/SyncActions.tsx | 35 +- packages/ui/src/lib/api/types.ts | 68 +- packages/ui/src/lib/gitApi.ts | 59 + packages/ui/src/lib/gitApiHttp.ts | 49 + packages/ui/src/lib/worktreeSessionCreator.ts | 90 +- .../ui/src/lib/worktrees/worktreeCreate.ts | 120 ++ .../ui/src/lib/worktrees/worktreeManager.ts | 256 ++-- packages/ui/src/stores/sessionStore.ts | 12 +- packages/ui/src/stores/types/sessionTypes.ts | 4 +- packages/ui/src/stores/useAgentGroupsStore.ts | 87 +- packages/ui/src/stores/useMultiRunStore.ts | 17 +- packages/vscode/src/bridge.ts | 53 +- packages/vscode/src/gitService.ts | 1227 ++++++++++++++++- packages/vscode/src/githubPulls.ts | 3 +- packages/vscode/webview/api/git.ts | 59 + packages/web/server/index.js | 75 +- packages/web/server/lib/git-service.js | 1162 +++++++++++++++- packages/web/src/api/git.ts | 9 + 26 files changed, 3320 insertions(+), 658 deletions(-) create mode 100644 packages/ui/src/lib/worktrees/worktreeCreate.ts 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, + }, });