diff --git a/FORK.md b/FORK.md index 3ac7b3657301c..b2ac91bb9c7fb 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`). +- #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`). diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 4604da894a86c..e047c24b6cbaf 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -300,6 +300,58 @@ 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)), + ); + + 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)), ); 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,