Files
openchamber/packages/ui/src/sync/session-worktree-contract.ts
T
Bohdan Triapitsyn e0113c637d 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.
2026-06-06 23:25:39 +03:00

242 lines
8.2 KiB
TypeScript

import type { WorktreeMetadata } from '@/types/worktree';
import type { SessionWorktreeAttachment } from '@/stores/types/sessionTypes';
export type ResolveSessionWorktreeStateInput = {
sessionDirectory: string | null;
metadata: WorktreeMetadata | null;
cwdExists?: boolean;
runtimeResolution?: SessionWorktreeAttachment | null;
};
export type WorktreeDirectoryValidation = {
valid: boolean;
insideWorktreeRoot: boolean;
resolvedWorktreeRoot: string | null;
resolvedCwd: string | null;
};
export type WorktreeCanonicalizationResult = {
worktreeRoot: string | null;
cwd: string | null;
branch: string | null;
headState: 'branch' | 'detached' | 'unborn';
worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo';
legacy: boolean;
degraded: boolean;
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
};
export type SessionWorktreeCanonicalizationOptions = {
existingAttachment?: SessionWorktreeAttachment | null;
fallbackDirectory?: string | null;
worktreeSource?: SessionWorktreeAttachment['worktreeSource'];
};
const normalizePath = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') return '/';
return replaced.replace(/\/+$/, '') || replaced;
};
export function isWithinWorktreeRoot(candidate: string | null, worktreeRoot: string | null): boolean {
if (!candidate || !worktreeRoot) return false;
const c = normalizePath(candidate);
const r = normalizePath(worktreeRoot);
return c === r || c.startsWith(r + '/');
}
export function getAttachedSessionDirectory(
attachment: SessionWorktreeAttachment | null | undefined,
fallbackDirectory?: string | null,
): string | null {
if (attachment) {
if (!attachment.degraded && attachment.cwd) {
return normalizePath(attachment.cwd);
}
if (attachment.worktreeRoot) {
return normalizePath(attachment.worktreeRoot);
}
if (attachment.cwd) {
return normalizePath(attachment.cwd);
}
}
if (fallbackDirectory) {
return normalizePath(fallbackDirectory);
}
return null;
}
export function buildAttachmentFromCanonicalization(
canonical: WorktreeCanonicalizationResult,
options: SessionWorktreeCanonicalizationOptions = {},
): SessionWorktreeAttachment {
const existingAttachment = options.existingAttachment ?? null;
const fallbackDirectory = options.fallbackDirectory ?? null;
const preferredDirectory = canonical.degraded
? canonical.worktreeRoot ?? canonical.cwd ?? fallbackDirectory
: canonical.cwd ?? canonical.worktreeRoot ?? fallbackDirectory;
return {
worktreeRoot: canonical.worktreeRoot ?? fallbackDirectory,
cwd: preferredDirectory,
branch: canonical.branch ?? existingAttachment?.branch ?? null,
headState: canonical.headState,
worktreeStatus: canonical.worktreeStatus,
worktreeSource: options.worktreeSource ?? existingAttachment?.worktreeSource ?? null,
legacy: canonical.legacy,
degraded: canonical.degraded,
attentionReason: canonical.attentionReason ?? null,
};
}
export function resolveSessionWorktreeState(
input: ResolveSessionWorktreeStateInput
): SessionWorktreeAttachment {
const { sessionDirectory, metadata, cwdExists = true, runtimeResolution } = input;
if (runtimeResolution) {
return {
worktreeRoot: runtimeResolution.worktreeRoot ?? metadata?.path ?? sessionDirectory ?? null,
cwd: runtimeResolution.cwd ?? sessionDirectory ?? metadata?.path ?? null,
branch: runtimeResolution.branch ?? metadata?.branch ?? null,
headState: runtimeResolution.headState ?? 'branch',
worktreeStatus: runtimeResolution.worktreeStatus ?? 'ready',
worktreeSource: runtimeResolution.worktreeSource ?? (metadata?.source === 'sdk' ? 'created-for-session' : 'existing'),
legacy: false,
degraded: runtimeResolution.degraded,
attentionReason: runtimeResolution.attentionReason ?? null,
};
}
if (!metadata) {
return {
worktreeRoot: sessionDirectory ?? null,
cwd: sessionDirectory ?? null,
branch: null,
headState: 'branch',
worktreeStatus: sessionDirectory ? 'invalid' : 'not-a-repo',
worktreeSource: null,
legacy: true,
degraded: true,
attentionReason: null,
};
}
const worktreeRoot = metadata.worktreeRoot ?? metadata.path;
const cwd = sessionDirectory ?? worktreeRoot;
const cwdValid = cwdExists && (cwd === worktreeRoot || isWithinWorktreeRoot(cwd, worktreeRoot));
return {
worktreeRoot,
cwd: cwdValid ? cwd : worktreeRoot,
branch: metadata.branch ?? null,
headState: metadata.headState ?? (metadata.branch ? 'branch' : 'detached'),
worktreeStatus: metadata.worktreeStatus ?? 'ready',
worktreeSource: metadata.source === 'sdk' ? 'created-for-session' : 'existing',
legacy: false,
degraded: !cwdValid,
attentionReason: null,
};
}
export function formatSessionWorktreeBadge(
attachment: SessionWorktreeAttachment,
labels?: { pending?: string }
): string {
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 === 'not-a-repo') return 'Not a repo';
if (attachment.worktreeStatus === 'invalid') return 'Needs attention';
if (attachment.attentionReason) return 'Needs attention';
if (attachment.headState === 'detached') return 'Detached HEAD';
if (attachment.headState === 'unborn') return 'Unborn branch';
if (attachment.branch) return `Current branch: ${attachment.branch}`;
return 'No branch';
}
export type SessionWorktreeRepairAction = 'locate' | 'open-without-worktree-features';
export function getSessionWorktreeRepairActions(
attachment: SessionWorktreeAttachment
): SessionWorktreeRepairAction[] {
if (attachment.worktreeStatus === 'missing' || attachment.worktreeStatus === 'invalid') {
return ['open-without-worktree-features'];
}
return [];
}
export type MutationBlockingReason =
| { reason: 'attention'; attentionReason: NonNullable<SessionWorktreeAttachment['attentionReason']> }
| { reason: 'missing' }
| { reason: 'invalid' }
| { reason: 'dirty'; dirtyFiles?: number };
export function getMutationBlockingReasons(
attachment: SessionWorktreeAttachment | null | undefined,
gitStatus?: { isClean?: boolean; files?: Array<{ path: string }> }
): MutationBlockingReason[] {
const reasons: MutationBlockingReason[] = [];
if (gitStatus && gitStatus.isClean === false) {
const dirtyFiles = gitStatus.files?.length;
reasons.push(dirtyFiles != null ? { reason: 'dirty', dirtyFiles } : { reason: 'dirty' });
}
if (!attachment) return reasons;
if (attachment.worktreeStatus === 'missing') {
reasons.push({ reason: 'missing' });
}
if (attachment.worktreeStatus === 'invalid') {
reasons.push({ reason: 'invalid' });
}
if (attachment.attentionReason) {
reasons.push({ reason: 'attention', attentionReason: attachment.attentionReason });
}
return reasons;
}
export type SessionTargetOption = {
value: string;
label: string;
kind: 'root' | 'worktree';
pending?: boolean;
};
export function buildSessionTargetOptions(input: {
projectRoot: string;
rootBranch: string;
worktrees: Array<{ path: string; branch: string; label: string; projectDirectory: string }>;
pendingBootstrapDirectory?: string | null;
}): SessionTargetOption[] {
const options: SessionTargetOption[] = [];
if (input.projectRoot) {
options.push({
value: input.projectRoot,
label: input.rootBranch || input.projectRoot.split('/').pop() || input.projectRoot,
kind: 'root',
});
}
const pendingNormalized = input.pendingBootstrapDirectory
? normalizePath(input.pendingBootstrapDirectory)
: null;
for (const wt of input.worktrees) {
const normalizedPath = normalizePath(wt.path);
if (normalizedPath === input.projectRoot) continue;
const isPending = normalizedPath === pendingNormalized;
options.push({
value: normalizedPath,
label: wt.branch?.trim() || wt.label || normalizedPath.split('/').pop() || normalizedPath,
kind: 'worktree',
pending: isPending || undefined,
});
}
return options;
}