From 5d24d6cb2a34610d4295f6824d256e3be794bd9e Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Sat, 1 Aug 2026 18:11:32 +0300 Subject: [PATCH] fix: dedupe shared worktree ownership Cherry-picked from claude branch 7b3bec48 (fix: dedupe shared worktree ownership and title sessions immediately). Only the worktree-dedupe half applies to main: partitionWorktreesByRegisteredProject and its callers (SessionSidebar, ElectronMiniChatApp, MobileApp, MobileSessionsSheet) plus tests and sidebar docs. The session-title half (title sessions immediately) remains on the claude branch: the session-title runtime and its claude-code transcript translator imports do not exist on main and cannot build there until the Claude harness lands. --- packages/ui/src/apps/ElectronMiniChatApp.tsx | 16 ++-- packages/ui/src/apps/MobileApp.tsx | 14 +-- packages/ui/src/apps/MobileSessionsSheet.tsx | 11 ++- .../src/components/session/SessionSidebar.tsx | 17 ++-- .../session/sidebar/DOCUMENTATION.md | 1 + .../src/lib/worktrees/worktreeManager.test.ts | 90 ++++++++++++++++++- .../ui/src/lib/worktrees/worktreeManager.ts | 72 +++++++++++++++ 7 files changed, 199 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index d7f1dafc..10a993d8 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -19,7 +19,11 @@ import { useSync } from '@/sync/use-sync'; import { SyncRuntimeEffects } from './AppEffects'; import { useAppFontEffects } from './useAppFontEffects'; import { useMiniChatKeyboardShortcuts } from '@/hooks/useMiniChatKeyboardShortcuts'; -import { listProjectWorktrees, worktreeMapsEqual } from '@/lib/worktrees/worktreeManager'; +import { + listProjectWorktrees, + partitionWorktreesByRegisteredProject, + worktreeMapsEqual, +} from '@/lib/worktrees/worktreeManager'; import type { WorktreeMetadata } from '@/types/worktree'; const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence'; @@ -175,7 +179,6 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => const discoverWorktrees = async () => { const worktreesByProject = new Map(); - const allWorktrees: WorktreeMetadata[] = []; await Promise.all(projects.map(async (project) => { const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, ''); @@ -187,7 +190,6 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); if (cancelled || worktrees.length === 0) return; worktreesByProject.set(projectPath, worktrees); - allWorktrees.push(...worktrees); } catch { // Worktree discovery is best-effort; draft selector falls back to the project root. } @@ -195,12 +197,14 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => if (cancelled) return; + const partitionedWorktreesByProject = partitionWorktreesByRegisteredProject(projects, worktreesByProject); + // Skip update if nothing changed — see worktreeMapsEqual JSDoc. const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; - if (!worktreeMapsEqual(worktreesByProject, currentByProject)) { + if (!worktreeMapsEqual(partitionedWorktreesByProject, currentByProject)) { useSessionUIStore.setState({ - availableWorktrees: allWorktrees, - availableWorktreesByProject: worktreesByProject, + availableWorktrees: [...partitionedWorktreesByProject.values()].flat(), + availableWorktreesByProject: partitionedWorktreesByProject, }); } }; diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index e6fff3b6..bb8c5424 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -40,7 +40,11 @@ import { useGitStatus, useGitStore } from '@/stores/useGitStore'; import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { listProjectWorktrees, worktreeMapsEqual } from '@/lib/worktrees/worktreeManager'; +import { + listProjectWorktrees, + partitionWorktreesByRegisteredProject, + worktreeMapsEqual, +} from '@/lib/worktrees/worktreeManager'; import { useUIStore } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -1148,14 +1152,14 @@ export function MobileApp({ apis }: MobileAppProps) { if (cancelled) return; - const allWorktrees = Array.from(worktreesByProject.values()).flat(); + const partitionedWorktreesByProject = partitionWorktreesByRegisteredProject(projects, worktreesByProject); // Skip update if nothing changed — see worktreeMapsEqual JSDoc. const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; - if (!worktreeMapsEqual(worktreesByProject, currentByProject)) { + if (!worktreeMapsEqual(partitionedWorktreesByProject, currentByProject)) { useSessionUIStore.setState({ - availableWorktrees: allWorktrees, - availableWorktreesByProject: worktreesByProject, + availableWorktrees: [...partitionedWorktreesByProject.values()].flat(), + availableWorktreesByProject: partitionedWorktreesByProject, }); } }; diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 65133b18..30a5ce90 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -45,7 +45,10 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useI18n } from '@/lib/i18n'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { cn } from '@/lib/utils'; -import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; +import { + listProjectWorktrees, + partitionWorktreesByRegisteredProject, +} from '@/lib/worktrees/worktreeManager'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { mergeLiveSessionWithGlobalSession, refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore'; @@ -948,15 +951,15 @@ export const MobileSessionsSheet: React.FC = ({ open, }), ); if (cancelled) return; - const next = new Map(); + const discoveredWorktreesByProject = new Map(); const nextGitProjectPaths = new Set(); for (const entry of entries) { if (entry) { - next.set(entry[0], entry[1]); + discoveredWorktreesByProject.set(entry[0], entry[1]); if (entry[2]) nextGitProjectPaths.add(entry[0]); } } - setWorktreesByProject(next); + setWorktreesByProject(partitionWorktreesByRegisteredProject(projects, discoveredWorktreesByProject)); setGitProjectPaths(nextGitProjectPaths); }; void run(); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 974d6ce9..d92659d8 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -47,7 +47,11 @@ import { SessionNodeItem } from './sidebar/SessionNodeItem'; import type { SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useShallow } from 'zustand/react/shallow'; -import { listProjectWorktrees, worktreeMapsEqual } from '@/lib/worktrees/worktreeManager'; +import { + listProjectWorktrees, + partitionWorktreesByRegisteredProject, + worktreeMapsEqual, +} from '@/lib/worktrees/worktreeManager'; import { checkIsGitRepository } from '@/lib/gitApi'; import type { WorktreeMetadata } from '@/types/worktree'; import type { SortableDragHandleProps } from './sidebar/sortableItems'; @@ -564,8 +568,8 @@ const SessionSidebarComponent: React.FC = ({ return; } - const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; - const worktreesByProject = new Map(currentByProject); + const knownWorktreesByProject = useSessionUIStore.getState().availableWorktreesByProject; + const worktreesByProject = new Map(knownWorktreesByProject); const unresolvedProjectPaths = new Set(); // Constrain fanout: previously `Promise.all(projects.map(...))` could @@ -618,16 +622,17 @@ const SessionSidebarComponent: React.FC = ({ worktreesByProject.delete(projectPath); } } - const allWorktrees = [...worktreesByProject.values()].flat(); + const partitionedWorktreesByProject = partitionWorktreesByRegisteredProject(projectEntries, worktreesByProject); + const allWorktrees = [...partitionedWorktreesByProject.values()].flat(); // Newly appearing worktrees sort to the top of their project's // worktree list (see worktreeFirstSeen.ts). recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), Date.now()); // Skip update if nothing changed — see worktreeMapsEqual JSDoc. - if (!worktreeMapsEqual(worktreesByProject, currentByProject)) { + if (!worktreeMapsEqual(partitionedWorktreesByProject, knownWorktreesByProject)) { useSessionUIStore.setState({ availableWorktrees: allWorktrees, - availableWorktreesByProject: worktreesByProject, + availableWorktreesByProject: partitionedWorktreesByProject, }); } setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths); diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index ae05ed68..bbd99077 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -13,6 +13,7 @@ - Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface. - Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle. - Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution. +- When multiple configured projects are checkouts of the same Git repository, exactly one project owns the shared worktree topology: the configured canonical primary root when present, otherwise the first configured source for that repository. Any worktree path that is also a configured project is omitted from subordinate worktree groups, so every directory has one sidebar location while remaining part of bootstrap demand. ## VS Code grouping diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index f2aab3b6..71c6ccb4 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -86,7 +86,13 @@ mock.module('@/lib/gitApi', () => ({ }, })); -const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager'); +const { + createWorktree, + getLatestWorktreeMetadata, + listProjectWorktrees, + partitionWorktreesByRegisteredProject, + worktreeMapsEqual, +} = await import('./worktreeManager'); const waitForListCallCount = async (count: number): Promise => { for (let attempt = 0; attempt < 10; attempt += 1) { @@ -284,3 +290,85 @@ describe('worktreeMapsEqual', () => { expect(worktreeMapsEqual(a, b)).toBe(false); }); }); + +describe('partitionWorktreesByRegisteredProject', () => { + const worktree = (path: string, projectDirectory = '/repo'): WorktreeMetadata => { + const label = path.split('/').pop() ?? path; + return { path, projectDirectory, branch: label, label }; + }; + + test('assigns shared topology to the primary project and omits configured worktree projects', () => { + const projects = [ + { path: '/repo' }, + { path: '/worktrees/alpha' }, + { path: '/worktrees/beta' }, + ]; + const topology = new Map([ + ['/repo', [ + worktree('/worktrees/alpha'), + worktree('/worktrees/beta'), + worktree('/worktrees/loose'), + ]], + ['/worktrees/alpha', [ + worktree('/repo'), + worktree('/worktrees/beta'), + worktree('/worktrees/loose'), + ]], + ['/worktrees/beta', [ + worktree('/repo'), + worktree('/worktrees/alpha'), + worktree('/worktrees/loose'), + ]], + ]); + + const result = partitionWorktreesByRegisteredProject(projects, topology); + + expect([...result.keys()]).toEqual(['/repo']); + expect(result.get('/repo')?.map((entry) => entry.path)).toEqual(['/worktrees/loose']); + }); + + test('uses the first configured checkout when the primary project is not configured', () => { + const projects = [ + { path: '/worktrees/alpha' }, + { path: '/worktrees/beta' }, + ]; + const topology = new Map([ + ['/worktrees/beta', [ + worktree('/repo'), + worktree('/worktrees/alpha'), + worktree('/worktrees/loose'), + ]], + ['/worktrees/alpha', [ + worktree('/repo'), + worktree('/worktrees/beta'), + worktree('/worktrees/loose'), + ]], + ]); + + const result = partitionWorktreesByRegisteredProject(projects, topology); + + expect([...result.keys()]).toEqual(['/worktrees/alpha']); + expect(result.get('/worktrees/alpha')?.map((entry) => entry.path)).toEqual([ + '/repo', + '/worktrees/loose', + ]); + }); + + test('keeps primary ownership when topology comes from another configured checkout', () => { + const projects = [ + { path: '/repo' }, + { path: '/worktrees/alpha' }, + ]; + const topology = new Map([ + ['/worktrees/alpha', [ + worktree('/repo'), + worktree('/worktrees/loose'), + ]], + ]); + + const result = partitionWorktreesByRegisteredProject(projects, topology); + + expect([...result.keys()]).toEqual(['/repo']); + expect(result.get('/repo')?.map((entry) => entry.path)).toEqual(['/worktrees/loose']); + }); +}); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 2ba1edc4..543c1d41 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -300,6 +300,78 @@ export const worktreeMapsEqual = ( 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>();