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.
This commit is contained in:
Mohamed Boudra
2026-07-28 21:21:29 +00:00
parent 47e095270a
commit 9e86b13b1f
3 changed files with 99 additions and 9 deletions

View File

@@ -59,9 +59,65 @@ interface WorkspaceStructureSession {
emptyProjects?: Iterable<EmptyProjectDescriptor>;
}
interface MaterializedWorkspaceStructureSession {
serverId: string;
workspaces: WorkspaceDescriptor[];
emptyProjects: EmptyProjectDescriptor[];
}
function findAmbiguousProjectGroupKeys(
sessions: MaterializedWorkspaceStructureSession[],
): Set<string> {
const projectIdsByHostByGroupKey = new Map<string, Map<string, Set<string>>>();
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>;
}): 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);

View File

@@ -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: [

View File

@@ -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;