diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index 29ee97c3..17708f5c 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -18,54 +18,131 @@ export type GitSource = { pinned: boolean; }; +function splitRef(url: string): { repo: string; ref?: string } { + const lastAt = url.lastIndexOf("@"); + if (lastAt <= 0) { + return { repo: url }; + } + + const lastSlash = url.lastIndexOf("/"); + const hasScheme = url.includes("://"); + const scpLikeMatch = url.match(/^[^@]+@[^:]+:/); + if (scpLikeMatch) { + const separatorIndex = scpLikeMatch[0].length - 1; + if (lastAt <= separatorIndex || lastAt <= lastSlash) { + return { repo: url }; + } + } else if (hasScheme) { + const schemeIndex = url.indexOf("://"); + const pathStart = url.indexOf("/", schemeIndex + 3); + if (pathStart < 0 || lastAt <= pathStart || lastAt <= lastSlash) { + return { repo: url }; + } + } else if (lastAt <= lastSlash) { + return { repo: url }; + } + + return { + repo: url.slice(0, lastAt), + ref: url.slice(lastAt + 1), + }; +} + +function parseGenericGitUrl(url: string): GitSource | null { + const { repo: repoWithoutRef, ref } = splitRef(url); + let repo = repoWithoutRef; + let host = ""; + let path = ""; + + const scpLikeMatch = repoWithoutRef.match(/^git@([^:]+):(.+)$/); + if (scpLikeMatch) { + host = scpLikeMatch[1] ?? ""; + path = scpLikeMatch[2] ?? ""; + } else if ( + repoWithoutRef.startsWith("https://") || + repoWithoutRef.startsWith("http://") || + repoWithoutRef.startsWith("ssh://") + ) { + try { + const parsed = new URL(repoWithoutRef); + host = parsed.hostname; + path = parsed.pathname.replace(/^\/+/, ""); + } catch { + return null; + } + } else { + const slashIndex = repoWithoutRef.indexOf("/"); + if (slashIndex < 0) { + return null; + } + host = repoWithoutRef.slice(0, slashIndex); + path = repoWithoutRef.slice(slashIndex + 1); + if (!host.includes(".") && host !== "localhost") { + return null; + } + repo = `https://${repoWithoutRef}`; + } + + const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, ""); + if (!host || !normalizedPath || normalizedPath.split("/").length < 2) { + return null; + } + + return { + type: "git", + repo, + host, + path: normalizedPath, + ref, + pinned: Boolean(ref), + }; +} + /** * Parse any git URL (SSH or HTTPS) into a GitSource. */ export function parseGitUrl(source: string): GitSource | null { - let url = source.startsWith("git:") ? source.slice(4).trim() : source; + const url = source.startsWith("git:") ? source.slice(4).trim() : source; + const split = splitRef(url); - // Try hosted-git-info, converting @ref to #ref if needed - let info = hostedGitInfo.fromUrl(url); - const lastAt = url.lastIndexOf("@"); - if ((info?.project?.includes("@") || !info) && lastAt > 0) { - info = hostedGitInfo.fromUrl(`${url.slice(0, lastAt)}#${url.slice(lastAt + 1)}`) ?? info; - url = url.slice(0, lastAt); // strip ref from url for repo field + const hostedCandidates = [url, split.ref ? `${split.repo}#${split.ref}` : undefined].filter( + (value): value is string => Boolean(value), + ); + for (const candidate of hostedCandidates) { + const info = hostedGitInfo.fromUrl(candidate); + if (info) { + const useHttpsPrefix = + !split.repo.startsWith("http://") && + !split.repo.startsWith("https://") && + !split.repo.startsWith("ssh://") && + !split.repo.startsWith("git@"); + return { + type: "git", + repo: useHttpsPrefix ? `https://${split.repo}` : split.repo, + host: info.domain || "", + path: `${info.user}/${info.project}`, + ref: info.committish || split.ref || undefined, + pinned: Boolean(info.committish || split.ref), + }; + } } - // Try with https:// prefix for shorthand URLs - if (!info) { - info = hostedGitInfo.fromUrl(`https://${url}`); - if (info) url = `https://${url}`; // make repo a valid clone URL + const httpsCandidates = [`https://${url}`, split.ref ? `https://${split.repo}#${split.ref}` : undefined].filter( + (value): value is string => Boolean(value), + ); + for (const candidate of httpsCandidates) { + const info = hostedGitInfo.fromUrl(candidate); + if (info) { + return { + type: "git", + repo: `https://${split.repo}`, + host: info.domain || "", + path: `${info.user}/${info.project}`, + ref: info.committish || split.ref || undefined, + pinned: Boolean(info.committish || split.ref), + }; + } } - if (info) { - return { - type: "git", - repo: url, - host: info.domain || "", - path: `${info.user}/${info.project}`, - ref: info.committish || undefined, - pinned: Boolean(info.committish), - }; - } - - // Fallback for codeberg (not in hosted-git-info) - const normalized = url.replace(/^https?:\/\//, "").replace(/@[^/]*$/, ""); - const codebergHost = "codeberg.org"; - if (normalized.startsWith(`${codebergHost}/`)) { - const ref = url.match(/@([^/]+)$/)?.[1]; - const repoUrl = ref ? url.slice(0, url.lastIndexOf("@")) : url; - // Ensure repo is a valid clone URL - const cloneableRepo = repoUrl.startsWith("http") ? repoUrl : `https://${repoUrl}`; - return { - type: "git", - repo: cloneableRepo, - host: codebergHost, - path: normalized.slice(codebergHost.length + 1).replace(/\.git$/, ""), - ref, - pinned: Boolean(ref), - }; - } - - return null; + return parseGenericGitUrl(url); } diff --git a/packages/coding-agent/test/package-manager-ssh.test.ts b/packages/coding-agent/test/package-manager-ssh.test.ts index 2f09567c..5c681c10 100644 --- a/packages/coding-agent/test/package-manager-ssh.test.ts +++ b/packages/coding-agent/test/package-manager-ssh.test.ts @@ -146,5 +146,30 @@ describe("Package Manager SSH URL Support", () => { expect(parsed.path).toBe("team/repo"); expect(parsed.repo).toBe("git@bitbucket.org:team/repo"); }); + + it("should parse unknown enterprise host shorthand", () => { + const parsed = (packageManager as any).parseSource("git:github.tools.sap/agent-dev/sap-pie"); + expect(parsed.type).toBe("git"); + expect(parsed.host).toBe("github.tools.sap"); + expect(parsed.path).toBe("agent-dev/sap-pie"); + expect(parsed.repo).toBe("https://github.tools.sap/agent-dev/sap-pie"); + }); + + it("should parse unknown enterprise host with ref", () => { + const parsed = (packageManager as any).parseSource("git:github.tools.sap/agent-dev/sap-pie@v1"); + expect(parsed.type).toBe("git"); + expect(parsed.host).toBe("github.tools.sap"); + expect(parsed.path).toBe("agent-dev/sap-pie"); + expect(parsed.ref).toBe("v1"); + expect(parsed.repo).toBe("https://github.tools.sap/agent-dev/sap-pie"); + expect(parsed.pinned).toBe(true); + }); + + it("should normalize unknown enterprise host identities", () => { + const withPrefix = (packageManager as any).getPackageIdentity("git:github.tools.sap/agent-dev/sap-pie"); + const withHttps = (packageManager as any).getPackageIdentity("https://github.tools.sap/agent-dev/sap-pie"); + expect(withPrefix).toBe("git:github.tools.sap/agent-dev/sap-pie"); + expect(withPrefix).toBe(withHttps); + }); }); });