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: '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): string { if (attachment.legacy) return 'Legacy session'; 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 } | { 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; }