fix(projects): harden project reference identity

This commit is contained in:
Mohamed Boudra
2026-07-29 09:55:37 +00:00
parent 30a6122deb
commit 0060c88399
4 changed files with 64 additions and 1 deletions

View File

@@ -33,6 +33,21 @@ describe("deriveProjectKey", () => {
);
});
test("preserves queries that distinguish generic HTTP remotes", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("https://git.example.com/acme/app.git?tenant=a")).not.toBe(
derive("https://git.example.com/acme/app.git?tenant=b"),
);
});
test("normalizes the default SSH port", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>

View File

@@ -123,6 +123,7 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null {
const preserveLeadingSlash = isSsh && !forgeHost;
let remotePath = parsed.pathname || null;
if (remotePath && isSsh) remotePath += `${parsed.search}${parsed.hash}`;
if (remotePath && !isSsh && !forgeHost) remotePath += parsed.search;
if (remotePath && !preserveLeadingSlash) remotePath = remotePath.replace(/^\/+/, "");
if (!host || !remotePath) return null;
return {

View File

@@ -0,0 +1,47 @@
import { describe, expect, test, vi } from "vitest";
import type { PersistedProjectRecord } from "./workspace-registry.js";
import { resolveProjectReference } from "./project-reference.js";
function project(input: {
projectId: string;
projectKey: string;
rootPath: string;
archivedAt?: string | null;
}): PersistedProjectRecord {
return {
projectId: input.projectId,
projectKey: input.projectKey,
rootPath: input.rootPath,
kind: "git",
displayName: "acme/app",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: input.archivedAt ?? null,
};
}
describe("resolveProjectReference", () => {
test("resolves an active opaque project when an archived legacy project owns the direct id", async () => {
const reference = "remote:github.com/acme/app";
const archivedLegacyProject = project({
projectId: reference,
projectKey: reference,
rootPath: "/old/app",
archivedAt: "2026-02-01T00:00:00.000Z",
});
const activeProject = project({
projectId: "opaque-project-id",
projectKey: reference,
rootPath: "/new/app",
});
const registry = {
get: vi.fn().mockResolvedValue(archivedLegacyProject),
list: vi.fn().mockResolvedValue([archivedLegacyProject, activeProject]),
};
await expect(resolveProjectReference(reference, registry, ["/new/app"])).resolves.toEqual(
activeProject,
);
});
});

View File

@@ -7,7 +7,7 @@ export async function resolveProjectReference(
pathHints: readonly string[] = [],
): Promise<PersistedProjectRecord | null> {
const direct = await projectRegistry.get(reference);
if (direct) return direct;
if (direct && !direct.archivedAt) return direct;
// COMPAT(projectKeyAsProjectId): added in v0.2.4 on 2026-07-29; remove after 2027-01-29.
// Older clients return ProjectPlacementPayload.projectKey in workspace source.projectId.