From 13dc703223a6a5a727279347b6ebe19e879c70a6 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 16:43:50 -0500 Subject: [PATCH 01/10] test(server): pin pushes to the configured push remote on tracked branches Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 84861d6dd4ae0..04825db5a87e0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4401,6 +4401,52 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.notEqual(originMain.exitCode, 0); }), ); + + it.effect.each(["branch.topic.pushRemote", "remote.pushDefault"] as const)( + "pushes a branch tracking its upstream head to the %s remote", + (configKey) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const upstreamRemote = yield* makeTmpDir("git-upstream-remote-"); + const forkRemote = yield* makeTmpDir("git-fork-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(upstreamRemote, ["init", "--bare"]); + yield* git(forkRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); + yield* git(cwd, ["remote", "add", "fork", forkRemote]); + yield* git(cwd, ["checkout", "-b", "topic"]); + yield* git(cwd, ["push", "upstream", "topic"]); + yield* git(cwd, ["push", "fork", "topic"]); + yield* git(cwd, ["branch", "--set-upstream-to=upstream/topic"]); + yield* git(cwd, ["config", configKey, "fork"]); + const upstreamTopicSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* writeTextFile(cwd, "fork.txt", "fork\n"); + yield* git(cwd, ["add", "-A"]); + yield* driver.commit(cwd, "Add fork update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "topic", + upstreamBranch: "fork/topic", + setUpstream: false, + }); + assert.equal( + yield* git(forkRemote, ["--git-dir=.", "log", "-1", "--pretty=%s", "topic"]), + "Add fork update", + ); + assert.equal( + yield* git(upstreamRemote, ["--git-dir=.", "rev-parse", "topic"]), + upstreamTopicSha, + ); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "upstream/topic", + ); + }), + ); }); }); From eb23a1e16d2e8e4a22db2b954a490bf8ea45a78e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 16:47:47 -0500 Subject: [PATCH 02/10] fix(server): honor the configured push remote on tracked branches A branch tracking a same-named upstream branch was always pushed to that upstream, ignoring branch..pushRemote and remote.pushDefault. In a fork checkout this wrote to the source repository instead of the fork. Push to the configured remote and keep the fetch upstream, as git push does. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.ts | 34 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 62798374d2aa5..9a22815779f5d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1692,7 +1692,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); }); - const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( + const resolveConfiguredPushRemoteName = Effect.fn("resolveConfiguredPushRemoteName")(function* ( cwd: string, refName: string, ) { @@ -1712,8 +1712,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["config", "--get", "remote.pushDefault"], true, ).pipe(Effect.map((stdout) => stdout.trim())); - if (pushDefaultRemote.length > 0) { - return pushDefaultRemote; + return pushDefaultRemote.length > 0 ? pushDefaultRemote : null; + }); + + const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( + cwd: string, + refName: string, + ) { + const configuredRemote = yield* resolveConfiguredPushRemoteName(cwd, refName); + if (configuredRemote) { + return configuredRemote; } return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); @@ -2667,6 +2675,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } + // A fork workflow fetches from the upstream but pushes to the configured + // push remote, and keeps the upstream for pulls, as bare `git push` does. + const configuredPushRemoteName = yield* resolveConfiguredPushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + if (configuredPushRemoteName && configuredPushRemoteName !== currentUpstream.remoteName) { + yield* runGit( + "GitVcsDriver.pushCurrentBranch.pushToPushRemote", + cwd, + ["push", "--", configuredPushRemoteName, `HEAD:refs/heads/${currentUpstream.branchName}`], + { timeoutMs: null }, + ); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${configuredPushRemoteName}/${currentUpstream.branchName}`, + setUpstream: false, + }; + } + yield* runGit( "GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, From 978fef81c21ce7738e12a4a4413a17736a5bde60 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 03:46:50 -0500 Subject: [PATCH 03/10] chore(server): clarify the push remote comment wording Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 9a22815779f5d..0347bd8c42fcd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2676,7 +2676,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } // A fork workflow fetches from the upstream but pushes to the configured - // push remote, and keeps the upstream for pulls, as bare `git push` does. + // push remote, and keeps the upstream for pulls, as plain `git push` does. const configuredPushRemoteName = yield* resolveConfiguredPushRemoteName(cwd, branch).pipe( Effect.orElseSucceed(() => null), ); From 22568fb05815a6d78dee47f279f6a0f8c1c43403 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 21:09:16 -0500 Subject: [PATCH 04/10] fix(server): create the PR from the fork a tracked branch was pushed to Push now sends a branch tracking origin/topic to the configured push remote and keeps tracking origin. Push & create PR then resolved the PR head from that retained tracking ref, so it asked for `topic` on the source repository and not `:topic`. runPrStep now takes the pushed ref and, when it differs from the tracked upstream, resolves the PR head from the push remote. Tracking, base resolution and other callers are unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/git/GitManager.test.ts | 45 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 38 +++++++++++++++++++--- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 660e2de9cbd8b..b3b8eb17aa4c4 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3985,6 +3985,51 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("creates the PR from the push remote when the branch tracks the base repository", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "topic"]); + yield* runGit(repoDir, ["push", "-u", "origin", "topic"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.com:octocat/codething-mvp.git", + forkDir, + ); + yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + + const { manager, ghCalls } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.push).toMatchObject({ status: "pushed", upstreamBranch: "fork/topic" }); + expect(result.pr.status).toBe("created"); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head octocat:topic")), + ).toBe(true); + expect(ghCalls.some((call) => call.includes("pr create --base main --head topic"))).toBe( + false, + ); + expect(yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toMatchObject({ + stdout: "origin/topic\n", + }); + }), + ); + it.effect("rejects push/pr actions from detached HEAD", () => 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 1b5002ec110e8..bc3ca3021bafa 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -89,7 +89,11 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; -import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; +import { + extractBranchNameFromRemoteRef, + parseRemoteNames, + parseRemoteRefWithRemoteNames, +} from "./remoteRefs.ts"; import { detachStackFrame } from "./detachStackFrame.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; @@ -2276,6 +2280,7 @@ export const make = Effect.gen(function* () { fallbackBranch: string | null, threadId: ThreadId | undefined, emit: GitActionProgressEmitter, + pushedUpstreamRef?: string, ) { const provider = yield* sourceControlProvider(cwd); const terms = getChangeRequestTerminologyForKind(provider.kind); @@ -2296,10 +2301,32 @@ export const make = Effect.gen(function* () { }); } - const headContext = yield* resolveBranchHeadContext(cwd, { - branch, - upstreamRef: details.upstreamRef, - }); + // A push to a configured push remote keeps tracking the fetch upstream, so + // the PR head is where the commits went, not the tracked ref. + const pushedRemoteRef = + pushedUpstreamRef && pushedUpstreamRef !== details.upstreamRef + ? parseRemoteRefWithRemoteNames( + pushedUpstreamRef, + parseRemoteNames( + (yield* gitCore.execute({ + operation: "GitManager.runPrStep.remotes", + cwd, + args: ["remote"], + timeoutMs: 5_000, + })).stdout, + ), + ) + : null; + const headContext = yield* resolveBranchHeadContext( + cwd, + pushedRemoteRef + ? { + branch, + upstreamRef: pushedRemoteRef.remoteRef, + remoteName: pushedRemoteRef.remoteName, + } + : { branch, upstreamRef: details.upstreamRef }, + ); const existing = yield* findOpenPr(cwd, headContext); if (existing) { @@ -3240,6 +3267,7 @@ export const make = Effect.gen(function* () { currentBranch, input.threadId, progress.emit, + push.status === "pushed" ? push.upstreamBranch : undefined, ), ), ) From 63f29e3333aa226db322401c302752ea8d68a768 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 22:32:44 +0000 Subject: [PATCH 05/10] fix(server): check the push remote before skipping an up-to-date branch A branch that matched its tracked upstream was reported up to date even when its configured push remote was missing those commits, so Push left the fork behind and the PR head came from the tracked ref. The skip now checks the push remote's branch first and pushes when it lacks HEAD. The push result now names the push remote it used, and the PR step takes it from there instead of re-reading `git remote` after the push. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/git/GitManager.test.ts | 82 +++++++++++++++ apps/server/src/git/GitManager.ts | 38 ++----- apps/server/src/vcs/GitVcsDriver.ts | 2 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 68 ++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 103 ++++++++++++------- 5 files changed, 228 insertions(+), 65 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index b3b8eb17aa4c4..4529dd21ef505 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4030,6 +4030,88 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("creates the PR from the push remote when its branch there already has HEAD", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "topic"]); + yield* runGit(repoDir, ["push", "-u", "origin", "topic"]); + yield* runGit(repoDir, ["push", "fork", "topic"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.com:octocat/codething-mvp.git", + forkDir, + ); + yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); + + const { manager, ghCalls } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.push).toMatchObject({ + status: "skipped_up_to_date", + upstreamBranch: "fork/topic", + }); + expect(result.pr.status).toBe("created"); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head octocat:topic")), + ).toBe(true); + }), + ); + + it.effect("keeps the tracked branch as the PR base when the push goes to the push remote", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/heads/release"]); + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, ["checkout", "-b", "topic", "--track", "origin/release"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.com:octocat/codething-mvp.git", + forkDir, + ); + yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + + const { manager, ghCalls } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.push).toMatchObject({ status: "pushed", upstreamBranch: "fork/topic" }); + expect(result.pr.status).toBe("created"); + expect( + ghCalls.some((call) => call.includes("pr create --base release --head octocat:topic")), + ).toBe(true); + }), + ); + it.effect("rejects push/pr actions from detached HEAD", () => 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 bc3ca3021bafa..39e77e178166f 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -89,11 +89,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; -import { - extractBranchNameFromRemoteRef, - parseRemoteNames, - parseRemoteRefWithRemoteNames, -} from "./remoteRefs.ts"; +import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import { detachStackFrame } from "./detachStackFrame.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; @@ -2280,7 +2276,7 @@ export const make = Effect.gen(function* () { fallbackBranch: string | null, threadId: ThreadId | undefined, emit: GitActionProgressEmitter, - pushedUpstreamRef?: string, + pushRemoteRef?: { readonly remoteName: string; readonly upstreamRef: string }, ) { const provider = yield* sourceControlProvider(cwd); const terms = getChangeRequestTerminologyForKind(provider.kind); @@ -2301,31 +2297,11 @@ export const make = Effect.gen(function* () { }); } - // A push to a configured push remote keeps tracking the fetch upstream, so - // the PR head is where the commits went, not the tracked ref. - const pushedRemoteRef = - pushedUpstreamRef && pushedUpstreamRef !== details.upstreamRef - ? parseRemoteRefWithRemoteNames( - pushedUpstreamRef, - parseRemoteNames( - (yield* gitCore.execute({ - operation: "GitManager.runPrStep.remotes", - cwd, - args: ["remote"], - timeoutMs: 5_000, - })).stdout, - ), - ) - : null; + // A configured push remote receives the commits while the branch keeps + // tracking its upstream, so the PR head is the push remote's branch. const headContext = yield* resolveBranchHeadContext( cwd, - pushedRemoteRef - ? { - branch, - upstreamRef: pushedRemoteRef.remoteRef, - remoteName: pushedRemoteRef.remoteName, - } - : { branch, upstreamRef: details.upstreamRef }, + pushRemoteRef ? { branch, ...pushRemoteRef } : { branch, upstreamRef: details.upstreamRef }, ); const existing = yield* findOpenPr(cwd, headContext); @@ -3267,7 +3243,9 @@ export const make = Effect.gen(function* () { currentBranch, input.threadId, progress.emit, - push.status === "pushed" ? push.upstreamBranch : undefined, + "pushRemote" in push && push.pushRemote && push.upstreamBranch + ? { remoteName: push.pushRemote, upstreamRef: push.upstreamBranch } + : undefined, ), ), ) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 684285d608d5f..78e4e9b0dc724 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -181,6 +181,8 @@ export interface GitPushResult { status: "pushed" | "skipped_up_to_date"; branch: string; upstreamBranch?: string | undefined; + /** Set when `upstreamBranch` is on a configured push remote instead of the tracked upstream. */ + pushRemote?: string | undefined; setUpstream?: boolean | undefined; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 04825db5a87e0..acb9b23700b59 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4431,6 +4431,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { status: "pushed", branch: "topic", upstreamBranch: "fork/topic", + pushRemote: "fork", setUpstream: false, }); assert.equal( @@ -4447,6 +4448,73 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); }), ); + + it.effect.each(["absent", "behind"] as const)( + "pushes an up-to-date tracked branch to the push remote when its branch there is %s", + (forkState) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const upstreamRemote = yield* makeTmpDir("git-upstream-remote-"); + const forkRemote = yield* makeTmpDir("git-fork-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(upstreamRemote, ["init", "--bare"]); + yield* git(forkRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); + yield* git(cwd, ["remote", "add", "fork", forkRemote]); + yield* git(cwd, ["checkout", "-b", "topic"]); + if (forkState === "behind") { + yield* git(cwd, ["push", "fork", "topic"]); + } + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* git(cwd, ["add", "-A"]); + yield* driver.commit(cwd, "Add upstream update", ""); + yield* git(cwd, ["push", "-u", "upstream", "topic"]); + yield* git(cwd, ["config", "remote.pushDefault", "fork"]); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + upstreamBranch: "fork/topic", + pushRemote: "fork", + }); + assert.equal( + yield* git(forkRemote, ["--git-dir=.", "log", "-1", "--pretty=%s", "topic"]), + "Add upstream update", + ); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "upstream/topic", + ); + }), + ); + + it.effect("skips an up-to-date tracked branch the push remote already has", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const upstreamRemote = yield* makeTmpDir("git-upstream-remote-"); + const forkRemote = yield* makeTmpDir("git-fork-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(upstreamRemote, ["init", "--bare"]); + yield* git(forkRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); + yield* git(cwd, ["remote", "add", "fork", forkRemote]); + yield* git(cwd, ["checkout", "-b", "topic"]); + yield* git(cwd, ["push", "-u", "upstream", "topic"]); + yield* git(cwd, ["push", "fork", "topic"]); + yield* git(cwd, ["config", "remote.pushDefault", "fork"]); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "skipped_up_to_date", + upstreamBranch: "fork/topic", + pushRemote: "fork", + }); + }), + ); }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0347bd8c42fcd..987e352ad400b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -390,6 +390,19 @@ function parseUpstreamRefByFirstSeparator( }; } +// A branch tracking a differently named ref was cut from it, the way `git +// checkout -b feature origin/dev` and our own worktree flow leave it. That +// upstream is the branch's base, not its publish target. +function isAliasOfUpstreamHead( + branch: string, + upstream: { readonly upstreamRef: string; readonly branchName: string }, +): boolean { + return ( + branch === upstream.branchName || + (branch.endsWith(`/${upstream.branchName}`) && upstream.upstreamRef.endsWith(`/${branch}`)) + ); +} + function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); @@ -1727,6 +1740,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); + // A fork workflow fetches from the upstream but pushes to the configured + // push remote, and keeps the upstream for pulls, as plain `git push` does. + const resolveSeparatePushRemoteName = Effect.fn("resolveSeparatePushRemoteName")(function* ( + cwd: string, + branch: string, + upstreamRemoteName: string, + ) { + const pushRemoteName = yield* resolveConfiguredPushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + return pushRemoteName && pushRemoteName !== upstreamRemoteName ? pushRemoteName : null; + }); + + const remoteRefContainsHead = (cwd: string, remoteRef: string) => + executeGit( + "GitVcsDriver.pushCurrentBranch.publishContainsHead", + cwd, + ["merge-base", "--is-ancestor", "HEAD", `refs/remotes/${remoteRef}`], + { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => result.exitCode === 0), + Effect.orElseSucceed(() => false), + ); + const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn( "ensureRemote", )(function* (input) { @@ -2546,15 +2583,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta) { - if (details.hasUpstream) { + if (hasNoLocalDelta && details.hasUpstream) { + // Matching the upstream says nothing about the push remote, which can + // still be missing these commits. + const upstream = yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)); + const pushRemote = + upstream && isAliasOfUpstreamHead(branch, upstream) + ? yield* resolveSeparatePushRemoteName(cwd, branch, upstream.remoteName) + : null; + if (!upstream || !pushRemote) { return { status: "skipped_up_to_date" as const, branch, ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } - + const pushRemoteRef = `${pushRemote}/${upstream.branchName}`; + if (yield* remoteRefContainsHead(cwd, pushRemoteRef)) { + return { + status: "skipped_up_to_date" as const, + branch, + upstreamBranch: pushRemoteRef, + pushRemote, + }; + } + } else if (hasNoLocalDelta) { const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( Effect.orElseSucceed(() => null), ); @@ -2571,21 +2624,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const publishBranch = yield* resolvePublishBranchName(cwd, branch); // The configured diff base can contain HEAD while the publish branch is still behind. - const publishContainsHead = yield* executeGit( - "GitVcsDriver.pushCurrentBranch.publishContainsHead", - cwd, - [ - "merge-base", - "--is-ancestor", - "HEAD", - `refs/remotes/${publishRemoteName}/${publishBranch}`, - ], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => result.exitCode === 0), - Effect.orElseSucceed(() => false), - ); - if (publishContainsHead) { + if (yield* remoteRefContainsHead(cwd, `${publishRemoteName}/${publishBranch}`)) { return { status: "skipped_up_to_date" as const, branch, @@ -2625,16 +2664,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { - // A branch tracking a differently named ref was cut from it, the way `git - // checkout -b feature origin/dev` and our own worktree flow leave it. - // That upstream is the branch's base, not its publish target, and pushing - // HEAD onto it would write feature commits to a shared branch (bare `git - // push` refuses this under push.default=simple). - const isAliasOfUpstreamHead = - branch === currentUpstream.branchName || - (branch.endsWith(`/${currentUpstream.branchName}`) && - currentUpstream.upstreamRef.endsWith(`/${branch}`)); - if (!isAliasOfUpstreamHead) { + // Pushing HEAD onto a base upstream would write feature commits to a + // shared branch (bare `git push` refuses this under push.default=simple). + if (!isAliasOfUpstreamHead(branch, currentUpstream)) { const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( Effect.orElseSucceed(() => null), ); @@ -2675,22 +2707,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - // A fork workflow fetches from the upstream but pushes to the configured - // push remote, and keeps the upstream for pulls, as plain `git push` does. - const configuredPushRemoteName = yield* resolveConfiguredPushRemoteName(cwd, branch).pipe( - Effect.orElseSucceed(() => null), + const pushRemote = yield* resolveSeparatePushRemoteName( + cwd, + branch, + currentUpstream.remoteName, ); - if (configuredPushRemoteName && configuredPushRemoteName !== currentUpstream.remoteName) { + if (pushRemote) { yield* runGit( "GitVcsDriver.pushCurrentBranch.pushToPushRemote", cwd, - ["push", "--", configuredPushRemoteName, `HEAD:refs/heads/${currentUpstream.branchName}`], + ["push", "--", pushRemote, `HEAD:refs/heads/${currentUpstream.branchName}`], { timeoutMs: null }, ); return { status: "pushed" as const, branch, - upstreamBranch: `${configuredPushRemoteName}/${currentUpstream.branchName}`, + upstreamBranch: `${pushRemote}/${currentUpstream.branchName}`, + pushRemote, setUpstream: false, }; } From 7d94105bd88580b2819f073365a485aedcac1b9d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 22:51:08 +0000 Subject: [PATCH 06/10] fix(server): fetch the push remote's branch before skipping an up-to-date push The up-to-date skip trusted the local refs/remotes//, which stays stale after the fork branch is deleted or rewound elsewhere, so Push could report success without publishing. The push-remote path now fetches that branch and skips only when the fresh ref contains HEAD. The check moves into the push-remote path, which already knows whether the branch tracks its own name, so the alias test stays inline where it was. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 39 +++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 76 +++++++++++--------- 2 files changed, 82 insertions(+), 33 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index acb9b23700b59..198fbd5db3f4b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4449,7 +4449,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect.each(["absent", "behind"] as const)( + it.effect.each(["absent", "behind", "deleted", "rewound"] as const)( "pushes an up-to-date tracked branch to the push remote when its branch there is %s", (forkState) => Effect.gen(function* () { @@ -4471,6 +4471,17 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* driver.commit(cwd, "Add upstream update", ""); yield* git(cwd, ["push", "-u", "upstream", "topic"]); yield* git(cwd, ["config", "remote.pushDefault", "fork"]); + if (forkState === "deleted" || forkState === "rewound") { + // Another checkout changes the fork after this one last fetched it, + // leaving a local fork/topic that still contains HEAD. + yield* git(cwd, ["push", "fork", "topic"]); + yield* git( + forkRemote, + forkState === "deleted" + ? ["--git-dir=.", "update-ref", "-d", "refs/heads/topic"] + : ["--git-dir=.", "update-ref", "refs/heads/topic", "topic~1"], + ); + } const pushed = yield* driver.pushCurrentBranch(cwd, null); @@ -4515,6 +4526,32 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); }), ); + + it.effect("skips an up-to-date branch whose differently named upstream is its base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const upstreamRemote = yield* makeTmpDir("git-upstream-remote-"); + const forkRemote = yield* makeTmpDir("git-fork-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(upstreamRemote, ["init", "--bare"]); + yield* git(forkRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); + yield* git(cwd, ["remote", "add", "fork", forkRemote]); + yield* git(cwd, ["push", "upstream", "HEAD:refs/heads/release"]); + yield* git(cwd, ["fetch", "upstream"]); + yield* git(cwd, ["checkout", "-b", "topic", "--track", "upstream/release"]); + yield* git(cwd, ["config", "remote.pushDefault", "fork"]); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "skipped_up_to_date", + upstreamBranch: "upstream/release", + }); + assert.equal(yield* git(forkRemote, ["--git-dir=.", "branch", "--list", "topic"]), ""); + }), + ); }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 987e352ad400b..c6afe47c99f93 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -390,19 +390,6 @@ function parseUpstreamRefByFirstSeparator( }; } -// A branch tracking a differently named ref was cut from it, the way `git -// checkout -b feature origin/dev` and our own worktree flow leave it. That -// upstream is the branch's base, not its publish target. -function isAliasOfUpstreamHead( - branch: string, - upstream: { readonly upstreamRef: string; readonly branchName: string }, -): boolean { - return ( - branch === upstream.branchName || - (branch.endsWith(`/${upstream.branchName}`) && upstream.upstreamRef.endsWith(`/${branch}`)) - ); -} - function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); @@ -2584,29 +2571,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; if (hasNoLocalDelta && details.hasUpstream) { - // Matching the upstream says nothing about the push remote, which can - // still be missing these commits. + // Matching the upstream says nothing about a separate push remote, which + // the push path below checks before skipping. const upstream = yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)); - const pushRemote = - upstream && isAliasOfUpstreamHead(branch, upstream) - ? yield* resolveSeparatePushRemoteName(cwd, branch, upstream.remoteName) - : null; - if (!upstream || !pushRemote) { + const pushRemote = upstream + ? yield* resolveSeparatePushRemoteName(cwd, branch, upstream.remoteName) + : null; + if (!pushRemote) { return { status: "skipped_up_to_date" as const, branch, ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } - const pushRemoteRef = `${pushRemote}/${upstream.branchName}`; - if (yield* remoteRefContainsHead(cwd, pushRemoteRef)) { - return { - status: "skipped_up_to_date" as const, - branch, - upstreamBranch: pushRemoteRef, - pushRemote, - }; - } } else if (hasNoLocalDelta) { const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( Effect.orElseSucceed(() => null), @@ -2664,9 +2641,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { - // Pushing HEAD onto a base upstream would write feature commits to a - // shared branch (bare `git push` refuses this under push.default=simple). - if (!isAliasOfUpstreamHead(branch, currentUpstream)) { + // A branch tracking a differently named ref was cut from it, the way `git + // checkout -b feature origin/dev` and our own worktree flow leave it. + // That upstream is the branch's base, not its publish target, and pushing + // HEAD onto it would write feature commits to a shared branch (bare `git + // push` refuses this under push.default=simple). + const isAliasOfUpstreamHead = + branch === currentUpstream.branchName || + (branch.endsWith(`/${currentUpstream.branchName}`) && + currentUpstream.upstreamRef.endsWith(`/${branch}`)); + if (!isAliasOfUpstreamHead) { + if (hasNoLocalDelta) { + return { + status: "skipped_up_to_date" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; + } const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( Effect.orElseSucceed(() => null), ); @@ -2713,6 +2704,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* currentUpstream.remoteName, ); if (pushRemote) { + const pushRemoteRef = `${pushRemote}/${currentUpstream.branchName}`; + // The local ref can be stale when the branch was deleted or rewound + // elsewhere, so read the push remote before skipping. + if ( + hasNoLocalDelta && + (yield* fetchRemoteTrackingBranch({ + cwd, + remoteName: pushRemote, + remoteBranch: currentUpstream.branchName, + }).pipe( + Effect.andThen(remoteRefContainsHead(cwd, pushRemoteRef)), + Effect.orElseSucceed(() => false), + )) + ) { + return { + status: "skipped_up_to_date" as const, + branch, + upstreamBranch: pushRemoteRef, + pushRemote, + }; + } yield* runGit( "GitVcsDriver.pushCurrentBranch.pushToPushRemote", cwd, From 41cb117e48f4a527a41a33a441b36dec793a9fd0 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 22:57:32 +0000 Subject: [PATCH 07/10] test(server): record PR creation through a source control provider The push-remote PR tests read the fake gh's argv to see which head and base the PR was created with. They now pass makeManager a small provider that records createChangeRequest, so they assert the base and head selector directly and keep working however the default fake forge records its calls. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/git/GitManager.test.ts | 45 +++++++++++++++++--------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 4529dd21ef505..dac058bc4305c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3985,6 +3985,27 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + // A forge with no open change requests that records the ones created. + const makeChangeRequestRecorder = () => { + const created: Array<{ readonly baseRefName: string; readonly headSelector: string }> = []; + const unexpected = (operation: string) => + Effect.die(new Error(`Unexpected source control call: ${operation}`)); + const provider: SourceControlProvider["Service"] = { + kind: SourceControlProviderKind.make("github"), + listChangeRequests: () => Effect.succeed([]), + getDefaultBranch: () => Effect.succeed("main"), + createChangeRequest: (input) => + Effect.sync(() => { + created.push({ baseRefName: input.baseRefName, headSelector: input.headSelector }); + }), + getChangeRequest: () => unexpected("getChangeRequest"), + getRepositoryCloneUrls: () => unexpected("getRepositoryCloneUrls"), + createRepository: () => unexpected("createRepository"), + checkoutChangeRequest: () => unexpected("checkoutChangeRequest"), + }; + return { provider, created }; + }; + it.effect("creates the PR from the push remote when the branch tracks the base repository", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4010,7 +4031,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - const { manager, ghCalls } = yield* makeManager(); + const { provider, created } = makeChangeRequestRecorder(); + const { manager } = yield* makeManager({ sourceControlProvider: provider }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", @@ -4018,12 +4040,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.push).toMatchObject({ status: "pushed", upstreamBranch: "fork/topic" }); expect(result.pr.status).toBe("created"); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head octocat:topic")), - ).toBe(true); - expect(ghCalls.some((call) => call.includes("pr create --base main --head topic"))).toBe( - false, - ); + expect(created).toEqual([{ baseRefName: "main", headSelector: "octocat:topic" }]); expect(yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toMatchObject({ stdout: "origin/topic\n", }); @@ -4055,7 +4072,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ); yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); - const { manager, ghCalls } = yield* makeManager(); + const { provider, created } = makeChangeRequestRecorder(); + const { manager } = yield* makeManager({ sourceControlProvider: provider }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", @@ -4066,9 +4084,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { upstreamBranch: "fork/topic", }); expect(result.pr.status).toBe("created"); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head octocat:topic")), - ).toBe(true); + expect(created).toEqual([{ baseRefName: "main", headSelector: "octocat:topic" }]); }), ); @@ -4098,7 +4114,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - const { manager, ghCalls } = yield* makeManager(); + const { provider, created } = makeChangeRequestRecorder(); + const { manager } = yield* makeManager({ sourceControlProvider: provider }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit_push_pr", @@ -4106,9 +4123,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.push).toMatchObject({ status: "pushed", upstreamBranch: "fork/topic" }); expect(result.pr.status).toBe("created"); - expect( - ghCalls.some((call) => call.includes("pr create --base release --head octocat:topic")), - ).toBe(true); + expect(created).toEqual([{ baseRefName: "release", headSelector: "octocat:topic" }]); }), ); From a84a69828dbf94a5a0a8703d5450b90c515572ec Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 10 Nov 2026 11:27:38 +0000 Subject: [PATCH 08/10] fix(server): a tracked branch skips the untracked base check before pushing The merge with main split the base-branch check from the tracked-branch check, so an up-to-date tracked branch whose push-remote branch was deleted read the stale local remote-tracking ref and skipped the push. Only branches without an upstream reach that check again. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 06eca919bd691..067e44554ddef 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2802,8 +2802,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* pushRemote, }; } - } - if (hasNoLocalDelta) { + } else if (hasNoLocalDelta) { const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( Effect.orElseSucceed(() => null), ); From 023b5ed7190dca502256d4b97826be86f10a21a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 08:13:48 +0000 Subject: [PATCH 09/10] test(server): trim push remote tests to the cases main fails Also give the local-upstream push destination an explicit pushRemote so every destination has the same shape. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/git/GitManager.test.ts | 82 -------------------- apps/server/src/vcs/GitVcsDriverCore.test.ts | 25 +++--- apps/server/src/vcs/GitVcsDriverCore.ts | 1 + 3 files changed, 11 insertions(+), 97 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index a6fc411a01052..3aa6c1996d578 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4783,88 +4783,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("creates the PR from the push remote when its branch there already has HEAD", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "topic"]); - yield* runGit(repoDir, ["push", "-u", "origin", "topic"]); - yield* runGit(repoDir, ["push", "fork", "topic"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - originDir, - ); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); - - const { manager, providerCalls } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.push).toMatchObject({ - status: "skipped_up_to_date", - upstreamBranch: "fork/topic", - }); - expect(result.pr.status).toBe("created"); - expect(created(providerCalls)).toEqual([ - { baseRefName: "main", headSelector: "octocat:topic" }, - ]); - }), - ); - - it.effect("keeps the tracked branch as the PR base when the push goes to the push remote", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); - yield* runGit(repoDir, ["push", "origin", "HEAD:refs/heads/release"]); - yield* runGit(repoDir, ["fetch", "origin"]); - yield* runGit(repoDir, ["checkout", "-b", "topic", "--track", "origin/release"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - originDir, - ); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - - const { manager, providerCalls } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.push).toMatchObject({ status: "pushed", upstreamBranch: "fork/topic" }); - expect(result.pr.status).toBe("created"); - expect(created(providerCalls)).toEqual([ - { baseRefName: "release", headSelector: "octocat:topic" }, - ]); - }), - ); - it.effect("rejects push/pr actions from detached HEAD", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 2313774f558d6..2378fab9124d4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -7999,7 +7999,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect.each(["absent", "behind", "deleted", "rewound"] as const)( + it.effect.each(["deleted", "rewound"] as const)( "pushes an up-to-date tracked branch to the push remote when its branch there is %s", (forkState) => Effect.gen(function* () { @@ -8013,25 +8013,20 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); yield* git(cwd, ["remote", "add", "fork", forkRemote]); yield* git(cwd, ["checkout", "-b", "topic"]); - if (forkState === "behind") { - yield* git(cwd, ["push", "fork", "topic"]); - } yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add upstream update", ""); yield* git(cwd, ["push", "-u", "upstream", "topic"]); yield* git(cwd, ["config", "remote.pushDefault", "fork"]); - if (forkState === "deleted" || forkState === "rewound") { - // Another checkout changes the fork after this one last fetched it, - // leaving a local fork/topic that still contains HEAD. - yield* git(cwd, ["push", "fork", "topic"]); - yield* git( - forkRemote, - forkState === "deleted" - ? ["--git-dir=.", "update-ref", "-d", "refs/heads/topic"] - : ["--git-dir=.", "update-ref", "refs/heads/topic", "topic~1"], - ); - } + // Another checkout changes the fork after this one last fetched it, + // leaving a local fork/topic that still contains HEAD. + yield* git(cwd, ["push", "fork", "topic"]); + yield* git( + forkRemote, + forkState === "deleted" + ? ["--git-dir=.", "update-ref", "-d", "refs/heads/topic"] + : ["--git-dir=.", "update-ref", "refs/heads/topic", "topic~1"], + ); const pushed = yield* driver.pushCurrentBranch(cwd, null); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 740592e1bb5fe..2e1deca4daacd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2880,6 +2880,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* branchName: branch, publishes: true, upstream: { remoteName: ".", branchName: localUpstream, upstreamRef: localUpstream }, + pushRemote: null, }; } From 725ffc44e86e1052e9ef329d5b47bd720c3d05f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 08:30:52 +0000 Subject: [PATCH 10/10] fix(server): create_pr publishes to a push remote that lacks HEAD A tracked branch whose upstream already has HEAD skipped the push step, so create_pr never published to the configured push remote and opened the PR from the upstream instead. resolvePushTarget now reports when pushes go to a distinct push remote, and create_pr runs the push step in that case. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/git/GitManager.test.ts | 85 ++++++++++++++----------- apps/server/src/git/GitManager.ts | 16 ++++- apps/server/src/vcs/GitVcsDriver.ts | 2 + apps/server/src/vcs/GitVcsDriverCore.ts | 6 +- 4 files changed, 69 insertions(+), 40 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 3aa6c1996d578..cdc7bde90fa72 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4741,46 +4741,55 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("creates the PR from the push remote when the branch tracks the base repository", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "topic"]); - yield* runGit(repoDir, ["push", "-u", "origin", "topic"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - originDir, - ); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + it.effect( + "create_pr publishes to the push remote when the tracked upstream already has HEAD", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "topic"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Topic change"]); + yield* runGit(repoDir, ["push", "-u", "origin", "topic"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.com:octocat/codething-mvp.git", + forkDir, + ); + yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); - const { manager, providerCalls } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); + const { manager, providerCalls } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); - expect(result.push).toMatchObject({ status: "pushed", upstreamBranch: "fork/topic" }); - expect(result.pr.status).toBe("created"); - expect(created(providerCalls)).toEqual([ - { baseRefName: "main", headSelector: "octocat:topic" }, - ]); - expect(yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toMatchObject({ - stdout: "origin/topic\n", - }); - }), + expect(result.push).toMatchObject({ status: "pushed", upstreamBranch: "fork/topic" }); + expect(result.pr.status).toBe("created"); + expect( + yield* runGit(forkDir, ["--git-dir=.", "log", "-1", "--pretty=%s", "topic"]), + ).toMatchObject({ + stdout: "Topic change\n", + }); + expect(created(providerCalls)).toEqual([ + { baseRefName: "main", headSelector: "octocat:topic" }, + ]); + expect(yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toMatchObject({ + stdout: "origin/topic\n", + }); + }), ); it.effect("rejects push/pr actions from detached HEAD", () => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 0fbaae34200ee..c3f573a9391e6 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -3405,12 +3405,26 @@ export const make = Effect.gen(function* () { > { const initialStatus = yield* gitCore.statusDetails(input.cwd); const wantsCommit = isCommitAction(input.action); + const upToDateBranch = + input.action === "create_pr" && + initialStatus.hasUpstream && + initialStatus.aheadCount === 0 + ? initialStatus.branch + : null; + // A current upstream says nothing about a separate push remote, which may lack HEAD. + const pushTarget = upToDateBranch + ? yield* gitCore + .resolvePushTarget(input.cwd, { branch: upToDateBranch, hasUpstream: true }) + .pipe(Effect.orElseSucceed(() => null)) + : null; const wantsPush = input.action === "push" || input.action === "commit_push" || input.action === "commit_push_pr" || (input.action === "create_pr" && - (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); + (!initialStatus.hasUpstream || + initialStatus.aheadCount > 0 || + pushTarget?.viaPushRemote === true)); const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; if (input.featureBranch && !wantsCommit) { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index ef6cc64efee9d..319f1ffb86651 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -185,6 +185,8 @@ export interface GitPushResult { export interface GitPushTarget { remoteName: string; branchName: string; + /** The tracked upstream is current, yet pushes go to this configured push remote. */ + viaPushRemote: boolean; } export interface GitRangeContext { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2e1deca4daacd..abdd21614f683 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2925,7 +2925,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* )(function* (cwd, current) { const destination = yield* resolvePushDestination(cwd, current.branch, current.hasUpstream); return destination - ? { remoteName: destination.remoteName, branchName: destination.branchName } + ? { + remoteName: destination.remoteName, + branchName: destination.branchName, + viaPushRemote: destination.pushRemote !== null, + } : null; });