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:
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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[]>>();
|
||||
|
||||
Reference in New Issue
Block a user