fix(projects): preserve remote path semantics

This commit is contained in:
Mohamed Boudra
2026-07-29 00:43:52 +00:00
parent a3188a7699
commit d853df73a4
4 changed files with 55 additions and 6 deletions

View File

@@ -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");

View File

@@ -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<Record<string, string>> = {
"git:": "9418",
"git+ssh:": "22",
"ssh:": "22",
"ssh+git:": "22",
};
if (remoteUrl.port === defaultPorts[remoteUrl.protocol]) return remoteUrl.hostname || null;
return remoteUrl.host || null;