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, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi'; import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
import { resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
export type WorktreeBaseOption = { export type WorktreeBaseOption = {
value: string; value: string;
@@ -38,6 +39,18 @@ export interface BranchSelectorState {
isGitRepository: boolean | null; 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. * 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); const branchData = await getGitBranches(directory).catch(() => null);
if (cancelled) return; if (cancelled) return;
const rootTrackingRemote = await resolveRootTrackingRemote(directory).catch(() => null);
if (cancelled) return;
const worktreeBaseOptions: WorktreeBaseOption[] = []; const worktreeBaseOptions: WorktreeBaseOption[] = [];
const headLabel = branchData?.current ? `Current (HEAD: ${branchData.current})` : 'Current (HEAD)'; const headLabel = branchData?.current ? `Current (HEAD: ${branchData.current})` : 'Current (HEAD)';
worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' }); worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' });
@@ -84,6 +100,17 @@ export function useBranchOptions(directory: string | null): BranchSelectorState
if (branchData) { if (branchData) {
const localBranches = branchData.all const localBranches = branchData.all
.filter((branchName) => !branchName.startsWith('remotes/')) .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)); .sort((a, b) => a.localeCompare(b));
localBranches.forEach((branchName) => { localBranches.forEach((branchName) => {
worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'local' }); worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'local' });
@@ -92,6 +119,16 @@ export function useBranchOptions(directory: string | null): BranchSelectorState
const remoteBranches = branchData.all const remoteBranches = branchData.all
.filter((branchName) => branchName.startsWith('remotes/')) .filter((branchName) => branchName.startsWith('remotes/'))
.map((branchName) => branchName.replace(/^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)); .sort((a, b) => a.localeCompare(b));
remoteBranches.forEach((branchName) => { remoteBranches.forEach((branchName) => {
worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'remote' }); worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'remote' });
@@ -19,7 +19,7 @@ import {
RiSearchLine, RiSearchLine,
} from '@remixicon/react'; } from '@remixicon/react';
import { cn } from '@/lib/utils'; 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'; import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
export interface BranchPickerProject { export interface BranchPickerProject {
@@ -59,7 +59,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
try { try {
const [b, w] = await Promise.all([ const [b, w] = await Promise.all([
getGitBranches(project.path), getGitBranches(project.path),
listGitWorktrees(project.path), git.worktree.list(project.path),
]); ]);
setBranches(b); setBranches(b);
setWorktrees(w); setWorktrees(w);
@@ -28,9 +28,15 @@ import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator'; import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator';
import { gitFetch } from '@/lib/gitApi'; import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager';
import { execCommand, execCommands } from '@/lib/execCommands'; import { getRemotes } from '@/lib/gitApi';
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types'; import type {
GitHubPullRequestContextResult,
GitHubPullRequestHeadRepo,
GitHubPullRequestSummary,
GitHubPullRequestsListResult,
GitRemote,
} from '@/lib/api/types';
const parsePullRequestNumber = (value: string): number | null => { const parsePullRequestNumber = (value: string): number | null => {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -64,6 +70,35 @@ const sanitizeGitRemoteName = (value: string): string => {
.slice(0, 64); .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({ export function GitHubPullRequestPickerDialog({
open, open,
onOpenChange, onOpenChange,
@@ -79,6 +114,15 @@ export function GitHubPullRequestPickerDialog({
const activeProject = useProjectsStore((state) => state.getActiveProject()); const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null; 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 [query, setQuery] = React.useState('');
const [createInWorktree, setCreateInWorktree] = React.useState(false); const [createInWorktree, setCreateInWorktree] = React.useState(false);
@@ -91,8 +135,14 @@ export function GitHubPullRequestPickerDialog({
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [existingBranchHeads, setExistingBranchHeads] = React.useState<Map<string, boolean>>(new Map()); 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 [error, setError] = React.useState<string | null>(null);
const preferredPushTransport = React.useMemo(
() => resolvePreferredPushTransport(projectRemotes),
[projectRemotes]
);
const refresh = React.useCallback(async () => { const refresh = React.useCallback(async () => {
if (!projectDirectory) { if (!projectDirectory) {
setResult(null); setResult(null);
@@ -166,13 +216,37 @@ export function GitHubPullRequestPickerDialog({
setIsLoading(false); setIsLoading(false);
setError(null); setError(null);
setExistingBranchHeads(new Map()); setExistingBranchHeads(new Map());
setProjectRemotes([]);
return; return;
} }
void refresh(); void refresh();
}, [open, 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[]) => { 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))); const unique = Array.from(new Set(heads.map((h) => (h || '').trim()).filter(Boolean)));
if (unique.length === 0) return; if (unique.length === 0) return;
@@ -180,28 +254,36 @@ export function GitHubPullRequestPickerDialog({
const unknown = unique.filter((h) => !existingBranchHeads.has(h)); const unknown = unique.filter((h) => !existingBranchHeads.has(h));
if (unknown.length === 0) return; if (unknown.length === 0) return;
// optimistic UI: no spinner; disable once results arrive const results = await Promise.all(
{ unknown.map(async (head) => {
// Avoid shell wrappers; rely on exit code only. const validation = await validateWorktreeCreate(projectRef, {
const commands = unknown.map((h) => `git show-ref --verify --quiet ${JSON.stringify(`refs/heads/${h}`)}`); mode: 'new',
const res = await execCommands(commands, projectDirectory); branchName: head,
setExistingBranchHeads((prev) => { worktreeName: head,
const next = new Map(prev); }).catch(() => ({ ok: false, errors: [{ code: 'validation_failed', message: 'Validation failed' }] }));
for (let i = 0; i < unknown.length; i += 1) {
const head = unknown[i]; const blockedByBranch = validation.errors.some((entry) =>
next.set(head, Boolean(res.results[i]?.success)); entry.code === 'branch_in_use' || entry.code === 'branch_exists'
} );
return next; return { head, blocked: blockedByBranch };
}); })
} );
}, [projectDirectory, existingBranchHeads]);
setExistingBranchHeads((prev) => {
const next = new Map(prev);
for (const item of results) {
next.set(item.head, item.blocked);
}
return next;
});
}, [projectRef, existingBranchHeads]);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
if (!projectDirectory) return; if (!projectRef) return;
if (!createInWorktree) return; if (!createInWorktree) return;
void checkLocalBranchExists(prs.map((pr) => pr.head)); void checkLocalBranchExists(prs.map((pr) => pr.head));
}, [open, projectDirectory, createInWorktree, prs, checkLocalBranchExists]); }, [open, projectRef, createInWorktree, prs, checkLocalBranchExists]);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
@@ -290,7 +372,7 @@ export function GitHubPullRequestPickerDialog({
baseRepo: GitHubPullRequestsListResult['repo'] | undefined, baseRepo: GitHubPullRequestsListResult['repo'] | undefined,
pr: GitHubPullRequestSummary, pr: GitHubPullRequestSummary,
): Promise<{ id: string } | null> => { ): Promise<{ id: string } | null> => {
if (!projectDirectory) return null; if (!projectDirectory || !projectRef) return null;
const headRef = pr.head; const headRef = pr.head;
const headRepo = pr.headRepo; const headRepo = pr.headRepo;
if (!headRef) { if (!headRef) {
@@ -303,33 +385,53 @@ export function GitHubPullRequestPickerDialog({
(headRepo.owner !== baseRepo.owner || headRepo.repo !== baseRepo.repo) (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 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. // Prevent clobbering/removing an existing local branch when using PR worktree mode.
if (existingBranchHeads.get(preferredBranch) === true) { if (existingBranchHeads.get(preferredBranch) === true) {
throw new Error(`Local branch already exists: ${preferredBranch}`); throw new Error(`Local branch already exists: ${preferredBranch}`);
} }
const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, headCommitish, { const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, startRef, {
kind: 'pr', kind: 'pr',
worktreeName: preferredBranch,
setUpstream: true,
upstreamRemote: remoteName,
upstreamBranch: preferredBranch,
ensureRemoteName: isFork ? remoteName : undefined,
ensureRemoteUrl: isFork ? remoteUrl : undefined,
createdFromBranch: pr.base,
}); });
if (!session?.id) { if (!session?.id) {
throw new Error('Failed to create PR worktree session'); throw new Error('Failed to create PR worktree session');
@@ -341,53 +443,6 @@ export function GitHubPullRequestPickerDialog({
throw new Error('Worktree directory not found'); 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. // Update stored metadata for better UX + reintegration target.
useSessionStore.getState().setWorktreeMetadata(session.id, { useSessionStore.getState().setWorktreeMetadata(session.id, {
...(meta || { path: worktreeDir, projectDirectory, branch: preferredBranch, label: preferredBranch }), ...(meta || { path: worktreeDir, projectDirectory, branch: preferredBranch, label: preferredBranch }),
@@ -400,7 +455,7 @@ export function GitHubPullRequestPickerDialog({
}); });
return { id: session.id }; return { id: session.id };
}, [projectDirectory, existingBranchHeads]); }, [projectDirectory, projectRef, existingBranchHeads, preferredPushTransport]);
const startSession = React.useCallback(async (number: number) => { const startSession = React.useCallback(async (number: number) => {
if (!projectDirectory) { if (!projectDirectory) {
@@ -433,14 +488,8 @@ export function GitHubPullRequestPickerDialog({
const sessionId = await (async () => { const sessionId = await (async () => {
if (createInWorktree) { if (createInWorktree) {
try { const worktreeSession = await createPrWorktreeSession(prContext.repo, pr);
const worktreeSession = await createPrWorktreeSession(prContext.repo, pr); return worktreeSession?.id || null;
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 session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null); const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null);
return session?.id || null; return session?.id || null;
@@ -572,7 +621,7 @@ Nice-to-have:
toast.success('Session created from PR'); toast.success('Session created from PR');
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : String(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 { } finally {
setStartingNumber(null); setStartingNumber(null);
} }
@@ -690,7 +739,7 @@ Nice-to-have:
<p className="typography-small text-foreground truncate ml-0.5">{pr.title}</p> <p className="typography-small text-foreground truncate ml-0.5">{pr.title}</p>
{createInWorktree && disabledByWorktree ? ( {createInWorktree && disabledByWorktree ? (
<p className="typography-micro text-muted-foreground mt-0.5 ml-0.5"> <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> </p>
) : null} ) : null}
</div> </div>
@@ -53,6 +53,7 @@ export const SessionDialogs: React.FC = () => {
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null); const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]); const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false); const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
const [deleteDialogShouldDeleteLocalBranch, setDeleteDialogShouldDeleteLocalBranch] = React.useState(false);
const [isProcessingDelete, setIsProcessingDelete] = React.useState(false); const [isProcessingDelete, setIsProcessingDelete] = React.useState(false);
const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false); const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false);
const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState<Set<string>>(new Set()); const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState<Set<string>>(new Set());
@@ -102,6 +103,7 @@ export const SessionDialogs: React.FC = () => {
const shouldArchiveWorktree = isWorktreeDelete; const shouldArchiveWorktree = isWorktreeDelete;
const removeRemoteOptionDisabled = const removeRemoteOptionDisabled =
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches; isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete;
React.useEffect(() => { React.useEffect(() => {
loadSessions(); loadSessions();
@@ -186,6 +188,7 @@ export const SessionDialogs: React.FC = () => {
setDeleteDialog(null); setDeleteDialog(null);
setDeleteDialogSummaries([]); setDeleteDialogSummaries([]);
setDeleteDialogShouldRemoveRemote(false); setDeleteDialogShouldRemoveRemote(false);
setDeleteDialogShouldDeleteLocalBranch(false);
setIsProcessingDelete(false); setIsProcessingDelete(false);
setHasCompletedDirtyCheck(false); setHasCompletedDirtyCheck(false);
setDirtyWorktreePaths(new Set()); setDirtyWorktreePaths(new Set());
@@ -207,6 +210,7 @@ export const SessionDialogs: React.FC = () => {
if (!deleteDialog) { if (!deleteDialog) {
setDeleteDialogSummaries([]); setDeleteDialogSummaries([]);
setDeleteDialogShouldRemoveRemote(false); setDeleteDialogShouldRemoveRemote(false);
setDeleteDialogShouldDeleteLocalBranch(false);
setHasCompletedDirtyCheck(false); setHasCompletedDirtyCheck(false);
setDirtyWorktreePaths(new Set()); setDirtyWorktreePaths(new Set());
return; return;
@@ -326,6 +330,26 @@ export const SessionDialogs: React.FC = () => {
} }
}, [canRemoveRemoteBranches]); }, [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 () => { const handleConfirmDelete = React.useCallback(async () => {
if (!deleteDialog) { if (!deleteDialog) {
return; return;
@@ -335,22 +359,15 @@ export const SessionDialogs: React.FC = () => {
try { try {
const shouldArchive = shouldArchiveWorktree; const shouldArchive = shouldArchiveWorktree;
const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote; const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote;
const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch;
if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) {
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
try { if (!removed) {
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.'),
});
closeDeleteDialog(); closeDeleteDialog();
return; return;
} }
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.'; const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.';
toast.success('Worktree removed', { toast.success('Worktree removed', {
description: renderToastDescription(archiveNote), 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). // Don't try to derive worktree removal from per-session metadata (may be missing).
archiveWorktree: isWorktreeDelete ? false : shouldArchive, archiveWorktree: isWorktreeDelete ? false : shouldArchive,
deleteRemoteBranch: removeRemoteBranch, deleteRemoteBranch: removeRemoteBranch,
deleteLocalBranch,
}); });
if (!success) { if (!success) {
toast.error('Failed to delete session'); toast.error('Failed to delete session');
@@ -390,23 +408,15 @@ export const SessionDialogs: React.FC = () => {
const { deletedIds, failedIds } = await deleteSessions(ids, { const { deletedIds, failedIds } = await deleteSessions(ids, {
archiveWorktree: isWorktreeDelete ? false : shouldArchive, archiveWorktree: isWorktreeDelete ? false : shouldArchive,
deleteRemoteBranch: removeRemoteBranch, deleteRemoteBranch: removeRemoteBranch,
deleteLocalBranch,
}); });
if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) {
// Remove selected worktree even if per-session metadata is missing. // Remove selected worktree even if per-session metadata is missing.
// Use same projectRef logic as the no-sessions path. // Use same projectRef logic as the no-sessions path.
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
try { if (removed) {
await removeProjectWorktree(
getProjectRefForWorktree(deleteDialog.worktree),
deleteDialog.worktree,
{ deleteRemoteBranch: shouldRemoveRemote, force: true }
);
await loadSessions(); 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) { if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) {
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
try { if (removed) {
await removeProjectWorktree(
getProjectRefForWorktree(deleteDialog.worktree),
deleteDialog.worktree,
{ deleteRemoteBranch: shouldRemoveRemote, force: true }
);
await loadSessions(); 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, deleteDialog,
deleteDialogShouldRemoveRemote, deleteDialogShouldRemoveRemote,
deleteDialogShouldDeleteLocalBranch,
deleteSession, deleteSession,
deleteSessions, deleteSessions,
closeDeleteDialog, closeDeleteDialog,
shouldArchiveWorktree, shouldArchiveWorktree,
isWorktreeDelete, isWorktreeDelete,
canRemoveRemoteBranches, canRemoveRemoteBranches,
getProjectRefForWorktree, removeSelectedWorktree,
loadSessions, loadSessions,
]); ]);
@@ -590,9 +592,36 @@ export const SessionDialogs: React.FC = () => {
) )
) : null; ) : 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 ? ( const deleteDialogActions = isWorktreeDelete ? (
<div className="flex w-full items-center justify-between gap-3"> <div className="flex w-full items-center justify-between gap-3">
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
{deleteLocalBranchAction}
{deleteRemoteBranchAction} {deleteRemoteBranchAction}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -43,6 +43,7 @@ const toViewKeyBindings = (bindings: readonly unknown[]): readonly KeyBinding[]
return bindings as 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 openSearchPanelCompat = openSearchPanel as unknown as (view: EditorView) => void;
const closeSearchPanelCompat = closeSearchPanel 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, parent: hostRef.current,
}); });
forceParsing(viewRef.current, viewRef.current.state.doc.length, 200); forceParsingCompat(viewRef.current, viewRef.current.state.doc.length, 200);
viewRef.current.requestMeasure(); viewRef.current.requestMeasure();
if (viewRef.current) { 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(); view.requestMeasure();
// Force a re-render to ensure Portals can find the new widget containers in the DOM // 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 [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]); const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge'); 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 // Conflict state persistence key
const conflictStorageKey = React.useMemo(() => { const conflictStorageKey = React.useMemo(() => {
@@ -727,22 +725,28 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
}); });
}, [status, changeEntries, hasUserAdjustedSelection]); }, [status, changeEntries, hasUserAdjustedSelection]);
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote: GitRemote) => { const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
if (!currentDirectory) return; if (!currentDirectory) return;
setSyncAction(action); setSyncAction(action);
try { try {
if (action === 'fetch') { if (action === 'fetch') {
if (!remote) {
throw new Error('No remote available for fetch');
}
await git.gitFetch(currentDirectory, { remote: remote.name }); await git.gitFetch(currentDirectory, { remote: remote.name });
toast.success(`Fetched from ${remote.name}`); toast.success(`Fetched from ${remote.name}`);
} else if (action === 'pull') { } else if (action === 'pull') {
if (!remote) {
throw new Error('No remote available for pull');
}
const result = await git.gitPull(currentDirectory, { remote: remote.name }); const result = await git.gitPull(currentDirectory, { remote: remote.name });
toast.success( toast.success(
`Pulled ${result.files.length} file${result.files.length === 1 ? '' : 's'} from ${remote.name}` `Pulled ${result.files.length} file${result.files.length === 1 ? '' : 's'} from ${remote.name}`
); );
} else if (action === 'push') { } else if (action === 'push') {
await git.gitPush(currentDirectory, { remote: remote.name }); await git.gitPush(currentDirectory);
toast.success(`Pushed to ${remote.name}`); toast.success('Pushed to upstream');
} }
await refreshStatusAndBranches(false); 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 (!currentDirectory) return;
if (!commitMessage.trim()) { if (!commitMessage.trim()) {
toast.error('Please enter a commit message'); toast.error('Please enter a commit message');
@@ -771,17 +775,6 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
return; 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'; const action: CommitAction = options.pushAfter ? 'commitAndPush' : 'commit';
setCommitAction(action); setCommitAction(action);
@@ -798,9 +791,8 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
await refreshStatusAndBranches(); await refreshStatusAndBranches();
if (options.pushAfter) { if (options.pushAfter) {
const remoteName = targetRemote?.name; await git.gitPush(currentDirectory);
await git.gitPush(currentDirectory, remoteName ? { remote: remoteName } : undefined); toast.success('Pushed to upstream');
toast.success(remoteName ? `Pushed to ${remoteName}` : 'Pushed to remote');
triggerFireworks(); triggerFireworks();
await refreshStatusAndBranches(false); await refreshStatusAndBranches(false);
} else { } 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 () => { const handleGenerateCommitMessage = React.useCallback(async () => {
if (!currentDirectory) return; if (!currentDirectory) return;
if (selectedPaths.size === 0) { if (selectedPaths.size === 0) {
@@ -1611,7 +1590,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
remotes={effectiveRemotes} remotes={effectiveRemotes}
onFetch={(remote) => handleSyncAction('fetch', remote)} onFetch={(remote) => handleSyncAction('fetch', remote)}
onPull={(remote) => handleSyncAction('pull', remote)} onPull={(remote) => handleSyncAction('pull', remote)}
onPush={(remote) => handleSyncAction('push', remote)} onPush={() => handleSyncAction('push')}
onCheckoutBranch={handleCheckoutBranch} onCheckoutBranch={handleCheckoutBranch}
onCreateBranch={handleCreateBranch} onCreateBranch={handleCreateBranch}
onRenameBranch={handleRenameBranch} onRenameBranch={handleRenameBranch}
@@ -1697,12 +1676,11 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
onGenerateMessage={handleGenerateCommitMessage} onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage} isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })} onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={(remote) => handleCommit({ pushAfter: true, remote })} onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction} commitAction={commitAction}
isBusy={isBusy} isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled} gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
remotes={remotes}
/> />
</> </>
) : ( ) : (
@@ -1885,34 +1863,6 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
project={branchPickerProject} 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> </div>
); );
}; };
@@ -4,7 +4,6 @@ import {
RiAiGenerate2, RiAiGenerate2,
RiLoader4Line, RiLoader4Line,
RiEmotionHappyLine, RiEmotionHappyLine,
RiArrowDownSLine,
} from '@remixicon/react'; } from '@remixicon/react';
import { import {
Collapsible, Collapsible,
@@ -16,13 +15,6 @@ import { CommitInput } from './CommitInput';
import { AIHighlightsBox } from './AIHighlightsBox'; import { AIHighlightsBox } from './AIHighlightsBox';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; 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; type CommitAction = 'commit' | 'commitAndPush' | null;
@@ -36,12 +28,11 @@ interface CommitSectionProps {
onGenerateMessage: () => void; onGenerateMessage: () => void;
isGeneratingMessage: boolean; isGeneratingMessage: boolean;
onCommit: () => void; onCommit: () => void;
onCommitAndPush: (remote?: GitRemote) => void; onCommitAndPush: () => void;
commitAction: CommitAction; commitAction: CommitAction;
isBusy: boolean; isBusy: boolean;
gitmojiEnabled: boolean; gitmojiEnabled: boolean;
onOpenGitmojiPicker: () => void; onOpenGitmojiPicker: () => void;
remotes?: GitRemote[];
variant?: 'framed' | 'plain'; variant?: 'framed' | 'plain';
} }
@@ -60,13 +51,11 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
isBusy, isBusy,
gitmojiEnabled, gitmojiEnabled,
onOpenGitmojiPicker, onOpenGitmojiPicker,
remotes = [],
variant = 'framed', variant = 'framed',
}) => { }) => {
const hasSelectedFiles = selectedCount > 0; const hasSelectedFiles = selectedCount > 0;
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null; const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
const { isMobile, hasTouchInput } = useDeviceInfo(); const { isMobile, hasTouchInput } = useDeviceInfo();
const hasMultipleRemotes = remotes.length > 1;
const containerClassName = const containerClassName =
variant === 'framed' variant === 'framed'
@@ -177,109 +166,27 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
</ButtonLarge> </ButtonLarge>
{isMobile ? ( {isMobile ? (
hasMultipleRemotes ? ( <Tooltip>
<DropdownMenu> <TooltipTrigger asChild>
<Tooltip> <Button
<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
variant="default" variant="default"
size="sm"
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage} disabled={!canCommit || isGeneratingMessage}
className="commit-actions__btn" className="h-7 w-7 p-0"
aria-label="Commit & Push" aria-label="Commit & Push"
> >
{commitAction === 'commitAndPush' ? ( {commitAction === 'commitAndPush' ? (
<> <RiLoader4Line className="size-4 animate-spin" />
<RiLoader4Line className="size-4 animate-spin" />
<span className="commit-actions__label commit-actions__label--long">Pushing...</span>
</>
) : ( ) : (
<> <RiArrowUpLine className="size-4" />
<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" />
</>
)} )}
</ButtonLarge> </Button>
</DropdownMenuTrigger> </TooltipTrigger>
<DropdownMenuContent align="end" className="min-w-[200px]"> <TooltipContent side="top">
{remotes.map((remote) => ( <p>Commit & Push</p>
<DropdownMenuItem key={remote.name} onSelect={() => onCommitAndPush(remote)}> </TooltipContent>
<div className="flex flex-col"> </Tooltip>
<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>
) : ( ) : (
<ButtonLarge <ButtonLarge
variant="default" variant="default"
@@ -38,7 +38,7 @@ interface GitHeaderProps {
remotes: GitRemote[]; remotes: GitRemote[];
onFetch: (remote: GitRemote) => void; onFetch: (remote: GitRemote) => void;
onPull: (remote: GitRemote) => void; onPull: (remote: GitRemote) => void;
onPush: (remote: GitRemote) => void; onPush: () => void;
onCheckoutBranch: (branch: string) => void; onCheckoutBranch: (branch: string) => void;
onCreateBranch: (name: string, remote?: GitRemote) => Promise<void>; onCreateBranch: (name: string, remote?: GitRemote) => Promise<void>;
onRenameBranch?: (oldName: string, newName: string) => Promise<void>; onRenameBranch?: (oldName: string, newName: string) => Promise<void>;
@@ -22,7 +22,7 @@ interface SyncActionsProps {
remotes: GitRemote[]; remotes: GitRemote[];
onFetch: (remote: GitRemote) => void; onFetch: (remote: GitRemote) => void;
onPull: (remote: GitRemote) => void; onPull: (remote: GitRemote) => void;
onPush: (remote: GitRemote) => void; onPush: () => void;
disabled: boolean; disabled: boolean;
iconOnly?: boolean; iconOnly?: boolean;
tooltipDelayMs?: number; tooltipDelayMs?: number;
@@ -61,9 +61,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
}; };
const handlePush = () => { const handlePush = () => {
const remote = remotes[0]; if (remotes.length >= 1) {
if (remotes.length === 1 && remote) { onPush();
onPush(remote);
} }
}; };
@@ -202,25 +201,15 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
behindCount behindCount
)} )}
{hasMultipleRemotes {renderButton(
? renderDropdownButton( 'push',
'push', <RiArrowUpLine className="size-4" />,
<RiArrowUpLine className="size-4" />, <RiLoader4Line className="size-4 animate-spin" />,
<RiLoader4Line className="size-4 animate-spin" />, 'Push',
'Push', handlePush,
onPush, aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes', aheadCount
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> </div>
); );
}; };
+65 -3
View File
@@ -285,9 +285,59 @@ export interface GitCommitFilesResponse {
} }
export interface GitWorktreeInfo { export interface GitWorktreeInfo {
worktree: string; head: string;
head?: string; name: string;
branch?: 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 { export interface GitDeleteBranchPayload {
@@ -322,6 +372,13 @@ export interface GeneratedPullRequestDescription {
body: string; 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 { export interface GitAPI {
checkIsGitRepository(directory: string): Promise<boolean>; checkIsGitRepository(directory: string): Promise<boolean>;
getGitStatus(directory: string): Promise<GitStatus>; getGitStatus(directory: string): Promise<GitStatus>;
@@ -338,6 +395,9 @@ export interface GitAPI {
payload: { base: string; head: string; context?: string; zenModel?: string } payload: { base: string; head: string; context?: string; zenModel?: string }
): Promise<GeneratedPullRequestDescription>; ): Promise<GeneratedPullRequestDescription>;
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>; 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>; createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult>; 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>; 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 }>; stash(directory: string, options?: { message?: string; includeUntracked?: boolean }): Promise<{ success: boolean }>;
stashPop(directory: string): Promise<{ success: boolean }>; stashPop(directory: string): Promise<{ success: boolean }>;
getConflictDetails(directory: string): Promise<MergeConflictDetails>; getConflictDetails(directory: string): Promise<MergeConflictDetails>;
worktree?: GitWorktreeAPI;
} }
export interface FileListEntry { export interface FileListEntry {
@@ -633,6 +694,7 @@ export type GitHubPullRequestHeadRepo = {
repo: string; repo: string;
url: string; url: string;
cloneUrl?: string; cloneUrl?: string;
sshUrl?: string;
}; };
export type GitHubPullRequestSummary = GitHubPullRequest & { export type GitHubPullRequestSummary = GitHubPullRequest & {
+59
View File
@@ -18,6 +18,11 @@ export type {
GitLogEntry, GitLogEntry,
GitLogResponse, GitLogResponse,
GitWorktreeInfo, GitWorktreeInfo,
CreateGitWorktreePayload,
GitWorktreeCreateResult,
RemoveGitWorktreePayload,
GitWorktreeValidationError,
GitWorktreeValidationResult,
GitDeleteBranchPayload, GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload, GitDeleteRemoteBranchPayload,
DiscoveredGitCredential, DiscoveredGitCredential,
@@ -120,10 +125,64 @@ export async function generatePullRequestDescription(
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> { export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
const runtime = getRuntimeGit(); const runtime = getRuntimeGit();
if (runtime?.worktree?.list) {
return runtime.worktree.list(directory);
}
if (runtime) return runtime.listGitWorktrees(directory); if (runtime) return runtime.listGitWorktrees(directory);
return gitHttp.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( export async function createGitCommit(
directory: string, directory: string,
message: string, message: string,
+49
View File
@@ -11,6 +11,10 @@ import type {
GitDeleteRemoteBranchPayload, GitDeleteRemoteBranchPayload,
GeneratedCommitMessage, GeneratedCommitMessage,
GitWorktreeInfo, GitWorktreeInfo,
CreateGitWorktreePayload,
GitWorktreeCreateResult,
RemoveGitWorktreePayload,
GitWorktreeValidationResult,
CreateGitCommitOptions, CreateGitCommitOptions,
GitCommitResult, GitCommitResult,
GitPushResult, GitPushResult,
@@ -299,6 +303,51 @@ export async function listGitWorktrees(directory: string): Promise<GitWorktreeIn
return response.json(); 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( export async function createGitCommit(
directory: string, directory: string,
message: 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 { getRootBranch, getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { import {
createSdkWorktree,
removeProjectWorktree, removeProjectWorktree,
type ProjectRef, type ProjectRef,
} from '@/lib/worktrees/worktreeManager'; } from '@/lib/worktrees/worktreeManager';
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
import { startConfigUpdate, finishConfigUpdate } from '@/lib/configUpdate'; import { startConfigUpdate, finishConfigUpdate } from '@/lib/configUpdate';
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value; 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 setupCommands = await getWorktreeSetupCommands(projectRef);
const rootBranch = await getRootBranch(projectRef.path); const rootBranch = await getRootBranch(projectRef.path);
const metadata = await createSdkWorktree(projectRef, { const metadata = await createWorktreeWithDefaults(projectRef, {
preferredName, preferredName,
mode: 'new',
branchName: preferredName,
worktreeName: preferredName,
setupCommands, setupCommands,
}); });
@@ -118,7 +121,7 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
const session = await sessionStore.createSession(undefined, metadata.path); const session = await sessionStore.createSession(undefined, metadata.path);
if (!session) { if (!session) {
// Clean up the worktree if session creation failed // 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', { toast.error('Failed to create session', {
description: 'Could not create a session for the worktree.', 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 projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
const preferredName = generateBranchName(); const preferredName = generateBranchName();
const setupCommands = await getWorktreeSetupCommands(projectRef); const setupCommands = await getWorktreeSetupCommands(projectRef);
const metadata = await createSdkWorktree(projectRef, { const metadata = await createWorktreeWithDefaults(projectRef, {
preferredName, preferredName,
mode: 'new',
branchName: preferredName,
worktreeName: preferredName,
setupCommands, setupCommands,
}); });
@@ -303,7 +309,18 @@ export async function createWorktreeOnly(): Promise<string | null> {
*/ */
export async function createWorktreeSessionForBranch( export async function createWorktreeSessionForBranch(
projectDirectory: string, 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> { ): Promise<{ id: string } | null> {
if (isCreatingWorktreeSession) { if (isCreatingWorktreeSession) {
return null; return null;
@@ -335,15 +352,25 @@ export async function createWorktreeSessionForBranch(
const setupCommands = await getWorktreeSetupCommands(projectRef); const setupCommands = await getWorktreeSetupCommands(projectRef);
const rootBranch = await getRootBranch(projectRef.path); const rootBranch = await getRootBranch(projectRef.path);
const metadata = await createSdkWorktree(projectRef, { const metadata = await createWorktreeWithDefaults(projectRef, {
preferredName: branchName, 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, setupCommands,
}); });
const kind = options?.kind ?? 'standard';
const createdMetadata = { const createdMetadata = {
...metadata, ...metadata,
createdFromBranch: rootBranch, createdFromBranch: options?.createdFromBranch || rootBranch,
kind: 'standard' as const, kind,
}; };
// Get worktree status // Get worktree status
@@ -355,7 +382,7 @@ export async function createWorktreeSessionForBranch(
const session = await sessionStore.createSession(undefined, metadata.path); const session = await sessionStore.createSession(undefined, metadata.path);
if (!session) { if (!session) {
// Clean up the worktree if session creation failed // 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', { toast.error('Failed to create session', {
description: 'Could not create a session for the worktree.', description: 'Could not create a session for the worktree.',
}); });
@@ -467,7 +494,16 @@ export async function createWorktreeSessionForNewBranch(
projectDirectory: string, projectDirectory: string,
preferredBranchName: string, preferredBranchName: string,
startPoint?: 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> { ): Promise<{ id: string; branch: string } | null> {
if (isCreatingWorktreeSession) { if (isCreatingWorktreeSession) {
return null; return null;
@@ -506,15 +542,23 @@ export async function createWorktreeSessionForNewBranch(
const setupCommands = await getWorktreeSetupCommands(projectRef); const setupCommands = await getWorktreeSetupCommands(projectRef);
const rootBranch = await getRootBranch(projectRef.path); const rootBranch = await getRootBranch(projectRef.path);
try { try {
const metadata = await createSdkWorktree(projectRef, { const metadata = await createWorktreeWithDefaults(projectRef, {
preferredName: base, 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, setupCommands,
}); });
const createdMetadata = { const createdMetadata = {
...metadata, ...metadata,
createdFromBranch: rootBranch || start, createdFromBranch: options?.createdFromBranch || rootBranch || start,
kind, kind,
}; };
@@ -524,7 +568,7 @@ export async function createWorktreeSessionForNewBranch(
const sessionStore = useSessionStore.getState(); const sessionStore = useSessionStore.getState();
const session = await sessionStore.createSession(undefined, metadata.path); const session = await sessionStore.createSession(undefined, metadata.path);
if (!session) { 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.'); throw new Error('Could not create a session for the worktree.');
} }
@@ -616,9 +660,25 @@ export async function createWorktreeSessionForNewBranchExact(
projectDirectory: string, projectDirectory: string,
branchName: string, branchName: string,
startPoint: 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> { ): Promise<{ id: string; branch: string } | null> {
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, {
kind: options?.kind, 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 { substituteCommandVariables } from '@/lib/openchamberConfig';
import type { WorktreeMetadata } from '@/types/worktree'; 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 }; export type ProjectRef = { id: string; path: string };
@@ -16,21 +22,24 @@ const normalizePath = (value: string): string => {
const slugifyWorktreeName = (value: string): string => { const slugifyWorktreeName = (value: string): string => {
return value return value
.trim() .trim()
.toLowerCase() .replace(/^refs\/heads\//, '')
.replace(/[^a-z0-9]+/g, '-') .replace(/^heads\//, '')
.replace(/\s+/g, '-')
.replace(/^\/+|\/+$/g, '')
.split('/').join('-')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
.slice(0, 80); .slice(0, 80);
}; };
const unwrapSdkData = (value: unknown): unknown => { const normalizeBranchName = (value: string): string => {
if (!value || typeof value !== 'object') { return value
return value; .trim()
} .replace(/^refs\/heads\//, '')
const record = value as Record<string, unknown>; .replace(/^heads\//, '')
if ('data' in record) { .replace(/\s+/g, '-')
return record.data; .replace(/^\/+|\/+$/g, '');
}
return value;
}; };
const deriveSdkWorktreeNameFromDirectory = (directory: string): string => { const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
@@ -57,106 +66,73 @@ export const buildSdkStartCommand = (args: {
return joined.trim().length > 0 ? joined : undefined; return joined.trim().length > 0 ? joined : undefined;
}; };
const waitForSdkWorktreeReady = async (directory: string, timeoutMs = 60_000): Promise<void> => { const toCreatePayload = (args: {
const target = normalizePath(directory); preferredName?: string;
if (!target) { setupCommands?: string[];
return; 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) => { const worktreeNameSeed = args.worktreeName ?? args.preferredName ?? '';
let done = false; const worktreeName = slugifyWorktreeName(worktreeNameSeed);
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();
}
};
timeout = setTimeout(() => { const branchNameSeed = args.branchName ?? (mode === 'new' ? args.preferredName : undefined) ?? '';
finish({ error: 'Worktree startup timed out' }); const branchName = normalizeBranchName(branchNameSeed);
}, timeoutMs);
unsubscribe = opencodeClient.subscribeToGlobalEvents( const existingBranch = normalizeBranchName(args.existingBranch ?? args.branchName ?? '');
(event) => { const startRef = (args.startRef || '').trim();
const payload = event.payload as { type?: string; properties?: Record<string, unknown> };
if (payload?.type === 'worktree.ready') { const commands = Array.isArray(args.setupCommands) ? args.setupCommands : [];
finish(); const startCommand = buildSdkStartCommand({
return; projectDirectory,
} setupCommands: commands,
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 }
);
}); });
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[]> { export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
const projectDirectory = project.path; const projectDirectory = project.path;
const scoped = opencodeClient.getScopedApiClient(projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory);
const results: WorktreeMetadata[] = []; const worktrees = await git.worktree.list(projectDirectory).catch(() => []);
const results: WorktreeMetadata[] = worktrees
// SDK worktrees .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0)
try { .map((entry) => {
const raw = await scoped.worktree.list(); const worktreePath = normalizePath(entry.path);
const data = unwrapSdkData(raw); const branch = (entry.branch || '').replace(/^refs\/heads\//, '').trim();
const directories = Array.isArray(data) ? data : []; const name = (entry.name || '').trim();
return {
for (const entry of directories) { source: 'sdk' as const,
if (typeof entry !== 'string' || entry.trim().length === 0) { name: name || deriveSdkWorktreeNameFromDirectory(worktreePath),
continue; path: worktreePath,
}
const directory = normalizePath(entry);
const name = deriveSdkWorktreeNameFromDirectory(directory);
results.push({
source: 'sdk',
name,
path: directory,
projectDirectory, projectDirectory,
branch: '', branch,
label: name, label: branch || name || deriveSdkWorktreeNameFromDirectory(worktreePath),
}); };
}
} 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
}
}) })
); .filter((entry) => normalizePath(entry.path) !== normalizedProjectDirectory);
return results.sort((a, b) => { return results.sort((a, b) => {
const aLabel = (a.label || a.branch || a.path).toLowerCase(); 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; preferredName?: string;
setupCommands?: 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 projectDirectory = project.path;
const scoped = opencodeClient.getScopedApiClient(projectDirectory); const payload = toCreatePayload(args, projectDirectory);
const baseName = typeof args.preferredName === 'string' ? slugifyWorktreeName(args.preferredName) : ''; const created = await git.worktree.create(projectDirectory, payload);
const seed = baseName || undefined; 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 : []; if (!returnedName || !returnedPath) {
const startCommand = buildSdkStartCommand({ throw new Error('Worktree create missing name/path');
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');
} }
const metadata: WorktreeMetadata = { const metadata: WorktreeMetadata = {
source: 'sdk', source: 'sdk',
name: returnedName, name: returnedName,
path: normalizePath(returnedDirectory), path: normalizePath(returnedPath),
projectDirectory, projectDirectory,
branch: returnedBranch, branch: returnedBranch,
label: returnedName, label: returnedBranch || returnedName,
}; };
await waitForSdkWorktreeReady(metadata.path);
return metadata; 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?: { export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: {
deleteRemoteBranch?: boolean; deleteRemoteBranch?: boolean;
deleteLocalBranch?: boolean;
remoteName?: string; remoteName?: string;
force?: boolean;
}): Promise<void> { }): Promise<void> {
const projectDirectory = project.path; const projectDirectory = project.path;
const deleteRemote = Boolean(options?.deleteRemoteBranch); const deleteRemote = Boolean(options?.deleteRemoteBranch);
const deleteLocalBranch = options?.deleteLocalBranch === true;
const remoteName = options?.remoteName; const remoteName = options?.remoteName;
const scoped = opencodeClient.getScopedApiClient(projectDirectory); const raw = await git.worktree.remove(projectDirectory, {
const raw = await scoped.worktree.remove({ worktreeRemoveInput: { directory: worktree.path } }); directory: worktree.path,
const ok = unwrapSdkData(raw); deleteLocalBranch,
if (ok !== true) { });
if (!raw?.success) {
throw new Error('Worktree removal failed'); throw new Error('Worktree removal failed');
} }
+7 -5
View File
@@ -29,8 +29,8 @@ interface SessionState {
interface SessionActions { interface SessionActions {
loadSessions: () => Promise<void>; loadSessions: () => Promise<void>;
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>; createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>; deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise<boolean>;
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; 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>; updateSessionTitle: (id: string, title: string) => Promise<void>;
shareSession: (id: string) => Promise<Session | null>; shareSession: (id: string) => Promise<Session | null>;
unshareSession: (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 ( const archiveSessionWorktree = async (
metadata: WorktreeMetadata, metadata: WorktreeMetadata,
options?: { deleteRemoteBranch?: boolean; remoteName?: string } options?: { deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }
) => { ) => {
const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined)); const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined));
@@ -150,8 +150,8 @@ const archiveSessionWorktree = async (
status ? ({ ...metadata, status } as WorktreeMetadata) : metadata, status ? ({ ...metadata, status } as WorktreeMetadata) : metadata,
{ {
deleteRemoteBranch: options?.deleteRemoteBranch, deleteRemoteBranch: options?.deleteRemoteBranch,
deleteLocalBranch: options?.deleteLocalBranch,
remoteName: options?.remoteName, remoteName: options?.remoteName,
force: Boolean(status?.isDirty),
} }
); );
}; };
@@ -955,6 +955,7 @@ export const useSessionStore = create<SessionStore>()(
try { try {
await archiveSessionWorktree(metadata, { await archiveSessionWorktree(metadata, {
deleteRemoteBranch: options?.deleteRemoteBranch, deleteRemoteBranch: options?.deleteRemoteBranch,
deleteLocalBranch: options?.deleteLocalBranch,
remoteName: options?.remoteName, remoteName: options?.remoteName,
}); });
archiveSucceeded = true; archiveSucceeded = true;
@@ -1012,7 +1013,7 @@ export const useSessionStore = create<SessionStore>()(
deleteSessions: async ( deleteSessions: async (
ids: string[], 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))); const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0)));
if (uniqueIds.length === 0) { if (uniqueIds.length === 0) {
@@ -1064,6 +1065,7 @@ export const useSessionStore = create<SessionStore>()(
try { try {
await archiveSessionWorktree(metadata, { await archiveSessionWorktree(metadata, {
deleteRemoteBranch: options?.deleteRemoteBranch, deleteRemoteBranch: options?.deleteRemoteBranch,
deleteLocalBranch: options?.deleteLocalBranch,
remoteName: options?.remoteName, remoteName: options?.remoteName,
}); });
archivedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory }); 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>; createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>; createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>; deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise<boolean>;
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; 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>; updateSessionTitle: (id: string, title: string) => Promise<void>;
shareSession: (id: string) => Promise<Session | null>; shareSession: (id: string) => Promise<Session | null>;
unshareSession: (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; 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. * Parse a session title to extract group, provider, model, and index.
* Title format: groupSlug/provider/model[/index] * Title format: groupSlug/provider/model[/index]
@@ -555,27 +588,11 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const apiClient = opencodeClient.getApiClient(); const { failedIds } = await deleteGroupWorktreeSessions({
const candidates = await collectDeleteCandidates({
apiClient,
group, group,
projectDirectory: normalize(projectDirectory), projectDirectory: normalize(projectDirectory),
worktreePaths: group.sessions.map((s) => s.path), 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) { if (failedIds.length > 0) {
set({ error: 'Failed to delete some sessions' }); set({ error: 'Failed to delete some sessions' });
} }
@@ -613,27 +630,11 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const apiClient = opencodeClient.getApiClient(); const { failedIds } = await deleteGroupWorktreeSessions({
const candidates = await collectDeleteCandidates({
apiClient,
group, group,
projectDirectory: normalize(projectDirectory), projectDirectory: normalize(projectDirectory),
worktreePaths: [normalizedWorktreePath], 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) { if (failedIds.length > 0) {
set({ error: 'Failed to delete some sessions' }); set({ error: 'Failed to delete some sessions' });
} }
@@ -690,27 +691,11 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const apiClient = opencodeClient.getApiClient(); const { failedIds } = await deleteGroupWorktreeSessions({
const candidates = await collectDeleteCandidates({
apiClient,
group, group,
projectDirectory: normalize(projectDirectory), projectDirectory: normalize(projectDirectory),
worktreePaths: toDelete, 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) { if (failedIds.length > 0) {
set({ error: 'Failed to delete some sessions' }); 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 type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; 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 { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { checkIsGitRepository } from '@/lib/gitApi'; import { checkIsGitRepository } from '@/lib/gitApi';
import { useSessionStore } from './sessionStore'; import { useSessionStore } from './sessionStore';
@@ -31,8 +32,8 @@ const toModelSlug = (providerID: string, modelID: string): string => {
}; };
/** /**
* Seed name for SDK worktree creation. * Seed name for worktree creation.
* Uses slashes for readability; SDK will slugify. * Uses slashes for readability; create payload will slugify.
*/ */
const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => { const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => {
return `${groupSlug}/${modelSlug}`; return `${groupSlug}/${modelSlug}`;
@@ -123,6 +124,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
const groupSlug = toGitSafeSlug(groupName); const groupSlug = toGitSafeSlug(groupName);
const rootBranch = await getRootBranch(directory); const rootBranch = await getRootBranch(directory);
const rootTrackingRemote = await resolveRootTrackingRemote(directory);
const createdRuns: Array<{ const createdRuns: Array<{
sessionId: string; sessionId: string;
@@ -156,11 +158,16 @@ export const useMultiRunStore = create<MultiRunStore>()(
const preferredName = count > 1 const preferredName = count > 1
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`) ? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
: generateWorktreeNameSeed(groupSlug, modelSlug); : generateWorktreeNameSeed(groupSlug, modelSlug);
try { try {
const worktreeMetadata = await createSdkWorktree(project, { const worktreeMetadata = await createWorktreeWithDefaults(project, {
preferredName, preferredName,
mode: 'new',
branchName: preferredName,
worktreeName: preferredName,
startRef: params.worktreeBaseBranch || 'HEAD',
setupCommands: commandsToRun, setupCommands: commandsToRun,
}, {
resolvedRootTrackingRemote: rootTrackingRemote,
}); });
const enrichedMetadata = { const enrichedMetadata = {
+51 -2
View File
@@ -2282,12 +2282,61 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
} }
case 'api:git/worktrees': { case 'api:git/worktrees': {
const { directory, method } = (payload || {}) as {
directory?: string;
method?: string;
body?: unknown;
directoryPath?: string;
deleteLocalBranch?: boolean;
};
if (!directory) {
return { id, type, success: false, error: 'Directory is required' };
}
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const worktrees = await gitService.listGitWorktrees(directory);
return { id, type, success: true, data: worktrees };
}
if (normalizedMethod === 'POST') {
const created = await gitService.createWorktree(directory, (payload || {}) as gitService.CreateGitWorktreePayload);
return { id, type, success: true, data: created };
}
if (normalizedMethod === 'DELETE') {
const removePayload = payload as {
body?: { directory?: string; deleteLocalBranch?: boolean };
directory?: string;
deleteLocalBranch?: boolean;
};
const bodyDirectory = typeof removePayload?.body?.directory === 'string'
? removePayload.body.directory
: '';
const legacyDirectory = typeof removePayload?.directory === 'string' ? removePayload.directory : '';
const worktreeDirectory = bodyDirectory || legacyDirectory || '';
if (!worktreeDirectory) {
return { id, type, success: false, error: 'Worktree directory is required' };
}
const removed = await gitService.removeWorktree(directory, {
directory: worktreeDirectory,
deleteLocalBranch: removePayload?.body?.deleteLocalBranch === true || removePayload?.deleteLocalBranch === true,
});
return { id, type, success: true, data: { success: Boolean(removed) } };
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:git/worktrees/validate': {
const { directory } = (payload || {}) as { directory?: string }; const { directory } = (payload || {}) as { directory?: string };
if (!directory) { if (!directory) {
return { id, type, success: false, error: 'Directory is required' }; return { id, type, success: false, error: 'Directory is required' };
} }
const worktrees = await gitService.listGitWorktrees(directory); const result = await gitService.validateWorktreeCreate(directory, (payload || {}) as gitService.CreateGitWorktreePayload);
return { id, type, success: true, data: worktrees }; return { id, type, success: true, data: result };
} }
case 'api:git/diff': { case 'api:git/diff': {
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -57,7 +57,7 @@ type GitHubCheckRun = {
}>; }>;
}; };
type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string }; type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string; sshUrl?: string };
type GitHubPullRequestSummary = { type GitHubPullRequestSummary = {
number: number; number: number;
@@ -188,6 +188,7 @@ const mapHeadRepo = (raw: unknown): GitHubPullRequestHeadRepo | null => {
repo, repo,
url, url,
cloneUrl: readString(rec?.clone_url) || undefined, cloneUrl: readString(rec?.clone_url) || undefined,
sshUrl: readString(rec?.ssh_url) || undefined,
}; };
}; };
+59
View File
@@ -17,6 +17,10 @@ import type {
GeneratedCommitMessage, GeneratedCommitMessage,
GeneratedPullRequestDescription, GeneratedPullRequestDescription,
GitWorktreeInfo, GitWorktreeInfo,
CreateGitWorktreePayload,
GitWorktreeValidationResult,
GitWorktreeCreateResult,
RemoveGitWorktreePayload,
GitCommitResult, GitCommitResult,
CreateGitCommitOptions, CreateGitCommitOptions,
GitPushResult, GitPushResult,
@@ -113,6 +117,32 @@ export const createVSCodeGitAPI = (): GitAPI => ({
return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' }); return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' });
}, },
validateGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult> => {
return sendBridgeMessage<GitWorktreeValidationResult>('api:git/worktrees/validate', {
directory,
...(payload || {}),
});
},
createGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees', {
directory,
method: 'POST',
...(payload || {}),
});
},
deleteGitWorktree: async (directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> => {
return sendBridgeMessage<{ success: boolean }>('api:git/worktrees', {
directory,
method: 'DELETE',
body: {
directory: payload.directory,
deleteLocalBranch: payload.deleteLocalBranch === true,
},
});
},
createGitCommit: async (directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult> => { createGitCommit: async (directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult> => {
return sendBridgeMessage<GitCommitResult>('api:git/commit', { return sendBridgeMessage<GitCommitResult>('api:git/commit', {
directory, directory,
@@ -281,4 +311,33 @@ export const createVSCodeGitAPI = (): GitAPI => ({
operation: 'merge' | 'rebase'; operation: 'merge' | 'rebase';
}>('api:git/conflict-details', { directory }); }>('api:git/conflict-details', { directory });
}, },
worktree: {
list: async (directory: string): Promise<GitWorktreeInfo[]> => {
return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' });
},
validate: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult> => {
return sendBridgeMessage<GitWorktreeValidationResult>('api:git/worktrees/validate', {
directory,
...(payload || {}),
});
},
create: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees', {
directory,
method: 'POST',
...(payload || {}),
});
},
remove: async (directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> => {
return sendBridgeMessage<{ success: boolean }>('api:git/worktrees', {
directory,
method: 'DELETE',
body: {
directory: payload.directory,
deleteLocalBranch: payload.deleteLocalBranch === true,
},
});
},
},
}); });
+74 -1
View File
@@ -281,7 +281,10 @@ const resolveWorkspacePathFromWorktrees = async (targetPath, baseDirectory) => {
const worktrees = await getWorktrees(resolvedBase); const worktrees = await getWorktrees(resolvedBase);
for (const worktree of worktrees) { for (const worktree of worktrees) {
const candidate = typeof worktree?.worktree === 'string' ? normalizeDirectoryPath(worktree.worktree) : ''; const candidatePath = typeof worktree?.path === 'string'
? worktree.path
: (typeof worktree?.worktree === 'string' ? worktree.worktree : '');
const candidate = normalizeDirectoryPath(candidatePath);
if (!candidate) { if (!candidate) {
continue; continue;
} }
@@ -8093,6 +8096,7 @@ async function main(options = {}) {
repo: pr.head.repo.name, repo: pr.head.repo.name,
url: pr.head.repo.html_url, url: pr.head.repo.html_url,
cloneUrl: pr.head.repo.clone_url, cloneUrl: pr.head.repo.clone_url,
sshUrl: pr.head.repo.ssh_url,
} }
: null; : null;
return { return {
@@ -8160,6 +8164,7 @@ async function main(options = {}) {
repo: prData.head.repo.name, repo: prData.head.repo.name,
url: prData.head.repo.html_url, url: prData.head.repo.html_url,
cloneUrl: prData.head.repo.clone_url, cloneUrl: prData.head.repo.clone_url,
sshUrl: prData.head.repo.ssh_url,
} }
: null; : null;
@@ -9485,6 +9490,74 @@ Context:
} }
}); });
app.post('/api/git/worktrees/validate', async (req, res) => {
const { validateWorktreeCreate } = await getGitLibraries();
if (typeof validateWorktreeCreate !== 'function') {
return res.status(501).json({ error: 'Worktree validation is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await validateWorktreeCreate(directory, req.body || {});
res.json(result);
} catch (error) {
console.error('Failed to validate worktree creation:', error);
res.status(500).json({ error: error.message || 'Failed to validate worktree creation' });
}
});
app.post('/api/git/worktrees', async (req, res) => {
const { createWorktree } = await getGitLibraries();
if (typeof createWorktree !== 'function') {
return res.status(501).json({ error: 'Worktree creation is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const created = await createWorktree(directory, req.body || {});
res.json(created);
} catch (error) {
console.error('Failed to create worktree:', error);
res.status(500).json({ error: error.message || 'Failed to create worktree' });
}
});
app.delete('/api/git/worktrees', async (req, res) => {
const { removeWorktree } = await getGitLibraries();
if (typeof removeWorktree !== 'function') {
return res.status(501).json({ error: 'Worktree removal is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const worktreeDirectory = typeof req.body?.directory === 'string' ? req.body.directory : '';
if (!worktreeDirectory) {
return res.status(400).json({ error: 'worktree directory is required' });
}
const result = await removeWorktree(directory, {
directory: worktreeDirectory,
deleteLocalBranch: req.body?.deleteLocalBranch === true,
});
res.json({ success: Boolean(result) });
} catch (error) {
console.error('Failed to remove worktree:', error);
res.status(500).json({ error: error.message || 'Failed to remove worktree' });
}
});
app.get('/api/git/worktree-type', async (req, res) => { app.get('/api/git/worktree-type', async (req, res) => {
const { isLinkedWorktree } = await getGitLibraries(); const { isLinkedWorktree } = await getGitLibraries();
try { try {
File diff suppressed because it is too large Load Diff
+9
View File
@@ -18,6 +18,9 @@ export const createWebGitAPI = (): GitAPI => ({
generateCommitMessage: gitApiHttp.generateCommitMessage, generateCommitMessage: gitApiHttp.generateCommitMessage,
generatePullRequestDescription: gitApiHttp.generatePullRequestDescription, generatePullRequestDescription: gitApiHttp.generatePullRequestDescription,
listGitWorktrees: gitApiHttp.listGitWorktrees, listGitWorktrees: gitApiHttp.listGitWorktrees,
validateGitWorktree: gitApiHttp.validateGitWorktree,
createGitWorktree: gitApiHttp.createGitWorktree,
deleteGitWorktree: gitApiHttp.deleteGitWorktree,
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) { createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) {
return gitApiHttp.createGitCommit(directory, message, options); return gitApiHttp.createGitCommit(directory, message, options);
}, },
@@ -48,4 +51,10 @@ export const createWebGitAPI = (): GitAPI => ({
stash: gitApiHttp.stash, stash: gitApiHttp.stash,
stashPop: gitApiHttp.stashPop, stashPop: gitApiHttp.stashPop,
getConflictDetails: gitApiHttp.getConflictDetails, getConflictDetails: gitApiHttp.getConflictDetails,
worktree: {
list: gitApiHttp.listGitWorktrees,
validate: gitApiHttp.validateGitWorktree,
create: gitApiHttp.createGitWorktree,
remove: gitApiHttp.deleteGitWorktree,
},
}); });