diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9f9d23a8e409b..3fe8cee5a3cd8 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,62 @@ 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"]), + ); + + 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"); + 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"]), + ); + + 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" }); + + 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 a084664bc447c..b3519c10b6c3f 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)); }); @@ -2232,6 +2237,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 +2251,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 +2265,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 +2290,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } + if (!upstreamRefExists && !isUnbornHead) upstreamRef = null; + const isDefaultBranch = refName !== null && (refName === defaultBranch || @@ -2618,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) { @@ -2651,6 +2685,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } + // 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 !== ".") { + if ( + mergeBranch === branch || + mergeBranch === (yield* resolvePublishBranchName(cwd, branch)) + ) { + goneUpstream = { remoteName, branchName: mergeBranch }; + } else { + goneBaseUpstream = { + branchName: mergeBranch, + upstreamRef: `${remoteName}/${mergeBranch}`, + }; + } + } + } + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; if (hasNoLocalDelta) { if (details.hasUpstream) { @@ -2665,9 +2724,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, + goneUpstream?.remoteName, + ).pipe(Effect.orElseSucceed(() => null)); if (!publishRemoteName) { return { status: "skipped_up_to_date" as const, @@ -2675,7 +2736,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", @@ -2701,7 +2763,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch, goneUpstream?.remoteName); if (!publishRemoteName) { return yield* new GitCommandError({ ...gitCommandContext({ @@ -2712,7 +2774,9 @@ 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)); + if (goneBaseUpstream) yield* preserveUpstreamBase(cwd, branch, goneBaseUpstream); yield* runGit( "GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, @@ -2742,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,