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.
This commit is contained in:
Serhii Dziupin
2026-08-02 09:34:49 +03:00
parent 37ff3a8164
commit 5d24d6cb2a
7 changed files with 199 additions and 22 deletions
+10 -6
View File
@@ -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<string, WorktreeMetadata[]>();
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,
});
}
};
+9 -5
View File
@@ -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,
});
}
};
+7 -4
View File
@@ -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<MobileSessionsSheetProps> = ({ open,
}),
);
if (cancelled) return;
const next = new Map<string, WorktreeMetadata[]>();
const discoveredWorktreesByProject = new Map<string, WorktreeMetadata[]>();
const nextGitProjectPaths = new Set<string>();
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();
@@ -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<SessionSidebarProps> = ({
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<string>();
// Constrain fanout: previously `Promise.all(projects.map(...))` could
@@ -618,16 +622,17 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
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);
@@ -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
@@ -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<void> => {
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<string, WorktreeMetadata[]>([
['/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<string, WorktreeMetadata[]>([
['/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<string, WorktreeMetadata[]>([
['/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']);
});
});
@@ -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<Pick<ProjectRef, 'path'>>,
worktreesByProject: ReadonlyMap<string, WorktreeMetadata[]>,
): Map<string, WorktreeMetadata[]> => {
const configuredProjectOrder = new Map<string, number>();
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<string, RepositorySource[]>();
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<string, WorktreeMetadata[]>();
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<string>();
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<string, { value: WorktreeMetadata[]; at: number }>();
const _worktreeListInflight = new Map<string, Promise<WorktreeMetadata[]>>();