fix(projects): isolate legacy host identity

This commit is contained in:
Mohamed Boudra
2026-07-28 23:11:50 +00:00
parent 6544ca7419
commit 95ceb238ee
4 changed files with 109 additions and 25 deletions

View File

@@ -17,6 +17,7 @@ const SECONDARY_HOST_LABEL = "Secondary Host";
const LEGACY_PRIMARY_HOST_ID = "project-grouping-legacy-primary";
const LEGACY_SECONDARY_HOST_ID = "project-grouping-legacy-secondary";
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
const SHARED_LEGACY_PROJECT_ID = "remote:github.com/paseo-e2e/grouped-project";
interface HostProject {
serverId: string;
@@ -100,7 +101,33 @@ async function removePersistedProjectGroupKeys(host: IsolatedHostDaemon): Promis
expect(persisted.every((project) => !("projectGroupKey" in project))).toBe(true);
}
async function createReconciliationFixture(): Promise<{
async function rewritePersistedProjectId(
host: IsolatedHostDaemon,
previousProjectId: string,
nextProjectId: string,
): Promise<void> {
const projectsDirectory = path.join(host.paseoHome, "projects");
const projectsPath = path.join(projectsDirectory, "projects.json");
const workspacesPath = path.join(projectsDirectory, "workspaces.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
const workspaces = JSON.parse(await readFile(workspacesPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) {
if (project.projectId === previousProjectId) project.projectId = nextProjectId;
}
for (const workspace of workspaces) {
if (workspace.projectId === previousProjectId) workspace.projectId = nextProjectId;
}
await Promise.all([
writeFile(projectsPath, JSON.stringify(projects)),
writeFile(workspacesPath, JSON.stringify(workspaces)),
]);
}
async function createReconciliationFixture(options?: { sharedLegacyProjectId?: string }): Promise<{
fixture: ReconciledCrossHostProject;
cleanup: () => Promise<void>;
}> {
@@ -116,12 +143,24 @@ async function createReconciliationFixture(): Promise<{
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
try {
const primary = await createProject(primaryClient, primaryRepo, primaryHost.serverId);
const secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
let primary = await createProject(primaryClient, primaryRepo, primaryHost.serverId);
let secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectGroupKeys(primaryHost);
await removePersistedProjectGroupKeys(secondaryHost);
if (options?.sharedLegacyProjectId) {
await Promise.all([
rewritePersistedProjectId(primaryHost, primary.projectId, options.sharedLegacyProjectId),
rewritePersistedProjectId(
secondaryHost,
secondary.projectId,
options.sharedLegacyProjectId,
),
]);
primary = { ...primary, projectId: options.sharedLegacyProjectId };
secondary = { ...secondary, projectId: options.sharedLegacyProjectId };
}
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
return {
fixture: { primaryHost, secondaryHost, primary, secondary },
@@ -146,6 +185,7 @@ async function createReconciliationFixture(): Promise<{
const test = base.extend<{
crossHostProject: CrossHostProject;
reconciledCrossHostProject: ReconciledCrossHostProject;
sharedLegacyIdCrossHostProject: ReconciledCrossHostProject;
}>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon(SECONDARY_HOST_ID);
@@ -183,6 +223,16 @@ const test = base.extend<{
await resource.cleanup();
}
},
sharedLegacyIdCrossHostProject: async ({ page: _page }, provide) => {
const resource = await createReconciliationFixture({
sharedLegacyProjectId: SHARED_LEGACY_PROJECT_ID,
});
try {
await provide(resource.fixture);
} finally {
await resource.cleanup();
}
},
});
test.describe("Sidebar project grouping", () => {
@@ -245,19 +295,33 @@ test.describe("Sidebar project grouping", () => {
test("resets a rename draft when switching grouped-project hosts", async ({
page,
crossHostProject,
sharedLegacyIdCrossHostProject,
}) => {
expect(sharedLegacyIdCrossHostProject.primary.projectId).toBe(
sharedLegacyIdCrossHostProject.secondary.projectId,
);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: crossHostProject.secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: crossHostProject.secondaryHost.port,
await addConnectedHostsAndReload(page, [
{
serverId: sharedLegacyIdCrossHostProject.primaryHost.serverId,
label: "Legacy Primary Host",
port: sharedLegacyIdCrossHostProject.primaryHost.port,
},
{
serverId: sharedLegacyIdCrossHostProject.secondaryHost.serverId,
label: "Legacy Secondary Host",
port: sharedLegacyIdCrossHostProject.secondaryHost.port,
},
]);
await waitForConnectedHost(page, {
serverId: sharedLegacyIdCrossHostProject.primaryHost.serverId,
endpoint: `localhost:${sharedLegacyIdCrossHostProject.primaryHost.port}`,
});
await waitForConnectedHost(page, {
serverId: crossHostProject.secondaryHost.serverId,
endpoint: `localhost:${crossHostProject.secondaryHost.port}`,
serverId: sharedLegacyIdCrossHostProject.secondaryHost.serverId,
endpoint: `localhost:${sharedLegacyIdCrossHostProject.secondaryHost.port}`,
});
await expectOneProjectContainsBothWorkspaces(page, crossHostProject);
await expectOneProjectContainsBothWorkspaces(page, sharedLegacyIdCrossHostProject);
await openGroupedProjectSettings(page);
await page.getByTestId("project-name-edit-button").click();
@@ -265,7 +329,9 @@ test.describe("Sidebar project grouping", () => {
await nameInput.fill("Draft from the first host");
await page.getByTestId("host-picker").click();
await page.getByTestId(`host-picker-item-${crossHostProject.secondary.serverId}`).click();
await page
.getByTestId(`host-picker-item-${sharedLegacyIdCrossHostProject.secondary.serverId}`)
.click();
await expect(nameInput).not.toBeVisible();
await page.getByTestId("project-name-edit-button").click();

View File

@@ -242,7 +242,7 @@ function ProjectSettingsBody({
projectKey={project.projectKey}
/>
<ProjectNameEditor
key={selectedHost.projectId ?? project.projectKey}
key={`${selectedHost.serverId}:${selectedHost.projectId ?? project.projectKey}`}
projectName={selectedHost.projectName}
projectCustomName={selectedHost.projectCustomName}
projectId={selectedHost.projectId ?? project.projectKey}

View File

@@ -1,3 +1,5 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { deriveProjectGroupKey } from "./project-group-key.js";
@@ -82,4 +84,29 @@ describe("deriveProjectGroupKey", () => {
}),
).toBe(path.join(mainRepoRoot, "packages", "app"));
});
test.skipIf(process.platform === "win32")(
"preserves a selected subproject reached through a symlink",
() => {
const tempDir = mkdtempSync(path.join(tmpdir(), "project-group-key-"));
try {
const worktreeRoot = path.join(tempDir, "repo");
const selectedRoot = path.join(worktreeRoot, "packages", "app");
const linkedRoot = path.join(tempDir, "app-link");
mkdirSync(selectedRoot, { recursive: true });
symlinkSync(selectedRoot, linkedRoot, "dir");
expect(
deriveProjectGroupKey({
rootPath: linkedRoot,
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot,
mainRepoRoot: worktreeRoot,
}),
).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
});

View File

@@ -1,4 +1,5 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import { resolve, sep } from "node:path";
import { getRealpathAwareRelativePath } from "../utils/path.js";
/**
* Derives the persisted, opaque equivalence key used to group projects across hosts.
@@ -23,17 +24,7 @@ export function deriveProjectGroupKey(input: {
function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): string | null {
if (!worktreeRoot) return null;
const selectedPath = relative(resolve(worktreeRoot), resolve(rootPath));
if (
!selectedPath ||
selectedPath === "." ||
selectedPath === ".." ||
selectedPath.startsWith(`..${sep}`) ||
isAbsolute(selectedPath)
) {
return null;
}
return selectedPath;
return getRealpathAwareRelativePath(worktreeRoot, rootPath) || null;
}
function encodeSelectedPath(selectedPath: string): string {