diff --git a/packages/host-service/src/trpc/router/project/utils/git-remote.ts b/packages/host-service/src/trpc/router/project/utils/git-remote.ts index cc27dafdcf1..e2ce519f84a 100644 --- a/packages/host-service/src/trpc/router/project/utils/git-remote.ts +++ b/packages/host-service/src/trpc/router/project/utils/git-remote.ts @@ -7,20 +7,30 @@ import type { SimpleGit } from "simple-git"; export type { ParsedGitHubRemote }; /** - * Get all fetch remote URLs from a git repository. - * Returns a map of remote name → fetch URL. + * Map of remote name → URL, read from git config. + * + * Avoids `git remote -v`: that output appends partial-clone markers like + * `[blob:none]` after `(fetch)` when `remote..promisor` is set, and is + * otherwise human-readable rather than machine-stable. */ export async function getAllRemoteUrls( git: SimpleGit, ): Promise> { const remotes = new Map(); - const output = await git.remote(["-v"]); - if (!output) return remotes; + const output = await git + .raw(["config", "--get-regexp", "^remote\\..*\\.url$"]) + .catch(() => ""); - for (const line of output.trim().split(/\r?\n/)) { - const match = line.trim().match(/^(\S+)\s+(\S+)\s+\(fetch\)$/); - if (match?.[1] && match[2]) { - remotes.set(match[1], match[2]); + for (const line of output.split(/\r?\n/)) { + const spaceIdx = line.indexOf(" "); + if (spaceIdx <= 0) continue; + const key = line.slice(0, spaceIdx); + const url = line.slice(spaceIdx + 1); + // Greedy `.+` so a remote literally named `foo.url` resolves to + // `foo.url`, not `foo`. + const remoteName = key.match(/^remote\.(.+)\.url$/)?.[1]; + if (remoteName && url) { + remotes.set(remoteName, url); } } diff --git a/packages/host-service/src/trpc/router/project/utils/resolve-repo.test.ts b/packages/host-service/src/trpc/router/project/utils/resolve-repo.test.ts index 5bfdf207ad4..e18feec44a7 100644 --- a/packages/host-service/src/trpc/router/project/utils/resolve-repo.test.ts +++ b/packages/host-service/src/trpc/router/project/utils/resolve-repo.test.ts @@ -107,6 +107,26 @@ describe("resolveLocalRepo", () => { expect(resolved.parsed?.name).toBe("App"); }); + test("returns origin when it is configured as a partial clone (`[blob:none]` suffix)", async () => { + // Partial clones (e.g. `git clone --filter=blob:none`) make + // `git remote -v` append a `[blob:none]` marker after the URL. + // Earlier the parser anchored on `(fetch)$`, so the origin line was + // silently skipped and we fell back to the alphabetically-first + // remote. Regression: `aaa` must NOT win over a partial-clone origin. + const repo = join(workRoot, "partial-clone-origin"); + const git = await initRepoAt(repo); + await git.addRemote("aaa", "https://github.com/Other/Repo.git"); + await git.addRemote("origin", "git@github.com:Acme/App.git"); + await git.raw(["config", "remote.origin.promisor", "true"]); + await git.raw(["config", "remote.origin.partialclonefilter", "blob:none"]); + + const resolved = await resolveLocalRepo(repo); + + expect(resolved.remoteName).toBe("origin"); + expect(resolved.parsed?.owner).toBe("Acme"); + expect(resolved.parsed?.name).toBe("App"); + }); + test("falls back to first GitHub remote when origin is missing", async () => { const repo = join(workRoot, "no-origin"); const git = await initRepoAt(repo);