diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c63258d187a65..707c983d32ea6 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -926,6 +926,54 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("status reads a remote's first URL when it has a second push URL", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/mirrored"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/mirrored"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + remoteDir, + ); + // `git remote set-url --add`: Git fetches from the first URL and also pushes to this one. + yield* runGit(repoDir, [ + "config", + "--add", + "remote.origin.url", + "git@gitlab.com:mirror-owner/codething-mvp.git", + ]); + + const { manager } = yield* makeManager({ + providerScenario: { + prListSequence: [ + [ + { + number: 16, + title: "Mirrored branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/16", + baseRefName: "main", + headRefName: "feature/mirrored", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + ], + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.sourceControlProvider?.kind).toBe("github"); + expect(status.pr?.number).toBe(16); + }), + ); + it.effect("status reports the latest of a branch's closed and merged PRs", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index dfd13a7c06609..f2746009e8905 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -705,6 +705,16 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + // Git fetches from a remote's first URL; `config --get` returns the last of several. + const readRemoteUrl = (cwd: string, remoteName: string) => + gitCore + .execute({ + operation: "GitManager.readRemoteUrl", + cwd, + args: ["config", "--get-all", `remote.${remoteName}.url`], + allowNonZeroExit: true, + }) + .pipe(Effect.map((result) => result.stdout.split("\n", 1)[0]?.trim() || null)); const changeRequestTermsFor = Effect.fn("GitManager.changeRequestTermsFor")(function* ( cwd: string, ) { @@ -987,7 +997,7 @@ export const make = Effect.gen(function* () { repository: repositoryNameWithOwner, ...pullRequestHostOptions(pullRequest.url), }); - const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const originRemoteUrl = yield* readRemoteUrl(cwd, "origin"); const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; const preferredRemoteName = pullRequest.headRepositoryOwnerLogin?.trim() || @@ -1072,7 +1082,7 @@ export const make = Effect.gen(function* () { ? Effect.fail(cause) : Effect.gen(function* () { const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); - const remoteUrl = yield* gitCore.readConfigValue(cwd, `remote.${remoteName}.url`); + const remoteUrl = yield* readRemoteUrl(cwd, remoteName); const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); if ( remoteUrl === null || @@ -1101,7 +1111,7 @@ export const make = Effect.gen(function* () { repository: repositoryNameWithOwner, ...pullRequestHostOptions(pullRequest.url), }); - const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const originRemoteUrl = yield* readRemoteUrl(cwd, "origin"); const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; const preferredRemoteName = pullRequest.headRepositoryOwnerLogin?.trim() || @@ -1511,6 +1521,8 @@ export const make = Effect.gen(function* () { const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); + const readRemoteUrlNullable = (cwd: string, remoteName: string) => + readRemoteUrl(cwd, remoteName).pipe(Effect.orElseSucceed(() => null)); // Whether the remote's host creates change requests in this remote's repository. A host's tools // can let configuration, such as gh's default repository, name another one. @@ -1544,8 +1556,8 @@ export const make = Effect.gen(function* () { ? "origin" : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); const remoteUrl = - (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? - (yield* readConfigValueNullable(cwd, "remote.origin.url")); + (yield* readRemoteUrlNullable(cwd, preferredRemoteName)) ?? + (yield* readRemoteUrlNullable(cwd, "origin")); const provider = remoteUrl ? sourceControlDefinitions.recognizeRemote(remoteUrl) : null; if (!remoteUrl || provider?.kind !== "unknown") return provider; @@ -1571,7 +1583,7 @@ export const make = Effect.gen(function* () { }; } - const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + const remoteUrl = yield* readRemoteUrlNullable(cwd, remoteName); let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); if ( remoteUrl !== null && @@ -1827,7 +1839,7 @@ export const make = Effect.gen(function* () { remoteName = matches[0]; } if (remoteName === undefined) return null; - const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + const remoteUrl = yield* readRemoteUrlNullable(cwd, remoteName); return remoteUrl === null ? null : remoteName; }); @@ -2609,7 +2621,13 @@ export const make = Effect.gen(function* () { const readRemoteUrlKey = (remoteName: string | null) => remoteName === null ? Effect.succeed(null) - : readConfig(`remote.${remoteName}.url`).pipe( + : rememberPrLookupGitState( + cwd, + `remoteUrl\u0000${remoteName}`, + fresh, + readRemoteUrl(cwd, remoteName), + ).pipe( + Effect.orElseSucceed(() => null), Effect.map((remoteUrl) => remoteUrl ? sourceControlDefinitions.repositoryKeyFromRemote(remoteUrl) : null, ), diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts index c8d7508619f83..ba33d33c7534e 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts @@ -9,6 +9,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as Queue from "effect/Queue"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -63,6 +64,14 @@ const repositoryJson = { mainbranch: { name: "main" }, }; +const gitOutput = (stdout: string) => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; /** Holds the answer until it resolves, in place of answering at once. */ @@ -85,6 +94,9 @@ function makeLayer(input: { ), ); const gitMock = { + execute: vi.fn(() => + Effect.succeed(gitOutput("git@bitbucket.org:pingdotgg/t3code.git\n")), + ), readConfigValue: vi.fn(() => Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), ), @@ -253,6 +265,44 @@ it.effect.each([false, true])("honors PR URL repositories, context=%s", (withCon }).pipe(Effect.provide(layer)); }); +it.effect("adds a fork remote with the protocol of the URL origin fetches from", () => { + // `git remote -v` shows origin's SSH `insteadOf` rewrite; config holds HTTPS, then an SSH push URL. + const { git, layer } = makeLayer({ + git: { + execute: () => + Effect.succeed( + gitOutput( + "https://bitbucket.org/pingdotgg/t3code.git\ngit@bitbucket.org:pingdotgg/t3code.git\n", + ), + ), + }, + response: (request) => + Response.json( + request.url.endsWith("/pullrequests/42") + ? bitbucketPullRequest + : { + ...repositoryJson, + full_name: "octocat/t3code", + links: { + clone: [ + { name: "https", href: "https://bitbucket.org/octocat/t3code.git" }, + { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" }, + ], + }, + }, + ), + }); + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.checkoutPullRequest({ cwd: "/repo", reference: "#42" }); + assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], { + cwd: "/repo", + preferredName: "octocat", + url: "https://bitbucket.org/octocat/t3code.git", + }); + }).pipe(Effect.provide(layer)); +}); + it.effect.each([false, true])( "reuses matching checkout remotes for numeric references, context=%s", (withContext) => { diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index fe68110cbfa55..987efa87c5aff 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -918,7 +918,18 @@ export const make = Effect.gen(function* () { repository: input.sourceRepositoryName, ...(input.context ? { context: input.context } : {}), }).pipe(Effect.map(normalizeRepositoryCloneUrls)); - const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); + // Git fetches from a remote's first URL; `config --get` returns the last of several, and + // `git remote -v` applies `insteadOf` rewrites. + const originRemoteUrl = yield* git + .execute({ + operation: "BitbucketApi.readOriginRemoteUrl", + cwd: input.cwd, + args: ["config", "--get-all", "remote.origin.url"], + }) + .pipe( + Effect.map((result) => result.stdout.split("\n", 1)[0]?.trim() || null), + Effect.orElseSucceed(() => null), + ); return yield* git.ensureRemote({ cwd: input.cwd, preferredName: input.isCrossRepository diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 134966231ceb1..9c4554348c095 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -164,6 +164,7 @@ function harness(input: { readonly localBranches?: ReadonlyArray; readonly resolved?: string; readonly gitFailure?: unknown; + readonly originUrls?: string; }) { const git: Array = []; const graphqlCalls: Array = []; @@ -178,7 +179,9 @@ function harness(input: { execute: (args) => Effect.sync(() => { git.push(["execute", args.args]); - return processOutput(""); + return processOutput( + args.args[0] === "config" ? (input.originUrls ?? "git@github.com:acme/web.git\n") : "", + ); }), resolvePrimaryRemoteName: () => Effect.succeed("origin"), readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), @@ -682,6 +685,41 @@ describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { }).pipe(Effect.provide(layer)); }); + it.effect("adds a fork remote with the protocol of the URL origin fetches from", () => { + // `git remote -v` shows origin's SSH `insteadOf` rewrite; config holds HTTPS, then an SSH push URL. + const { layer, git } = harness({ + remotes: [ + "origin\tgit@github.com:acme/web.git (fetch)", + "origin\tgit@github.com:acme/web.git (push)", + "origin\tgit@github.com:acme/web.git (push)", + ].join("\n"), + originUrls: "https://github.com/acme/web.git\ngit@github.com:acme/web.git\n", + graphql: () => ({ pullRequest: node(7, "feature", "someone") }), + api: { + rest: (input) => { + const fullName = input.path.slice("repos/".length); + return Effect.succeed( + restResponse({ + full_name: fullName, + html_url: `https://github.com/${fullName}`, + ssh_url: `git@github.com:${fullName}.git`, + default_branch: "main", + }), + ); + }, + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "7" }); + assert.deepStrictEqual(git.find(([name]) => name === "ensureRemote")?.[1], { + cwd: "/repo", + preferredName: "someone", + url: "https://github.com/someone/web", + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("keeps git's own output out of what a failed checkout reports", () => { const { layer } = harness({ remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index e510473aa756d..4928e9d7ba2ff 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -822,7 +822,16 @@ export const make = Effect.gen(function* () { if (known !== null) return known; const [owner, name] = headNameWithOwner.split("/"); const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); - const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); + // Git fetches from a remote's first URL; `config --get` returns the last of several, and + // `git remote -v` applies `insteadOf` rewrites. + const originUrl = yield* runGit(input.cwd, "readOriginUrl", [ + "config", + "--get-all", + "remote.origin.url", + ]).pipe( + Effect.map((result) => result.stdout.split("\n", 1)[0]?.trim() || null), + Effect.orElseSucceed(() => null), + ); return yield* git.ensureRemote({ cwd: input.cwd, preferredName: headOwner ?? "fork",