From 683d0c179874d26ffdfc0ee9d197effd875f2586 Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Fri, 20 Mar 2026 16:50:13 +0200 Subject: [PATCH] fix: resolve draft project for worktree paths (#722) --- .../ui/src/lib/worktrees/worktreeManager.ts | 71 ++++++++++++++++- packages/ui/src/stores/useSessionStore.ts | 77 ++++++++++++++++++- 2 files changed, 140 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index f67e4c5c..66f3a622 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -1,5 +1,6 @@ import { substituteCommandVariables } from '@/lib/openchamberConfig'; import type { WorktreeMetadata } from '@/types/worktree'; +import { execCommand } from '@/lib/execCommands'; import { deleteRemoteBranch, git, @@ -19,6 +20,66 @@ const normalizePath = (value: string): string => { return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; }; +const toAbsolutePath = (baseDir: string, maybeRelativePath: string): string => { + const normalizedBase = normalizePath(baseDir); + const normalizedInput = normalizePath(maybeRelativePath); + if (!normalizedInput) return normalizedBase; + if (normalizedInput.startsWith('/')) return normalizedInput; + + const stack = normalizedBase.split('/').filter(Boolean); + const parts = normalizedInput.split('/').filter(Boolean); + for (const part of parts) { + if (part === '.') continue; + if (part === '..') { + stack.pop(); + continue; + } + stack.push(part); + } + return `/${stack.join('/')}`; +}; + +const derivePrimaryWorktreeRootFromGitDir = (gitDir: string): string | null => { + const normalized = normalizePath(gitDir); + if (!normalized) return null; + if (normalized.endsWith('/.git')) { + return normalized.slice(0, -'/.git'.length) || null; + } + const worktreesMarker = '/.git/worktrees/'; + const markerIndex = normalized.indexOf(worktreesMarker); + if (markerIndex > 0) { + return normalized.slice(0, markerIndex) || null; + } + return null; +}; + +const resolvePrimaryWorktreeDirectory = async (directory: string): Promise => { + const normalizedDirectory = normalizePath(directory); + + const absoluteGitDirResult = await execCommand('git rev-parse --absolute-git-dir', normalizedDirectory); + const absoluteGitDir = normalizePath((absoluteGitDirResult.stdout || '').trim()); + if (absoluteGitDirResult.success && absoluteGitDir) { + const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir); + if (rootFromAbsoluteGitDir) { + return rootFromAbsoluteGitDir; + } + } + + const commonDirResult = await execCommand('git rev-parse --git-common-dir', normalizedDirectory); + const rawCommonDir = normalizePath((commonDirResult.stdout || '').trim()); + if (!commonDirResult.success || !rawCommonDir) { + return normalizedDirectory; + } + + const commonDir = toAbsolutePath(normalizedDirectory, rawCommonDir); + const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir); + if (rootFromCommonDir) { + return rootFromCommonDir; + } + + return normalizedDirectory; +}; + const slugifyWorktreeName = (value: string): string => { return value .trim() @@ -113,7 +174,8 @@ const toCreatePayload = (args: { }; export async function listProjectWorktrees(project: ProjectRef): Promise { - const projectDirectory = project.path; + const projectDirectory = normalizePath(project.path); + const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory); const worktrees = await git.worktree.list(projectDirectory).catch(() => []); @@ -127,7 +189,7 @@ export async function listProjectWorktrees(project: ProjectRef): Promise { - const projectDirectory = project.path; + const projectDirectory = normalizePath(project.path); + const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory); const payload = toCreatePayload(args, projectDirectory); const created = await git.worktree.create(projectDirectory, payload); @@ -173,7 +236,7 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr source: 'sdk', name: returnedName, path: normalizePath(returnedPath), - projectDirectory, + projectDirectory: metadataProjectDirectory, branch: returnedBranch, label: returnedBranch || returnedName, }; diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index 51e1c459..bb27127a 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -23,6 +23,7 @@ import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"; import { flattenAssistantTextParts } from "@/lib/messages/messageText"; import { normalizeMessageRecordsForProjection } from "./utils/messageProjectors"; import type { ProjectEntry } from "@/lib/api/types"; +import type { WorktreeMetadata } from "@/types/worktree"; export type { AttachedFile, EditPermissionMode }; export { MEMORY_LIMITS, ACTIVE_SESSION_WINDOW } from "./types/sessionTypes"; @@ -106,6 +107,69 @@ const resolveProjectForDirectory = (projects: ProjectEntry[], directory: string return bestMatch; }; +const resolveProjectFromWorktreeDirectory = ( + projects: ProjectEntry[], + availableWorktreesByProject: Map, + directory: string | null, +): ProjectEntry | null => { + const normalizedDirectory = normalizePath(directory); + if (!normalizedDirectory) { + return null; + } + + let matchedWorktree: WorktreeMetadata | null = null; + let matchedProjectPath: string | null = null; + let bestPathLength = -1; + + for (const [projectPath, worktrees] of availableWorktreesByProject.entries()) { + for (const worktree of worktrees) { + const worktreePath = normalizePath(worktree.path); + if (!worktreePath) { + continue; + } + const isExact = normalizedDirectory === worktreePath; + const isNested = normalizedDirectory.startsWith(`${worktreePath}/`); + if (!isExact && !isNested) { + continue; + } + if (worktreePath.length > bestPathLength) { + bestPathLength = worktreePath.length; + matchedWorktree = worktree; + matchedProjectPath = normalizePath(projectPath); + } + } + } + + if (!matchedWorktree) { + return null; + } + + const normalizedMetadataProjectPath = normalizePath(matchedWorktree.projectDirectory); + const candidates = [normalizedMetadataProjectPath, matchedProjectPath].filter((value): value is string => Boolean(value)); + + for (const candidatePath of candidates) { + const exact = projects.find((project) => normalizePath(project.path) === candidatePath) ?? null; + if (exact) { + return exact; + } + const nested = resolveProjectForDirectory(projects, candidatePath); + if (nested) { + return nested; + } + } + + return null; +}; + +const resolveDraftProjectForDirectory = ( + projects: ProjectEntry[], + availableWorktreesByProject: Map, + directory: string | null, +): ProjectEntry | null => { + return resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) + ?? resolveProjectForDirectory(projects, directory); +}; + const buildSessionChoiceAnalysisSignature = (messages: Array<{ info: Message; parts: Part[] }>): string => { const lastMessage = messages[messages.length - 1]; const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : ''; @@ -209,6 +273,7 @@ export const useSessionStore = create()( openNewSessionDraft: (options) => { const projectsState = useProjectsStore.getState(); const projects = projectsState.projects; + const availableWorktreesByProject = get().availableWorktreesByProject; const activeProject = projectsState.getActiveProject(); const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null); const persistedTarget = readPersistedDraftTarget(); @@ -220,7 +285,7 @@ export const useSessionStore = create()( ? projects.find((project) => project.id === options.projectId) ?? null : null; - const inferredProjectFromDirectory = resolveProjectForDirectory(projects, explicitDirectory); + const inferredProjectFromDirectory = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory); const fallbackProject = (() => { if (activeProject) { return activeProject; @@ -234,8 +299,8 @@ export const useSessionStore = create()( const persistedProjectById = persistedTarget?.projectId ? projects.find((project) => project.id === persistedTarget.projectId) ?? null : null; - const persistedProjectByDirectory = resolveProjectForDirectory(projects, persistedTarget?.directory ?? null); - const currentDirectoryProject = resolveProjectForDirectory(projects, currentDirectory); + const persistedProjectByDirectory = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null); + const currentDirectoryProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory); const selectedProject = (() => { if (explicitProject || explicitDirectory !== null) { @@ -1209,7 +1274,11 @@ useDirectoryStore.subscribe((state, prevState) => { } const projects = useProjectsStore.getState().projects; - const resolvedProject = resolveProjectForDirectory(projects, nextDirectory); + const resolvedProject = resolveDraftProjectForDirectory( + projects, + useSessionStore.getState().availableWorktreesByProject, + nextDirectory, + ); useSessionStore.setState((store) => ({ newSessionDraft: {