fix(projects): model SCP remote semantics

This commit is contained in:
Mohamed Boudra
2026-07-29 00:59:35 +00:00
parent d853df73a4
commit a924d51e4c
2 changed files with 108 additions and 26 deletions

View File

@@ -70,6 +70,49 @@ describe("deriveProjectGroupKey", () => {
expect(derive("example.com:srv/repo.git")).not.toBe(derive("example.com:/srv/repo.git"));
});
test("distinguishes SSH users for home-relative SCP paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("alice@git.example.com:repo.git")).not.toBe(
derive("bob@git.example.com:repo.git"),
);
});
test("keeps percent sequences literal in SCP paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("git.example.com:acme/repo%41.git")).not.toBe(
derive("git.example.com:acme/repoA.git"),
);
});
test("does not parse drive-relative Windows paths as SCP remotes", () => {
const rootPath = path.resolve("repo");
expect(
deriveProjectGroupKey({
rootPath,
remoteUrl: "C:repo",
worktreeRoot: rootPath,
mainRepoRoot: null,
}),
).toBe(rootPath);
});
test.each(["git+ssh:", "ssh+git:"])("normalizes SSH alias default ports for %s", (scheme) => {
const rootPath = path.resolve("repo");

View File

@@ -34,39 +34,78 @@ function encodeSelectedPath(selectedPath: string): string {
function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null {
const trimmed = remoteUrl?.trim();
if (!trimmed) return 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(/^(?:[^@/:]+@)?(\[[^\]]+\]|[^/:]+):(.+)$/)
: 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);
host = deriveRemoteHost(parsed);
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
} catch {
return null;
}
}
if (!host || !remotePath) return null;
const cleanedPath = normalizeRemotePath(remotePath, preserveLeadingSlash);
const remote = parseRemoteLocation(trimmed);
if (!remote) return null;
const cleanedPath = normalizeRemotePath(
remote.path,
remote.preserveLeadingSlash,
remote.decodePercentEncoding,
);
if (!cleanedPath) return null;
return `remote:${host.toLowerCase()}/${cleanedPath}`;
const userPrefix = remote.relativePathUser
? `${encodeURIComponent(remote.relativePathUser)}@`
: "";
return `remote:${userPrefix}${remote.host.toLowerCase()}/${cleanedPath}`;
}
function normalizeRemotePath(remotePath: string, preserveLeadingSlash: boolean): string {
interface RemoteLocation {
host: string;
path: string;
relativePathUser: string | null;
preserveLeadingSlash: boolean;
decodePercentEncoding: boolean;
}
function parseRemoteLocation(remoteUrl: string): RemoteLocation | null {
return remoteUrl.includes("://") ? parseUrlRemote(remoteUrl) : parseScpRemote(remoteUrl);
}
function parseScpRemote(remoteUrl: string): RemoteLocation | null {
if (/^[A-Za-z]:/.test(remoteUrl)) return null;
const match = remoteUrl.match(/^(?:(?<user>[^@/:]+)@)?(?<host>\[[^\]]+\]|[^/:]+):(?<path>.+)$/);
const host = match?.groups?.host;
const remotePath = match?.groups?.path;
if (!host || !remotePath) return null;
const preserveLeadingSlash = remotePath.startsWith("/");
const user = match.groups?.user;
return {
host,
path: remotePath,
relativePathUser: user && user !== "git" && !preserveLeadingSlash ? user : null,
preserveLeadingSlash,
decodePercentEncoding: false,
};
}
function parseUrlRemote(remoteUrl: string): RemoteLocation | null {
try {
const parsed = new URL(remoteUrl);
const host = deriveRemoteHost(parsed);
const remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
if (!host || !remotePath) return null;
return {
host,
path: remotePath,
relativePathUser: null,
preserveLeadingSlash: false,
decodePercentEncoding: true,
};
} catch {
return null;
}
}
function normalizeRemotePath(
remotePath: string,
preserveLeadingSlash: boolean,
decodePercentEncoding: boolean,
): string {
const trimmedPath = remotePath.trim().replace(/\/+$/, "");
const pathForEncoding = preserveLeadingSlash ? trimmedPath : trimmedPath.replace(/^\/+/, "");
const segments = pathForEncoding.split("/");
const decodedSegments = segments.map((segment) => {
if (!decodePercentEncoding) return segment;
try {
return decodeURIComponent(segment);
} catch {