From ea00046414f643f705ab5629ed291c9164bfb19c Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Thu, 5 Nov 2026 05:52:02 +0000 Subject: [PATCH 1/3] feat(server): a fork's PR list follows the gh default repository A checkout with both `upstream` and `origin` always took `upstream` as its repository, so a fork's PR view listed upstream's pull requests and none of its own. The resolver now prefers the remote `gh repo set-default` marked (`remote..gh-resolved base`), then `upstream`, then `origin`. Co-Authored-By: Claude Opus 5.5 --- FORK.md | 3 ++ .../RepositoryIdentityResolver.test.ts | 7 ++++ .../src/project/RepositoryIdentityResolver.ts | 42 ++++++++++++++----- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/FORK.md b/FORK.md index 3ac7b3657301c..a9bb53759840a 100644 --- a/FORK.md +++ b/FORK.md @@ -53,3 +53,6 @@ adds the feature, and remove it in the PR that drops the feature. - #21 Open in VS Code and other SSH editor links use the host the client reached directly when sshd is listening, instead of the server's first advertised host: `apps/web/src/remoteOpen.ts`, `apps/server/src/environment/RemoteOpenTargets.ts` (`sshd`). +- A checkout's pull requests and repository come from the remote `gh repo set-default` + chose, ahead of `upstream`, so a fork lists its own PRs: + `apps/server/src/project/RepositoryIdentityResolver.ts` (`parseGhDefaultRemote`). diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 4604da894a86c..fda6fd72dff21 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -300,6 +300,13 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { remoteUrl: "git@github.com:julius/t3code-fork.git", displayName: "julius/t3code-fork", }); + + yield* git(cwd, ["config", "remote.origin.gh-resolved", "base"]); + const forkDefault = yield* resolver.resolve(cwd, { refresh: true }); + + expect(forkDefault?.locator.remoteName).toBe("origin"); + expect(forkDefault?.canonicalKey).toBe("github.com/julius/t3code-fork"); + expect(forkDefault?.origin).toBeUndefined(); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index cdf9dc5e1b309..7e3071bfd355f 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -70,10 +70,21 @@ function parseRemoteFetchUrls(stdout: string): Map { return remotes; } +/** `gh repo set-default` marks the chosen remote `remote..gh-resolved base`. */ +function parseGhDefaultRemote(stdout: string): string | undefined { + for (const line of stdout.split("\n")) { + const match = /^remote\.(.+)\.gh-resolved\s+base$/.exec(line.trim()); + if (match?.[1]) return match[1]; + } + return undefined; +} + function pickPrimaryRemote( remotes: ReadonlyMap, + ghDefaultRemote: string | undefined, ): { readonly remoteName: string; readonly remoteUrl: string } | null { - for (const preferredRemoteName of ["upstream", "origin"] as const) { + for (const preferredRemoteName of [ghDefaultRemote, "upstream", "origin"]) { + if (preferredRemoteName === undefined) continue; const remoteUrl = remotes.get(preferredRemoteName); if (remoteUrl) { return { remoteName: preferredRemoteName, remoteUrl }; @@ -227,20 +238,31 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( cacheKey: RepositoryIdentityCacheKey, ): Effect.fn.Return { const processRunner = yield* ProcessRunner.ProcessRunner; - const remoteResult = yield* processRunner - .run({ - command: "git", - args: ["-C", cacheKey.rootPath, ...(cacheKey.isBare ? ["--git-dir=."] : []), "remote", "-v"], - env: explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform), - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.option); + const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform); + const runGit = (args: ReadonlyArray) => + processRunner + .run({ + command: "git", + args: ["-C", cacheKey.rootPath, ...(cacheKey.isBare ? ["--git-dir=."] : []), ...args], + env, + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + const [remoteResult, ghDefaultResult] = yield* Effect.all( + [runGit(["remote", "-v"]), runGit(["config", "--get-regexp", "^remote\\..+\\.gh-resolved$"])], + { concurrency: 2 }, + ); if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { return null; } const remotes = parseRemoteFetchUrls(remoteResult.value.stdout); - const remote = pickPrimaryRemote(remotes); + const remote = pickPrimaryRemote( + remotes, + ghDefaultResult._tag === "Some" && ghDefaultResult.value.code === 0 + ? parseGhDefaultRemote(ghDefaultResult.value.stdout) + : undefined, + ); return remote ? buildRepositoryIdentity({ ...remote, From a2c5d985f5bf262d2247a5721b17e1778c7a7232 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Thu, 5 Nov 2026 06:17:03 +0000 Subject: [PATCH 2/3] docs(fork): number the gh default repository entry Co-Authored-By: Claude Opus 5.5 --- FORK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index a9bb53759840a..b2ac91bb9c7fb 100644 --- a/FORK.md +++ b/FORK.md @@ -53,6 +53,6 @@ adds the feature, and remove it in the PR that drops the feature. - #21 Open in VS Code and other SSH editor links use the host the client reached directly when sshd is listening, instead of the server's first advertised host: `apps/web/src/remoteOpen.ts`, `apps/server/src/environment/RemoteOpenTargets.ts` (`sshd`). -- A checkout's pull requests and repository come from the remote `gh repo set-default` +- #30 A checkout's pull requests and repository come from the remote `gh repo set-default` chose, ahead of `upstream`, so a fork lists its own PRs: `apps/server/src/project/RepositoryIdentityResolver.ts` (`parseGhDefaultRemote`). From 841441f78238c3c8aad050e7b658403bb60e4073 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Thu, 5 Nov 2026 08:04:21 +0000 Subject: [PATCH 3/3] test(server): gh default remote edge cases fall back to upstream Co-Authored-By: Claude Opus 5.5 --- .../RepositoryIdentityResolver.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index fda6fd72dff21..e047c24b6cbaf 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -310,6 +310,51 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); + it.effect.each([ + { + name: "a dotted remote name", + remote: ["my.fork", "git@github.com:julius/dotted-fork.git"], + marker: ["remote.my.fork.gh-resolved", "base"], + expected: "my.fork", + }, + { + name: "a value other than base", + remote: undefined, + marker: ["remote.origin.gh-resolved", "julius/t3code-fork"], + expected: "upstream", + }, + { + name: "a remote that no longer exists", + remote: undefined, + marker: ["remote.gone.gh-resolved", "base"], + expected: "upstream", + }, + { + name: "a local-path remote", + remote: ["gb-local", "."], + marker: ["remote.gb-local.gh-resolved", "base"], + expected: "upstream", + }, + ] as const)("resolves a gh default marking $name to $expected", ({ remote, marker, expected }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-gh-default-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]); + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + if (remote) yield* git(cwd, ["remote", "add", ...remote]); + yield* git(cwd, ["config", ...marker]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity?.locator.remoteName).toBe(expected); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + it.effect("uses the last remote path segment as the repository name for nested groups", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem;