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
This commit is contained in:
Iuliia Ivashko
2026-02-13 19:18:22 +02:00
committed by GitHub
parent 523eafdf65
commit 081be1b7d0
26 changed files with 3320 additions and 658 deletions
@@ -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);
@@ -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<Map<string, boolean>>(new Map());
const [projectRemotes, setProjectRemotes] = React.useState<GitRemote[]>([]);
const [error, setError] = React.useState<string | null>(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:
<p className="typography-small text-foreground truncate ml-0.5">{pr.title}</p>
{createInWorktree && disabledByWorktree ? (
<p className="typography-micro text-muted-foreground mt-0.5 ml-0.5">
PR worktree disabled: local branch exists ({pr.head})
PR worktree disabled: branch already exists or is in use ({pr.head})
</p>
) : null}
</div>
@@ -53,6 +53,7 @@ export const SessionDialogs: React.FC = () => {
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
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<Set<string>>(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<boolean> => {
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 ? (
<button
type="button"
onClick={() => {
if (deleteLocalOptionDisabled) {
return;
}
setDeleteDialogShouldDeleteLocalBranch((prev) => !prev);
}}
disabled={deleteLocalOptionDisabled}
className={cn(
'flex items-center gap-2 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors',
deleteLocalOptionDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
>
{deleteDialogShouldDeleteLocalBranch ? (
<RiCheckboxLine className="size-4 text-primary" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
Delete local branch
</button>
) : null;
const deleteDialogActions = isWorktreeDelete ? (
<div className="flex w-full items-center justify-between gap-3">
<div className="flex flex-col items-start gap-1">
{deleteLocalBranchAction}
{deleteRemoteBranchAction}
</div>
<div className="flex items-center gap-2">