feat: support fast worktree-backed session flows

Add a directory-created fast path for worktree creation so session and send flows can continue once the target directory exists while Git attachment and bootstrap finish in the background.

Track bootstrap status explicitly in shared UI contracts, including pending, ready, and failed states. Background watchers now surface failures and timeouts, update stored worktree metadata, and keep web and VS Code runtime behavior in parity.

Move GitHub issue/PR worktree sessions and assistant-answer fork sessions onto the unified send path so provider, model, agent, and variant selections are preserved. The assistant-answer fork dialog can optionally create a worktree outside VS Code.

Make worktree deletion dialogs close after linked-session cleanup while removing the worktree in the background, and clean up failed fast-create artifacts safely without recursively deleting user or agent-written files.

Validation: bun test packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts packages/ui/src/lib/worktrees/worktreeManager.test.ts; bun run type-check; bun run lint.
This commit is contained in:
Bohdan Triapitsyn
2026-06-06 23:25:39 +03:00
parent d4ef1fd8c0
commit e0113c637d
34 changed files with 901 additions and 264 deletions
@@ -91,37 +91,69 @@ export const MobileDeleteWorktreeDialog: React.FC<MobileDeleteWorktreeDialogProp
}; };
}, [open, worktree?.path, worktree?.status?.isDirty]); }, [open, worktree?.path, worktree?.status?.isDirty]);
const removeWorktreeInBackground = React.useCallback((target: WorktreeMetadata) => {
void (async () => {
try {
await removeProjectWorktree(project, target, {
deleteRemoteBranch: hasBranch && deleteRemoteBranch,
deleteLocalBranch: hasBranch && deleteLocalBranch,
});
// If the removed worktree was the active directory, fall back to the project root.
if (normalizePath(currentDirectory) === worktreePath && normalizePath(project.path)) {
useDirectoryStore.getState().setDirectory(normalizePath(project.path), { showOverlay: false });
}
toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), {
description:
hasBranch && deleteRemoteBranch
? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote')
: t('sessions.sidebar.sessionDialogs.worktree.removed'),
});
onDeleted?.();
} catch (error) {
toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), {
description: error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain'),
});
}
})();
}, [currentDirectory, deleteLocalBranch, deleteRemoteBranch, hasBranch, onDeleted, project, t, worktreePath]);
const handleConfirm = async () => { const handleConfirm = async () => {
if (!worktree || isProcessing) return; if (!worktree || isProcessing) return;
setIsProcessing(true); setIsProcessing(true);
try { try {
if (linkedSessions.length > 0) { if (linkedSessions.length > 0) {
await archiveSessions(linkedSessions.map((session) => session.id)); const { archivedIds, failedIds } = await archiveSessions(linkedSessions.map((session) => session.id));
if (failedIds.length > 0) {
if (archivedIds.length > 0) {
toast.success(
archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }),
);
}
toast.error(
failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }),
{ description: t('sessions.sidebar.dialogs.deleteResult.tryAgain') },
);
setIsProcessing(false);
return;
}
} }
await removeProjectWorktree(project, worktree, { removeWorktreeInBackground(worktree);
deleteRemoteBranch: hasBranch && deleteRemoteBranch,
deleteLocalBranch: hasBranch && deleteLocalBranch,
});
// If the removed worktree was the active directory, fall back to the project root.
if (normalizePath(currentDirectory) === worktreePath && normalizePath(project.path)) {
useDirectoryStore.getState().setDirectory(normalizePath(project.path), { showOverlay: false });
}
toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), {
description:
hasBranch && deleteRemoteBranch
? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote')
: t('sessions.sidebar.sessionDialogs.worktree.removed'),
});
onDeleted?.();
onClose(); onClose();
} catch (error) { } catch (error) {
toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), {
description: error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain'), description: error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain'),
}); });
} finally {
setIsProcessing(false); setIsProcessing(false);
} finally {
if (!open) {
setIsProcessing(false);
}
} }
}; };
+5 -2
View File
@@ -1219,13 +1219,16 @@ export const Header: React.FC<HeaderProps> = ({
const worktreeBadge = React.useMemo(() => { const worktreeBadge = React.useMemo(() => {
if (!worktreeAttachment) return null; if (!worktreeAttachment) return null;
return formatSessionWorktreeBadge(worktreeAttachment); return formatSessionWorktreeBadge(worktreeAttachment, {
}, [worktreeAttachment]); pending: t('gitView.empty.worktreeSetupInProgress'),
});
}, [t, worktreeAttachment]);
const worktreeBadgeKind = React.useMemo(() => { const worktreeBadgeKind = React.useMemo(() => {
if (!worktreeAttachment) return null; if (!worktreeAttachment) return null;
if (worktreeAttachment.legacy) return 'legacy'; if (worktreeAttachment.legacy) return 'legacy';
if (worktreeAttachment.degraded) return 'degraded'; if (worktreeAttachment.degraded) return 'degraded';
if (worktreeAttachment.worktreeStatus === 'pending') return 'pending';
if (worktreeAttachment.worktreeStatus === 'missing') return 'missing'; if (worktreeAttachment.worktreeStatus === 'missing') return 'missing';
if (worktreeAttachment.worktreeStatus === 'invalid') return 'invalid'; if (worktreeAttachment.worktreeStatus === 'invalid') return 'invalid';
if (worktreeAttachment.attentionReason) return 'attention'; if (worktreeAttachment.attentionReason) return 'attention';
@@ -7,6 +7,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { ModelSelector } from '@/components/sections/agents/ModelSelector'; import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { AgentSelector } from '@/components/sections/commands/AgentSelector'; import { AgentSelector } from '@/components/sections/commands/AgentSelector';
import { ThinkingPill } from '@/components/session/ThinkingPill'; import { ThinkingPill } from '@/components/session/ThinkingPill';
@@ -15,6 +16,7 @@ import { useAgentsStore } from '@/stores/useAgentsStore';
import { isPrimaryMode } from '@/components/chat/mobileControlsUtils'; import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
import { EXECUTION_FORK_DEFAULT_INSTRUCTIONS } from '@/lib/messages/executionMeta'; import { EXECUTION_FORK_DEFAULT_INSTRUCTIONS } from '@/lib/messages/executionMeta';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { isVSCodeRuntime } from '@/lib/desktop';
export type ForkSessionExecution = { export type ForkSessionExecution = {
providerID: string; providerID: string;
@@ -22,6 +24,7 @@ export type ForkSessionExecution = {
variant: string; variant: string;
agent: string; agent: string;
instructions: string; instructions: string;
createWorktree?: boolean;
}; };
type ForkSessionDialogProps = { type ForkSessionDialogProps = {
@@ -50,6 +53,8 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
const [variant, setVariant] = React.useState(currentVariant); const [variant, setVariant] = React.useState(currentVariant);
const [agent, setAgent] = React.useState(currentAgentName); const [agent, setAgent] = React.useState(currentAgentName);
const [instructions, setInstructions] = React.useState(EXECUTION_FORK_DEFAULT_INSTRUCTIONS); const [instructions, setInstructions] = React.useState(EXECUTION_FORK_DEFAULT_INSTRUCTIONS);
const [createWorktree, setCreateWorktree] = React.useState(false);
const showCreateWorktree = React.useMemo(() => !isVSCodeRuntime(), []);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
@@ -69,6 +74,7 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
setVariant(config.currentVariant || ''); setVariant(config.currentVariant || '');
setAgent(config.currentAgentName || ''); setAgent(config.currentAgentName || '');
setInstructions(EXECUTION_FORK_DEFAULT_INSTRUCTIONS); setInstructions(EXECUTION_FORK_DEFAULT_INSTRUCTIONS);
setCreateWorktree(false);
}, [open]); }, [open]);
React.useEffect(() => { React.useEffect(() => {
@@ -106,8 +112,15 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
const handleSubmit = React.useCallback(() => { const handleSubmit = React.useCallback(() => {
if (!canConfirm || submitting) return; if (!canConfirm || submitting) return;
void onConfirm({ providerID, modelID, variant, agent, instructions }); void onConfirm({
}, [canConfirm, submitting, onConfirm, providerID, modelID, variant, agent, instructions]); providerID,
modelID,
variant,
agent,
instructions,
createWorktree: showCreateWorktree && createWorktree,
});
}, [canConfirm, submitting, onConfirm, providerID, modelID, variant, agent, instructions, showCreateWorktree, createWorktree]);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
@@ -173,15 +186,35 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className={`flex items-center gap-3 ${showCreateWorktree ? 'justify-between' : 'justify-end'}`}>
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={submitting}> {showCreateWorktree ? (
{t('rightSidebar.contextNotesTodo.sendDialog.actions.cancel')} <div className="flex min-w-0 items-center gap-2">
</Button> <Checkbox
<Button size="sm" onClick={handleSubmit} disabled={!canConfirm || submitting}> checked={createWorktree}
{submitting onChange={setCreateWorktree}
? t('rightSidebar.contextNotesTodo.sendDialog.actions.sending') disabled={submitting}
: t('rightSidebar.contextNotesTodo.sendDialog.actions.send')} ariaLabel={t('chat.messageBody.forkDialog.createWorktree')}
</Button> />
<button
type="button"
className="truncate typography-ui-label text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
disabled={submitting}
onClick={() => setCreateWorktree((value) => !value)}
>
{t('chat.messageBody.forkDialog.createWorktree')}
</button>
</div>
) : null}
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('rightSidebar.contextNotesTodo.sendDialog.actions.cancel')}
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canConfirm || submitting}>
{submitting
? t('rightSidebar.contextNotesTodo.sendDialog.actions.sending')
: t('rightSidebar.contextNotesTodo.sendDialog.actions.send')}
</Button>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -19,10 +19,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store'; import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions'; import * as sessionActions from '@/sync/session-actions';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { opencodeClient } from '@/lib/opencode/client';
import { renderMagicPrompt } from '@/lib/magicPrompts'; import { renderMagicPrompt } from '@/lib/magicPrompts';
import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
@@ -248,9 +246,9 @@ export function GitHubIssuePickerDialog({
const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => {
const configState = useConfigStore.getState(); const configState = useConfigStore.getState();
const settingsDefaultVariant = configState.settingsDefaultVariant; const settingsDefaultVariant = configState.settingsDefaultVariant;
if (!settingsDefaultVariant) { const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID
return undefined; ? configState.currentVariant
} : undefined;
const provider = configState.providers.find((p) => p.id === providerID); const provider = configState.providers.find((p) => p.id === providerID);
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as
@@ -258,12 +256,15 @@ export function GitHubIssuePickerDialog({
| undefined; | undefined;
const variants = model?.variants; const variants = model?.variants;
if (!variants) { if (!variants) {
return undefined; return settingsDefaultVariant || currentVariant || undefined;
} }
if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
return undefined; return settingsDefaultVariant;
} }
return settingsDefaultVariant; if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) {
return currentVariant;
}
return undefined;
}, []); }, []);
const startSession = React.useCallback(async (issueNumber: number, sourceRepo?: GitHubRepoSelector | null) => { const startSession = React.useCallback(async (issueNumber: number, sourceRepo?: GitHubRepoSelector | null) => {
@@ -366,12 +367,14 @@ export function GitHubIssuePickerDialog({
const sessionTitle = `#${issue.number} ${issue.title}`.trim(); const sessionTitle = `#${issue.number} ${issue.title}`.trim();
const { sessionId, sessionDirectory } = await (async () => { const { sessionId } = await (async () => {
if (createInWorktree) { if (createInWorktree) {
const preferred = `issue-${issue.number}-${generateBranchSlug()}`; const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
const created = await createWorktreeSessionForNewBranch( const created = await createWorktreeSessionForNewBranch(
projectDirectory, projectDirectory,
preferred preferred,
undefined,
{ returnAfterDirectoryCreated: true }
); );
if (!created?.id) { if (!created?.id) {
throw new Error('Failed to create worktree session'); throw new Error('Failed to create worktree session');
@@ -412,64 +415,27 @@ export function GitHubIssuePickerDialog({
const variant = resolveDefaultVariant(providerID, modelID); const variant = resolveDefaultVariant(providerID, modelID);
try {
useContextStore.getState().saveSessionModelSelection(sessionId, providerID, modelID);
} catch {
// ignore
}
if (agentName) {
try {
configState.setAgent(agentName);
} catch {
// ignore
}
try {
useContextStore.getState().saveSessionAgentSelection(sessionId, agentName);
} catch {
// ignore
}
try {
useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerID, modelID);
} catch {
// ignore
}
if (variant !== undefined) {
try {
configState.setCurrentVariant(variant);
} catch {
// ignore
}
try {
useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerID, modelID, variant);
} catch {
// ignore
}
}
}
const visiblePromptText = await renderMagicPrompt('github.issue.review.visible', { const visiblePromptText = await renderMagicPrompt('github.issue.review.visible', {
issue_number: String(issue.number), issue_number: String(issue.number),
}); });
const instructionsText = await renderMagicPrompt('github.issue.review.instructions'); const instructionsText = await renderMagicPrompt('github.issue.review.instructions');
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
void opencodeClient.sendMessage({ void useSessionUIStore.getState().sendMessage(
id: sessionId, visiblePromptText,
providerID, providerID,
modelID, modelID,
agent: agentName, agentName,
variant, undefined,
text: visiblePromptText, undefined,
additionalParts: [ [
{ text: instructionsText, synthetic: true }, { text: instructionsText, synthetic: true },
{ text: contextText, synthetic: true }, { text: contextText, synthetic: true },
], ],
directory: sessionDirectory, variant,
}).catch((e) => { undefined,
{ sessionId },
).catch((e) => {
const message = e instanceof Error ? e.message : String(e); const message = e instanceof Error ? e.message : String(e);
toast.error(t('session.githubIssuePicker.toast.sendContextFailed'), { toast.error(t('session.githubIssuePicker.toast.sendContextFailed'), {
description: message, description: message,
@@ -31,13 +31,11 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store'; import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions'; import * as sessionActions from '@/sync/session-actions';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import { opencodeClient } from '@/lib/opencode/client';
import { renderMagicPrompt } from '@/lib/magicPrompts'; import { renderMagicPrompt } from '@/lib/magicPrompts';
import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch'; import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
@@ -446,66 +444,19 @@ export function NewWorktreeDialog({
const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => {
const configState = useConfigStore.getState(); const configState = useConfigStore.getState();
const settingsDefaultVariant = configState.settingsDefaultVariant; const settingsDefaultVariant = configState.settingsDefaultVariant;
if (!settingsDefaultVariant) return undefined; const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID
? configState.currentVariant
: undefined;
const provider = configState.providers.find((p) => p.id === providerID); const provider = configState.providers.find((p) => p.id === providerID);
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as
| { variants?: Record<string, unknown> } | { variants?: Record<string, unknown> }
| undefined; | undefined;
const variants = model?.variants; const variants = model?.variants;
if (!variants) return undefined; if (!variants) return settingsDefaultVariant || currentVariant || undefined;
if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return undefined; if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return settingsDefaultVariant;
return settingsDefaultVariant; if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) return currentVariant;
}, []); return undefined;
const applySessionModelAndAgentDefaults = React.useCallback((args: {
sessionId: string;
providerID: string;
modelID: string;
agentName?: string;
variant?: string;
}) => {
const configState = useConfigStore.getState();
try {
useContextStore.getState().saveSessionModelSelection(args.sessionId, args.providerID, args.modelID);
} catch {
// ignore
}
if (!args.agentName) {
return;
}
try {
configState.setAgent(args.agentName);
} catch {
// ignore
}
try {
useContextStore.getState().saveSessionAgentSelection(args.sessionId, args.agentName);
} catch {
// ignore
}
try {
useContextStore.getState().saveAgentModelForSession(args.sessionId, args.agentName, args.providerID, args.modelID);
} catch {
// ignore
}
if (args.variant !== undefined) {
try {
configState.setCurrentVariant(args.variant);
} catch {
// ignore
}
try {
useContextStore
.getState()
.saveAgentModelVariantForSession(args.sessionId, args.agentName, args.providerID, args.modelID, args.variant);
} catch {
// ignore
}
}
}, []); }, []);
const sendLinkedContextMessage = React.useCallback(async (args: { const sendLinkedContextMessage = React.useCallback(async (args: {
@@ -533,14 +484,6 @@ export function NewWorktreeDialog({
const variant = resolveDefaultVariant(providerID, modelID); const variant = resolveDefaultVariant(providerID, modelID);
applySessionModelAndAgentDefaults({
sessionId: args.sessionId,
providerID,
modelID,
agentName,
variant,
});
if (args.issue) { if (args.issue) {
if (!github.issueGet || !github.issueComments) { if (!github.issueGet || !github.issueComments) {
return; return;
@@ -566,19 +509,21 @@ export function NewWorktreeDialog({
comments: commentsRes.comments ?? [], comments: commentsRes.comments ?? [],
}); });
await opencodeClient.sendMessage({ await useSessionUIStore.getState().sendMessage(
id: args.sessionId, visiblePromptText,
providerID, providerID,
modelID, modelID,
agent: agentName, agentName,
variant, undefined,
text: visiblePromptText, undefined,
additionalParts: [ [
{ text: instructionsText, synthetic: true }, { text: instructionsText, synthetic: true },
{ text: contextText, synthetic: true }, { text: contextText, synthetic: true },
], ],
directory: args.directory, variant,
}); undefined,
{ sessionId: args.sessionId },
);
toast.success(t('session.newWorktree.toast.sessionFromIssue')); toast.success(t('session.newWorktree.toast.sessionFromIssue'));
return; return;
@@ -603,24 +548,25 @@ export function NewWorktreeDialog({
const instructionsText = await renderMagicPrompt('github.pr.review.instructions'); const instructionsText = await renderMagicPrompt('github.pr.review.instructions');
const contextText = buildPullRequestContextText(prContext); const contextText = buildPullRequestContextText(prContext);
await opencodeClient.sendMessage({ await useSessionUIStore.getState().sendMessage(
id: args.sessionId, visiblePromptText,
providerID, providerID,
modelID, modelID,
agent: agentName, agentName,
variant, undefined,
text: visiblePromptText, undefined,
additionalParts: [ [
{ text: instructionsText, synthetic: true }, { text: instructionsText, synthetic: true },
{ text: contextText, synthetic: true }, { text: contextText, synthetic: true },
], ],
directory: args.directory, variant,
}); undefined,
{ sessionId: args.sessionId },
);
toast.success(t('session.newWorktree.toast.sessionFromPr')); toast.success(t('session.newWorktree.toast.sessionFromPr'));
} }
}, [ }, [
applySessionModelAndAgentDefaults,
github, github,
projectDirectory, projectDirectory,
resolveDefaultAgentName, resolveDefaultAgentName,
@@ -854,8 +800,13 @@ export function NewWorktreeDialog({
setIsCreating(true); setIsCreating(true);
try { try {
const setupCommands = await getWorktreeSetupCommands(projectRef);
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
const shouldCreateSession = Boolean(linkedIssue || linkedPrState);
const setupCommands = await getWorktreeSetupCommands(projectRef);
const sourceBranch = newBranchState.sourceBranch; const sourceBranch = newBranchState.sourceBranch;
let sourceLabel = ''; let sourceLabel = '';
@@ -873,6 +824,7 @@ export function NewWorktreeDialog({
setUpstream: prConfig.setUpstream, setUpstream: prConfig.setUpstream,
upstreamRemote: prConfig.upstreamRemote, upstreamRemote: prConfig.upstreamRemote,
upstreamBranch: prConfig.upstreamBranch, upstreamBranch: prConfig.upstreamBranch,
returnAfterDirectoryCreated: true,
...(prConfig.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}), ...(prConfig.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
...(prConfig.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}), ...(prConfig.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
}; };
@@ -886,20 +838,18 @@ export function NewWorktreeDialog({
worktreeName: normalizedWorktree, worktreeName: normalizedWorktree,
existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined, existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined,
setupCommands, setupCommands,
returnAfterDirectoryCreated: true,
...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}), ...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}),
}; };
})(); })();
const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args); const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args);
const metadata = await createWorktree(projectRef, resolvedArgs);
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; const metadata = await createWorktree(projectRef, resolvedArgs);
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
let createdSessionId: string | null = null; let createdSessionId: string | null = null;
if (linkedIssue || linkedPrState) { if (shouldCreateSession) {
const sessionTitle = linkedIssue const sessionTitle = linkedIssue
? `#${linkedIssue.number} ${linkedIssue.title}`.trim() ? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
: linkedPrState : linkedPrState
@@ -912,6 +862,10 @@ export function NewWorktreeDialog({
} }
createdSessionId = session.id; createdSessionId = session.id;
onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId });
onOpenChange(false);
setIsCreating(false);
void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined); void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined);
try { try {
@@ -919,6 +873,9 @@ export function NewWorktreeDialog({
} catch { } catch {
// ignore // ignore
} }
} else {
onOpenChange(false);
setIsCreating(false);
} }
// Save source branch preference (only if not from PR) // Save source branch preference (only if not from PR)
@@ -932,10 +889,7 @@ export function NewWorktreeDialog({
}), }),
}); });
onOpenChange(false);
if (createdSessionId) { if (createdSessionId) {
onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId });
void sendLinkedContextMessage({ void sendLinkedContextMessage({
sessionId: createdSessionId, sessionId: createdSessionId,
directory: metadata.path, directory: metadata.path,
@@ -373,6 +373,25 @@ export const SessionDialogs: React.FC = () => {
} }
}, [canRemoveRemoteBranches, currentDirectory, deleteDialogShouldRemoveRemote, getProjectRefForWorktree, newSessionDraft?.directoryOverride, newSessionDraft?.open, setDraftBootstrapPendingDirectory, setNewSessionDraftTarget, t]); }, [canRemoveRemoteBranches, currentDirectory, deleteDialogShouldRemoveRemote, getProjectRefForWorktree, newSessionDraft?.directoryOverride, newSessionDraft?.open, setDraftBootstrapPendingDirectory, setNewSessionDraftTarget, t]);
const removeSelectedWorktreeInBackground = React.useCallback((
worktree: WorktreeMetadata,
deleteLocalBranch: boolean
): void => {
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
void (async () => {
const removed = await removeSelectedWorktree(worktree, deleteLocalBranch);
if (!removed) {
return;
}
const archiveNote = shouldRemoveRemote
? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote')
: t('sessions.sidebar.sessionDialogs.worktree.removed');
toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), {
description: renderToastDescription(archiveNote),
});
})();
}, [canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, removeSelectedWorktree, t]);
const handleConfirmDelete = React.useCallback(async () => { const handleConfirmDelete = React.useCallback(async () => {
if (!deleteDialog) { if (!deleteDialog) {
return; return;
@@ -385,18 +404,7 @@ export const SessionDialogs: React.FC = () => {
const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch; const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch;
if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) {
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch);
if (!removed) {
closeDeleteDialog();
return;
}
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
const archiveNote = shouldRemoveRemote
? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote')
: t('sessions.sidebar.sessionDialogs.worktree.removed');
toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), {
description: renderToastDescription(archiveNote),
});
closeDeleteDialog(); closeDeleteDialog();
return; return;
} }
@@ -450,7 +458,7 @@ export const SessionDialogs: React.FC = () => {
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.
await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch);
// sync handles session refresh automatically // sync handles session refresh automatically
} }
@@ -506,7 +514,7 @@ export const SessionDialogs: React.FC = () => {
} }
if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) {
await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch);
// sync bootstrap refreshes sessions automatically // sync bootstrap refreshes sessions automatically
} }
@@ -525,8 +533,7 @@ export const SessionDialogs: React.FC = () => {
closeDeleteDialog, closeDeleteDialog,
shouldArchiveWorktree, shouldArchiveWorktree,
isWorktreeDelete, isWorktreeDelete,
canRemoveRemoteBranches, removeSelectedWorktreeInBackground,
removeSelectedWorktree,
t, t,
]); ]);
@@ -1719,7 +1719,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setCurrentSession(options.sessionId, worktreePath); setCurrentSession(options.sessionId, worktreePath);
return; return;
} }
openNewSessionDraft({ directoryOverride: worktreePath }); openNewSessionDraft({ directoryOverride: worktreePath, preserveDirectoryOverride: true });
}} }}
/> />
+5 -1
View File
@@ -398,6 +398,8 @@ export interface CreateGitWorktreePayload {
/** Optional remote provisioning (used for fork PR workflows). */ /** Optional remote provisioning (used for fork PR workflows). */
ensureRemoteName?: string; ensureRemoteName?: string;
ensureRemoteUrl?: string; ensureRemoteUrl?: string;
/** Return once the target directory exists and finish Git worktree setup in the background. */
returnAfterDirectoryCreated?: boolean;
} }
export interface GitWorktreeCreateResult { export interface GitWorktreeCreateResult {
@@ -405,6 +407,8 @@ export interface GitWorktreeCreateResult {
name: string; name: string;
branch: string; branch: string;
path: string; path: string;
directoryCreated?: true;
bootstrapStatus?: GitWorktreeBootstrapStatus;
} }
export interface RemoveGitWorktreePayload { export interface RemoveGitWorktreePayload {
@@ -538,7 +542,7 @@ export interface GitAPI {
cwd: string | null; cwd: string | null;
branch: string | null; branch: string | null;
headState: 'branch' | 'detached' | 'unborn'; headState: 'branch' | 'detached' | 'unborn';
worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo';
legacy: boolean; legacy: boolean;
degraded: boolean; degraded: boolean;
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
+1 -1
View File
@@ -883,7 +883,7 @@ export async function canonicalizeWorktreeState(
cwd: string | null; cwd: string | null;
branch: string | null; branch: string | null;
headState: 'branch' | 'detached' | 'unborn'; headState: 'branch' | 'detached' | 'unborn';
worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo';
legacy: boolean; legacy: boolean;
degraded: boolean; degraded: boolean;
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
+1 -1
View File
@@ -1052,7 +1052,7 @@ export async function canonicalizeWorktreeState(
cwd: string | null; cwd: string | null;
branch: string | null; branch: string | null;
headState: 'branch' | 'detached' | 'unborn'; headState: 'branch' | 'detached' | 'unborn';
worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo';
legacy: boolean; legacy: boolean;
degraded: boolean; degraded: boolean;
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
+2 -2
View File
@@ -1,4 +1,4 @@
export { I18nProvider } from './context'; export { I18nProvider } from './context';
export { useI18n } from './useI18n'; export { useI18n } from './useI18n';
export { initializeLocale } from './store'; export { formatMessage, initializeLocale, useI18nStore } from './store';
export type { I18nKey, Locale } from './store'; export type { I18nKey, I18nParams, Locale } from './store';
+4
View File
@@ -765,6 +765,9 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.', 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.', 'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress', 'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
'worktree.bootstrap.toast.failed': 'Worktree setup failed',
'worktree.bootstrap.toast.failedDescription': 'The worktree was created, but background setup did not finish.',
'worktree.bootstrap.toast.timeoutDescription': 'The worktree was created, but background setup timed out.',
'gitView.gitmoji.empty': 'No gitmoji found', 'gitView.gitmoji.empty': 'No gitmoji found',
'gitView.gitmoji.searchPlaceholder': 'Search gitmoji...', 'gitView.gitmoji.searchPlaceholder': 'Search gitmoji...',
'gitView.gitmoji.title': 'Insert gitmoji', 'gitView.gitmoji.title': 'Insert gitmoji',
@@ -1716,6 +1719,7 @@ export const dict = {
'chat.messageBody.actions.startNewMultiRun': 'Start new multi-run from this answer', 'chat.messageBody.actions.startNewMultiRun': 'Start new multi-run from this answer',
'chat.messageBody.forkDialog.instructions.label': 'Instructions', 'chat.messageBody.forkDialog.instructions.label': 'Instructions',
'chat.messageBody.forkDialog.instructions.placeholder': 'Add instructions for the new session…', 'chat.messageBody.forkDialog.instructions.placeholder': 'Add instructions for the new session…',
'chat.messageBody.forkDialog.createWorktree': 'Create worktree',
'chat.generatedResult.actions.copy': 'Copy', 'chat.generatedResult.actions.copy': 'Copy',
'chat.generatedResult.actions.copied': 'Copied', 'chat.generatedResult.actions.copied': 'Copied',
'chat.generatedResult.commit.title': 'Generated commit message', 'chat.generatedResult.commit.title': 'Generated commit message',
+4
View File
@@ -766,6 +766,9 @@ export const dict: Record<I18nKey, string> = {
"gitView.empty.worktreeFeaturesUnavailable": "Las características de worktree no están disponibles en este modo de espacio de trabajo.", "gitView.empty.worktreeFeaturesUnavailable": "Las características de worktree no están disponibles en este modo de espacio de trabajo.",
"gitView.empty.worktreeSetupDescription": "Finalizando la configuración de worktree y preparando el estado del repositorio.", "gitView.empty.worktreeSetupDescription": "Finalizando la configuración de worktree y preparando el estado del repositorio.",
"gitView.empty.worktreeSetupInProgress": "Configuración de worktree en progreso", "gitView.empty.worktreeSetupInProgress": "Configuración de worktree en progreso",
"worktree.bootstrap.toast.failed": "Error al configurar el worktree",
"worktree.bootstrap.toast.failedDescription": "El worktree se creó, pero la configuración en segundo plano no terminó.",
"worktree.bootstrap.toast.timeoutDescription": "El worktree se creó, pero la configuración en segundo plano agotó el tiempo de espera.",
"gitView.gitmoji.empty": "No se encontraron gitmojis", "gitView.gitmoji.empty": "No se encontraron gitmojis",
"gitView.gitmoji.searchPlaceholder": "Buscar gitmojis...", "gitView.gitmoji.searchPlaceholder": "Buscar gitmojis...",
"gitView.gitmoji.title": "Insertar gitmoji", "gitView.gitmoji.title": "Insertar gitmoji",
@@ -1682,6 +1685,7 @@ export const dict: Record<I18nKey, string> = {
"chat.messageBody.actions.startNewMultiRun": "Iniciar nuevo multi-run desde esta respuesta", "chat.messageBody.actions.startNewMultiRun": "Iniciar nuevo multi-run desde esta respuesta",
"chat.messageBody.forkDialog.instructions.label": "Instrucciones", "chat.messageBody.forkDialog.instructions.label": "Instrucciones",
"chat.messageBody.forkDialog.instructions.placeholder": "Añade instrucciones para la nueva sesión…", "chat.messageBody.forkDialog.instructions.placeholder": "Añade instrucciones para la nueva sesión…",
"chat.messageBody.forkDialog.createWorktree": "Crear worktree",
"chat.generatedResult.actions.copy": "Copiar", "chat.generatedResult.actions.copy": "Copiar",
"chat.generatedResult.actions.copied": "Copiado", "chat.generatedResult.actions.copied": "Copiado",
"chat.generatedResult.commit.title": "Mensaje de commit generado", "chat.generatedResult.commit.title": "Mensaje de commit generado",
+4
View File
@@ -766,6 +766,9 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.', 'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.',
'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.', 'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.',
'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중', 'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중',
'worktree.bootstrap.toast.failed': '워크트리 설정 실패',
'worktree.bootstrap.toast.failedDescription': '워크트리는 생성되었지만 백그라운드 설정이 완료되지 않았습니다.',
'worktree.bootstrap.toast.timeoutDescription': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.',
'gitView.gitmoji.empty': 'gitmoji 없음', 'gitView.gitmoji.empty': 'gitmoji 없음',
'gitView.gitmoji.searchPlaceholder': 'gitmoji 검색…', 'gitView.gitmoji.searchPlaceholder': 'gitmoji 검색…',
'gitView.gitmoji.title': 'gitmoji 삽입', 'gitView.gitmoji.title': 'gitmoji 삽입',
@@ -1716,6 +1719,7 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.startNewMultiRun': '이 응답에서 새 멀티런 시작', 'chat.messageBody.actions.startNewMultiRun': '이 응답에서 새 멀티런 시작',
'chat.messageBody.forkDialog.instructions.label': '지침', 'chat.messageBody.forkDialog.instructions.label': '지침',
'chat.messageBody.forkDialog.instructions.placeholder': '새 세션에 대한 지침을 입력하세요…', 'chat.messageBody.forkDialog.instructions.placeholder': '새 세션에 대한 지침을 입력하세요…',
'chat.messageBody.forkDialog.createWorktree': '워크트리 만들기',
'chat.generatedResult.actions.copy': '복사', 'chat.generatedResult.actions.copy': '복사',
'chat.generatedResult.actions.copied': '복사됨', 'chat.generatedResult.actions.copied': '복사됨',
'chat.generatedResult.commit.title': '생성된 커밋 메시지', 'chat.generatedResult.commit.title': '생성된 커밋 메시지',
+4
View File
@@ -700,6 +700,7 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.startNewMultiRun': 'Rozpocznij nowe wielokrotne uruchomienie z tej odpowiedzi', 'chat.messageBody.actions.startNewMultiRun': 'Rozpocznij nowe wielokrotne uruchomienie z tej odpowiedzi',
'chat.messageBody.forkDialog.instructions.label': 'Instrukcje', 'chat.messageBody.forkDialog.instructions.label': 'Instrukcje',
'chat.messageBody.forkDialog.instructions.placeholder': 'Dodaj instrukcje dla nowej sesji…', 'chat.messageBody.forkDialog.instructions.placeholder': 'Dodaj instrukcje dla nowej sesji…',
'chat.messageBody.forkDialog.createWorktree': 'Utwórz drzewo pracy',
'chat.generatedResult.actions.copy': 'Kopiuj', 'chat.generatedResult.actions.copy': 'Kopiuj',
'chat.generatedResult.actions.copied': 'Skopiowano', 'chat.generatedResult.actions.copied': 'Skopiowano',
'chat.generatedResult.commit.title': 'Wygenerowana wiadomość commita', 'chat.generatedResult.commit.title': 'Wygenerowana wiadomość commita',
@@ -1639,6 +1640,9 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.', 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.', 'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress', 'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
'worktree.bootstrap.toast.failed': 'Konfiguracja drzewa pracy nie powiodła się',
'worktree.bootstrap.toast.failedDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle nie została ukończona.',
'worktree.bootstrap.toast.timeoutDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle przekroczyła limit czasu.',
'gitView.gitmoji.empty': 'No gitmoji found', 'gitView.gitmoji.empty': 'No gitmoji found',
'gitView.gitmoji.searchPlaceholder': 'Search gitmoji...', 'gitView.gitmoji.searchPlaceholder': 'Search gitmoji...',
'gitView.gitmoji.title': 'Insert gitmoji', 'gitView.gitmoji.title': 'Insert gitmoji',
@@ -766,6 +766,9 @@ export const dict: Record<I18nKey, string> = {
"gitView.empty.worktreeFeaturesUnavailable": "Os recursos de worktree não estão disponíveis neste modo de workspace.", "gitView.empty.worktreeFeaturesUnavailable": "Os recursos de worktree não estão disponíveis neste modo de workspace.",
"gitView.empty.worktreeSetupDescription": "Finalizando a configuração de worktree e preparando o status do repositório.", "gitView.empty.worktreeSetupDescription": "Finalizando a configuração de worktree e preparando o status do repositório.",
"gitView.empty.worktreeSetupInProgress": "Configuração de worktree em andamento", "gitView.empty.worktreeSetupInProgress": "Configuração de worktree em andamento",
"worktree.bootstrap.toast.failed": "Falha na configuração do worktree",
"worktree.bootstrap.toast.failedDescription": "O worktree foi criado, mas a configuração em segundo plano não terminou.",
"worktree.bootstrap.toast.timeoutDescription": "O worktree foi criado, mas a configuração em segundo plano atingiu o tempo limite.",
"gitView.gitmoji.empty": "Nenhum gitmoji encontrado", "gitView.gitmoji.empty": "Nenhum gitmoji encontrado",
"gitView.gitmoji.searchPlaceholder": "Buscar gitmojis...", "gitView.gitmoji.searchPlaceholder": "Buscar gitmojis...",
"gitView.gitmoji.title": "Insertar gitmoji", "gitView.gitmoji.title": "Insertar gitmoji",
@@ -1682,6 +1685,7 @@ export const dict: Record<I18nKey, string> = {
"chat.messageBody.actions.startNewMultiRun": "Iniciar novo multi-run a partir desta resposta", "chat.messageBody.actions.startNewMultiRun": "Iniciar novo multi-run a partir desta resposta",
"chat.messageBody.forkDialog.instructions.label": "Instruções", "chat.messageBody.forkDialog.instructions.label": "Instruções",
"chat.messageBody.forkDialog.instructions.placeholder": "Adicione instruções para a nova sessão…", "chat.messageBody.forkDialog.instructions.placeholder": "Adicione instruções para a nova sessão…",
"chat.messageBody.forkDialog.createWorktree": "Criar worktree",
"chat.generatedResult.actions.copy": "Copiar", "chat.generatedResult.actions.copy": "Copiar",
"chat.generatedResult.actions.copied": "Copiado", "chat.generatedResult.actions.copied": "Copiado",
"chat.generatedResult.commit.title": "Mensagem de commit gerada", "chat.generatedResult.commit.title": "Mensagem de commit gerada",
+4
View File
@@ -766,6 +766,9 @@ export const dict: Record<I18nKey, string> = {
"gitView.empty.worktreeFeaturesUnavailable": "У цьому режимі робочої області функції worktree недоступні.", "gitView.empty.worktreeFeaturesUnavailable": "У цьому режимі робочої області функції worktree недоступні.",
"gitView.empty.worktreeSetupDescription": "Завершення налаштування worktree та підготовка стану сховища.", "gitView.empty.worktreeSetupDescription": "Завершення налаштування worktree та підготовка стану сховища.",
"gitView.empty.worktreeSetupInProgress": "Виконується налаштування worktree", "gitView.empty.worktreeSetupInProgress": "Виконується налаштування worktree",
"worktree.bootstrap.toast.failed": "Не вдалося налаштувати worktree",
"worktree.bootstrap.toast.failedDescription": "Worktree створено, але фонове налаштування не завершилося.",
"worktree.bootstrap.toast.timeoutDescription": "Worktree створено, але час очікування фонового налаштування минув.",
"gitView.gitmoji.empty": "Gitmoji не знайдено", "gitView.gitmoji.empty": "Gitmoji не знайдено",
"gitView.gitmoji.searchPlaceholder": "Пошук gitmoji...", "gitView.gitmoji.searchPlaceholder": "Пошук gitmoji...",
"gitView.gitmoji.title": "Вставте gitmoji", "gitView.gitmoji.title": "Вставте gitmoji",
@@ -1682,6 +1685,7 @@ export const dict: Record<I18nKey, string> = {
"chat.messageBody.actions.startNewMultiRun": "Почніть новий Multi-run із цієї відповіді", "chat.messageBody.actions.startNewMultiRun": "Почніть новий Multi-run із цієї відповіді",
"chat.messageBody.forkDialog.instructions.label": "Інструкції", "chat.messageBody.forkDialog.instructions.label": "Інструкції",
"chat.messageBody.forkDialog.instructions.placeholder": "Додайте інструкції для нової сесії…", "chat.messageBody.forkDialog.instructions.placeholder": "Додайте інструкції для нової сесії…",
"chat.messageBody.forkDialog.createWorktree": "Створити worktree",
"chat.generatedResult.actions.copy": "Копіювати", "chat.generatedResult.actions.copy": "Копіювати",
"chat.generatedResult.actions.copied": "Скопійовано", "chat.generatedResult.actions.copied": "Скопійовано",
"chat.generatedResult.commit.title": "Згенероване повідомлення коміту", "chat.generatedResult.commit.title": "Згенероване повідомлення коміту",
@@ -766,6 +766,9 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': '当前工作区模式下,工作树功能不可用。', 'gitView.empty.worktreeFeaturesUnavailable': '当前工作区模式下,工作树功能不可用。',
'gitView.empty.worktreeSetupDescription': '正在完成工作树设置并准备仓库状态。', 'gitView.empty.worktreeSetupDescription': '正在完成工作树设置并准备仓库状态。',
'gitView.empty.worktreeSetupInProgress': '工作树设置进行中', 'gitView.empty.worktreeSetupInProgress': '工作树设置进行中',
'worktree.bootstrap.toast.failed': '工作树设置失败',
'worktree.bootstrap.toast.failedDescription': '工作树已创建,但后台设置未完成。',
'worktree.bootstrap.toast.timeoutDescription': '工作树已创建,但后台设置超时。',
'gitView.gitmoji.empty': '未找到 gitmoji', 'gitView.gitmoji.empty': '未找到 gitmoji',
'gitView.gitmoji.searchPlaceholder': '搜索 gitmoji...', 'gitView.gitmoji.searchPlaceholder': '搜索 gitmoji...',
'gitView.gitmoji.title': '插入 gitmoji', 'gitView.gitmoji.title': '插入 gitmoji',
@@ -1682,6 +1685,7 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.startNewMultiRun': '基于此回答开始新的多运行', 'chat.messageBody.actions.startNewMultiRun': '基于此回答开始新的多运行',
'chat.messageBody.forkDialog.instructions.label': '说明', 'chat.messageBody.forkDialog.instructions.label': '说明',
'chat.messageBody.forkDialog.instructions.placeholder': '为新会话添加说明…', 'chat.messageBody.forkDialog.instructions.placeholder': '为新会话添加说明…',
'chat.messageBody.forkDialog.createWorktree': '创建工作树',
'chat.generatedResult.actions.copy': '复制', 'chat.generatedResult.actions.copy': '复制',
'chat.generatedResult.actions.copied': '已复制', 'chat.generatedResult.actions.copied': '已复制',
'chat.generatedResult.commit.title': '生成的提交消息', 'chat.generatedResult.commit.title': '生成的提交消息',
@@ -778,6 +778,9 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': '目前工作區模式下,worktree 功能無法使用。', 'gitView.empty.worktreeFeaturesUnavailable': '目前工作區模式下,worktree 功能無法使用。',
'gitView.empty.worktreeSetupDescription': '正在完成 worktree 設定並準備儲存庫狀態。', 'gitView.empty.worktreeSetupDescription': '正在完成 worktree 設定並準備儲存庫狀態。',
'gitView.empty.worktreeSetupInProgress': 'worktree 設定進行中', 'gitView.empty.worktreeSetupInProgress': 'worktree 設定進行中',
'worktree.bootstrap.toast.failed': 'worktree 設定失敗',
'worktree.bootstrap.toast.failedDescription': 'worktree 已建立,但背景設定未完成。',
'worktree.bootstrap.toast.timeoutDescription': 'worktree 已建立,但背景設定逾時。',
'gitView.gitmoji.empty': '找不到 gitmoji', 'gitView.gitmoji.empty': '找不到 gitmoji',
'gitView.gitmoji.searchPlaceholder': '搜尋 gitmoji...', 'gitView.gitmoji.searchPlaceholder': '搜尋 gitmoji...',
'gitView.gitmoji.title': '插入 gitmoji', 'gitView.gitmoji.title': '插入 gitmoji',
@@ -1686,6 +1689,7 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.startNewMultiRun': '基於此回答開始新的 Multi-run', 'chat.messageBody.actions.startNewMultiRun': '基於此回答開始新的 Multi-run',
'chat.messageBody.forkDialog.instructions.label': '說明', 'chat.messageBody.forkDialog.instructions.label': '說明',
'chat.messageBody.forkDialog.instructions.placeholder': '為新工作階段新增說明…', 'chat.messageBody.forkDialog.instructions.placeholder': '為新工作階段新增說明…',
'chat.messageBody.forkDialog.createWorktree': '建立 worktree',
'chat.generatedResult.actions.copy': '複製', 'chat.generatedResult.actions.copy': '複製',
'chat.generatedResult.actions.copied': '已複製', 'chat.generatedResult.actions.copied': '已複製',
'chat.generatedResult.commit.title': '生成的提交訊息', 'chat.generatedResult.commit.title': '生成的提交訊息',
-5
View File
@@ -14,7 +14,6 @@ import type {
} from "@opencode-ai/sdk/v2"; } from "@opencode-ai/sdk/v2";
import type { PermissionRequest } from "@/types/permission"; import type { PermissionRequest } from "@/types/permission";
import type { QuestionRequest } from "@/types/question"; import type { QuestionRequest } from "@/types/question";
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap";
import { getRuntimeUrlResolver } from "@/lib/runtime-url"; import { getRuntimeUrlResolver } from "@/lib/runtime-url";
import { runtimeFetch } from "@/lib/runtime-fetch"; import { runtimeFetch } from "@/lib/runtime-fetch";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
@@ -800,10 +799,6 @@ class OpencodeService {
const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory; const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory;
if (requestDirectory) {
await waitForWorktreeBootstrap(requestDirectory);
}
if (params.format) { if (params.format) {
console.info('[git-generation][browser] send structured message', { console.info('[git-generation][browser] send structured message', {
sessionId: params.id, sessionId: params.id,
@@ -233,6 +233,7 @@ const createInstantWorktreeDraft = async (options?: {
branchName: preferredName, branchName: preferredName,
worktreeName: preferredName, worktreeName: preferredName,
setupCommands, setupCommands,
returnAfterDirectoryCreated: true,
}); });
resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path); resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path);
@@ -325,6 +326,7 @@ export async function createWorktreeOnly(): Promise<string | null> {
branchName: preferredName, branchName: preferredName,
worktreeName: preferredName, worktreeName: preferredName,
setupCommands, setupCommands,
returnAfterDirectoryCreated: true,
}); });
@@ -361,6 +363,7 @@ export async function createWorktreeSessionForBranch(
ensureRemoteName?: string; ensureRemoteName?: string;
ensureRemoteUrl?: string; ensureRemoteUrl?: string;
createdFromBranch?: string; createdFromBranch?: string;
returnAfterDirectoryCreated?: boolean;
} }
): Promise<{ id: string } | null> { ): Promise<{ id: string } | null> {
if (isCreatingWorktreeSession) { if (isCreatingWorktreeSession) {
@@ -404,6 +407,7 @@ export async function createWorktreeSessionForBranch(
ensureRemoteName: options?.ensureRemoteName, ensureRemoteName: options?.ensureRemoteName,
ensureRemoteUrl: options?.ensureRemoteUrl, ensureRemoteUrl: options?.ensureRemoteUrl,
setupCommands, setupCommands,
returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated,
}); });
const kind = options?.kind ?? 'standard'; const kind = options?.kind ?? 'standard';
@@ -456,6 +460,7 @@ export async function createWorktreeSessionForNewBranch(
ensureRemoteName?: string; ensureRemoteName?: string;
ensureRemoteUrl?: string; ensureRemoteUrl?: string;
createdFromBranch?: string; createdFromBranch?: string;
returnAfterDirectoryCreated?: boolean;
} }
): Promise<{ id: string; branch: string; path: string } | null> { ): Promise<{ id: string; branch: string; path: string } | null> {
if (isCreatingWorktreeSession) { if (isCreatingWorktreeSession) {
@@ -507,6 +512,7 @@ export async function createWorktreeSessionForNewBranch(
ensureRemoteName: options?.ensureRemoteName, ensureRemoteName: options?.ensureRemoteName,
ensureRemoteUrl: options?.ensureRemoteUrl, ensureRemoteUrl: options?.ensureRemoteUrl,
setupCommands, setupCommands,
returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated,
}); });
const createdMetadata = { const createdMetadata = {
...metadata, ...metadata,
@@ -551,6 +557,7 @@ export async function createWorktreeSessionForNewBranchExact(
ensureRemoteName?: string; ensureRemoteName?: string;
ensureRemoteUrl?: string; ensureRemoteUrl?: string;
createdFromBranch?: string; createdFromBranch?: string;
returnAfterDirectoryCreated?: boolean;
} }
): Promise<{ id: string; branch: string; path: string } | null> { ): Promise<{ id: string; branch: string; path: string } | null> {
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, {
@@ -562,5 +569,6 @@ export async function createWorktreeSessionForNewBranchExact(
ensureRemoteName: options?.ensureRemoteName, ensureRemoteName: options?.ensureRemoteName,
ensureRemoteUrl: options?.ensureRemoteUrl, ensureRemoteUrl: options?.ensureRemoteUrl,
createdFromBranch: options?.createdFromBranch, createdFromBranch: options?.createdFromBranch,
returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated,
}); });
} }
@@ -1,7 +1,27 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'; import { beforeEach, describe, expect, mock, test } from 'bun:test';
const bootstrapStatusCalls: string[] = []; const bootstrapStatusCalls: string[] = [];
let bootstrapStatusResult = { status: 'ready' as const, error: null, updatedAt: 1 }; let bootstrapStatusResult: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number } = {
status: 'ready',
error: null,
updatedAt: 1,
};
const toastErrors: Array<{ title: string; description?: string }> = [];
mock.module('@/components/ui', () => ({
toast: {
error: (title: string, options?: { description?: string }) => {
toastErrors.push({ title, description: options?.description });
},
},
}));
mock.module('@/lib/i18n', () => ({
formatMessage: (_dictionary: Record<string, string>, key: string) => key,
useI18nStore: {
getState: () => ({ dictionary: {} }),
},
}));
mock.module('@/contexts/runtimeAPIRegistry', () => ({ mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: () => ({ getRegisteredRuntimeAPIs: () => ({
@@ -25,13 +45,24 @@ mock.module('@/lib/gitApiHttp', () => ({
const { const {
clearWorktreeBootstrapState, clearWorktreeBootstrapState,
getWorktreeBootstrapState,
markWorktreeBootstrapPending, markWorktreeBootstrapPending,
startWorktreeBootstrapWatcher,
waitForWorktreeBootstrap, waitForWorktreeBootstrap,
} = await import('./worktreeBootstrap'); } = await import('./worktreeBootstrap');
const waitFor = async (predicate: () => boolean): Promise<void> => {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error('Timed out waiting for condition');
};
describe('worktreeBootstrap.waitForWorktreeBootstrap', () => { describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
beforeEach(() => { beforeEach(() => {
bootstrapStatusCalls.length = 0; bootstrapStatusCalls.length = 0;
toastErrors.length = 0;
bootstrapStatusResult = { status: 'ready', error: null, updatedAt: 1 }; bootstrapStatusResult = { status: 'ready', error: null, updatedAt: 1 };
clearWorktreeBootstrapState('/repo'); clearWorktreeBootstrapState('/repo');
clearWorktreeBootstrapState('/repo-wt'); clearWorktreeBootstrapState('/repo-wt');
@@ -50,4 +81,61 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
expect(bootstrapStatusCalls).toEqual(['/repo-wt']); expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
}); });
test('background watcher polls pending worktrees without blocking', async () => {
markWorktreeBootstrapPending('/repo-wt');
const readyStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = [];
startWorktreeBootstrapWatcher('/repo-wt', {
pollIntervalMs: 0,
onReady: (status) => readyStatuses.push(status),
});
await waitFor(() => readyStatuses.length === 1);
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
expect(readyStatuses.map((status) => status.status)).toEqual(['ready']);
expect(toastErrors).toEqual([]);
});
test('background watcher shows a toast when bootstrap fails', async () => {
bootstrapStatusResult = { status: 'failed', error: 'setup failed', updatedAt: 2 };
markWorktreeBootstrapPending('/repo-wt');
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 0 });
await waitFor(() => toastErrors.length === 1);
expect(toastErrors).toEqual([{ title: 'worktree.bootstrap.toast.failed', description: 'setup failed' }]);
});
test('background watcher marks failed and toasts when bootstrap times out', async () => {
bootstrapStatusResult = { status: 'pending', error: null, updatedAt: 2 };
markWorktreeBootstrapPending('/repo-wt');
const failedStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = [];
startWorktreeBootstrapWatcher('/repo-wt', {
timeoutMs: 0,
pollIntervalMs: 0,
onFailed: (status) => failedStatuses.push(status),
});
await waitFor(() => toastErrors.length === 1);
expect(getWorktreeBootstrapState('/repo-wt')?.status).toBe('failed');
expect(failedStatuses.map((status) => status.status)).toEqual(['failed']);
expect(toastErrors).toEqual([{
title: 'worktree.bootstrap.toast.failed',
description: 'worktree.bootstrap.toast.timeoutDescription',
}]);
});
test('background watcher is deduped per directory', async () => {
bootstrapStatusResult = { status: 'pending', error: null, updatedAt: 2 };
markWorktreeBootstrapPending('/repo-wt');
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 });
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 });
await waitFor(() => bootstrapStatusCalls.length === 1);
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
clearWorktreeBootstrapState('/repo-wt');
});
}); });
@@ -1,8 +1,12 @@
import * as gitHttp from '@/lib/gitApiHttp'; import * as gitHttp from '@/lib/gitApiHttp';
import type { GitWorktreeBootstrapStatus } from '@/lib/api/types'; import type { GitWorktreeBootstrapStatus } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { toast } from '@/components/ui';
import { formatMessage, useI18nStore, type I18nKey, type I18nParams } from '@/lib/i18n';
type WorktreeBootstrapState = GitWorktreeBootstrapStatus; type WorktreeBootstrapState = GitWorktreeBootstrapStatus;
type WorktreeBootstrapFailureHandler = (status: GitWorktreeBootstrapStatus) => void;
type WorktreeBootstrapReadyHandler = (status: GitWorktreeBootstrapStatus) => void;
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const POLL_INTERVAL_MS = 250; const POLL_INTERVAL_MS = 250;
@@ -11,6 +15,7 @@ const normalizePath = (value: string): string => value.replace(/\\/g, '/').repla
const state = new Map<string, WorktreeBootstrapState>(); const state = new Map<string, WorktreeBootstrapState>();
const waiters = new Map<string, Promise<void>>(); const waiters = new Map<string, Promise<void>>();
const watchers = new Map<string, { cancelled: boolean; promise: Promise<void> }>();
const getKey = (directory: string): string => normalizePath(directory); const getKey = (directory: string): string => normalizePath(directory);
@@ -42,6 +47,11 @@ export const clearWorktreeBootstrapState = (directory: string): void => {
if (!key) { if (!key) {
return; return;
} }
const watcher = watchers.get(key);
if (watcher) {
watcher.cancelled = true;
watchers.delete(key);
}
state.delete(key); state.delete(key);
waiters.delete(key); waiters.delete(key);
}; };
@@ -65,6 +75,28 @@ export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapS
return state.get(key) ?? null; return state.get(key) ?? null;
}; };
const t = (key: I18nKey, params?: I18nParams): string => {
const dictionary = useI18nStore.getState().dictionary;
return formatMessage(dictionary, key, params);
};
const createFailedStatus = (error: string): GitWorktreeBootstrapStatus => ({
status: 'failed',
error,
updatedAt: Date.now(),
});
const markBootstrapFailed = (
directory: string,
error: string,
onFailed?: WorktreeBootstrapFailureHandler,
): GitWorktreeBootstrapStatus => {
const failed = createFailedStatus(error);
setWorktreeBootstrapState(directory, failed);
onFailed?.(failed);
return failed;
};
const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise<void> => { const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise<void> => {
const startedAt = Date.now(); const startedAt = Date.now();
@@ -83,7 +115,100 @@ const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: n
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
} }
throw new Error('Timed out waiting for worktree bootstrap'); const failed = markBootstrapFailed(directory, t('worktree.bootstrap.toast.timeoutDescription'));
throw new Error(failed.error || 'Timed out waiting for worktree bootstrap');
};
const pollWorktreeBootstrapInBackground = async (
directory: string,
watcher: { cancelled: boolean },
timeoutMs: number,
pollIntervalMs: number,
onFailed?: WorktreeBootstrapFailureHandler,
onReady?: WorktreeBootstrapReadyHandler,
): Promise<void> => {
const startedAt = Date.now();
while (!watcher.cancelled && Date.now() - startedAt < timeoutMs) {
const result = await getGitWorktreeBootstrapStatus(directory);
if (watcher.cancelled) {
return;
}
setWorktreeBootstrapState(directory, result);
if (result.status === 'ready') {
onReady?.(result);
return;
}
if (result.status === 'failed') {
onFailed?.(result);
toast.error(t('worktree.bootstrap.toast.failed'), {
description: result.error || t('worktree.bootstrap.toast.failedDescription'),
});
return;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
if (!watcher.cancelled) {
const failed = markBootstrapFailed(directory, t('worktree.bootstrap.toast.timeoutDescription'), onFailed);
toast.error(t('worktree.bootstrap.toast.failed'), {
description: failed.error || t('worktree.bootstrap.toast.failedDescription'),
});
}
};
export const startWorktreeBootstrapWatcher = (
directory: string,
options?: {
timeoutMs?: number;
pollIntervalMs?: number;
onFailed?: WorktreeBootstrapFailureHandler;
onReady?: WorktreeBootstrapReadyHandler;
},
): void => {
const key = getKey(directory);
if (!key) {
return;
}
const current = state.get(key);
if (current?.status !== 'pending') {
return;
}
if (watchers.has(key)) {
return;
}
const watcher = { cancelled: false, promise: Promise.resolve() };
watcher.promise = pollWorktreeBootstrapInBackground(
directory,
watcher,
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
options?.pollIntervalMs ?? POLL_INTERVAL_MS,
options?.onFailed,
options?.onReady,
).catch((error) => {
if (watcher.cancelled) {
return;
}
const failed = markBootstrapFailed(
directory,
error instanceof Error ? error.message : String(error),
options?.onFailed,
);
toast.error(t('worktree.bootstrap.toast.failed'), {
description: failed.error || t('worktree.bootstrap.toast.failedDescription'),
});
}).finally(() => {
if (watchers.get(key) === watcher) {
watchers.delete(key);
}
});
watchers.set(key, watcher);
}; };
export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> => { export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> => {
@@ -14,6 +14,8 @@ const createdWorktree = {
name: 'feature', name: 'feature',
branch: 'feature', branch: 'feature',
path: '/repo-feature', path: '/repo-feature',
directoryCreated: true as const,
bootstrapStatus: { status: 'pending' as const, error: null, updatedAt: 1 },
}; };
const sessionState = { const sessionState = {
@@ -28,6 +30,8 @@ mock.module('@/lib/openchamberConfig', () => ({
mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ mock.module('@/lib/worktrees/worktreeBootstrap', () => ({
clearWorktreeBootstrapState: mock(), clearWorktreeBootstrapState: mock(),
markWorktreeBootstrapPending: mock(), markWorktreeBootstrapPending: mock(),
setWorktreeBootstrapState: mock(),
startWorktreeBootstrapWatcher: mock(),
})); }));
mock.module('@/lib/worktrees/worktreeStatus', () => ({ mock.module('@/lib/worktrees/worktreeStatus', () => ({
@@ -103,4 +107,17 @@ describe('worktreeManager list invalidation', () => {
expect(listCalls).toEqual(['/repo', '/repo']); expect(listCalls).toEqual(['/repo', '/repo']);
expect(result.map((entry) => entry.path)).toEqual(['/repo-feature']); expect(result.map((entry) => entry.path)).toEqual(['/repo-feature']);
}); });
test('marks fast-created worktrees pending until bootstrap settles', async () => {
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
preferredName: 'feature',
mode: 'new',
branchName: 'feature',
worktreeName: 'feature',
returnAfterDirectoryCreated: true,
});
expect(metadata.worktreeStatus).toBe('pending');
expect(sessionState.availableWorktrees[0]?.worktreeStatus).toBe('pending');
});
}); });
@@ -7,10 +7,13 @@ import {
import { import {
clearWorktreeBootstrapState, clearWorktreeBootstrapState,
markWorktreeBootstrapPending, markWorktreeBootstrapPending,
setWorktreeBootstrapState,
startWorktreeBootstrapWatcher,
} from '@/lib/worktrees/worktreeBootstrap'; } from '@/lib/worktrees/worktreeBootstrap';
import { invalidateResolvedProjectRootCache, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus'; import { invalidateResolvedProjectRootCache, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus';
import type { import type {
CreateGitWorktreePayload, CreateGitWorktreePayload,
GitWorktreeBootstrapStatus,
GitWorktreeValidationResult, GitWorktreeValidationResult,
} from '@/lib/api/types'; } from '@/lib/api/types';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -77,6 +80,83 @@ const normalizeBranchName = (value: string): string => {
.replace(/^\/+|\/+$/g, ''); .replace(/^\/+|\/+$/g, '');
}; };
const setStoredWorktreeStatus = (directory: string, status: NonNullable<WorktreeMetadata['worktreeStatus']>): void => {
const target = normalizePath(directory);
if (!target) {
return;
}
useSessionUIStore.setState((state) => {
let changed = false;
const applyStatus = (metadata: WorktreeMetadata): WorktreeMetadata => {
if (normalizePath(metadata.path) !== target || metadata.worktreeStatus === status) {
return metadata;
}
changed = true;
return { ...metadata, worktreeStatus: status };
};
let availableWorktrees = state.availableWorktrees;
let availableWorktreesChanged = false;
const nextAvailableWorktrees = state.availableWorktrees.map((metadata) => {
const next = applyStatus(metadata);
if (next !== metadata) {
availableWorktreesChanged = true;
}
return next;
});
if (availableWorktreesChanged) {
availableWorktrees = nextAvailableWorktrees;
}
let availableWorktreesByProject = state.availableWorktreesByProject;
for (const [projectKey, entries] of state.availableWorktreesByProject) {
let projectChanged = false;
const nextEntries = entries.map((metadata) => {
const next = applyStatus(metadata);
if (next !== metadata) {
projectChanged = true;
}
return next;
});
if (projectChanged) {
if (availableWorktreesByProject === state.availableWorktreesByProject) {
availableWorktreesByProject = new Map(state.availableWorktreesByProject);
}
availableWorktreesByProject.set(projectKey, nextEntries);
}
}
let worktreeMetadata = state.worktreeMetadata;
for (const [sessionId, metadata] of state.worktreeMetadata) {
const next = applyStatus(metadata);
if (next !== metadata) {
if (worktreeMetadata === state.worktreeMetadata) {
worktreeMetadata = new Map(state.worktreeMetadata);
}
worktreeMetadata.set(sessionId, next);
}
}
if (!changed) {
return {};
}
return {
availableWorktrees,
availableWorktreesByProject,
worktreeMetadata,
};
});
};
const getWorktreeStatusFromBootstrap = (status?: GitWorktreeBootstrapStatus): WorktreeMetadata['worktreeStatus'] => {
if (status?.status === 'pending') {
return 'pending';
}
return status?.status === 'failed' ? 'invalid' : 'ready';
};
const deriveSdkWorktreeNameFromDirectory = (directory: string): string => { const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
const normalized = normalizePath(directory); const normalized = normalizePath(directory);
const parts = normalized.split('/').filter(Boolean); const parts = normalized.split('/').filter(Boolean);
@@ -101,7 +181,7 @@ export const buildSdkStartCommand = (args: {
return joined.trim().length > 0 ? joined : undefined; return joined.trim().length > 0 ? joined : undefined;
}; };
const toCreatePayload = (args: { export const toCreatePayload = (args: {
preferredName?: string; preferredName?: string;
setupCommands?: string[]; setupCommands?: string[];
mode?: 'new' | 'existing'; mode?: 'new' | 'existing';
@@ -114,6 +194,7 @@ const toCreatePayload = (args: {
upstreamBranch?: string; upstreamBranch?: string;
ensureRemoteName?: string; ensureRemoteName?: string;
ensureRemoteUrl?: string; ensureRemoteUrl?: string;
returnAfterDirectoryCreated?: boolean;
}, projectDirectory: string): CreateGitWorktreePayload => { }, projectDirectory: string): CreateGitWorktreePayload => {
const mode = args.mode === 'existing' ? 'existing' : 'new'; const mode = args.mode === 'existing' ? 'existing' : 'new';
@@ -144,6 +225,7 @@ const toCreatePayload = (args: {
...(args.upstreamBranch ? { upstreamBranch: args.upstreamBranch } : {}), ...(args.upstreamBranch ? { upstreamBranch: args.upstreamBranch } : {}),
...(args.ensureRemoteName ? { ensureRemoteName: args.ensureRemoteName } : {}), ...(args.ensureRemoteName ? { ensureRemoteName: args.ensureRemoteName } : {}),
...(args.ensureRemoteUrl ? { ensureRemoteUrl: args.ensureRemoteUrl } : {}), ...(args.ensureRemoteUrl ? { ensureRemoteUrl: args.ensureRemoteUrl } : {}),
...(args.returnAfterDirectoryCreated ? { returnAfterDirectoryCreated: true } : {}),
}; };
}; };
@@ -247,6 +329,7 @@ export type CreateWorktreeArgs = {
upstreamBranch?: string; upstreamBranch?: string;
ensureRemoteName?: string; ensureRemoteName?: string;
ensureRemoteUrl?: string; ensureRemoteUrl?: string;
returnAfterDirectoryCreated?: boolean;
}; };
export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise<WorktreeMetadata> { export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise<WorktreeMetadata> {
@@ -271,12 +354,20 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr
branch: returnedBranch, branch: returnedBranch,
label: returnedBranch || returnedName, label: returnedBranch || returnedName,
worktreeRoot: normalizePath(returnedPath), worktreeRoot: normalizePath(returnedPath),
worktreeStatus: 'ready', worktreeStatus: getWorktreeStatusFromBootstrap(created?.bootstrapStatus),
headState: returnedBranch ? 'branch' : 'unborn', headState: returnedBranch ? 'branch' : 'unborn',
worktreeSource: 'created-for-session', worktreeSource: 'created-for-session',
}; };
markWorktreeBootstrapPending(metadata.path); if (created?.bootstrapStatus) {
setWorktreeBootstrapState(metadata.path, created.bootstrapStatus);
} else {
markWorktreeBootstrapPending(metadata.path);
}
startWorktreeBootstrapWatcher(metadata.path, {
onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'),
onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'),
});
invalidateWorktreeList(projectDirectory); invalidateWorktreeList(projectDirectory);
// The new worktree changes the repo's worktree topology; drop cached root // The new worktree changes the repo's worktree topology; drop cached root
+2 -2
View File
@@ -7,7 +7,7 @@ export type SessionWorktreeAttachment = {
cwd: string | null; cwd: string | null;
branch: string | null; branch: string | null;
headState: 'branch' | 'detached' | 'unborn'; headState: 'branch' | 'detached' | 'unborn';
worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo';
worktreeSource: 'existing' | 'created-for-session' | null; worktreeSource: 'existing' | 'created-for-session' | null;
legacy: boolean; legacy: boolean;
degraded: boolean; degraded: boolean;
@@ -235,7 +235,7 @@ export interface SessionStore {
closeNewSessionDraft: () => void; closeNewSessionDraft: () => 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>;
createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string }) => Promise<void>; createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string; createWorktree?: boolean }) => Promise<void>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: 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; deleteLocalBranch?: 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[] }>;
+84 -23
View File
@@ -28,7 +28,6 @@ import { getSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution" import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
import { import {
@@ -158,6 +157,15 @@ type SendMessageOptions = {
sessionId?: string sessionId?: string
} }
type AssistantMessageSessionExecution = {
providerID: string
modelID: string
variant: string
agent: string
instructions: string
createWorktree?: boolean
}
function notifyMessageSent(sessionId: string): void { function notifyMessageSent(sessionId: string): void {
runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" }) runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" })
.catch(() => { /* ignore */ }) .catch(() => { /* ignore */ })
@@ -272,7 +280,7 @@ export type SessionUIState = {
forkFromMessage: (sessionId: string, messageId: string) => Promise<void> forkFromMessage: (sessionId: string, messageId: string) => Promise<void>
handleSlashUndo: (sessionId: string) => Promise<void> handleSlashUndo: (sessionId: string) => Promise<void>
handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise<void> handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise<void>
createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string }) => Promise<void> createSessionFromAssistantMessage: (sourceMessageId: string, execution: AssistantMessageSessionExecution) => Promise<void>
// Data access helpers (read from sync) // Data access helpers (read from sync)
getSessionsByDirectory: (directory: string) => Session[] getSessionsByDirectory: (directory: string) => Session[]
@@ -874,10 +882,6 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
? [...(additionalParts || []), ...draftSyntheticParts] ? [...(additionalParts || []), ...draftSyntheticParts]
: additionalParts : additionalParts
if (createdDirectory) {
await waitForWorktreeBootstrap(createdDirectory)
}
notifyMessageSent(created.id) notifyMessageSent(created.id)
markPendingUserSendAnimation(created.id) markPendingUserSendAnimation(created.id)
@@ -922,8 +926,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const configAgentName = useConfigStore.getState().currentAgentName const configAgentName = useConfigStore.getState().currentAgentName
const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined
if (targetSessionId) {
useSelectionStore.getState().saveSessionModelSelection(targetSessionId, providerID, modelID)
}
if (targetSessionId && effectiveAgent) { if (targetSessionId && effectiveAgent) {
useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent) useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent)
useSelectionStore.getState().saveAgentModelForSession(targetSessionId, effectiveAgent, providerID, modelID)
useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant) useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant)
} }
@@ -947,10 +956,6 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const currentSessionDirectory = targetSessionId const currentSessionDirectory = targetSessionId
? normalizePath(get().getDirectoryForSession(targetSessionId)) ? normalizePath(get().getDirectoryForSession(targetSessionId))
: null : null
if (currentSessionDirectory) {
await waitForWorktreeBootstrap(currentSessionDirectory)
}
if (targetSessionId) { if (targetSessionId) {
notifyMessageSent(targetSessionId) notifyMessageSent(targetSessionId)
} }
@@ -1203,25 +1208,81 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
sourceSessionId ?? null, sourceSessionId ?? null,
(sid) => get().worktreeMetadata.get(sid), (sid) => get().worktreeMetadata.get(sid),
) )
const sourceWorktreeMetadata = sourceSessionId ? get().worktreeMetadata.get(sourceSessionId) : undefined
const session = await get().createSession(undefined, directory ?? null, null)
if (!session) return
const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
if (!pID || !mID) return if (!pID || !mID) return
const sessionDirectory = normalizePath(directory ?? session.directory ?? null) const sourceDirectory = normalizePath(directory ?? opencodeClient.getDirectory() ?? null)
await opencodeClient.sendMessage({ let sessionDirectory = sourceDirectory
id: session.id, let createdWorktree: WorktreeMetadata | null = null
providerID: pID, let createdWorktreeProject: { id: string; path: string } | null = null
modelID: mID,
variant: execution.variant || undefined, if (execution.createWorktree) {
text: composeForkSessionMessage(execution.instructions, assistantPlanText), const projects = useProjectsStore.getState().projects
agent: execution.agent || undefined, const project = resolveProjectForSessionDirectory(
directory: sessionDirectory, projects,
}) get().availableWorktreesByProject,
sourceDirectory,
) ?? resolveProjectForSessionDirectory(
projects,
get().availableWorktreesByProject,
sourceWorktreeMetadata?.projectDirectory ?? null,
)
if (!project?.path) {
throw new Error("Project is not registered in OpenChamber")
}
const [branchNameModule, configModule, createModule] = await Promise.all([
import("@/lib/git/branchNameGenerator"),
import("@/lib/openchamberConfig"),
import("@/lib/worktrees/worktreeCreate"),
])
const branchName = branchNameModule.generateBranchName()
createdWorktreeProject = { id: project.id, path: project.path }
const setupCommands = await configModule.getWorktreeSetupCommands(createdWorktreeProject)
createdWorktree = await createModule.createWorktreeWithDefaults(createdWorktreeProject, {
preferredName: branchName,
mode: "new",
branchName,
worktreeName: branchName,
setupCommands,
returnAfterDirectoryCreated: true,
})
sessionDirectory = normalizePath(createdWorktree.path)
}
const session = await get().createSession(undefined, sessionDirectory || null, null)
if (!session) {
if (createdWorktree && createdWorktreeProject) {
const { removeProjectWorktree } = await import("@/lib/worktrees/worktreeManager")
await removeProjectWorktree(createdWorktreeProject, createdWorktree, { deleteLocalBranch: true }).catch(() => undefined)
}
return
}
if (createdWorktree) {
get().setWorktreeMetadata(session.id, {
...createdWorktree,
kind: "standard",
})
useDirectoryStore.getState().setDirectory(createdWorktree.path, { showOverlay: false })
}
await get().sendMessage(
composeForkSessionMessage(execution.instructions, assistantPlanText),
pID,
mID,
execution.agent || undefined,
undefined,
undefined,
undefined,
execution.variant || undefined,
undefined,
{ sessionId: session.id },
)
}, },
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -20,7 +20,7 @@ export type WorktreeCanonicalizationResult = {
cwd: string | null; cwd: string | null;
branch: string | null; branch: string | null;
headState: 'branch' | 'detached' | 'unborn'; headState: 'branch' | 'detached' | 'unborn';
worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo';
legacy: boolean; legacy: boolean;
degraded: boolean; degraded: boolean;
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
@@ -143,8 +143,12 @@ export function resolveSessionWorktreeState(
}; };
} }
export function formatSessionWorktreeBadge(attachment: SessionWorktreeAttachment): string { export function formatSessionWorktreeBadge(
attachment: SessionWorktreeAttachment,
labels?: { pending?: string }
): string {
if (attachment.legacy) return 'Legacy session'; if (attachment.legacy) return 'Legacy session';
if (attachment.worktreeStatus === 'pending') return labels?.pending ?? 'Needs attention';
if (attachment.worktreeStatus === 'missing') return 'Worktree missing'; if (attachment.worktreeStatus === 'missing') return 'Worktree missing';
if (attachment.worktreeStatus === 'not-a-repo') return 'Not a repo'; if (attachment.worktreeStatus === 'not-a-repo') return 'Not a repo';
if (attachment.worktreeStatus === 'invalid') return 'Needs attention'; if (attachment.worktreeStatus === 'invalid') return 'Needs attention';
+1 -1
View File
@@ -40,7 +40,7 @@ export interface WorktreeMetadata {
worktreeRoot?: string; worktreeRoot?: string;
/** Operational status of this worktree. */ /** Operational status of this worktree. */
worktreeStatus?: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; worktreeStatus?: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo';
/** Git HEAD state classification. */ /** Git HEAD state classification. */
headState?: 'branch' | 'detached' | 'unborn'; headState?: 'branch' | 'detached' | 'unborn';
+120 -12
View File
@@ -814,6 +814,8 @@ export interface GitWorktreeInfo {
name: string; name: string;
branch: string; branch: string;
path: string; path: string;
directoryCreated?: true;
bootstrapStatus?: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number };
} }
type WorktreeListEntry = { type WorktreeListEntry = {
@@ -850,6 +852,7 @@ export interface CreateGitWorktreePayload {
upstreamBranch?: string; upstreamBranch?: string;
ensureRemoteName?: string; ensureRemoteName?: string;
ensureRemoteUrl?: string; ensureRemoteUrl?: string;
returnAfterDirectoryCreated?: boolean;
} }
export interface RemoveGitWorktreePayload { export interface RemoveGitWorktreePayload {
@@ -1359,6 +1362,54 @@ const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: stri
}); });
}; };
const isInsideOrSameDirectory = (root: string, target: string): boolean => {
const relative = path.relative(root, target);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
};
const isAttachedGitWorktreeDirectory = async (directory: string): Promise<boolean> => {
try {
const result = await runGitCommand(directory, ['rev-parse', '--is-inside-work-tree']);
return result.success && String(result.stdout || '').trim() === 'true';
} catch {
return false;
}
};
const cleanupFailedFastWorktreeCreate = async (
context: Awaited<ReturnType<typeof resolveWorktreeProjectContext>>,
candidate: { directory: string }
): Promise<void> => {
const candidateDirectory = path.resolve(candidate.directory);
const worktreeRoot = path.resolve(context.worktreeRoot);
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
if (!isAttached) {
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
} catch (error) {
console.warn('[GitService] Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
}
}
if (!isInsideWorktreeRoot || isAttached) {
return;
}
try {
const entries = await fs.promises.readdir(candidateDirectory);
if (entries.length === 0) {
await fs.promises.rmdir(candidateDirectory);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(String(code || ''))) {
console.warn('[GitService] Failed to clean up empty worktree directory after creation failure:', error instanceof Error ? error.message : String(error));
}
}
};
const runWorktreeStartScripts = async (directory: string, projectID: string, startCommand: string | undefined) => { const runWorktreeStartScripts = async (directory: string, projectID: string, startCommand: string | undefined) => {
const projectStart = await loadProjectStartCommand(projectID); const projectStart = await loadProjectStartCommand(projectID);
if (projectStart) { if (projectStart) {
@@ -1725,24 +1776,17 @@ export async function previewWorktreeCreate(directory: string, input: CreateGitW
}; };
} }
export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> { async function attachGitWorktreeToCandidate(
context: Awaited<ReturnType<typeof resolveWorktreeProjectContext>>,
candidate: { name: string; directory: string; branch: string },
input: CreateGitWorktreePayload = {},
): Promise<GitWorktreeInfo> {
const mode = input?.mode === 'existing' ? 'existing' : 'new'; const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
await fs.promises.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const startRef = normalizeStartRef(input?.startRef); const startRef = normalizeStartRef(input?.startRef);
const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
preferredName,
mode === 'new' && preferredBranchName ? preferredBranchName : '',
context.primaryWorktree
);
let localBranch = ''; let localBranch = '';
let inferredUpstream: { remote: string; branch: string } | null = null; let inferredUpstream: { remote: string; branch: string } | null = null;
const worktreeAddArgs = ['worktree', 'add', '--no-checkout']; const worktreeAddArgs = ['worktree', 'add', '--no-checkout'];
@@ -1828,6 +1872,11 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
error: null,
updatedAt: Date.now(),
};
queueWorktreeBootstrap({ queueWorktreeBootstrap({
directory: candidate.directory, directory: candidate.directory,
@@ -1850,9 +1899,68 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
name: candidate.name, name: candidate.name,
branch: localBranch, branch: localBranch,
path: candidate.directory, path: candidate.directory,
directoryCreated: true,
bootstrapStatus,
}; };
} }
export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
await fs.promises.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
preferredName,
mode === 'new' && preferredBranchName ? preferredBranchName : '',
context.primaryWorktree
);
if (input?.returnAfterDirectoryCreated === true) {
await fs.promises.mkdir(candidate.directory, { recursive: false });
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
error: null,
updatedAt: Date.now(),
};
const localBranch = mode === 'existing'
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
: candidate.branch;
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => {
setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
void cleanupFailedFastWorktreeCreate(context, candidate);
console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error));
});
return {
head: '',
name: candidate.name,
branch: localBranch,
path: candidate.directory,
directoryCreated: true,
bootstrapStatus,
};
}
return attachGitWorktreeToCandidate(context, candidate, input);
}
export async function getWorktreeBootstrapStatus(directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> { export async function getWorktreeBootstrapStatus(directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> {
const key = toBootstrapStateKey(directory); const key = toBootstrapStateKey(directory);
if (!key) { if (!key) {
+12
View File
@@ -446,6 +446,18 @@ export const createVSCodeGitAPI = (): GitAPI => ({
...(payload || {}), ...(payload || {}),
}); });
}, },
bootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => {
return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', {
directory,
});
},
preview: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees/preview', {
directory,
method: 'POST',
...(payload || {}),
});
},
create: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => { create: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees', { return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees', {
directory, directory,
@@ -118,6 +118,9 @@ The following functions are internal helpers used by exported functions:
- `name`: Worktree name. - `name`: Worktree name.
- `branch`: Local branch name. - `branch`: Local branch name.
- `path`: Absolute path to worktree directory. - `path`: Absolute path to worktree directory.
- `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues.
- `bootstrapStatus`: Background setup status, with `pending`, `ready`, or `failed`.
- Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup.
### Log Response ### Log Response
- `all`: Array of commit objects with hash, date, message, author info, stats. - `all`: Array of commit objects with hash, date, message, author info, stats.
+104 -12
View File
@@ -1168,6 +1168,45 @@ const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath)
}); });
}; };
const isAttachedGitWorktreeDirectory = async (directory) => {
try {
const result = await runGitCommand(directory, ['rev-parse', '--is-inside-work-tree']);
return result.success && String(result.stdout || '').trim() === 'true';
} catch {
return false;
}
};
const cleanupFailedFastWorktreeCreate = async (context, candidate) => {
const candidateDirectory = path.resolve(candidate.directory);
const worktreeRoot = path.resolve(context.worktreeRoot);
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
if (!isAttached) {
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
} catch (error) {
console.warn('Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
}
}
if (!isInsideWorktreeRoot || isAttached) {
return;
}
try {
const entries = await fsp.readdir(candidateDirectory);
if (entries.length === 0) {
await fsp.rmdir(candidateDirectory);
}
} catch (error) {
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error?.code)) {
console.warn('Failed to clean up empty worktree directory after creation failure:', error instanceof Error ? error.message : String(error));
}
}
};
const runWorktreeStartScripts = async (directory, projectID, startCommand) => { const runWorktreeStartScripts = async (directory, projectID, startCommand) => {
const projectStart = await loadProjectStartCommand(projectID); const projectStart = await loadProjectStartCommand(projectID);
if (projectStart) { if (projectStart) {
@@ -3010,24 +3049,13 @@ export async function previewWorktreeCreate(directory, input = {}) {
}; };
} }
export async function createWorktree(directory, input = {}) { async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new'; const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
await fsp.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const startRef = normalizeStartRef(input?.startRef); const startRef = normalizeStartRef(input?.startRef);
const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
preferredName,
mode === 'new' && preferredBranchName ? preferredBranchName : '',
context.primaryWorktree
);
let localBranch = ''; let localBranch = '';
let inferredUpstream = null; let inferredUpstream = null;
const worktreeAddArgs = ['worktree', 'add', '--no-checkout']; const worktreeAddArgs = ['worktree', 'add', '--no-checkout'];
@@ -3113,6 +3141,11 @@ export async function createWorktree(directory, input = {}) {
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
error: null,
updatedAt: Date.now(),
};
queueWorktreeBootstrap({ queueWorktreeBootstrap({
directory: candidate.directory, directory: candidate.directory,
@@ -3135,9 +3168,68 @@ export async function createWorktree(directory, input = {}) {
name: candidate.name, name: candidate.name,
branch: localBranch, branch: localBranch,
path: candidate.directory, path: candidate.directory,
directoryCreated: true,
bootstrapStatus,
}; };
} }
export async function createWorktree(directory, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
await fsp.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
preferredName,
mode === 'new' && preferredBranchName ? preferredBranchName : '',
context.primaryWorktree
);
if (input?.returnAfterDirectoryCreated === true) {
await fsp.mkdir(candidate.directory, { recursive: false });
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
error: null,
updatedAt: Date.now(),
};
const localBranch = mode === 'existing'
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
: candidate.branch;
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => {
setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
void cleanupFailedFastWorktreeCreate(context, candidate);
console.warn('Background worktree creation failed:', error instanceof Error ? error.message : String(error));
});
return {
head: '',
name: candidate.name,
branch: localBranch,
path: candidate.directory,
directoryCreated: true,
bootstrapStatus,
};
}
return attachGitWorktreeToCandidate(context, candidate, input);
}
export async function getWorktreeBootstrapStatus(directory) { export async function getWorktreeBootstrapStatus(directory) {
const key = toBootstrapStateKey(directory); const key = toBootstrapStateKey(directory);
if (!key) { if (!key) {
+2
View File
@@ -71,6 +71,8 @@ export const createWebGitAPI = (): GitAPI => ({
worktree: { worktree: {
list: gitApiHttp.listGitWorktrees, list: gitApiHttp.listGitWorktrees,
validate: gitApiHttp.validateGitWorktree, validate: gitApiHttp.validateGitWorktree,
bootstrapStatus: gitApiHttp.getGitWorktreeBootstrapStatus,
preview: gitApiHttp.previewGitWorktree,
create: gitApiHttp.createGitWorktree, create: gitApiHttp.createGitWorktree,
remove: gitApiHttp.deleteGitWorktree, remove: gitApiHttp.deleteGitWorktree,
}, },