fix(projects): separate identity from presentation

This commit is contained in:
Mohamed Boudra
2026-07-29 01:40:55 +00:00
parent afbd527c8d
commit 8140d8508e
6 changed files with 191 additions and 33 deletions
@@ -2,7 +2,7 @@ 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";
import { deriveProjectGroupKey, deriveProjectGroupingDisplayName } from "./project-group-key.js";
describe("deriveProjectGroupKey", () => {
test("preserves an explicit remote port", () => {
@@ -15,7 +15,7 @@ describe("deriveProjectGroupKey", () => {
worktreeRoot: rootPath,
mainRepoRoot: null,
}),
).toBe("remote:git.example.com:8443/acme/app");
).toBe("remote:git.example.com:8443/acme/app.git");
});
test("normalizes the default SSH port", () => {
@@ -43,7 +43,33 @@ describe("deriveProjectGroupKey", () => {
worktreeRoot: rootPath,
mainRepoRoot: null,
}),
).toBe("remote:git.example.com/repo");
).toBe("remote:git.example.com/repo.git");
});
test("preserves meaningful dot-git suffixes on unknown Git servers", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("git@example.com:repos/foo.git")).not.toBe(derive("git@example.com:repos/foo"));
});
test("normalizes dot-git suffixes on known cloud forges", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("git@github.com:acme/foo.git")).toBe(derive("https://github.com/acme/foo"));
});
test("accepts an SCP-style remote without a username", () => {
@@ -155,7 +181,7 @@ describe("deriveProjectGroupKey", () => {
worktreeRoot: rootPath,
mainRepoRoot: null,
}),
).toBe("remote:[2001:db8::1]/getpaseo/paseo");
).toBe("remote:[2001:db8::1]/getpaseo/paseo.git");
});
test("includes the selected path within a repository", () => {
@@ -253,3 +279,18 @@ describe("deriveProjectGroupKey", () => {
},
);
});
describe("deriveProjectGroupingDisplayName", () => {
test.each([
["https://example.com/acme/my%20repo.git", "acme/my repo"],
["git@example.com:acme/my repo.git", "acme/my repo"],
["git@example.com:acme/my#repo.git", "acme/my#repo"],
["git@example.com:acme/my%repo.git", "acme/my%repo"],
])("derives a decoded display name from %s", (remoteUrl, expected) => {
const rootPath = path.resolve("repo");
expect(deriveProjectGroupingDisplayName({ rootPath, remoteUrl, worktreeRoot: rootPath })).toBe(
expected,
);
});
});
@@ -1,6 +1,17 @@
import { resolve } from "node:path";
import { FORGE_DEFINITIONS } from "@getpaseo/protocol/forge-manifest";
import { getRealpathAwareRelativePath } from "../utils/path.js";
const CLOUD_FORGE_HOSTS = new Set(
FORGE_DEFINITIONS.flatMap((forge) => forge.cloudHosts ?? []).map((host) => host.toLowerCase()),
);
const DEFAULT_REMOTE_PORTS: Partial<Record<string, string>> = {
"git:": "9418",
"git+ssh:": "22",
"ssh:": "22",
"ssh+git:": "22",
};
/**
* Derives the persisted, opaque equivalence key used to group projects across hosts.
* A Git remote is today's strongest shared fact, but callers treat the result as opaque.
@@ -40,6 +51,7 @@ function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null {
remote.path,
remote.preserveLeadingSlash,
remote.decodePercentEncoding,
remote.stripDotGitSuffix,
);
if (!cleanedPath) return null;
const userPrefix = remote.relativePathUser
@@ -54,6 +66,7 @@ interface RemoteLocation {
relativePathUser: string | null;
preserveLeadingSlash: boolean;
decodePercentEncoding: boolean;
stripDotGitSuffix: boolean;
}
function parseRemoteLocation(remoteUrl: string): RemoteLocation | null {
@@ -75,6 +88,7 @@ function parseScpRemote(remoteUrl: string): RemoteLocation | null {
relativePathUser: user && user !== "git" && !preserveLeadingSlash ? user : null,
preserveLeadingSlash,
decodePercentEncoding: false,
stripDotGitSuffix: CLOUD_FORGE_HOSTS.has(host.toLowerCase()),
};
}
@@ -92,6 +106,9 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null {
relativePathUser: null,
preserveLeadingSlash,
decodePercentEncoding: true,
stripDotGitSuffix:
(!parsed.port || parsed.port === DEFAULT_REMOTE_PORTS[parsed.protocol]) &&
CLOUD_FORGE_HOSTS.has(parsed.hostname.toLowerCase()),
};
} catch {
return null;
@@ -102,6 +119,7 @@ function normalizeRemotePath(
remotePath: string,
preserveLeadingSlash: boolean,
decodePercentEncoding: boolean,
stripDotGitSuffix: boolean,
): string {
const trimmedPath = remotePath.trim().replace(/\/+$/, "");
const pathForEncoding = preserveLeadingSlash ? trimmedPath : trimmedPath.replace(/^\/+/, "");
@@ -116,17 +134,53 @@ function normalizeRemotePath(
});
const lastIndex = decodedSegments.length - 1;
const lastSegment = decodedSegments[lastIndex];
if (lastSegment?.endsWith(".git")) decodedSegments[lastIndex] = lastSegment.slice(0, -4);
if (stripDotGitSuffix && lastSegment?.endsWith(".git")) {
decodedSegments[lastIndex] = lastSegment.slice(0, -4);
}
return decodedSegments.map(encodeURIComponent).join("/");
}
export function deriveProjectGroupingDisplayName(input: {
rootPath: string;
remoteUrl: string | null;
worktreeRoot: string | null;
}): string {
if (deriveSelectedPath(input.rootPath, input.worktreeRoot)) {
return lastPathSegment(input.rootPath);
}
const trimmed = input.remoteUrl?.trim();
const remote = trimmed ? parseRemoteLocation(trimmed) : null;
if (!remote) {
return lastPathSegment(input.rootPath);
}
const segments = remote.path
.trim()
.replace(/^\/+|\/+$/gu, "")
.split("/")
.filter(Boolean)
.map((segment) => {
if (!remote.decodePercentEncoding) return segment;
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
});
const lastIndex = segments.length - 1;
const lastSegment = segments[lastIndex];
if (lastSegment?.endsWith(".git")) segments[lastIndex] = lastSegment.slice(0, -4);
return segments.slice(-2).join("/") || input.rootPath;
}
function lastPathSegment(inputPath: string): string {
const segments = inputPath.split(/[\\/]/u).filter(Boolean);
return segments[segments.length - 1] ?? inputPath;
}
function deriveRemoteHost(remoteUrl: URL): string | null {
const defaultPorts: Partial<Record<string, string>> = {
"git:": "9418",
"git+ssh:": "22",
"ssh:": "22",
"ssh+git:": "22",
};
if (remoteUrl.port === defaultPorts[remoteUrl.protocol]) return remoteUrl.hostname || null;
if (remoteUrl.port === DEFAULT_REMOTE_PORTS[remoteUrl.protocol])
return remoteUrl.hostname || null;
return remoteUrl.host || null;
}
@@ -1,10 +1,10 @@
import { basename, resolve } from "node:path";
import { resolve } from "node:path";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { getRealpathAwareRelativePath } from "../utils/path.js";
import { deriveProjectGroupKey } from "./project-group-key.js";
import { deriveProjectGroupKey, deriveProjectGroupingDisplayName } from "./project-group-key.js";
import {
deriveProjectKind,
deriveWorkspaceDisplayName,
@@ -47,7 +47,11 @@ export function classifyDirectoryForProjectMembership(input: {
workspaceKind: deriveWorkspaceKind(checkout),
workspaceDisplayName: deriveWorkspaceDisplayName({ cwd, checkout }),
projectKey,
projectName: deriveProjectGroupingName(projectKey, cwd),
projectName: deriveProjectGroupingDisplayName({
rootPath: cwd,
remoteUrl: checkout.remoteUrl,
worktreeRoot: checkout.worktreeRoot,
}),
projectRootPath: deriveProjectRootPath({ cwd, checkout }),
projectKind: deriveProjectKind(checkout),
};
@@ -59,19 +63,6 @@ function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLiteP
return worktreeRoot && resolve(worktreeRoot) === selectedRoot ? worktreeRoot : selectedRoot;
}
function deriveProjectGroupingName(projectKey: string, selectedRoot: string): string {
if (projectKey.includes("#subdir:")) return basename(selectedRoot);
if (projectKey.startsWith("remote:")) {
const pathSegments = projectKey.slice("remote:".length).split("/").filter(Boolean).slice(1);
if (pathSegments.length >= 2) return pathSegments.slice(-2).join("/");
if (pathSegments.length === 1) return pathSegments[0];
return projectKey;
}
const segments = projectKey.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || projectKey;
}
function deriveProjectRootPath(input: {
cwd: string;
checkout: ProjectCheckoutLitePayload;