diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index a997459e63d..1351dd369ac 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -109,7 +109,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); - it.effect("prefers upstream over origin when both remotes are configured", () => + it.effect("uses the remote selected as gh's default repository", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ @@ -119,14 +119,47 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + yield* git(cwd, ["config", "remote.origin.gh-resolved", "pingdotgg/t3code"]); const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); expect(identity).not.toBeNull(); - expect(identity?.locator.remoteName).toBe("upstream"); - expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(identity?.displayName).toBe("t3tools/t3code"); + expect(identity?.locator.remoteName).toBe("origin"); + expect(identity?.canonicalKey).toBe("github.com/pingdotgg/t3code"); + expect(identity?.displayName).toBe("pingdotgg/t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + + it.effect("follows branch remote changes before gh's selected default", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-branch-remote-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["checkout", "-b", "feature/branch-target"]); + yield* git(cwd, ["config", "user.email", "test@example.com"]); + yield* git(cwd, ["config", "user.name", "Test User"]); + yield* git(cwd, ["commit", "--allow-empty", "-m", "Initial commit"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + yield* git(cwd, ["remote", "add", "fork", "git@github.com:julius/t3code.git"]); + yield* git(cwd, ["config", "remote.origin.gh-resolved", "base"]); + yield* git(cwd, ["config", "branch.feature/branch-target.remote", "fork"]); + yield* git(cwd, ["config", "branch.feature/branch-target.merge", "refs/heads/main"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity?.locator.remoteName).toBe("fork"); + expect(identity?.canonicalKey).toBe("github.com/julius/t3code"); + + yield* git(cwd, ["checkout", "-b", "feature/no-branch-target"]); + + const fallbackIdentity = yield* resolver.resolve(cwd); + expect(fallbackIdentity?.locator.remoteName).toBe("origin"); + expect(fallbackIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 50608e7704c..40b880e5598 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -15,6 +15,7 @@ import * as ProcessRunner from "../processRunner.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); +const CACHE_KEY_SEPARATOR = "\0"; export interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; @@ -29,26 +30,46 @@ export class RepositoryIdentityResolver extends Context.Service< } >()("t3/project/RepositoryIdentityResolver") {} -function parseRemoteFetchUrls(stdout: string): Map { +function parseRemoteConfig(stdout: string): { + readonly remotes: ReadonlyMap; + readonly ghDefaultRemote: { + readonly remoteName: string; + readonly repositoryPath: string | null; + } | null; +} { const remotes = new Map(); + let ghDefaultRemote: { + readonly remoteName: string; + readonly repositoryPath: string | null; + } | null = null; + 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; + const match = /^remote\.(.+)\.(url|gh-resolved)\s+(\S+)$/u.exec(line.trim()); + if (!match?.[1] || !match[2] || !match[3]) continue; + if (match[2] === "url") { + if (!remotes.has(match[1])) remotes.set(match[1], match[3]); + } else if (ghDefaultRemote === null) { + ghDefaultRemote = { + remoteName: match[1], + repositoryPath: match[3] === "base" ? null : match[3].toLowerCase(), + }; } - remotes.set(remoteName, remoteUrl); } - return remotes; + return { remotes, ghDefaultRemote }; +} + +function parseCurrentBranchRemoteName(stdout: string): string | null { + const current = stdout.split("\n").find((line) => line.startsWith("*\t")); + const remoteName = current?.slice(2).trim() ?? ""; + return remoteName.length > 0 ? remoteName : null; } function pickPrimaryRemote( remotes: ReadonlyMap, + preferredRemoteNames: ReadonlyArray, ): { readonly remoteName: string; readonly remoteUrl: string } | null { - for (const preferredRemoteName of ["upstream", "origin"] as const) { + for (const preferredRemoteName of preferredRemoteNames) { + if (preferredRemoteName === null) continue; const remoteUrl = remotes.get(preferredRemoteName); if (remoteUrl) { return { remoteName: preferredRemoteName, remoteUrl }; @@ -63,9 +84,15 @@ function pickPrimaryRemote( function buildRepositoryIdentity(input: { readonly remoteName: string; readonly remoteUrl: string; + readonly repositoryPath?: string; readonly rootPath: string; }): RepositoryIdentity { - const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); + const remoteCanonicalKey = normalizeGitRemoteUrl(input.remoteUrl); + const remoteHost = remoteCanonicalKey.split("/")[0] ?? ""; + const canonicalKey = + input.repositoryPath && remoteHost + ? `${remoteHost}/${input.repositoryPath}` + : remoteCanonicalKey; const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); const repositoryPath = canonicalKey.split("/").slice(1).join("/"); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); @@ -90,7 +117,7 @@ function buildRepositoryIdentity(input: { const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; + let rootPath = cwd; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -101,16 +128,29 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); - if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; + if (topLevelResult._tag === "Some" && topLevelResult.value.code === 0) { + rootPath = topLevelResult.value.stdout.trim() || cwd; } - const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } + const branchRemoteResult = yield* processRunner + .run({ + command: "git", + args: [ + "-C", + rootPath, + "for-each-ref", + "--format=%(HEAD)%09%(upstream:remotename)", + "refs/heads", + ], + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + const branchRemoteName = + branchRemoteResult._tag === "Some" && branchRemoteResult.value.code === 0 + ? parseCurrentBranchRemoteName(branchRemoteResult.value.stdout) + : null; - return cacheKey; + return `${rootPath}${CACHE_KEY_SEPARATOR}${branchRemoteName ?? ""}`; }, ); @@ -120,19 +160,37 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( cacheKey: string, ): Effect.fn.Return { const processRunner = yield* ProcessRunner.ProcessRunner; - const remoteResult = yield* processRunner + const [rootPath = cacheKey, branchRemoteName = ""] = cacheKey.split(CACHE_KEY_SEPARATOR); + const remoteConfigResult = yield* processRunner .run({ command: "git", - args: ["-C", cacheKey, "remote", "-v"], + args: ["-C", rootPath, "config", "--get-regexp", "^remote\\..*\\.(url|gh-resolved)$"], timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); - if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { + if (remoteConfigResult._tag === "None" || remoteConfigResult.value.code !== 0) { return null; } - const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); - return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; + const { remotes, ghDefaultRemote } = parseRemoteConfig(remoteConfigResult.value.stdout); + const remote = pickPrimaryRemote(remotes, [ + branchRemoteName || null, + ghDefaultRemote?.remoteName ?? null, + "upstream", + "origin", + ]); + if (!remote) return null; + + const usesBranchRemote = branchRemoteName.length > 0 && remotes.has(branchRemoteName); + const repositoryPath = + !usesBranchRemote && remote.remoteName === ghDefaultRemote?.remoteName + ? (ghDefaultRemote.repositoryPath ?? undefined) + : undefined; + return buildRepositoryIdentity({ + ...remote, + rootPath, + ...(repositoryPath ? { repositoryPath } : {}), + }); }); export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( diff --git a/docs/user/source-control.md b/docs/user/source-control.md index c12e4b47fe7..60f12651237 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -44,6 +44,11 @@ T3 Code works with the platforms your team already uses: - Open the review directly in your browser with one click - Check out a teammate's branch to review code locally +When a GitHub project has multiple remotes, the **Pull requests** page follows the current branch's +tracked remote. If the branch has no tracked remote, it follows the repository selected by +`gh repo set-default`. The current branch's PR indicator verifies both the branch name and its head +repository, so a fork checkout does not pick up a same-named branch from `upstream`. + ### Start a Thread from a GitHub Issue Open the command palette and run **New thread from GitHub issue…** to pick from the current