From 9e86b13b1feebd6b89aaa559de72984ec4a70ad4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 28 Jul 2026 21:21:29 +0000 Subject: [PATCH] fix(projects): keep ambiguous clones separate A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate. --- .../app/src/projects/workspace-structure.ts | 66 +++++++++++++++++-- packages/app/src/utils/projects.test.ts | 23 +++++++ packages/app/src/utils/projects.ts | 19 ++++-- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 3c2a02e14..059385c0c 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -59,9 +59,65 @@ interface WorkspaceStructureSession { emptyProjects?: Iterable; } +interface MaterializedWorkspaceStructureSession { + serverId: string; + workspaces: WorkspaceDescriptor[]; + emptyProjects: EmptyProjectDescriptor[]; +} + +function findAmbiguousProjectGroupKeys( + sessions: MaterializedWorkspaceStructureSession[], +): Set { + const projectIdsByHostByGroupKey = new Map>>(); + for (const session of sessions) { + const projects = [ + ...session.emptyProjects.map((project) => ({ + projectId: project.projectId, + projectGroupKey: project.projectGroupKey, + })), + ...session.workspaces.map((workspace) => ({ + projectId: workspace.projectId, + projectGroupKey: workspace.projectGroupKey, + })), + ]; + for (const project of projects) { + const groupKey = resolveProjectGroupKey({ serverId: session.serverId, ...project }); + const byHost = projectIdsByHostByGroupKey.get(groupKey) ?? new Map(); + const projectIds = byHost.get(session.serverId) ?? new Set(); + projectIds.add(project.projectId); + byHost.set(session.serverId, projectIds); + projectIdsByHostByGroupKey.set(groupKey, byHost); + } + } + + return new Set( + [...projectIdsByHostByGroupKey].flatMap(([groupKey, byHost]) => + [...byHost.values()].some((projectIds) => projectIds.size > 1) ? [groupKey] : [], + ), + ); +} + +function resolveUnambiguousProjectGroupKey(input: { + serverId: string; + projectId: string; + projectGroupKey?: string | null; + ambiguousGroupKeys: ReadonlySet; +}): string { + const groupKey = resolveProjectGroupKey(input); + return input.ambiguousGroupKeys.has(groupKey) + ? `host:${input.serverId}:project:${input.projectId}` + : groupKey; +} + export function buildWorkspaceStructureProjects(input: { sessions: WorkspaceStructureSession[]; }): WorkspaceStructureProject[] { + const sessions = input.sessions.map((session) => ({ + serverId: session.serverId, + workspaces: [...session.workspaces], + emptyProjects: [...(session.emptyProjects ?? [])], + })); + const ambiguousGroupKeys = findAmbiguousProjectGroupKeys(sessions); const byProject = new Map< string, { @@ -74,12 +130,13 @@ export function buildWorkspaceStructureProjects(input: { } >(); - for (const session of input.sessions) { - for (const emptyProject of session.emptyProjects ?? []) { - const projectKey = resolveProjectGroupKey({ + for (const session of sessions) { + for (const emptyProject of session.emptyProjects) { + const projectKey = resolveUnambiguousProjectGroupKey({ serverId: session.serverId, projectId: emptyProject.projectId, projectGroupKey: emptyProject.projectGroupKey, + ambiguousGroupKeys, }); const placement = { serverId: session.serverId, @@ -108,10 +165,11 @@ export function buildWorkspaceStructureProjects(input: { } for (const workspace of session.workspaces) { - const projectKey = resolveProjectGroupKey({ + const projectKey = resolveUnambiguousProjectGroupKey({ serverId: session.serverId, projectId: workspace.projectId, projectGroupKey: workspace.projectGroupKey, + ambiguousGroupKeys, }); const existing = byProject.get(projectKey); diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index 8b7d2ff1c..71cf3d192 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -96,6 +96,29 @@ describe("buildProjects", () => { ]); }); + it("keeps independent same-host clones separate when their group key is ambiguous", () => { + const projectGroupKey = "remote:github.com/acme/app"; + const result = buildProjects({ + hosts: [ + { + serverId: "desktop", + serverName: "Desktop", + isOnline: true, + workspaces: [ + workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectGroupKey }), + workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }), + ], + }, + ], + }); + + expect(result.projects).toHaveLength(2); + expect(result.projects.map((project) => project.hosts[0]?.projectId).sort()).toEqual([ + "prj_a", + "prj_b", + ]); + }); + it("normalizes detached and blank branches out of workspace summaries", () => { const result = buildProjects({ hosts: [ diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index 491559bbd..ca543f1bd 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -185,6 +185,13 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { } const hostProjects = buildHostProjectEntries(host); + const projectKeyByProjectId = new Map( + hostProjects.flatMap((project) => + project.hosts + .filter((placement) => placement.serverId === host.serverId && placement.projectId) + .map((placement) => [placement.projectId!, project.projectKey] as const), + ), + ); for (const hostProject of hostProjects) { const customName = findProjectCustomName(host.workspaces, hostProject.projectKey); let group = groups.get(hostProject.projectKey); @@ -216,11 +223,13 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { } for (const workspace of host.workspaces) { - const key = resolveProjectGroupKey({ - serverId: host.serverId, - projectId: workspace.projectId, - projectGroupKey: workspace.projectGroupKey, - }); + const key = + projectKeyByProjectId.get(workspace.projectId) ?? + resolveProjectGroupKey({ + serverId: host.serverId, + projectId: workspace.projectId, + projectGroupKey: workspace.projectGroupKey, + }); const group = groups.get(key); const hostGroup = group?.hostsByServerId.get(host.serverId); if (!hostGroup) continue;