diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 5f4e60f01302..3b28a7ff40e0 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -175,6 +175,41 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layerResolver))); }); + it.effect("resolves a partial clone whose fetch line carries its filter", () => { + const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.succeed({ + stdout: input.args.includes("rev-parse") + ? "/repo\n" + : "origin\thttps://github.com/pingdotgg/t3code (fetch) [blob:none]\norigin\thttps://github.com/pingdotgg/t3code (push)\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + const layerResolver = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(layerProcessRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve("/repo"); + + expect(identity?.canonicalKey).toBe("github.com/pingdotgg/t3code"); + expect(identity?.locator).toEqual({ + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/pingdotgg/t3code", + }); + expect(identity?.provider).toBe("github"); + }).pipe(Effect.provide(layerResolver)); + }); + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 80d5b39823ab..c523357f179e 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -3,6 +3,7 @@ import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server import { detectSourceControlProviderFromGitRemoteUrl, normalizeGitRemoteUrl, + parseGitRemoteVerbose, } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -54,24 +55,11 @@ function isLocalRemoteUrl(url: string): boolean { } function parseRemoteFetchUrls(stdout: string): Map { - const remotes = new Map(); - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); - if (!match) continue; - const [, remoteName = "", remoteUrl = "", direction = ""] = match; - if ( - direction !== "fetch" || - remoteName.length === 0 || - remoteUrl.length === 0 || - isLocalRemoteUrl(remoteUrl) - ) { - continue; - } - remotes.set(remoteName, remoteUrl); - } - return remotes; + return new Map( + parseGitRemoteVerbose(stdout) + .filter((remote) => remote.direction === "fetch" && !isLocalRemoteUrl(remote.url)) + .map((remote) => [remote.name, remote.url]), + ); } function pickPrimaryRemote( diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 3c2b62259bff..21cd45d1f8a1 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -9,6 +9,7 @@ import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/process"; @@ -1149,6 +1150,37 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { ); }); +it.effect("GitVcsDriver lists a partial-clone remote whose fetch line carries its filter", () => { + const url = "https://github.com/pingdotgg/t3code"; + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + const { remotes } = yield* driver.listRemotes("/repo"); + + assert.deepStrictEqual( + remotes.map((remote) => ({ ...remote, pushUrl: Option.getOrNull(remote.pushUrl) })), + [{ name: "origin", url, pushUrl: url, isPrimary: true }], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: `origin\t${url} (fetch) [blob:none]\norigin\t${url} (push)\n`, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); +}); + it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publishing them", () => { const observedArgs: ReadonlyArray[] = []; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 88642ed9d740..b9cba2d15c24 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -32,6 +32,7 @@ import { type VcsStatusResult, type WorktreeSubmodules, } from "@t3tools/contracts"; +import { parseGitRemoteVerbose } from "@t3tools/shared/git"; import { makeGitVcsDriverCore, PATCH_RENDER_PREFIX_ARGS, @@ -459,23 +460,7 @@ function parseGitRemoteVerboseOutput( output: string, ): Map { const remotes = new Map(); - for (const line of output.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) { - continue; - } - - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); - if (!match) { - continue; - } - - const name = match[1]; - const url = match[2]; - const direction = match[3]; - if (!name || !url || !direction) { - continue; - } + for (const { name, url, direction } of parseGitRemoteVerbose(output)) { const remote = remotes.get(name) ?? {}; if (direction === "fetch") { remote.url = url; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7e65262e79cd..0924749e469c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3981,6 +3981,37 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin"); }), ); + + it.effect("ensureRemote reuses a partial-clone remote with an annotated fetch line", () => + Effect.gen(function* () { + const url = "https://github.com/pingdotgg/t3code.git"; + const commands: Array> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + commands.push(args); + return makeSuccessfulHandle( + args[0] === "remote" && args[1] === "-v" + ? `origin\t${url} (fetch) [blob:none]\norigin\t${url} (push)\n` + : "", + ); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + + const remoteName = yield* driver.ensureRemote({ + cwd: "/repo", + preferredName: "pingdotgg", + url: "git@github.com:pingdotgg/t3code.git", + }); + + assert.equal(remoteName, "origin"); + assert.isFalse(commands.some((args) => args[0] === "remote" && args[1] === "add")); + }), + ); }); describe("commit context", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3225a7fe4559..98a637d2842c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -28,7 +28,11 @@ import { type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { + dedupeRemoteBranchesWithLocalMatches, + normalizeGitRemoteUrl, + parseGitRemoteFetchUrls, +} from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -358,22 +362,6 @@ function sanitizeRemoteName(value: string): string { return sanitized.length > 0 ? sanitized : "fork"; } -function parseRemoteFetchUrls(stdout: string): Map { - const remotes = new Map(); - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); - if (!match) continue; - const [, remoteName = "", remoteUrl = "", direction = ""] = match; - if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { - continue; - } - remotes.set(remoteName, remoteUrl); - } - return remotes; -} - function parseUpstreamRefWithRemoteNames( upstreamRef: string, remoteNames: ReadonlyArray, @@ -1642,7 +1630,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.ensureRemote.listRemoteUrls", input.cwd, ["remote", "-v"], - ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); + ).pipe(Effect.map((stdout) => parseGitRemoteFetchUrls(stdout))); for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 7ff050fbb847..a5a7f8481910 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -10,6 +10,8 @@ import { isTemporaryWorktreeBranch, normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + parseGitRemoteFetchUrls, + parseGitRemoteVerbose, parseOriginUrlFromGitConfig, resolveAutoFeatureBranchName, sanitizeFeatureBranchName, @@ -87,6 +89,76 @@ describe("normalizeGitRemoteUrl", () => { }); }); +describe("parseGitRemoteVerbose", () => { + const url = "https://github.com/pingdotgg/t3code"; + + it("reads fetch and push lines", () => { + expect( + parseGitRemoteVerbose( + `origin\t${url} (fetch)\norigin\t${url} (push)\nfork\tgit@github.com:me/t3code.git (fetch)\n`, + ), + ).toEqual([ + { name: "origin", url, direction: "fetch" }, + { name: "origin", url, direction: "push" }, + { name: "fork", url: "git@github.com:me/t3code.git", direction: "fetch" }, + ]); + }); + + it("reads partial-clone lines that carry their filter after the direction", () => { + expect( + parseGitRemoteVerbose(`origin\t${url} (fetch) [blob:none]\norigin\t${url} (push)`), + ).toEqual([ + { name: "origin", url, direction: "fetch" }, + { name: "origin", url, direction: "push" }, + ]); + expect(parseGitRemoteVerbose(`origin\t${url} (fetch) [tree:0]`)).toEqual([ + { name: "origin", url, direction: "fetch" }, + ]); + expect(parseGitRemoteVerbose(`origin\t${url} (fetch) [blob:none] [tree:0]`)).toEqual([ + { name: "origin", url, direction: "fetch" }, + ]); + }); + + it("tolerates CRLF line endings and blank lines", () => { + expect( + parseGitRemoteVerbose( + `\r\norigin\t${url} (fetch) [blob:none]\r\n\r\norigin\t${url} (push)\r\n`, + ), + ).toEqual([ + { name: "origin", url, direction: "fetch" }, + { name: "origin", url, direction: "push" }, + ]); + }); + + it("skips lines that are not remote entries", () => { + expect( + parseGitRemoteVerbose( + [ + "origin", + `origin\t${url}`, + `origin\t${url} (pull)`, + `origin\t${url} (fetch) trailing`, + `origin\t${url} (fetch) [blob:none`, + `origin\t${url} (push)`, + ].join("\n"), + ), + ).toEqual([{ name: "origin", url, direction: "push" }]); + }); + + it("maps each remote to its fetch URL", () => { + expect( + parseGitRemoteFetchUrls( + `origin\t${url} (fetch) [blob:none]\norigin\thttps://push.example/t3code (push)\nfork\tgit@github.com:me/t3code.git (fetch)\n`, + ), + ).toEqual( + new Map([ + ["origin", url], + ["fork", "git@github.com:me/t3code.git"], + ]), + ); + }); +}); + describe("parseOriginUrlFromGitConfig", () => { it("reads the origin url and ignores other remotes", () => { const config = [ diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 14af818f4c21..3db85ab3c009 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -175,6 +175,33 @@ export function normalizeGitRemoteUrl(value: string): string { return normalized; } +/** + * Reads `git remote -v` output, one entry per remote and direction. Partial clones print the + * remote's filter after the direction, as in `origin (fetch) [blob:none]`, so trailing + * bracketed annotations are accepted and dropped. Lines that are not remote entries are skipped. + */ +export function parseGitRemoteVerbose(stdout: string): Array<{ + readonly name: string; + readonly url: string; + readonly direction: "fetch" | "push"; +}> { + return stdout.split("\n").flatMap((line) => { + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]*\])*$/.exec(line.trim()); + if (!match) return []; + const direction: "fetch" | "push" = match[3] === "fetch" ? "fetch" : "push"; + return [{ name: match[1]!, url: match[2]!, direction }]; + }); +} + +/** Fetch URL per remote name from `git remote -v` output; a later line for the same name wins. */ +export function parseGitRemoteFetchUrls(stdout: string): Map { + return new Map( + parseGitRemoteVerbose(stdout) + .filter((remote) => remote.direction === "fetch") + .map((remote) => [remote.name, remote.url]), + ); +} + function parseGitConfigValue(raw: string): string { let out = ""; let quoted = false; diff --git a/packages/source-control-forgejo/src/server/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts index 81e96f61fa9f..e951b63e67b5 100644 --- a/packages/source-control-forgejo/src/server/ForgejoCli.ts +++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts @@ -10,6 +10,7 @@ import * as Semaphore from "effect/Semaphore"; import * as NodePath from "node:path"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import { parseGitRemoteVerbose } from "@t3tools/shared/git"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; @@ -514,12 +515,13 @@ export const make = Effect.gen(function* () { if (input.host) { const matchingUrls = [ ...new Set( - result.stdout.split("\n").flatMap((line) => { - const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1]; - return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase() + parseGitRemoteVerbose(result.stdout).flatMap(({ url, direction }) => + direction === "fetch" && + /^https?:\/\//.test(url) && + parseForgejoRemote(url)?.host === input.host?.toLowerCase() ? [url] - : []; - }), + : [], + ), ), ]; const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))]; diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index 354edbb981e4..a7bee0686b5f 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -1065,6 +1065,50 @@ it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { ); }); +it.effect("matches a partial-clone Forgejo remote whose fetch line carries its filter", () => + Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const repository = yield* cli.resolveRepository({ + cwd: "/repo", + host: "forgejo.local:3000", + repository: "maria/project", + }); + // Only a matched HTTP remote opts the server into HTTP; without one it would be HTTPS. + assert.strictEqual(repository.baseUrl, "http://forgejo.local:3000"); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ + hosts: { "forgejo.local:3000": { type: "Application", token: "test-token" } }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected Forgejo HTTP request")), + ), + Effect.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => + Effect.succeed( + processOutput( + input.command === "git" + ? "origin\thttp://forgejo.local:3000/maria/project.git (fetch) [blob:none]\norigin\thttp://forgejo.local:3000/maria/project.git (push)\n" + : "", + ), + ), + }, + }), + ), + ), +); + it.effect("loads later fj review pages when the server caps pages below the requested size", () => { const pages: number[] = []; let issueCommentRequests = 0; diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index 7c6f754192bb..ea6794cf6e7b 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { gitHubApiHostForRemote, + resolveGitHubRepository, selectGitHubBaseRepository, } from "./gitHubRepositoryResolution.ts"; @@ -74,6 +75,21 @@ describe("selectGitHubBaseRepository", () => { }); }); +describe("resolveGitHubRepository", () => { + it("reads a partial-clone remote whose fetch line carries its filter", () => { + const url = "https://github.com/pingdotgg/t3code"; + assert.deepStrictEqual( + resolveGitHubRepository({ + remotes: `origin\t${url} (fetch) [blob:none]\norigin\t${url} (push)\n`, + resolved: "", + hostHint: undefined, + defaultHost: "github.com", + }), + { host: "github.com", locator: { host: "github.com", owner: "pingdotgg", name: "t3code" } }, + ); + }); +}); + describe("gitHubApiHostForRemote", () => { it("reads an SSH alias through github.com and a real host as itself", () => { assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index bd36758605e1..6a1be6364b6c 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -1,4 +1,4 @@ -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { normalizeGitRemoteUrl, parseGitRemoteVerbose } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl, isSshRemoteUrl, @@ -13,11 +13,9 @@ export interface GitHubRepositoryLocator { export function parseFetchRemotes( remotes: string, ): ReadonlyArray<{ readonly name: string; readonly url: string }> { - return remotes - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null) - .map((match) => ({ name: match[1]!, url: match[2]! })); + return parseGitRemoteVerbose(remotes) + .filter((remote) => remote.direction === "fetch") + .map(({ name, url }) => ({ name, url })); } /**