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
@@ -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' });
@@ -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">
@@ -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
+14 -64
View File
@@ -409,8 +409,6 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
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<GitViewProps> = ({ mode = 'full' }) => {
});
}, [status, changeEntries, hasUserAdjustedSelection]);
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote: GitRemote) => {
const handleSyncAction = async (action: Exclude<SyncAction, null>, 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<GitViewProps> = ({ 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<GitViewProps> = ({ 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<GitViewProps> = ({ 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<GitViewProps> = ({ 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<GitViewProps> = ({ 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<GitViewProps> = ({ 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<GitViewProps> = ({ mode = 'full' }) => {
project={branchPickerProject}
/>
<Dialog open={pushRemoteDialogOpen} onOpenChange={handlePushRemoteDialogClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Select remote</DialogTitle>
<DialogDescription>
Choose which remote to push to
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{remotes.map((remote) => (
<button
key={remote.name}
type="button"
onClick={() => handlePushRemoteSelect(remote)}
className="flex flex-col items-start gap-0.5 px-3 py-2 rounded-lg text-left border border-border/60 hover:bg-accent hover:border-border transition-colors"
>
<span className="typography-ui-label text-foreground font-medium">
{remote.name}
</span>
<span className="typography-meta text-muted-foreground truncate max-w-full">
{remote.pushUrl}
</span>
</button>
))}
</div>
</DialogContent>
</Dialog>
</div>
);
};
@@ -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<CommitSectionProps> = ({
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<CommitSectionProps> = ({
</ButtonLarge>
{isMobile ? (
hasMultipleRemotes ? (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
disabled={!canCommit || isGeneratingMessage}
className="h-7 gap-0.5 px-1.5"
aria-label="Commit & Push"
>
{commitAction === 'commitAndPush' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<>
<RiArrowUpLine className="size-4" />
<RiArrowDownSLine className="size-3 opacity-60" />
</>
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">
<p>Commit & Push</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[200px]">
{remotes.map((remote) => (
<DropdownMenuItem key={remote.name} onSelect={() => onCommitAndPush(remote)}>
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">
{remote.name}
</span>
<span className="typography-meta text-muted-foreground truncate">
{remote.pushUrl}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="h-7 w-7 p-0"
aria-label="Commit & Push"
>
{commitAction === 'commitAndPush' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiArrowUpLine className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Commit & Push</p>
</TooltipContent>
</Tooltip>
)
) : hasMultipleRemotes ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonLarge
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="commit-actions__btn"
className="h-7 w-7 p-0"
aria-label="Commit & Push"
>
{commitAction === 'commitAndPush' ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
<span className="commit-actions__label commit-actions__label--long">Pushing...</span>
</>
<RiLoader4Line className="size-4 animate-spin" />
) : (
<>
<RiArrowUpLine className="size-4" />
<span className="commit-actions__label commit-actions__label--long">Commit &amp; Push</span>
<RiArrowDownSLine className="size-3.5 opacity-60 -mr-0.5" />
</>
<RiArrowUpLine className="size-4" />
)}
</ButtonLarge>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[200px]">
{remotes.map((remote) => (
<DropdownMenuItem key={remote.name} onSelect={() => onCommitAndPush(remote)}>
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">
{remote.name}
</span>
<span className="typography-meta text-muted-foreground truncate">
{remote.pushUrl}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Commit & Push</p>
</TooltipContent>
</Tooltip>
) : (
<ButtonLarge
variant="default"
@@ -38,7 +38,7 @@ interface GitHeaderProps {
remotes: GitRemote[];
onFetch: (remote: GitRemote) => void;
onPull: (remote: GitRemote) => void;
onPush: (remote: GitRemote) => void;
onPush: () => void;
onCheckoutBranch: (branch: string) => void;
onCreateBranch: (name: string, remote?: GitRemote) => Promise<void>;
onRenameBranch?: (oldName: string, newName: string) => Promise<void>;
@@ -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<SyncActionsProps> = ({
};
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<SyncActionsProps> = ({
behindCount
)}
{hasMultipleRemotes
? renderDropdownButton(
'push',
<RiArrowUpLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Push',
onPush,
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
aheadCount
)
: renderButton(
'push',
<RiArrowUpLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Push',
handlePush,
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
aheadCount
)}
{renderButton(
'push',
<RiArrowUpLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Push',
handlePush,
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
aheadCount
)}
</div>
);
};
+65 -3
View File
@@ -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<GitWorktreeInfo[]>;
validate?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>;
create?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult>;
remove?(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }>;
}
export interface GitAPI {
checkIsGitRepository(directory: string): Promise<boolean>;
getGitStatus(directory: string): Promise<GitStatus>;
@@ -338,6 +395,9 @@ export interface GitAPI {
payload: { base: string; head: string; context?: string; zenModel?: string }
): Promise<GeneratedPullRequestDescription>;
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
validateGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>;
createGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult>;
deleteGitWorktree?(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }>;
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult>;
gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult>;
@@ -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<MergeConflictDetails>;
worktree?: GitWorktreeAPI;
}
export interface FileListEntry {
@@ -633,6 +694,7 @@ export type GitHubPullRequestHeadRepo = {
repo: string;
url: string;
cloneUrl?: string;
sshUrl?: string;
};
export type GitHubPullRequestSummary = GitHubPullRequest & {
+59
View File
@@ -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<import('./api/types').GitWorktreeInfo[]> {
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<import('./api/types').GitWorktreeValidationResult> {
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<import('./api/types').GitWorktreeCreateResult> {
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,
+49
View File
@@ -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<GitWorktreeIn
return response.json();
}
export async function validateGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult> {
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<GitWorktreeCreateResult> {
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,
+75 -15
View File
@@ -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<string | null> {
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<string | null> {
*/
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,
});
}
@@ -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<string | null> => {
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<CreateWorktreeArgs> => {
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);
};
+114 -142
View File
@@ -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<string, unknown>;
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<void> => {
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<void>((resolve, reject) => {
let done = false;
let unsubscribe = () => {};
let timeout: ReturnType<typeof setTimeout> | 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<string, unknown> };
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<WorktreeMetadata[]> {
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<Worktre
});
}
export async function createSdkWorktree(project: ProjectRef, args: {
export type CreateWorktreeArgs = {
preferredName?: string;
setupCommands?: string[];
}): Promise<WorktreeMetadata> {
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<WorktreeMetadata> {
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<string, unknown>;
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<GitWorktreeValidationResult> {
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<void> {
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');
}
+7 -5
View File
@@ -29,8 +29,8 @@ interface SessionState {
interface SessionActions {
loadSessions: () => Promise<void>;
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>;
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<boolean>;
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<void>;
shareSession: (id: string) => Promise<Session | null>;
unshareSession: (id: string) => Promise<Session | null>;
@@ -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<SessionStore>()(
try {
await archiveSessionWorktree(metadata, {
deleteRemoteBranch: options?.deleteRemoteBranch,
deleteLocalBranch: options?.deleteLocalBranch,
remoteName: options?.remoteName,
});
archiveSucceeded = true;
@@ -1012,7 +1013,7 @@ export const useSessionStore = create<SessionStore>()(
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<SessionStore>()(
try {
await archiveSessionWorktree(metadata, {
deleteRemoteBranch: options?.deleteRemoteBranch,
deleteLocalBranch: options?.deleteLocalBranch,
remoteName: options?.remoteName,
});
archivedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory });
+2 -2
View File
@@ -215,8 +215,8 @@ export interface SessionStore {
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>;
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<boolean>;
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<void>;
shareSession: (id: string) => Promise<Session | null>;
unshareSession: (id: string) => Promise<Session | null>;
+36 -51
View File
@@ -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<string>();
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<AgentGroupsStore>()(
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<string>();
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<AgentGroupsStore>()(
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<string>();
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<AgentGroupsStore>()(
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<string>();
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' });
}
+12 -5
View File
@@ -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<MultiRunStore>()(
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<MultiRunStore>()(
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 = {