mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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.
This commit is contained in:
@@ -2564,6 +2564,7 @@ describe("create_agent MCP tool", () => {
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
projectRegistry: {
|
||||
get: async (projectId) => (projectId === project.projectId ? project : null),
|
||||
list: async () => [project],
|
||||
},
|
||||
createPaseoWorktree,
|
||||
logger,
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface PaseoToolHostDependencies {
|
||||
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
|
||||
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
||||
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||
projectRegistry?: Pick<ProjectRegistry, "get">;
|
||||
projectRegistry?: Pick<ProjectRegistry, "get" | "list">;
|
||||
createDirectoryWorkspace?: (
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
|
||||
@@ -174,6 +174,51 @@ describe("deriveProjectKey", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("distinguishes SSH users for generic URL remotes", () => {
|
||||
const rootPath = path.resolve("repo");
|
||||
const derive = (remoteUrl: string) =>
|
||||
deriveProjectKey({
|
||||
rootPath,
|
||||
remoteUrl,
|
||||
worktreeRoot: rootPath,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
|
||||
expect(derive("ssh://alice@git.example.com/srv/repo.git")).not.toBe(
|
||||
derive("ssh://bob@git.example.com/srv/repo.git"),
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes equivalent absolute SSH remote forms", () => {
|
||||
const rootPath = path.resolve("repo");
|
||||
const derive = (remoteUrl: string) =>
|
||||
deriveProjectKey({
|
||||
rootPath,
|
||||
remoteUrl,
|
||||
worktreeRoot: rootPath,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
|
||||
expect(derive("git@example.com:/srv/repo.git")).toBe(
|
||||
derive("ssh://git@example.com/srv/repo.git"),
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves query and fragment text in SSH repository paths", () => {
|
||||
const rootPath = path.resolve("repo");
|
||||
const derive = (remoteUrl: string) =>
|
||||
deriveProjectKey({
|
||||
rootPath,
|
||||
remoteUrl,
|
||||
worktreeRoot: rootPath,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
|
||||
expect(derive("ssh://git@example.com/repo.git?x=1#one")).not.toBe(
|
||||
derive("ssh://git@example.com/repo.git?x=2#two"),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps percent sequences literal in SCP paths", () => {
|
||||
const rootPath = path.resolve("repo");
|
||||
const derive = (remoteUrl: string) =>
|
||||
|
||||
@@ -122,13 +122,17 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null {
|
||||
const host = forgeHost ?? deriveRemoteHost(parsed);
|
||||
const preserveLeadingSlash = isSsh && !forgeHost;
|
||||
let remotePath = parsed.pathname || null;
|
||||
if (remotePath && isSsh) remotePath += `${parsed.search}${parsed.hash}`;
|
||||
if (remotePath && !preserveLeadingSlash) remotePath = remotePath.replace(/^\/+/, "");
|
||||
if (!host || !remotePath) return null;
|
||||
return {
|
||||
host,
|
||||
path: remotePath,
|
||||
transport: forgeHost ? null : parsed.protocol.toLowerCase(),
|
||||
relativePathUser: null,
|
||||
transport: forgeHost || isSsh ? null : parsed.protocol.toLowerCase(),
|
||||
relativePathUser:
|
||||
isSsh && !forgeHost && parsed.username && parsed.username !== "git"
|
||||
? decodeUrlComponent(parsed.username)
|
||||
: null,
|
||||
preserveLeadingSlash,
|
||||
decodePercentEncoding: true,
|
||||
stripDotGitSuffix: forgeHost !== null,
|
||||
@@ -138,6 +142,14 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null {
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUrlComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCloudForgeHost(host: string): string {
|
||||
return CANONICAL_CLOUD_FORGE_HOSTS.get(host.toLowerCase()) ?? host;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
import type { PersistedProjectRecord, ProjectRegistry } from "./workspace-registry.js";
|
||||
|
||||
export async function resolveProjectReference(
|
||||
reference: string,
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list">,
|
||||
pathHints: readonly string[] = [],
|
||||
): Promise<PersistedProjectRecord | null> {
|
||||
const direct = await projectRegistry.get(reference);
|
||||
if (direct) 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.
|
||||
const candidates = (await projectRegistry.list()).filter(
|
||||
(project) => !project.archivedAt && project.projectKey === reference,
|
||||
);
|
||||
for (const pathHint of pathHints) {
|
||||
const matches = candidates.filter((project) =>
|
||||
createRealpathAwarePathMatcher(project.rootPath)(pathHint),
|
||||
);
|
||||
if (matches.length === 1) return matches[0] ?? null;
|
||||
}
|
||||
return candidates.length === 1 ? (candidates[0] ?? null) : null;
|
||||
}
|
||||
+20
@@ -457,6 +457,26 @@ test("createWorkspaceForDirectory refreshes an explicit project's stale Git kind
|
||||
});
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory accepts a legacy project-key reference", async () => {
|
||||
const rootPath = path.join(tmpDir, "repo");
|
||||
gitRoots.add(rootPath);
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
projectKey: "remote:github.com/acme/repo",
|
||||
timestamp: ARCHIVED_AT,
|
||||
});
|
||||
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(
|
||||
rootPath,
|
||||
null,
|
||||
"remote:github.com/acme/repo",
|
||||
);
|
||||
|
||||
expect(workspace.projectId).toBe(project.projectId);
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory classifies unknown and archived explicit projects", async () => {
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(path.join(tmpDir, "directory"), null, "missing"),
|
||||
|
||||
+14
-4
@@ -15,6 +15,7 @@ import {
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import { deriveProjectKey } from "../../project-key.js";
|
||||
import { resolveProjectReference } from "../../project-reference.js";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
|
||||
export interface ResolveOrCreateWorkspaceIdInput {
|
||||
@@ -175,8 +176,11 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
});
|
||||
}
|
||||
|
||||
async function requireActiveProject(projectId: string): Promise<PersistedProjectRecord> {
|
||||
const project = await projectRegistry.get(projectId);
|
||||
async function requireActiveProject(
|
||||
projectId: string,
|
||||
pathHints: readonly string[] = [],
|
||||
): Promise<PersistedProjectRecord> {
|
||||
const project = await resolveProjectReference(projectId, projectRegistry, pathHints);
|
||||
if (!project) throw new WorkspaceProvisioningError("unknown_project", projectId);
|
||||
if (project.archivedAt) throw new WorkspaceProvisioningError("archived_project", projectId);
|
||||
return project;
|
||||
@@ -191,7 +195,11 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
const project = projectId
|
||||
? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout)
|
||||
? await refreshProjectKind(
|
||||
await requireActiveProject(projectId, [normalizedCwd]),
|
||||
normalizedCwd,
|
||||
checkout,
|
||||
)
|
||||
: // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15.
|
||||
await findOrCreateProjectForDirectory(normalizedCwd);
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -247,7 +255,9 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
repoRoot: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
if (input.projectId) {
|
||||
return refreshProjectKind(await requireActiveProject(input.projectId));
|
||||
return refreshProjectKind(
|
||||
await requireActiveProject(input.projectId, [input.sourceCwd, input.repoRoot]),
|
||||
);
|
||||
}
|
||||
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { resolveProjectReference } from "./project-reference.js";
|
||||
import type { ProjectRegistry } from "./workspace-registry.js";
|
||||
|
||||
export interface WorktreeWorkspaceSource {
|
||||
@@ -8,7 +9,7 @@ export interface WorktreeWorkspaceSource {
|
||||
|
||||
export async function resolveWorktreeSourceCwd(
|
||||
source: WorktreeWorkspaceSource,
|
||||
projectRegistry: Pick<ProjectRegistry, "get">,
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list">,
|
||||
): Promise<string> {
|
||||
if (source.cwd) {
|
||||
return expandTilde(source.cwd);
|
||||
@@ -17,7 +18,7 @@ export async function resolveWorktreeSourceCwd(
|
||||
throw new Error("cwd or projectId is required for a worktree-backed workspace");
|
||||
}
|
||||
|
||||
const project = await projectRegistry.get(source.projectId);
|
||||
const project = await resolveProjectReference(source.projectId, projectRegistry);
|
||||
if (!project || project.archivedAt) {
|
||||
throw new Error(`Project not found: ${source.projectId}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user