Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 164 additions & 23 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}),
);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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-");
Expand All @@ -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, [
Expand Down Expand Up @@ -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/",
]),
"",
);
}),
);

Expand Down
73 changes: 60 additions & 13 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
});

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (includeDivergence) {
const parsed = parseBranchAb(line.slice("# branch.ab ".length).trim());
aheadCount = parsed.ahead;
behindCount = parsed.behind;
}
continue;
}
if (line.length > 0 && !line.startsWith("#")) {
Expand All @@ -2405,6 +2424,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
}
}

if (!upstreamRefExists && !isUnbornHead) upstreamRef = null;
Comment thread
saphid marked this conversation as resolved.
Resolved

const isDefaultBranch =
refName !== null &&
(refName === defaultBranch ||
Expand Down Expand Up @@ -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`)) === ".") {
Expand All @@ -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),
Expand Down
Loading