import { substituteCommandVariables } from '@/lib/openchamberConfig'; import type { WorktreeMetadata } from '@/types/worktree'; import { deleteRemoteBranch, git, } from '@/lib/gitApi'; import { clearWorktreeBootstrapState, markWorktreeBootstrapPending, setWorktreeBootstrapState, startWorktreeBootstrapWatcher, } from '@/lib/worktrees/worktreeBootstrap'; import { invalidateResolvedProjectRootCache, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus'; import type { CreateGitWorktreePayload, GitWorktreeBootstrapStatus, GitWorktreeValidationResult, } from '@/lib/api/types'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; type WorktreeListEntry = { path?: string; branch?: string; head?: string; name?: string; }; const deriveHeadStateFromWorktreeEntry = (entry: WorktreeListEntry): 'branch' | 'detached' | 'unborn' => { const branch = (entry.branch || '').trim(); const head = (entry.head || '').trim(); if (!branch) { if (!head) return 'unborn'; return 'detached'; } return 'branch'; }; const deriveCanonicalWorktreeFields = ( entry: WorktreeListEntry, worktreePath: string, ): Pick => { return { worktreeRoot: worktreePath, worktreeStatus: 'ready', headState: deriveHeadStateFromWorktreeEntry(entry), worktreeSource: 'existing', }; }; export type ProjectRef = { id: string; path: string }; const normalizePath = (value: string): string => { const replaced = value.replace(/\\/g, '/'); if (replaced === '/') { return '/'; } return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; }; export const getLatestWorktreeMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => { const target = normalizePath(metadata.path); const state = useSessionUIStore.getState(); const available = state.availableWorktrees.find((candidate) => normalizePath(candidate.path) === target); if (available) return available; for (const worktrees of state.availableWorktreesByProject.values()) { const candidate = worktrees.find((worktree) => normalizePath(worktree.path) === target); if (candidate) return candidate; } return metadata; }; const slugifyWorktreeName = (value: string): string => { return value .trim() .replace(/^refs\/heads\//, '') .replace(/^heads\//, '') .replace(/\s+/g, '-') .replace(/^\/+|\/+$/g, '') .split('/').join('-') .replace(/[^A-Za-z0-9._-]+/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80); }; const normalizeBranchName = (value: string): string => { return value .trim() .replace(/^refs\/heads\//, '') .replace(/^heads\//, '') .replace(/\s+/g, '-') .replace(/^\/+|\/+$/g, ''); }; const setStoredWorktreeStatus = (directory: string, status: NonNullable): void => { const target = normalizePath(directory); if (!target) { return; } const changedSessionIds: string[] = []; 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) { changedSessionIds.push(sessionId); if (worktreeMetadata === state.worktreeMetadata) { worktreeMetadata = new Map(state.worktreeMetadata); } worktreeMetadata.set(sessionId, next); } } if (!changed) { return {}; } return { availableWorktrees, availableWorktreesByProject, worktreeMetadata, }; }); if (changedSessionIds.length > 0) { useSessionWorktreeStore.setState((state) => { let attachments = state.attachments; for (const sessionId of changedSessionIds) { const attachment = attachments.get(sessionId); if (!attachment || attachment.worktreeStatus === status) continue; if (attachments === state.attachments) attachments = new Map(state.attachments); attachments.set(sessionId, { ...attachment, worktreeStatus: status }); } return attachments === state.attachments ? state : { attachments }; }); } }; const getWorktreeStatusFromBootstrap = (status?: GitWorktreeBootstrapStatus): WorktreeMetadata['worktreeStatus'] => { if (status?.status === 'pending') { return 'pending'; } return status?.status === 'failed' ? 'invalid' : 'ready'; }; const deriveSdkWorktreeNameFromDirectory = (directory: string): string => { const normalized = normalizePath(directory); const parts = normalized.split('/').filter(Boolean); return parts[parts.length - 1] ?? normalized; }; const buildSdkStartCommand = (args: { projectDirectory: string; setupCommands: string[]; }): string | undefined => { const commands: string[] = []; for (const raw of args.setupCommands) { const trimmed = raw.trim(); if (!trimmed) continue; commands.push( substituteCommandVariables(trimmed, { rootWorktreePath: args.projectDirectory }) ); } const joined = commands.filter(Boolean).join(' && '); return joined.trim().length > 0 ? joined : undefined; }; const toCreatePayload = (args: { preferredName?: string; setupCommands?: string[]; mode?: 'new' | 'existing'; worktreeName?: string; branchName?: string; existingBranch?: string; startRef?: string; setUpstream?: boolean; upstreamRemote?: string; upstreamBranch?: string; ensureRemoteName?: string; ensureRemoteUrl?: string; returnAfterDirectoryCreated?: boolean; }, projectDirectory: string): CreateGitWorktreePayload => { const mode = args.mode === 'existing' ? 'existing' : 'new'; const worktreeNameSeed = args.worktreeName ?? args.preferredName ?? ''; const worktreeName = slugifyWorktreeName(worktreeNameSeed); const branchNameSeed = args.branchName ?? (mode === 'new' ? args.preferredName : undefined) ?? ''; const branchName = normalizeBranchName(branchNameSeed); const existingBranch = normalizeBranchName(args.existingBranch ?? args.branchName ?? ''); const startRef = (args.startRef || '').trim(); const commands = Array.isArray(args.setupCommands) ? args.setupCommands : []; const startCommand = buildSdkStartCommand({ projectDirectory, setupCommands: commands, }); return { mode, ...(worktreeName ? { worktreeName } : {}), ...(branchName ? { branchName } : {}), ...(existingBranch ? { existingBranch } : {}), ...(startRef ? { startRef } : {}), ...(startCommand ? { startCommand } : {}), ...(args.setUpstream ? { setUpstream: true } : {}), ...(args.upstreamRemote ? { upstreamRemote: args.upstreamRemote } : {}), ...(args.upstreamBranch ? { upstreamBranch: args.upstreamBranch } : {}), ...(args.ensureRemoteName ? { ensureRemoteName: args.ensureRemoteName } : {}), ...(args.ensureRemoteUrl ? { ensureRemoteUrl: args.ensureRemoteUrl } : {}), ...(args.returnAfterDirectoryCreated ? { returnAfterDirectoryCreated: true } : {}), }; }; /** * Compare two worktree-by-project maps for equality. * Compares discovery-owned metadata (not reference equality) * because readStableProjectWorktrees creates new object instances on * each call, making reference checks always report changed. * * `branch` is included so an external `git checkout` between * discoveries — which changes `branch` (and the derived `label` * and `headState`) while leaving `path` unchanged — still triggers * a store update. Without this, the branch label in the sidebar * could go stale until the next worktree create/remove or project * switch, since there is no periodic worktree-list refresh. * * Status changes (`worktreeStatus`) are not compared here: those * flow through `setStoredWorktreeStatus`, which writes a new Map * reference that the persist subscriber picks up directly. * */ export const worktreeMapsEqual = ( a: Map, b: Map, ): boolean => { if (a.size !== b.size) return false; for (const [key, value] of a) { const existing = b.get(key); if (!existing || existing.length !== value.length) return false; for (let i = 0; i < value.length; i++) { const next = value[i]; const current = existing[i]; if (next.path !== current.path || next.branch !== current.branch || next.name !== current.name || next.label !== current.label || next.projectDirectory !== current.projectDirectory || next.worktreeRoot !== current.worktreeRoot || next.headState !== current.headState || next.worktreeSource !== current.worktreeSource || next.source !== current.source) return false; } } return true; }; /** * Partition shared Git worktree topology across configured projects. * * A configured project may itself be a linked worktree. Asking Git for the * worktree list from every configured checkout returns the same repository * topology each time, which would otherwise render every sibling worktree * under every project. The primary checkout owns the topology when it is * configured; otherwise the first configured checkout for that repository * owns it. Checkouts that are configured projects are omitted from the owned * worktree list because they already have their own project section. */ export const partitionWorktreesByRegisteredProject = ( projects: ReadonlyArray>, worktreesByProject: ReadonlyMap, ): Map => { const configuredProjectOrder = new Map(); projects.forEach((project, index) => { const projectPath = normalizePath(project.path.trim()); if (projectPath && !configuredProjectOrder.has(projectPath)) { configuredProjectOrder.set(projectPath, index); } }); type RepositorySource = { projectPath: string; worktrees: WorktreeMetadata[]; projectIndex: number; }; const sourcesByRepository = new Map(); for (const [rawProjectPath, worktrees] of worktreesByProject) { if (worktrees.length === 0) continue; const projectPath = normalizePath(rawProjectPath.trim()); const projectIndex = configuredProjectOrder.get(projectPath); if (!projectPath || projectIndex === undefined) continue; const metadataRoot = worktrees.find((worktree) => worktree.projectDirectory?.trim())?.projectDirectory; const repositoryRoot = normalizePath((metadataRoot || projectPath).trim()); if (!repositoryRoot) continue; const sources = sourcesByRepository.get(repositoryRoot) ?? []; sources.push({ projectPath, worktrees, projectIndex }); sourcesByRepository.set(repositoryRoot, sources); } const partitioned = new Map(); for (const [repositoryRoot, sources] of sourcesByRepository) { sources.sort((a, b) => a.projectIndex - b.projectIndex || a.projectPath.localeCompare(b.projectPath)); const firstSource = sources[0]; if (!firstSource) continue; const ownerPath = configuredProjectOrder.has(repositoryRoot) ? repositoryRoot : firstSource.projectPath; const topologySource = sources.find((candidate) => candidate.projectPath === ownerPath) ?? firstSource; const seenPaths = new Set(); const ownedWorktrees = topologySource.worktrees.filter((worktree) => { const worktreePath = normalizePath(worktree.path.trim()); if (!worktreePath || configuredProjectOrder.has(worktreePath) || seenPaths.has(worktreePath)) { return false; } seenPaths.add(worktreePath); return true; }); if (ownedWorktrees.length > 0) { partitioned.set(ownerPath, ownedWorktrees); } } return partitioned; }; // Cache worktree listings to avoid repeated git worktree list + rev-parse calls const _worktreeListCache = new Map(); const _worktreeListInflight = new Map }>(); const _worktreeListGeneration = new Map(); const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds const WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS = 3; const getWorktreeListGeneration = (projectDirectory: string): number => { return _worktreeListGeneration.get(projectDirectory) ?? 0; }; const invalidateWorktreeList = (projectDirectory: string): void => { _worktreeListGeneration.set(projectDirectory, getWorktreeListGeneration(projectDirectory) + 1); _worktreeListCache.delete(projectDirectory); }; const readProjectWorktrees = async (projectDirectory: string): Promise => { const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory); const worktrees = await git.worktree.list(projectDirectory); const results: WorktreeMetadata[] = worktrees .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0) .map((entry) => { const worktreePath = normalizePath(entry.path); const branch = (entry.branch || '').replace(/^refs\/heads\//, '').trim(); const name = (entry.name || '').trim(); // Derive canonical worktree metadata from worktree list entry const canonical = deriveCanonicalWorktreeFields(entry, worktreePath); return { source: 'sdk' as const, name: name || deriveSdkWorktreeNameFromDirectory(worktreePath), path: worktreePath, projectDirectory: metadataProjectDirectory, branch: branch, label: branch || name || deriveSdkWorktreeNameFromDirectory(worktreePath), worktreeRoot: canonical.worktreeRoot, worktreeStatus: canonical.worktreeStatus, headState: canonical.headState, worktreeSource: canonical.worktreeSource, }; }) .filter((entry) => normalizePath(entry.path) !== normalizedProjectDirectory); return results.sort((a, b) => { const aLabel = (a.label || a.branch || a.path).toLowerCase(); const bLabel = (b.label || b.branch || b.path).toLowerCase(); return aLabel.localeCompare(bLabel); }); }; const readStableProjectWorktrees = async ( projectDirectory: string, minimumGeneration = getWorktreeListGeneration(projectDirectory), ): Promise => { for (let attempt = 0; attempt < WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS; attempt += 1) { const generation = getWorktreeListGeneration(projectDirectory); const worktrees = await readProjectWorktrees(projectDirectory); if (generation >= minimumGeneration && generation === getWorktreeListGeneration(projectDirectory)) { _worktreeListCache.set(projectDirectory, { value: worktrees, at: Date.now() }); return worktrees; } } throw new Error( `Worktree list did not converge after ${WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS} attempts` ); }; export async function listProjectWorktrees(project: ProjectRef, options?: { force?: boolean }): Promise { const projectDirectory = normalizePath(project.path); const force = options?.force === true; const previousCache = force ? _worktreeListCache.get(projectDirectory) : undefined; if (force) { invalidateWorktreeList(projectDirectory); } const generation = getWorktreeListGeneration(projectDirectory); // Return cached if fresh const cached = _worktreeListCache.get(projectDirectory); if (!force && cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) { return cached.value; } // Dedup in-flight requests const inflight = _worktreeListInflight.get(projectDirectory); if (inflight && inflight.generation === generation) return inflight.promise; const promise = readStableProjectWorktrees(projectDirectory, generation) .catch((error) => { if ( previousCache && !_worktreeListCache.has(projectDirectory) && getWorktreeListGeneration(projectDirectory) === generation ) { _worktreeListCache.set(projectDirectory, previousCache); } throw error; }) .finally(() => { if (_worktreeListInflight.get(projectDirectory)?.promise === promise) { _worktreeListInflight.delete(projectDirectory); } }); _worktreeListInflight.set(projectDirectory, { generation, promise }); return promise; } export type CreateWorktreeArgs = { preferredName?: string; setupCommands?: string[]; mode?: 'new' | 'existing'; worktreeName?: string; branchName?: string; existingBranch?: string; startRef?: string; setUpstream?: boolean; upstreamRemote?: string; upstreamBranch?: string; ensureRemoteName?: string; ensureRemoteUrl?: string; returnAfterDirectoryCreated?: boolean; }; export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise { const projectDirectory = normalizePath(project.path); const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory); const payload = toCreatePayload(args, projectDirectory); const created = await git.worktree.create(projectDirectory, payload); const returnedName = typeof created?.name === 'string' ? created.name : ''; const returnedBranch = typeof created?.branch === 'string' ? created.branch : ''; const returnedPath = typeof created?.path === 'string' ? created.path : ''; if (!returnedName || !returnedPath) { throw new Error('Worktree create missing name/path'); } const metadata: WorktreeMetadata = { source: 'sdk', name: returnedName, path: normalizePath(returnedPath), projectDirectory: metadataProjectDirectory, branch: returnedBranch, label: returnedBranch || returnedName, worktreeRoot: normalizePath(returnedPath), worktreeStatus: getWorktreeStatusFromBootstrap(created?.bootstrapStatus), headState: returnedBranch ? 'branch' : 'unborn', worktreeSource: 'created-for-session', }; if (created?.bootstrapStatus) { setWorktreeBootstrapState(metadata.path, created.bootstrapStatus); } else if (created?.directoryCreated) { markWorktreeBootstrapPending(metadata.path); } const shouldWatchBootstrap = created?.bootstrapStatus?.status === 'pending' || (!created?.bootstrapStatus && created?.directoryCreated === true); if (shouldWatchBootstrap) { startWorktreeBootstrapWatcher(metadata.path, { onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'), onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'), }); } invalidateWorktreeList(projectDirectory); // The new worktree changes the repo's worktree topology; drop cached root // resolutions so root-branch lookups re-resolve against the new layout. invalidateResolvedProjectRootCache(); // Update sidebar store so new worktree appears immediately const sidebarProjectKey = projectDirectory; const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; const updatedByProject = new Map(currentByProject); const existing = updatedByProject.get(sidebarProjectKey) ?? []; updatedByProject.set(sidebarProjectKey, [...existing, metadata]); useSessionUIStore.setState({ availableWorktreesByProject: updatedByProject, availableWorktrees: [...useSessionUIStore.getState().availableWorktrees, metadata], }); return metadata; } export async function validateWorktreeCreate(project: ProjectRef, args: CreateWorktreeArgs): Promise { const projectDirectory = project.path; const payload = toCreatePayload(args, projectDirectory); return git.worktree.validate(projectDirectory, payload); } export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: { deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; }): Promise { const projectDirectory = normalizePath(project.path); const deleteRemote = Boolean(options?.deleteRemoteBranch); const deleteLocalBranch = options?.deleteLocalBranch === true; const remoteName = options?.remoteName; const raw = await git.worktree.remove(projectDirectory, { directory: worktree.path, deleteLocalBranch, }); if (!raw?.success) { throw new Error('Worktree removal failed'); } clearWorktreeBootstrapState(worktree.path); invalidateWorktreeList(normalizePath(project.path)); // Removing a worktree changes the repo's worktree topology; drop cached root // resolutions so root-branch lookups re-resolve against the new layout. invalidateResolvedProjectRootCache(); // Update sidebar store so removed worktree disappears immediately const normalizedWorktreePath = normalizePath(worktree.path); const sidebarProjectKey = projectDirectory; const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; const updatedByProject = new Map(currentByProject); const projectWorktrees = updatedByProject.get(sidebarProjectKey) ?? []; updatedByProject.set( sidebarProjectKey, projectWorktrees.filter((w) => normalizePath(w.path) !== normalizedWorktreePath), ); // Clean up worktreeMetadata for sessions in the removed worktree const currentMetadata = useSessionUIStore.getState().worktreeMetadata; const updatedMetadata = new Map(currentMetadata); for (const [sid, meta] of currentMetadata.entries()) { if (meta && normalizePath(meta.path) === normalizedWorktreePath) { updatedMetadata.delete(sid); } } useSessionUIStore.setState({ availableWorktreesByProject: updatedByProject, availableWorktrees: useSessionUIStore.getState().availableWorktrees.filter( (w) => normalizePath(w.path) !== normalizedWorktreePath, ), worktreeMetadata: updatedMetadata, }); const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim(); if (deleteRemote && branchName) { await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined); } }