diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index f77fd199..4d522fbf 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -66,6 +66,7 @@ ssh://git@github.com/user/repo@v1 - HTTPS and SSH URLs are both supported. - SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`). +- For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast. - Raw `https://` URLs work without the `git:` prefix. - Refs pin the package and skip `pi update`. - Cloned to `~/.pi/agent/git//` (global) or `.pi/git//` (project). diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index b6e7b240..cb9e0cdb 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -19,32 +19,57 @@ export type GitSource = { }; 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(/^[^@]+@[^:]+:/); + const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/); 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 }; + const pathWithMaybeRef = scpLikeMatch[2] ?? ""; + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) return { repo: url }; + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) return { repo: url }; + return { + repo: `git@${scpLikeMatch[1] ?? ""}:${repoPath}`, + ref, + }; } + if (url.includes("://")) { + try { + const parsed = new URL(url); + const pathWithMaybeRef = parsed.pathname.replace(/^\/+/, ""); + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) return { repo: url }; + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) return { repo: url }; + parsed.pathname = `/${repoPath}`; + return { + repo: parsed.toString().replace(/\/$/, ""), + ref, + }; + } catch { + return { repo: url }; + } + } + + const slashIndex = url.indexOf("/"); + if (slashIndex < 0) { + return { repo: url }; + } + const host = url.slice(0, slashIndex); + const pathWithMaybeRef = url.slice(slashIndex + 1); + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) { + return { repo: url }; + } + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) { + return { repo: url }; + } return { - repo: url.slice(0, lastAt), - ref: url.slice(lastAt + 1), + repo: `${host}/${repoPath}`, + ref, }; } @@ -111,6 +136,9 @@ export function parseGitUrl(source: string): GitSource | null { for (const candidate of hostedCandidates) { const info = hostedGitInfo.fromUrl(candidate); if (info) { + if (split.ref && info.project?.includes("@")) { + continue; + } const useHttpsPrefix = !split.repo.startsWith("http://") && !split.repo.startsWith("https://") && @@ -120,7 +148,7 @@ export function parseGitUrl(source: string): GitSource | null { type: "git", repo: useHttpsPrefix ? `https://${split.repo}` : split.repo, host: info.domain || "", - path: `${info.user}/${info.project}`, + path: `${info.user}/${info.project}`.replace(/\.git$/, ""), ref: info.committish || split.ref || undefined, pinned: Boolean(info.committish || split.ref), }; @@ -133,11 +161,14 @@ export function parseGitUrl(source: string): GitSource | null { for (const candidate of httpsCandidates) { const info = hostedGitInfo.fromUrl(candidate); if (info) { + if (split.ref && info.project?.includes("@")) { + continue; + } return { type: "git", repo: `https://${split.repo}`, host: info.domain || "", - path: `${info.user}/${info.project}`, + path: `${info.user}/${info.project}`.replace(/\.git$/, ""), ref: info.committish || split.ref || undefined, pinned: Boolean(info.committish || split.ref), }; diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index c79521dc..7a68ba0b 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -83,8 +83,9 @@ describe("DefaultPackageManager git update", () => { /** * Sets up a "remote" repository and clones it to the installed directory. * This simulates what packageManager.install() would do. + * @param sourceOverride Optional source string to use instead of gitSource (e.g., with @ref for pinned tests) */ - function setupRemoteAndInstall(): void { + function setupRemoteAndInstall(sourceOverride?: string): void { // Create "remote" repository mkdirSync(remoteDir, { recursive: true }); git(["init"], remoteDir); @@ -99,7 +100,7 @@ describe("DefaultPackageManager git update", () => { git(["config", "--local", "user.name", "Test"], installedDir); // Add to global packages so update() processes this source - settingsManager.setPackages([gitSource]); + settingsManager.setPackages([sourceOverride ?? gitSource]); } describe("normal updates (no force-push)", () => { @@ -203,10 +204,21 @@ describe("DefaultPackageManager git update", () => { describe("pinned sources", () => { it("should not update pinned git sources (with @ref)", async () => { - setupRemoteAndInstall(); - const initialCommit = getCurrentCommit(installedDir); + // Create remote repo first to get the initial commit + mkdirSync(remoteDir, { recursive: true }); + git(["init"], remoteDir); + git(["config", "--local", "user.email", "test@test.com"], remoteDir); + git(["config", "--local", "user.name", "Test"], remoteDir); + const initialCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); - // Reconfigure with pinned ref + // Install with pinned ref from the start - full clone to ensure commit is available + mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); + git(["clone", remoteDir, installedDir], tempDir); + git(["checkout", initialCommit], installedDir); + git(["config", "--local", "user.email", "test@test.com"], installedDir); + git(["config", "--local", "user.name", "Test"], installedDir); + + // Add to global packages with pinned ref settingsManager.setPackages([`${gitSource}@${initialCommit}`]); // Add new commit to remote