Restore project grouping across hosts (#2565)

* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* 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.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
Mohamed Boudra
2026-07-29 23:36:31 +02:00
committed by GitHub
parent 9e5accee3c
commit 20e46e3e3f
113 changed files with 3073 additions and 2038 deletions
+29 -277
View File
@@ -1,312 +1,64 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import { describe, expect, test } from "vitest";
import type { HostProjectListItem } from "./host-project-model";
import {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectId,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
} from "./host-project-model";
function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceStructureProject {
function project(): HostProjectListItem {
return {
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
canCreateWorktree: input.projectKind !== "directory",
projectId: "prj_a",
iconWorkingDir: "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
};
}
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
return {
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
serverId: "host-b",
projectId: "prj_b",
iconWorkingDir: "/repo/b",
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
workspaceKeys: [],
};
}
function workspace(input: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return {
id: input.id ?? "workspace-a",
projectId: input.projectId ?? "project-a",
projectDisplayName: input.projectDisplayName ?? "Project A",
projectRootPath: input.projectRootPath ?? "/repo/a",
workspaceDirectory: input.workspaceDirectory ?? "/repo/a",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
const routeProject = hostProject({
projectKey: "route-project",
projectName: "Route Project",
iconWorkingDir: "/repo/route",
});
const lastActiveProject = hostProject({
projectKey: "last-project",
projectName: "Last Project",
iconWorkingDir: "/repo/last",
});
const firstProject = hostProject({
projectKey: "first-project",
projectName: "First Project",
iconWorkingDir: "/repo/first",
});
describe("host project list", () => {
it("preserves workspace-structure order and project metadata", () => {
expect(
buildHostProjectList({
projects: [
structureProject({
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
}),
structureProject({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
}),
],
}),
).toEqual([
{
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
workspaceKeys: ["workspace-b"],
},
{
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["workspace-a"],
},
]);
describe("host project lookups", () => {
test("returns host-local ids and roots without falling back to the grouping key", () => {
expect(getHostProjectId(project(), "host-b")).toBe("prj_b");
expect(getHostProjectSourceDirectory(project(), "host-b")).toBe("/repo/b");
expect(getHostProjectId(project(), "missing")).toBeNull();
});
it("keeps worktree capability separate from project listability", () => {
expect(canCreateWorktreeForProjectKind("git")).toBe(true);
expect(canCreateWorktreeForProjectKind("directory")).toBe(false);
});
it("uses route project before last active project when it can create worktrees", () => {
test("checks workspace creation against the selected host placement", () => {
expect(
resolveInitialWorktreeProject({
routeProject,
lastActiveProject,
projects: [firstProject],
}),
).toEqual(routeProject);
});
it("skips non-worktree route and last-active projects", () => {
expect(
resolveInitialWorktreeProject({
routeProject: {
...routeProject,
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/route", canCreateWorktree: false }],
},
lastActiveProject: {
...lastActiveProject,
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/last", canCreateWorktree: false }],
},
projects: [
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
],
}),
).toMatchObject({ projectKey: "git-project" });
});
it("leaves the project empty when no worktree-capable project is available", () => {
expect(
resolveInitialWorktreeProject({
routeProject: null,
lastActiveProject: null,
projects: [
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
],
}),
).toBeNull();
});
it("filters new-workspace projects to the selected host", () => {
const hostAOnly = hostProject({
projectKey: "host-a-project",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
});
const hostBOnly = hostProject({
projectKey: "host-b-project",
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
});
expect(
filterWorkspaceProjectsForHost({
projects: [hostAOnly, hostBOnly],
canCreateWorkspaceForHostProject({
project: project(),
serverId: "host-b",
allowAllProjects: false,
}).map((project) => project.projectKey),
).toEqual(["host-b-project"]);
});
it("allows directory projects only when workspace multiplicity is supported", () => {
const directoryProject = hostProject({
projectKey: "directory-project",
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/directory", canCreateWorktree: false }],
});
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: false,
}),
).toBe(false);
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: true,
}),
).toBe(true);
});
it("falls back when the route project is not available on the selected host", () => {
const selectedHostProject = hostProject({
projectKey: "selected-host-project",
hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/selected-host", canCreateWorktree: true },
],
});
expect(
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject: null,
projects: [selectedHostProject],
serverId: "host-b",
allowAllProjects: false,
}),
).toEqual(selectedHostProject);
});
it("resolves the selected host project source directory", () => {
const project = hostProject({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
});
expect(getHostProjectSourceDirectory(project, "host-b")).toBe("/repo/b");
expect(getHostProjectSourceDirectory(project, "host-c")).toBeNull();
});
it("keeps a selected route project available before project hydration", () => {
expect(
resolveSelectedHostProject({
selectedProjectKey: routeProject.projectKey,
projects: [],
routeProject,
lastActiveProject: null,
}),
).toEqual(routeProject);
});
it("converts route project only when it has a key and source directory", () => {
test("builds an unhydrated route project around the routed project id", () => {
expect(
hostProjectFromRoute({
serverId: "host-a",
projectId: "project-a",
displayName: "Project A",
projectId: "prj_a",
displayName: "App",
sourceDirectory: "/repo/a",
}),
).toEqual({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: [],
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
});
it("converts last active workspaces with matching worktree capability", () => {
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["host-a:workspace-a"],
});
expect(
hostProjectFromWorkspace({
serverId: "host-a",
workspace: workspace({ projectKind: "directory" }),
}),
).toMatchObject({
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }],
projectKey: "prj_a",
hosts: [{ serverId: "host-a", projectId: "prj_a" }],
});
});
});