From b2d1e228c4fb3dfb6a019d7cd022e0a2e8d05970 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:10:28 +1100 Subject: [PATCH 1/8] fix(server): pushing a branch whose remote branch was deleted publishes it again Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 26 ++++++++++++++++++-- apps/server/src/vcs/GitVcsDriverCore.ts | 22 +++++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9f9d23a8e409b..f6f6b0e71a9a8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3431,7 +3431,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports remote status on unborn HEAD without failing", () => + it.effect("reports status on unborn HEAD without failing or dropping its upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const driver = yield* GitVcsDriver.GitVcsDriver; @@ -3445,6 +3445,17 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(status.hasUpstream, false); assert.equal(status.aheadCount, 0); assert.equal(status.behindCount, 0); + + const seed = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(seed); + yield* git(remote, ["init", "--bare"]); + yield* git(seed, ["push", remote, `HEAD:refs/heads/${initialBranch}`]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["config", `branch.${initialBranch}.remote`, "origin"]); + yield* git(cwd, ["config", `branch.${initialBranch}.merge`, `refs/heads/${initialBranch}`]); + assert.equal((yield* driver.statusDetailsLocal(cwd)).hasUpstream, true); }), ); @@ -5668,7 +5679,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("pushes with upstream setup and skips when already up to date", () => + it.effect("pushes, skips when up to date, and republishes a deleted upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-remote-"); @@ -5703,6 +5714,17 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { status: "skipped_up_to_date", branch: "feature/push", }); + + yield* git(cwd, ["push", "origin", "--delete", "feature/push"]); + yield* writeTextFile(cwd, "followup.txt", "followup\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add followup", ""); + const republished = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(republished, { status: "pushed", branch: "feature/push" }); + assert.equal( + yield* git(remote, ["--git-dir=.", "rev-parse", "refs/heads/feature/push"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a084664bc447c..cde7c1cfcc282 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2232,6 +2232,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let refName: string | null = null; let upstreamRef: string | null = null; + // Git omits branch.ab when the upstream ref is gone, e.g. deleted after its PR merged, + // and also for an unborn HEAD, which can still track a fetched upstream. + let upstreamRefExists = false; + let isUnbornHead = false; let aheadCount = 0; let behindCount = 0; let aheadOfDefaultCount = 0; @@ -2242,6 +2246,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusRecords = statusStdout.split("\0"); for (let index = 0; index < statusRecords.length; index++) { const line = statusRecords[index]!; + if (line === "# branch.oid (initial)") { + isUnbornHead = true; + continue; + } if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); refName = value.startsWith("(") ? null : value; @@ -2252,11 +2260,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* upstreamRef = value.length > 0 ? value : null; continue; } - if (includeDivergence && line.startsWith("# branch.ab ")) { - const value = line.slice("# branch.ab ".length).trim(); - const parsed = parseBranchAb(value); - aheadCount = parsed.ahead; - behindCount = parsed.behind; + if (line.startsWith("# branch.ab ")) { + upstreamRefExists = true; + if (includeDivergence) { + const parsed = parseBranchAb(line.slice("# branch.ab ".length).trim()); + aheadCount = parsed.ahead; + behindCount = parsed.behind; + } continue; } if (line.length > 0 && !line.startsWith("#")) { @@ -2275,6 +2285,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } + if (!upstreamRefExists && !isUnbornHead) upstreamRef = null; + const isDefaultBranch = refName !== null && (refName === defaultBranch || From 744e09251d1ca3ecac4e7109d041757e08889b34 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:00:18 +1100 Subject: [PATCH 2/8] fix(server): a republished branch goes back to the remote it tracked Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 18 ++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 25 ++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f6f6b0e71a9a8..b112caefa791d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5725,6 +5725,24 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(remote, ["--git-dir=.", "rev-parse", "refs/heads/feature/push"]), yield* git(cwd, ["rev-parse", "HEAD"]), ); + + const fork = yield* makeTmpDir("git-fork-"); + yield* git(fork, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "fork", fork]); + yield* git(cwd, ["push", "-u", "fork", "feature/push"]); + yield* git(cwd, ["push", "fork", "--delete", "feature/push"]); + yield* writeTextFile(cwd, "fork.txt", "fork\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add fork change", ""); + const forkRepublished = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch( + cwd, + null, + ); + assert.deepInclude(forkRepublished, { upstreamBranch: "fork/feature/push" }); + assert.equal( + yield* git(fork, ["--git-dir=.", "rev-parse", "refs/heads/feature/push"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index cde7c1cfcc282..c824fadaf6311 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1732,6 +1732,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( cwd: string, refName: string, + fallbackRemoteName: string | null = null, ) { const branchPushRemote = yield* runGitStdout( "GitVcsDriver.resolvePushRemoteName.branchPushRemote", @@ -1753,6 +1754,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return pushDefaultRemote; } + if (fallbackRemoteName) { + return fallbackRemoteName; + } + return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); @@ -2663,6 +2668,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } + // A deleted upstream keeps its config; republish to its remote when it was this branch's own. + let goneUpstreamRemoteName: string | null = null; + if (!details.hasUpstream) { + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + const mergeRef = yield* readConfigValue(cwd, `branch.${branch}.merge`); + if (mergeRef === `refs/heads/${publishBranch}`) { + goneUpstreamRemoteName = yield* readConfigValue(cwd, `branch.${branch}.remote`); + } + } + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; if (hasNoLocalDelta) { if (details.hasUpstream) { @@ -2677,9 +2692,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (comparableBaseBranch) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.orElseSucceed(() => null), - ); + const publishRemoteName = yield* resolvePushRemoteName( + cwd, + branch, + goneUpstreamRemoteName, + ).pipe(Effect.orElseSucceed(() => null)); if (!publishRemoteName) { return { status: "skipped_up_to_date" as const, @@ -2713,7 +2730,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch, goneUpstreamRemoteName); if (!publishRemoteName) { return yield* new GitCommandError({ ...gitCommandContext({ From 35a947de9e88d2622c8ae4cfc4572a7d8e61612b Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:42:20 +1100 Subject: [PATCH 3/8] fix(server): a republished branch keeps its literal name and skips local upstreams Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 12 +++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 26 ++++++++++++++------ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index b112caefa791d..304c470c05f78 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5729,6 +5729,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const fork = yield* makeTmpDir("git-fork-"); yield* git(fork, ["init", "--bare"]); yield* git(cwd, ["remote", "add", "fork", fork]); + yield* git(cwd, ["remote", "add", "feature", fork]); yield* git(cwd, ["push", "-u", "fork", "feature/push"]); yield* git(cwd, ["push", "fork", "--delete", "feature/push"]); yield* writeTextFile(cwd, "fork.txt", "fork\n"); @@ -5743,6 +5744,17 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(fork, ["--git-dir=.", "rev-parse", "refs/heads/feature/push"]), yield* git(cwd, ["rev-parse", "HEAD"]), ); + + yield* git(cwd, ["config", "branch.feature/push.remote", "."]); + yield* git(cwd, ["config", "branch.feature/push.merge", "refs/heads/push"]); + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add local change", ""); + const localUpstreamGone = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch( + cwd, + null, + ); + assert.deepInclude(localUpstreamGone, { upstreamBranch: "origin/push" }); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index c824fadaf6311..a5605e495444f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2669,12 +2669,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } // A deleted upstream keeps its config; republish to its remote when it was this branch's own. - let goneUpstreamRemoteName: string | null = null; + let goneUpstream: { remoteName: string; branchName: string } | null = null; if (!details.hasUpstream) { - const publishBranch = yield* resolvePublishBranchName(cwd, branch); const mergeRef = yield* readConfigValue(cwd, `branch.${branch}.merge`); - if (mergeRef === `refs/heads/${publishBranch}`) { - goneUpstreamRemoteName = yield* readConfigValue(cwd, `branch.${branch}.remote`); + const mergeBranch = mergeRef?.startsWith("refs/heads/") + ? mergeRef.slice("refs/heads/".length) + : null; + const remoteName = yield* readConfigValue(cwd, `branch.${branch}.remote`); + if ( + mergeBranch && + remoteName && + remoteName !== "." && + (mergeBranch === branch || mergeBranch === (yield* resolvePublishBranchName(cwd, branch))) + ) { + goneUpstream = { remoteName, branchName: mergeBranch }; } } @@ -2695,7 +2703,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const publishRemoteName = yield* resolvePushRemoteName( cwd, branch, - goneUpstreamRemoteName, + goneUpstream?.remoteName, ).pipe(Effect.orElseSucceed(() => null)); if (!publishRemoteName) { return { @@ -2704,7 +2712,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - const publishBranch = yield* resolvePublishBranchName(cwd, branch); + const publishBranch = + goneUpstream?.branchName ?? (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", @@ -2730,7 +2739,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch, goneUpstreamRemoteName); + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch, goneUpstream?.remoteName); if (!publishRemoteName) { return yield* new GitCommandError({ ...gitCommandContext({ @@ -2741,7 +2750,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: "Cannot push because no git remote is configured for this repository.", }); } - const publishBranch = yield* resolvePublishBranchName(cwd, branch); + const publishBranch = + goneUpstream?.branchName ?? (yield* resolvePublishBranchName(cwd, branch)); yield* runGit( "GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, From aa683a1797adf9e8365e76c5e10a4f22676b5772 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:02:33 +1100 Subject: [PATCH 4/8] fix(server): republishing a branch keeps its deleted base upstream as the base Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 15 ++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 57 ++++++++++++-------- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 304c470c05f78..3fe8cee5a3cd8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5755,6 +5755,21 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { null, ); assert.deepInclude(localUpstreamGone, { upstreamBranch: "origin/push" }); + + yield* git(cwd, ["push", "fork", "HEAD:refs/heads/release"]); + yield* git(cwd, ["branch", "-u", "fork/release"]); + yield* git(cwd, ["push", "fork", "--delete", "release"]); + yield* writeTextFile(cwd, "release.txt", "release\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add release change", ""); + yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.equal( + yield* (yield* GitVcsDriver.GitVcsDriver).readConfigValue( + cwd, + "branch.feature/push.t3-merge-base", + ), + "release", + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a5605e495444f..b3519c10b6c3f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2635,6 +2635,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + // Publishing retargets the upstream, so preserve its comparison base first. + const preserveUpstreamBase = Effect.fn("preserveUpstreamBase")(function* ( + cwd: string, + branch: string, + upstream: { readonly branchName: string; readonly upstreamRef: string }, + ) { + if ((yield* readBranchBase(cwd, branch)) !== null) return; + yield* writeConfigValue({ cwd, key: branchBaseConfigKey(branch), value: upstream.branchName }); + if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { + yield* writeConfigValue({ + cwd, + key: `branch.${branch}.t3-merge-base-ref`, + value: `refs/remotes/${upstream.upstreamRef}`, + }); + } + }); + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( "pushCurrentBranch", )(function* (cwd, fallbackBranch, options) { @@ -2668,21 +2685,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - // A deleted upstream keeps its config; republish to its remote when it was this branch's own. + // A deleted upstream keeps its config: republish to it when it was this branch's own, + // otherwise it was the base, so keep that before publishing. let goneUpstream: { remoteName: string; branchName: string } | null = null; + let goneBaseUpstream: { branchName: string; upstreamRef: string } | null = null; if (!details.hasUpstream) { const mergeRef = yield* readConfigValue(cwd, `branch.${branch}.merge`); const mergeBranch = mergeRef?.startsWith("refs/heads/") ? mergeRef.slice("refs/heads/".length) : null; const remoteName = yield* readConfigValue(cwd, `branch.${branch}.remote`); - if ( - mergeBranch && - remoteName && - remoteName !== "." && - (mergeBranch === branch || mergeBranch === (yield* resolvePublishBranchName(cwd, branch))) - ) { - goneUpstream = { remoteName, branchName: mergeBranch }; + if (mergeBranch && remoteName && remoteName !== ".") { + if ( + mergeBranch === branch || + mergeBranch === (yield* resolvePublishBranchName(cwd, branch)) + ) { + goneUpstream = { remoteName, branchName: mergeBranch }; + } else { + goneBaseUpstream = { + branchName: mergeBranch, + upstreamRef: `${remoteName}/${mergeBranch}`, + }; + } } } @@ -2752,6 +2776,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const publishBranch = goneUpstream?.branchName ?? (yield* resolvePublishBranchName(cwd, branch)); + if (goneBaseUpstream) yield* preserveUpstreamBase(cwd, branch, goneBaseUpstream); yield* runGit( "GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, @@ -2781,21 +2806,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const remoteName = publishRemoteName ?? currentUpstream.remoteName; const publishBranch = yield* resolvePublishBranchName(cwd, branch); - // Publishing retargets the upstream, so preserve its comparison base first. - if ((yield* readBranchBase(cwd, branch)) === null) { - yield* writeConfigValue({ - cwd, - key: branchBaseConfigKey(branch), - value: currentUpstream.branchName, - }); - if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { - yield* writeConfigValue({ - cwd, - key: `branch.${branch}.t3-merge-base-ref`, - value: `refs/remotes/${currentUpstream.upstreamRef}`, - }); - } - } + yield* preserveUpstreamBase(cwd, branch, currentUpstream); yield* runGit( "GitVcsDriver.pushCurrentBranch.pushOwnBranch", cwd, From e236ae0c42b230f8ef96f4467c9b038420da7834 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:12:41 +1100 Subject: [PATCH 5/8] fix(server): a branch whose local upstream was deleted publishes under its full name Main publishes a branch that tracks a local branch under its full local name, so a local branch named like origin/main never lands on remote main. Keep that rule when the local upstream has since been deleted and the branch takes the republish path. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 2 +- apps/server/src/vcs/GitVcsDriverCore.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d2501d311c88c..fcc7c7023018c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -7369,7 +7369,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { cwd, null, ); - assert.deepInclude(localUpstreamGone, { upstreamBranch: "origin/push" }); + assert.deepInclude(localUpstreamGone, { upstreamBranch: "origin/feature/push" }); yield* git(cwd, ["push", "fork", "HEAD:refs/heads/release"]); yield* git(cwd, ["branch", "-u", "fork/release"]); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 7a043b48f801f..561c05efc410c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2872,7 +2872,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const remoteName = yield* resolvePushRemoteName(cwd, branch, goneUpstream?.remoteName); if (!remoteName) return null; - const branchName = goneUpstream?.branchName ?? (yield* resolvePublishBranchName(cwd, branch)); + // A gone local upstream still gets the full local name, as below. + const branchName = + goneUpstream?.branchName ?? + (configuredRemoteName === "." ? branch : yield* resolvePublishBranchName(cwd, branch)); return { remoteName, branchName, publishes: true, upstream: goneBaseUpstream }; } From 719eaf8d869e087b37d35d8eafac31aa11807d98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 13:11:08 +1100 Subject: [PATCH 6/8] test(server): idle-resume replays wait for the session release before the next prompt (#35720) Co-authored-by: Claude Opus 5.5 (cherry picked from commit 48a56faaa2f1b9c3695bb4b506e32fbf249fb626) --- .../testkit/OrchestratorScenario.ts | 40 +++++++++++++++++-- .../claude_compact_after_resume_wake/input.ts | 2 +- .../fixtures/claude_idle_resume/input.ts | 2 +- .../fixtures/provider_thread_resume/input.ts | 2 +- .../testkit/fixtures/shared.ts | 10 ++++- .../input.ts | 2 +- 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index b413c0aefa259..0d428e1a6e1e0 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -14,6 +14,7 @@ import type { TurnItemId, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -37,6 +38,8 @@ export type OrchestratorV2ScenarioStep = | { readonly type: "advance_clock"; readonly duration: Duration.Input; + /** Wait until the jump idle-releases this thread's provider session. */ + readonly awaitProviderSessionRelease?: ThreadId; } | { readonly type: "await"; @@ -271,9 +274,21 @@ export function runOrchestratorV2Scenario( const orchestrator = yield* Orchestrator.OrchestratorV2; const storedEventGroups: Array> = []; const observedStoredEvents = yield* Ref.make>([]); + let releaseWatch: + | { readonly threadId: ThreadId; readonly released: Deferred.Deferred } + | undefined; yield* orchestrator.streamStoredEvents.pipe( - Stream.runForEach((event) => - Ref.update(observedStoredEvents, (existing) => [...existing, event]), + Stream.runForEach((stored) => + Ref.update(observedStoredEvents, (existing) => [...existing, stored]).pipe( + Effect.andThen( + releaseWatch !== undefined && + stored.event.type === "provider-session.updated" && + stored.event.threadId === releaseWatch.threadId && + stored.event.payload.status === "stopped" + ? Deferred.succeed(releaseWatch.released, undefined) + : Effect.void, + ), + ), ), Effect.forkScoped, ); @@ -647,9 +662,28 @@ export function runOrchestratorV2Scenario( ); break; } - case "advance_clock": + case "advance_clock": { + if (step.awaitProviderSessionRelease === undefined) { + yield* TestClock.adjust(step.duration); + break; + } + // The release runs on a woken fiber; a dispatch before it commits cancels it. + const released = yield* Deferred.make(); + releaseWatch = { threadId: step.awaitProviderSessionRelease, released }; yield* TestClock.adjust(step.duration); + const settled = yield* Deferred.await(released).pipe( + Effect.timeoutOption(Duration.millis(SCENARIO_WAIT_DEADLINE_MS)), + Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()), + ); + releaseWatch = undefined; + if (Option.isNone(settled)) { + return yield* new OrchestratorV2ScenarioStepError({ + scenario: scenario.name, + step: `advance_clock:${step.awaitProviderSessionRelease}:no_provider_session_release`, + }); + } break; + } case "await": yield* awaitDispatch(step.key); break; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_resume_wake/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_resume_wake/input.ts index 5c1ff9ad62baf..bb34f0d2eaf05 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_resume_wake/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_resume_wake/input.ts @@ -5,7 +5,7 @@ export function claudeCompactAfterResumeWakeInput(): OrchestratorFixtureInput { return { steps: [ { type: "message", text: CLAUDE_COMPACT_FIRST_PROMPT }, - { type: "advance_clock", duration: "31 minutes" }, + { type: "advance_clock", duration: "31 minutes", releasesProviderSession: true }, { type: "message", text: "/compact" }, ], }; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_idle_resume/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_idle_resume/input.ts index f074584dcd3d1..2663b1d6c0eab 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_idle_resume/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_idle_resume/input.ts @@ -7,7 +7,7 @@ export function claudeIdleResumeInput(): OrchestratorFixtureInput { return { steps: [ { type: "message", text: CLAUDE_IDLE_RESUME_PROMPT_1 }, - { type: "advance_clock", duration: "31 minutes" }, + { type: "advance_clock", duration: "31 minutes", releasesProviderSession: true }, { type: "message", text: CLAUDE_IDLE_RESUME_PROMPT_2 }, ], }; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/input.ts index 50625a832ce25..d4ae2dc064f9f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/input.ts @@ -9,7 +9,7 @@ export function providerThreadResumeInput(): OrchestratorFixtureInput { return { steps: [ { type: "message", text: PROVIDER_THREAD_RESUME_FIRST_PROMPT }, - { type: "advance_clock", duration: "31 minutes" }, + { type: "advance_clock", duration: "31 minutes", releasesProviderSession: true }, { type: "message", text: PROVIDER_THREAD_RESUME_SECOND_PROMPT }, ], }; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 1a550cd6786dc..63f85819acbaa 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -268,6 +268,8 @@ export type OrchestratorFixtureInputStep = | { readonly type: "advance_clock"; readonly duration: Duration.Input; + /** The jump idle-releases the provider session; later steps wait for that release. */ + readonly releasesProviderSession?: boolean; } | { /** Picks another model of the same provider instance; later messages use it. */ @@ -883,7 +885,13 @@ export function materializeFixtureInput(input: { }); break; case "advance_clock": - steps.push({ type: "advance_clock", duration: step.duration }); + steps.push({ + type: "advance_clock", + duration: step.duration, + ...(step.releasesProviderSession === true + ? { awaitProviderSessionRelease: ids.threadId } + : {}), + }); break; case "set_model": modelSelection = { ...input.modelSelection, model: step.model }; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/stop_background_work_after_failed_turn/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/stop_background_work_after_failed_turn/input.ts index aba2f3f305f0b..4faf0bef550e3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/stop_background_work_after_failed_turn/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/stop_background_work_after_failed_turn/input.ts @@ -16,7 +16,7 @@ export function stopBackgroundWorkAfterReleaseInput(): OrchestratorFixtureInput return { steps: [ { type: "message", text: STOP_BACKGROUND_WORK_AFTER_FAILED_TURN_PROMPT }, - { type: "advance_clock", duration: "31 minutes" }, + { type: "advance_clock", duration: "31 minutes", releasesProviderSession: true }, { type: "stop_background_work", targetRunIndex: 1 }, ], }; From 95d480a49c3475efa2dedd0e46b462db884ccd72 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:11:38 +1100 Subject: [PATCH 7/8] fix(server): preserve deleted-upstream push destinations Use the live-upstream alias identity when a tracking ref is gone, so a comparison base stays on its original remote and Git tracking aliases republish under their configured remote branch name. Extend the existing Git regression cases through deletion. Implemented by GPT-6.1 Sol through Codex in T3 Code. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 89 +++++++++++++++----- apps/server/src/vcs/GitVcsDriverCore.ts | 3 +- 2 files changed, 69 insertions(+), 23 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index fcc7c7023018c..7114016c6fb09 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -7534,14 +7534,16 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); it.effect.each([ - { baseBranch: "dev", local: false }, - { baseBranch: "dev", local: true }, - { baseBranch: "feature/base", local: true }, - { baseBranch: "upstream", local: true }, - { baseBranch: "upstream/feature/x", local: true }, + { baseBranch: "dev", local: false, branch: "feature/x", deleted: false }, + { baseBranch: "dev", local: true, branch: "feature/x", deleted: false }, + { baseBranch: "feature/base", local: true, branch: "feature/x", deleted: false }, + { baseBranch: "upstream", local: true, branch: "feature/x", deleted: false }, + { baseBranch: "upstream/feature/x", local: true, branch: "feature/x", deleted: false }, + { baseBranch: "dev", local: false, branch: "origin/dev", deleted: false }, + { baseBranch: "dev", local: false, branch: "origin/dev", deleted: true }, ])( - "publishes a branch tracking $baseBranch (local=$local) under its own name, not the base", - ({ baseBranch, local }) => + "publishes $branch tracking $baseBranch (local=$local, deleted=$deleted) without updating the base", + ({ baseBranch, local, branch, deleted }) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-remote-"); @@ -7566,51 +7568,68 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "checkout", "--track", "-b", - "feature/x", + branch, local ? `refs/heads/${baseBranch}` : `refs/remotes/upstream/${baseBranch}`, ]); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/x.remote"), + yield* driver.readConfigValue(cwd, `branch.${branch}.remote`), local ? "." : "upstream", ); yield* writeTextFile(cwd, "feature.txt", "feature\n"); yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add feature", ""); + if (deleted) { + yield* git(cwd, ["push", "upstream", "--delete", baseBranch]); + } + const publishBranch = branch === "origin/dev" ? "dev" : branch; + assert.deepEqual( + yield* driver.resolvePushTarget(cwd, { branch, hasUpstream: !deleted }), + { remoteName: "origin", branchName: publishBranch }, + ); const pushed = yield* driver.pushCurrentBranch(cwd, null); assert.deepInclude(pushed, { status: "pushed", - branch: "feature/x", - upstreamBranch: "origin/feature/x", + branch, + upstreamBranch: `origin/${publishBranch}`, setUpstream: true, }); assert.equal( - yield* git(remote, ["--git-dir=.", "log", "-1", "--pretty=%s", "feature/x"]), + yield* git(remote, ["--git-dir=.", "log", "-1", "--pretty=%s", publishBranch]), "Add feature", ); + if (publishBranch !== baseBranch) { + assert.equal( + yield* git(remote, ["--git-dir=.", "rev-parse", `refs/heads/${baseBranch}`]), + devSha, + ); + } assert.equal( - yield* git(remote, ["--git-dir=.", "rev-parse", `refs/heads/${baseBranch}`]), - devSha, - ); - assert.equal( - yield* git(upstream, ["--git-dir=.", "rev-parse", `refs/heads/${baseBranch}`]), - upstreamDevSha, + yield* git(upstream, [ + "--git-dir=.", + "for-each-ref", + "--format=%(objectname)", + `refs/heads/${baseBranch}`, + ]), + deleted ? "" : upstreamDevSha, ); assert.equal( - yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), - "origin/feature/x", + yield* git(cwd, ["rev-parse", "--symbolic-full-name", "@{upstream}"]), + `refs/remotes/origin/${publishBranch}`, ); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/x.t3-merge-base"), + yield* driver.readConfigValue(cwd, `branch.${branch}.t3-merge-base`), baseBranch, ); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/x.t3-merge-base-ref"), + yield* driver.readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`), local ? `refs/heads/${baseBranch}` : `refs/remotes/upstream/${baseBranch}`, ); assert.equal(yield* git(cwd, ["rev-parse", `refs/heads/${baseBranch}`]), upstreamDevSha); + if (deleted) return; + const preview = yield* driver.getReviewDiffPreview({ cwd }); const changes = preview.sources.find((source) => source.kind === "branch-range")!; assert.deepStrictEqual(changes.files, [ @@ -7759,6 +7778,32 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(remote, ["--git-dir=.", "log", "-1", "--pretty=%s", "effect-atom"]), "Add alias update", ); + + yield* git(cwd, ["push", "my-org/upstream", "--delete", "effect-atom"]); + assert.deepEqual( + yield* driver.resolvePushTarget(cwd, { + branch: "upstream/effect-atom", + hasUpstream: false, + }), + { remoteName: "my-org/upstream", branchName: "effect-atom" }, + ); + assert.deepInclude(yield* driver.pushCurrentBranch(cwd, null), { + upstreamBranch: "my-org/upstream/effect-atom", + setUpstream: true, + }); + assert.equal( + yield* git(remote, ["--git-dir=.", "rev-parse", "refs/heads/effect-atom"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + assert.equal( + yield* git(remote, [ + "--git-dir=.", + "for-each-ref", + "--format=%(refname)", + "refs/heads/upstream/", + ]), + "", + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 561c05efc410c..82c21d017a637 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2859,7 +2859,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (mergeBranch && configuredRemoteName && configuredRemoteName !== ".") { if ( mergeBranch === branch || - mergeBranch === (yield* resolvePublishBranchName(cwd, branch)) + (branch.endsWith(`/${mergeBranch}`) && + `${configuredRemoteName}/${mergeBranch}`.endsWith(`/${branch}`)) ) { goneUpstream = { remoteName: configuredRemoteName, branchName: mergeBranch }; } else { From 9e2b7009f4ab5098ff2236fefd41413d61abc340 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 14:25:18 +0000 Subject: [PATCH 8/8] refactor(server): share the upstream alias check and trim republish tests The deleted-upstream path reuses the tracked path's alias check instead of copying it. Push-target assertions tolerate main's new viaPushRemote field, and checks that only restated existing publish behaviour are dropped. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 24 ++++---------------- apps/server/src/vcs/GitVcsDriverCore.ts | 24 +++++++++++--------- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 77f042decc058..bc30d6cb0ac5f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -8065,22 +8065,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["add", "-A"]); yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); - const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); - assert.deepInclude(pushed, { - status: "pushed", - branch: "feature/push", - setUpstream: true, - }); - assert.equal( - yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), - "origin/feature/push", - ); - - const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); - assert.deepInclude(skipped, { - status: "skipped_up_to_date", - branch: "feature/push", - }); + yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); yield* git(cwd, ["push", "origin", "--delete", "feature/push"]); yield* writeTextFile(cwd, "followup.txt", "followup\n"); @@ -8102,7 +8087,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "fork.txt", "fork\n"); yield* git(cwd, ["add", "-A"]); yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add fork change", ""); - assert.deepEqual( + assert.deepInclude( yield* (yield* GitVcsDriver.GitVcsDriver).resolvePushTarget(cwd, { branch: "feature/push", hasUpstream: false, @@ -8308,7 +8293,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { { baseBranch: "feature/base", local: true, branch: "feature/x", deleted: false }, { baseBranch: "upstream", local: true, branch: "feature/x", deleted: false }, { baseBranch: "upstream/feature/x", local: true, branch: "feature/x", deleted: false }, - { baseBranch: "dev", local: false, branch: "origin/dev", deleted: false }, { baseBranch: "dev", local: false, branch: "origin/dev", deleted: true }, ])( "publishes $branch tracking $baseBranch (local=$local, deleted=$deleted) without updating the base", @@ -8351,7 +8335,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["push", "upstream", "--delete", baseBranch]); } const publishBranch = branch === "origin/dev" ? "dev" : branch; - assert.deepEqual( + assert.deepInclude( yield* driver.resolvePushTarget(cwd, { branch, hasUpstream: !deleted }), { remoteName: "origin", branchName: publishBranch }, ); @@ -8549,7 +8533,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); yield* git(cwd, ["push", "my-org/upstream", "--delete", "effect-atom"]); - assert.deepEqual( + assert.deepInclude( yield* driver.resolvePushTarget(cwd, { branch: "upstream/effect-atom", hasUpstream: false, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index fd8b5998db8b0..d04102533a65c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -69,6 +69,10 @@ const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base` * CLI's key, which branches cut before then still carry instead. */ const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; +// Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. +const isAliasOfUpstreamHead = (branch: string, upstreamBranch: string, upstreamRef: string) => + branch === upstreamBranch || + (branch.endsWith(`/${upstreamBranch}`) && upstreamRef.endsWith(`/${branch}`)); const gitProcesses = Semaphore.makeUnsafe(8); const backgroundGitProcesses = Semaphore.makeUnsafe(2); // The fetch before `git worktree add` downloads everything the remote gained since @@ -2956,11 +2960,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : null; const configuredRemoteName = yield* readConfigValue(cwd, `branch.${branch}.remote`); if (mergeBranch && configuredRemoteName && configuredRemoteName !== ".") { - if ( - mergeBranch === branch || - (branch.endsWith(`/${mergeBranch}`) && - `${configuredRemoteName}/${mergeBranch}`.endsWith(`/${branch}`)) - ) { + if (isAliasOfUpstreamHead(branch, mergeBranch, `${configuredRemoteName}/${mergeBranch}`)) { goneUpstream = { remoteName: configuredRemoteName, branchName: mergeBranch }; } else { goneBaseUpstream = { @@ -2976,7 +2976,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchName = goneUpstream?.branchName ?? (configuredRemoteName === "." ? branch : yield* resolvePublishBranchName(cwd, branch)); - return { remoteName, branchName, publishes: true, upstream: goneBaseUpstream, pushRemote: null }; + return { + remoteName, + branchName, + publishes: true, + upstream: goneBaseUpstream, + pushRemote: null, + }; } if ((yield* readConfigValue(cwd, `branch.${branch}.remote`)) === ".") { @@ -3000,11 +3006,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const upstream = yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)); if (!upstream) return null; - // Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. - const isAliasOfUpstreamHead = - branch === upstream.branchName || - (branch.endsWith(`/${upstream.branchName}`) && upstream.upstreamRef.endsWith(`/${branch}`)); - if (isAliasOfUpstreamHead) { + if (isAliasOfUpstreamHead(branch, upstream.branchName, upstream.upstreamRef)) { // A fork workflow pushes to the configured push remote and keeps the upstream for pulls, as plain `git push` does. const configuredPushRemote = yield* resolveConfiguredPushRemoteName(cwd, branch).pipe( Effect.orElseSucceed(() => null),