From b33d1140657d2ec76fa4ccdd325ea6cd8710743e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:08:35 +0000 Subject: [PATCH 1/2] test(server): fork PRs from origin into a gh-resolved upstream use an owner head Fails on main: GitManager compares the head remote against origin, so a branch on a fork origin is sent as a bare head to the upstream base repo. Refs pingdotgg/t3code#7382 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/git/GitManager.test.ts | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 8e9c3f8da3042..d1904945dadc2 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4514,6 +4514,48 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("creates a fork PR from origin into the upstream remote the provider targets", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", forkDir]); + yield* runGit(repoDir, [ + "remote", + "add", + "upstream", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.upstream.gh-resolved", "base"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/fork-only"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-only.txt"), "fork only\n"); + yield* runGit(repoDir, ["add", "fork-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork-only commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/fork-only"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:octocat/codething-mvp.git", + forkDir, + ); + + const { service, calls } = createFakeSourceControlProvider({ prListSequence: [[], []] }); + const { manager } = yield* makeManager({ + sourceControlProvider: { + ...service, + createsChangeRequestsIn: ({ repository }) => + Effect.succeed(repository === "pingdotgg/codething-mvp"), + }, + }); + const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" }); + + expect(result.pr.status).toBe("created"); + expect(created(calls)).toEqual([ + { baseRefName: "main", headSelector: "octocat:feature/fork-only" }, + ]); + }), + ); + it.effect("commit_push_pr publishes the default branch to the push remote's fork", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); From a1ecc15ae804e88d008cc8bebbed9c3690780afa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:19:58 +0000 Subject: [PATCH 2/2] fix(server): fork PRs into gh's upstream base use an owner-qualified head With origin as a fork and gh's base repository on another remote, the head was compared only against origin, so Create PR sent a bare branch to the upstream repository and GitHub rejected it as an invalid head. The branch is now cross-repository whenever the host creates change requests in another repository, and GitHub answers that from every remote and gh-resolved mark, as createChangeRequest does. Fixes pingdotgg/t3code#7382 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/git/GitManager.ts | 17 ++++-- .../GitHubSourceControlProvider.test.ts | 59 +++++++++++-------- .../src/server/GitHubSourceControlProvider.ts | 30 ++++++---- 3 files changed, 61 insertions(+), 45 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 884869f1cb927..d10b09b1be961 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1480,9 +1480,9 @@ export const make = Effect.gen(function* () { const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); - // Whether the remote's host creates change requests in the only remote. A host's tools can let - // configuration outside the remotes name another repository; the remote itself otherwise. - const targetsOnlyRemote = Effect.fn("targetsOnlyRemote")(function* ( + // 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. + const targetsRemote = Effect.fn("targetsRemote")(function* ( cwd: string, remoteName: string, remote: { readonly remoteUrl: string | null; readonly repositoryNameWithOwner: string | null }, @@ -1617,15 +1617,20 @@ export const make = Effect.gen(function* () { ), Effect.orElseSucceed(() => 0), )) === 1 && - (yield* targetsOnlyRemote(cwd, remoteName, remoteRepository)); + (yield* targetsRemote(cwd, remoteName, remoteRepository)); const isForeignRemote = remoteName !== null && remoteName !== "origin" && !isOnlyRemote; const isCrossRepository = - remoteRepository.repositoryNameWithOwner !== null && + (remoteRepository.repositoryNameWithOwner !== null && originRepository.repositoryNameWithOwner !== null ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== originRepository.repositoryNameWithOwner.toLowerCase() - : isForeignRemote && remoteRepository.repositoryNameWithOwner !== null; + : isForeignRemote && remoteRepository.repositoryNameWithOwner !== null) || + // A fork as origin, with the host targeting another remote such as upstream. + (remoteName !== null && + !isOnlyRemote && + remoteRepository.repositoryNameWithOwner !== null && + !(yield* targetsRemote(cwd, remoteName, remoteRepository))); const ownerHeadSelector = remoteRepository.ownerLogin && headBranch.length > 0 diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index a5a08fcfc2cc4..df3ddd29f7591 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -1190,6 +1190,10 @@ it.effect.each([ `${host}/repos/${expected}`, `${host}/repos/${expected}/pulls`, ]); + assert.deepStrictEqual( + yield* provider.createsChangeRequestsIn!({ ...input, repository: "me/web" }), + expected === "me/web", + ); }).pipe( Effect.provide(layer), Effect.provideService(HostProcess.Environment, { GH_REPO: envRepository }), @@ -1198,30 +1202,33 @@ it.effect.each([ }, ); -it.effect("says a GH_REPO naming an unparseable host targets no remote rather than failing", () => { - const { layer } = harness({ - remotes: remotesOutput(["upstream", "git@github.com:acme/web.git"]), - resolved: "", - api: {}, - }); - return Effect.gen(function* () { - const provider = yield* GitHubSourceControlProvider.make; - const targets = yield* provider.createsChangeRequestsIn!({ - cwd: "/repo", - context: { - provider: { - kind: SourceControlProviderKind.make("github"), - name: "GitHub", - baseUrl: "https://github.com", - }, - remoteName: "upstream", - remoteUrl: "git@github.com:acme/web.git", - }, - repository: "acme/web", +it.effect( + "says a GH_REPO naming an unparseable host falls back to the remotes, as creation does", + () => { + const { layer } = harness({ + remotes: remotesOutput(["upstream", "git@github.com:acme/web.git"]), + resolved: "", + api: {}, }); - assert.isFalse(targets); - }).pipe( - Effect.provide(layer), - Effect.provideService(HostProcess.Environment, { GH_REPO: "bad host/acme/web" }), - ); -}); + return Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make; + const targets = yield* provider.createsChangeRequestsIn!({ + cwd: "/repo", + context: { + provider: { + kind: SourceControlProviderKind.make("github"), + name: "GitHub", + baseUrl: "https://github.com", + }, + remoteName: "upstream", + remoteUrl: "git@github.com:acme/web.git", + }, + repository: "acme/web", + }); + assert.isTrue(targets); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Environment, { GH_REPO: "bad host/acme/web" }), + ); + }, +); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 9818a6b776edb..317a3a321db3a 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -474,6 +474,18 @@ export const make = Effect.gen(function* () { timeoutMs: 5_000, }); + const readRemotes = (cwd: string) => + Effect.all([ + gitRead(cwd, ["remote", "-v"]), + gitRead(cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), + ]).pipe( + Effect.orElseSucceed(() => [null, null] as const), + Effect.map(([remotes, resolved]) => ({ + remotes: remotes?.exitCode === 0 ? remotes.stdout : "", + resolved: resolved?.exitCode === 0 ? resolved.stdout : "", + })), + ); + const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( function* (input: { readonly cwd: string; @@ -487,13 +499,8 @@ export const make = Effect.gen(function* () { const locator = parseGitHubRepositorySelector(envRepository, defaultHost); if (locator !== null) return locator; } - const [remotes, resolved] = yield* Effect.all([ - gitRead(input.cwd, ["remote", "-v"]), - gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), - ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); const { host, locator } = resolveGitHubRepository({ - remotes: remotes?.exitCode === 0 ? remotes.stdout : "", - resolved: resolved?.exitCode === 0 ? resolved.stdout : "", + ...(yield* readRemotes(input.cwd)), hostHint, defaultHost, }); @@ -888,8 +895,9 @@ export const make = Effect.gen(function* () { }, ), // `createChangeRequest` targets what `resolveRepository` picks: GH_REPO when it parses, else - // the remote `gh repo set-default` marked (`gh-resolved`). An HTTPS remote keeps a - // non-default port in the context's host, and the mark is then ignored. + // gh's pick among the checkout's remotes, led by the one `gh repo set-default` marked + // (`gh-resolved`). An HTTPS remote keeps a non-default port in the context's host, and the + // mark is then ignored. createsChangeRequestsIn: Effect.fn("GitHubSourceControlProvider.createsChangeRequestsIn")( function* ({ cwd, context, repository }) { const host = URL.canParse(context.provider.baseUrl) @@ -909,12 +917,8 @@ export const make = Effect.gen(function* () { `${owner}/${name}`.toLowerCase() === target ); } - const mark = yield* git - .readConfigValue(cwd, `remote.${context.remoteName}.gh-resolved`) - .pipe(Effect.orElseSucceed(() => null)); const { locator } = resolveGitHubRepository({ - remotes: `${context.remoteName}\t${context.remoteUrl} (fetch)`, - resolved: mark === null ? "" : `remote.${context.remoteName}.gh-resolved ${mark}`, + ...(yield* readRemotes(cwd)), hostHint: host ?? undefined, defaultHost: host ?? "github.com", });