From a0cb02420d228bfed0595097913262bad512a5eb Mon Sep 17 00:00:00 2001 From: Ronak Agarwal Date: Fri, 2 Oct 2026 16:55:29 -0500 Subject: [PATCH] fix(server): accept partial-clone filter suffix in git remote -v parsing A partial clone (`git clone --filter=blob:none`) records remote..partialclonefilter, and `git remote -v` then prints the filter after the fetch direction: origin https://github.com/acme/repo.git (fetch) [blob:none] These parsers required the line to end at "(fetch)", so the fetch URL was dropped. Repository identity came back null, and "Group by repository" showed a partial-clone checkout on a remote server and a normal clone of the same repo on a laptop as two separate projects. The same drop made listRemotes hide the remote, ensureRemote add a duplicate remote instead of reusing origin, and the gh base-repository pick skip origin for a lower-ranked remote. The four parsers now accept an optional trailing " []" after the direction. The bracket body is matched as `.*` because git echoes the configured value verbatim, spaces and brackets included. The URL is still one non-space token and the direction is still fetch or push; lines with an unbracketed or unterminated suffix are still rejected. - project/RepositoryIdentityResolver.ts (parseRemoteFetchUrls) - vcs/GitVcsDriverCore.ts (parseRemoteFetchUrls, used by ensureRemote) - vcs/GitVcsDriver.ts (parseGitRemoteVerboseOutput, used by listRemotes) - sourceControl/GitHubCli.ts (selectGitHubBaseRepository) The Forgejo host lookup in ForgejoCli.ts has the same pattern but is left unchanged on purpose: it decides which scheme a stored fj token is sent over, so a partial clone keeps today's https fallback there. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../RepositoryIdentityResolver.test.ts | 58 +++++++++++++++++++ .../src/project/RepositoryIdentityResolver.ts | 3 +- .../src/sourceControl/GitHubCli.test.ts | 22 +++++++ apps/server/src/sourceControl/GitHubCli.ts | 3 +- apps/server/src/vcs/GitVcsDriver.test.ts | 47 +++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 3 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 40 +++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 3 +- 8 files changed, 175 insertions(+), 4 deletions(-) diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 58f199b834e2..8b873faa80d0 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -174,6 +174,64 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(Layer.merge(TestClock.layer(), resolverLayer))); }); + // A partial clone appends its filter to the fetch line, e.g. "(fetch) [blob:none]". + it.effect.each([ + { suffix: "", canonicalKey: "github.com/acme/repo" }, + { suffix: " [blob:none]", canonicalKey: "github.com/acme/repo" }, + { suffix: " [blob:limit=1m]", canonicalKey: "github.com/acme/repo" }, + { suffix: " [tree:0]", canonicalKey: "github.com/acme/repo" }, + { suffix: " blob:none", canonicalKey: null }, + { suffix: " [blob:none", canonicalKey: null }, + ])( + "reads the remote from a fetch line ending in '(fetch)$suffix'", + ({ suffix, canonicalKey }) => { + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.succeed({ + stdout: input.args.includes("rev-parse") + ? "/repo\n" + : `origin\thttps://github.com/acme/repo.git (fetch)${suffix}\norigin\thttps://github.com/acme/repo.git (push)\n`, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve("/repo"); + expect(identity?.canonicalKey ?? null).toBe(canonicalKey); + }).pipe(Effect.provide(resolverLayer)); + }, + ); + + it.effect("resolves a partial clone the same as a full clone of the repository", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-partial-clone-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "https://github.com/T3Tools/t3code.git"]); + // What `git clone --filter=blob:none` records for the remote. + yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + 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 5acafa47e2e2..b228e024f560 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -44,7 +44,8 @@ function parseRemoteFetchUrls(stdout: string): 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); + // A partial clone's fetch line ends with its filter, e.g. "(fetch) [blob:none]". + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[.*\])?$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index b604ec568aeb..e598db4d003f 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -221,6 +221,28 @@ describe("selectGitHubBaseRepository", () => { assert.strictEqual(select(input), null); } }); + + it("reads an origin whose fetch line carries a partial-clone filter", () => { + const withOrigin = (fetchSuffix: string) => + [ + `origin\tgit@github.com:acme/web.git (fetch)${fetchSuffix}`, + "origin\tgit@github.com:acme/web.git (push)", + remotes(["fork", "git@github.com:me/web.git"]), + ].join("\n"); + // gh ranks origin above fork, so origin wins whenever its fetch line is read. + for (const suffix of ["", " [blob:none]", " [blob:limit=1m]", " [tree:0]"]) { + assert.deepStrictEqual(select({ remotes: withOrigin(suffix) }), { + owner: "acme", + name: "web", + }); + } + for (const malformed of [" blob:none", " [blob:none"]) { + assert.deepStrictEqual(select({ remotes: withOrigin(malformed) }), { + owner: "me", + name: "web", + }); + } + }); }); describe("GitHubCli.listPullRequestsByHead", () => { diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 82e2da846881..9e6387690c97 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -528,7 +528,8 @@ export function selectGitHubBaseRepository(input: { const host = input.host.toLowerCase(); const repositories = new Map(); for (const line of input.remotes.split("\n")) { - const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); + // A partial clone's fetch line ends with its filter, e.g. "(fetch) [blob:none]". + const match = /^(\S+)\s+(\S+)\s+\(fetch\)(?:\s+\[.*\])?$/u.exec(line.trim()); if (!match) continue; const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); if (remoteHost !== host || !owner || !name || rest.length > 0) return null; diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 6b8920501281..cb92c3a9643b 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/unstable/process"; @@ -1190,3 +1191,49 @@ it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publi ), ); }); + +// A partial clone appends its filter to the fetch line, e.g. "(fetch) [blob:none]". +it.effect.each([ + { suffix: "", listed: true }, + { suffix: " [blob:none]", listed: true }, + { suffix: " [blob:limit=1m]", listed: true }, + { suffix: " [tree:0]", listed: true }, + // Git prints the configured filter verbatim, spaces and brackets included. + { suffix: " [sparse:oid=main:dir/a b]c]", listed: true }, + { suffix: " blob:none", listed: false }, + { suffix: " [blob:none", listed: false }, +])("GitVcsDriver lists a remote whose fetch line ends in '(fetch)$suffix'", ({ suffix, listed }) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const { remotes } = yield* driver.listRemotes("/repo"); + + assert.deepStrictEqual( + remotes.map(({ name, url, pushUrl }) => ({ name, url, pushUrl: Option.getOrNull(pushUrl) })), + listed + ? [ + { + name: "origin", + url: "https://github.com/acme/repo.git", + pushUrl: "git@github.com:acme/repo.git", + }, + ] + : [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: `origin\thttps://github.com/acme/repo.git (fetch)${suffix}\norigin\tgit@github.com:acme/repo.git (push)\n`, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ), +); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index d7e511afa8c9..7a8e7e0efd4c 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -469,7 +469,8 @@ function parseGitRemoteVerboseOutput( continue; } - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + // A partial clone's fetch line ends with its filter, e.g. "(fetch) [blob:none]". + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[.*\])?$/.exec(trimmed); if (!match) { continue; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 32dc3441dd6a..e3d49b379f2d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2888,6 +2888,46 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin"); }), ); + + // A partial clone appends its filter to the fetch line, e.g. "(fetch) [blob:none]". + it.effect.each([ + { suffix: "", remoteName: "origin" }, + { suffix: " [blob:none]", remoteName: "origin" }, + { suffix: " [blob:limit=1m]", remoteName: "origin" }, + { suffix: " [tree:0]", remoteName: "origin" }, + { suffix: " blob:none", remoteName: "pingdotgg" }, + { suffix: " [blob:none", remoteName: "pingdotgg" }, + ])( + "ensureRemote reads an origin fetch line ending in '(fetch)$suffix'", + ({ suffix, remoteName }) => + Effect.gen(function* () { + const added: Array> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + if (args[0] === "remote" && args[1] === "add") added.push(args); + return makeSuccessfulHandle( + args[0] === "remote" && args[1] === "-v" + ? `origin\thttps://github.com/pingdotgg/t3code.git (fetch)${suffix}\norigin\thttps://github.com/pingdotgg/t3code.git (push)\n` + : "", + ); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + + const resolved = yield* driver.ensureRemote({ + cwd: "/repo", + preferredName: "pingdotgg", + url: "git@github.com:pingdotgg/t3code.git", + }); + + assert.equal(resolved, remoteName); + assert.equal(added.length, remoteName === "origin" ? 0 : 1); + }), + ); }); describe("commit context", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 61c84424219b..08478962bb19 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -332,7 +332,8 @@ function parseRemoteFetchUrls(stdout: string): 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); + // A partial clone's fetch line ends with its filter, e.g. "(fetch) [blob:none]". + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[.*\])?$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {