diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts index 7e9c0514d..44892f2f4 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.test.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -107,8 +107,8 @@ describe("buildWorktreeSetupCalloutPolicy", () => { repoRoot: "/repo/project-1", }), ).toEqual({ - id: "worktree-setup-missing:project-1", - dismissalKey: "worktree-setup-missing:project-1", + id: "worktree-setup-missing:host:server-1:project:project-1", + dismissalKey: "worktree-setup-missing:host:server-1:project:project-1", priority: 100, title: "Set up worktree scripts", description: @@ -130,6 +130,23 @@ describe("buildWorktreeSetupCalloutPolicy", () => { ).toBe("/settings/projects/host%3Aserver-1%3Aproject%3Aprj_local"); }); + it("keeps dismissals scoped to the host placement", () => { + const hostA = buildWorktreeSetupCalloutPolicy({ + serverId: "host-a", + projectId: "project-a", + projectKey: "remote:github.com/acme/project", + repoRoot: "/host-a/project", + }); + const hostB = buildWorktreeSetupCalloutPolicy({ + serverId: "host-b", + projectId: "project-b", + projectKey: "remote:github.com/acme/project", + repoRoot: "/host-b/project", + }); + + expect(hostA.dismissalKey).not.toBe(hostB.dismissalKey); + }); + it("scopes retained legacy project IDs to the active host", () => { expect( buildWorktreeSetupCalloutPolicy({ diff --git a/packages/app/src/components/worktree-setup-callout-policy.ts b/packages/app/src/components/worktree-setup-callout-policy.ts index 14864b250..718f9ae51 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.ts @@ -68,11 +68,11 @@ export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResu export function buildWorktreeSetupCalloutPolicy( project: ActiveGitWorkspaceProject, ): WorktreeSetupCalloutPolicy { - const calloutKey = `worktree-setup-missing:${project.projectKey}`; const projectSettingsKey = resolveHostProjectSettingsRouteKey({ serverId: project.serverId, projectId: project.projectId, }); + const calloutKey = `worktree-setup-missing:${projectSettingsKey ?? project.projectKey}`; return { id: calloutKey, diff --git a/packages/server/src/server/project-group-key.test.ts b/packages/server/src/server/project-group-key.test.ts index f352f74ec..ffa8e2a2b 100644 --- a/packages/server/src/server/project-group-key.test.ts +++ b/packages/server/src/server/project-group-key.test.ts @@ -57,6 +57,32 @@ describe("deriveProjectGroupKey", () => { ).toBe("remote:github.com/getpaseo/paseo"); }); + test("distinguishes absolute and home-relative SCP paths", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectGroupKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("example.com:srv/repo.git")).not.toBe(derive("example.com:/srv/repo.git")); + }); + + test.each(["git+ssh:", "ssh+git:"])("normalizes SSH alias default ports for %s", (scheme) => { + const rootPath = path.resolve("repo"); + + expect( + deriveProjectGroupKey({ + rootPath, + remoteUrl: `${scheme}//git@github.com:22/getpaseo/paseo.git`, + worktreeRoot: rootPath, + mainRepoRoot: null, + }), + ).toBe("remote:github.com/getpaseo/paseo"); + }); + test("accepts an SCP-style remote with a bracketed IPv6 host", () => { const rootPath = path.resolve("repo"); diff --git a/packages/server/src/server/project-group-key.ts b/packages/server/src/server/project-group-key.ts index 0acb2566b..9143dd7f4 100644 --- a/packages/server/src/server/project-group-key.ts +++ b/packages/server/src/server/project-group-key.ts @@ -37,6 +37,7 @@ function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null { let host: string | null = null; let remotePath: string | null = null; + let preserveLeadingSlash = false; const scpLike = !trimmed.includes("://") && !/^[A-Za-z]:[\\/]/.test(trimmed) ? trimmed.match(/^(?:[^@/:]+@)?(\[[^\]]+\]|[^/:]+):(.+)$/) @@ -44,6 +45,7 @@ function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null { if (scpLike) { host = scpLike[1] ?? null; remotePath = scpLike[2] ?? null; + preserveLeadingSlash = remotePath?.startsWith("/") ?? false; } else if (trimmed.includes("://")) { try { const parsed = new URL(trimmed); @@ -55,13 +57,15 @@ function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null { } if (!host || !remotePath) return null; - const cleanedPath = normalizeRemotePath(remotePath); + const cleanedPath = normalizeRemotePath(remotePath, preserveLeadingSlash); if (!cleanedPath) return null; return `remote:${host.toLowerCase()}/${cleanedPath}`; } -function normalizeRemotePath(remotePath: string): string { - const segments = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "").split("/"); +function normalizeRemotePath(remotePath: string, preserveLeadingSlash: boolean): string { + const trimmedPath = remotePath.trim().replace(/\/+$/, ""); + const pathForEncoding = preserveLeadingSlash ? trimmedPath : trimmedPath.replace(/^\/+/, ""); + const segments = pathForEncoding.split("/"); const decodedSegments = segments.map((segment) => { try { return decodeURIComponent(segment); @@ -78,7 +82,9 @@ function normalizeRemotePath(remotePath: string): string { function deriveRemoteHost(remoteUrl: URL): string | null { const defaultPorts: Partial> = { "git:": "9418", + "git+ssh:": "22", "ssh:": "22", + "ssh+git:": "22", }; if (remoteUrl.port === defaultPorts[remoteUrl.protocol]) return remoteUrl.hostname || null; return remoteUrl.host || null;