diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 100ce5fe48671..bc30d6cb0ac5f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4659,7 +4659,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; @@ -4673,6 +4673,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); }), ); @@ -8035,6 +8046,92 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("republishes a branch whose upstream was deleted", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ + cwd, + refName: "feature/push", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "feature/push", + }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); + + yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + + 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", ""); + assert.deepInclude( + yield* (yield* GitVcsDriver.GitVcsDriver).resolvePushTarget(cwd, { + branch: "feature/push", + hasUpstream: false, + }), + { remoteName: "fork", branchName: "feature/push" }, + ); + 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/feature/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", + ); + }), + ); + it.effect("rebases a pushed branch's pulls when branch.autoSetupRebase asks for it", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -8191,14 +8288,15 @@ 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: 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-"); @@ -8223,51 +8321,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.deepInclude( + 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, [ @@ -8416,6 +8531,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.deepInclude( + 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 a2b985efed2a5..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 @@ -1857,12 +1861,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( cwd: string, refName: string, + fallbackRemoteName: string | null = null, ) { const configuredRemote = yield* resolveConfiguredPushRemoteName(cwd, refName); if (configuredRemote) { return configuredRemote; } + if (fallbackRemoteName) { + return fallbackRemoteName; + } + return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); @@ -2362,6 +2371,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; @@ -2372,6 +2385,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 === "(detached)" ? null : value; @@ -2382,11 +2399,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("#")) { @@ -2405,6 +2424,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } + if (!upstreamRefExists && !isUnbornHead) upstreamRef = null; + const isDefaultBranch = refName !== null && (refName === defaultBranch || @@ -2928,10 +2949,40 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* hasUpstream: boolean, ) { if (!hasUpstream) { - const remoteName = yield* resolvePushRemoteName(cwd, branch); + // 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: { remoteName: string; branchName: string; upstreamRef: string } | null = + null; + const mergeRef = yield* readConfigValue(cwd, `branch.${branch}.merge`); + const mergeBranch = mergeRef?.startsWith("refs/heads/") + ? mergeRef.slice("refs/heads/".length) + : null; + const configuredRemoteName = yield* readConfigValue(cwd, `branch.${branch}.remote`); + if (mergeBranch && configuredRemoteName && configuredRemoteName !== ".") { + if (isAliasOfUpstreamHead(branch, mergeBranch, `${configuredRemoteName}/${mergeBranch}`)) { + goneUpstream = { remoteName: configuredRemoteName, branchName: mergeBranch }; + } else { + goneBaseUpstream = { + remoteName: configuredRemoteName, + branchName: mergeBranch, + upstreamRef: `${configuredRemoteName}/${mergeBranch}`, + }; + } + } + const remoteName = yield* resolvePushRemoteName(cwd, branch, goneUpstream?.remoteName); if (!remoteName) return null; - const branchName = yield* resolvePublishBranchName(cwd, branch); - return { remoteName, branchName, publishes: true, upstream: null, pushRemote: null }; + // 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, + pushRemote: null, + }; } if ((yield* readConfigValue(cwd, `branch.${branch}.remote`)) === ".") { @@ -2955,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),