Skip to content
187 changes: 187 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3503,6 +3503,125 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);

it.effect.each([
{ localBranch: "develop" },
// Pushes to origin's develop under a name that differs from the base.
{ localBranch: "origin/develop" },
] as const)(
"commit_push_pr refuses to commit or push on the default branch when origin/HEAD is missing ($localBranch)",
({ localBranch }) =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["branch", "-m", "main", "develop"]);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "develop"]);
if (localBranch !== "develop") yield* runGit(repoDir, ["checkout", "-b", localBranch]);
const readRemoteDevelop = runGit(repoDir, [
"ls-remote",
"origin",
"refs/heads/develop",
]).pipe(Effect.map((output) => output.stdout));
const remoteBefore = yield* readRemoteDevelop;
const localBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout;
NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n");

const { manager } = yield* makeManager({
providerScenario: { defaultBranch: "develop", prListSequence: [[], []] },
});

const error = yield* runStackedAction(manager, {
cwd: repoDir,
action: "commit_push_pr",
}).pipe(Effect.flip);

expect(yield* readRemoteDevelop).toBe(remoteBefore);
expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(localBefore);
expect(error.message).toContain("develop is the base branch");
}),
);

it.effect("commit_push_pr on the default branch returns its open PR into another base", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
const releasePr = {
number: 43,
title: "Release",
url: "https://github.com/pingdotgg/codething-mvp/pull/43",
baseRefName: "release",
headRefName: "main",
};

const { manager } = yield* makeManager({
providerScenario: { defaultBranch: "main", prListSequence: [[releasePr], [releasePr]] },
});
const result = yield* runStackedAction(manager, {
cwd: repoDir,
action: "commit_push_pr",
});

expect(result.pr.status).toBe("opened_existing");
expect(result.pr.number).toBe(43);
}),
);

it.effect(
"commit_push_pr publishes a branch that tracks the default branch under its own name",
() =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["branch", "-m", "main", "develop"]);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "develop"]);
yield* runGit(repoDir, ["checkout", "-b", "t3code/feature", "--track", "origin/develop"]);
const readRemoteDevelop = runGit(repoDir, [
"ls-remote",
"origin",
"refs/heads/develop",
]).pipe(Effect.map((output) => output.stdout));
const remoteBefore = yield* readRemoteDevelop;
NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n");

const { manager, providerCalls } = yield* makeManager({
providerScenario: {
defaultBranch: "develop",
prListSequence: [
[],
[
{
number: 506,
title: "Feature",
url: "https://github.com/pingdotgg/codething-mvp/pull/506",
baseRefName: "develop",
headRefName: "t3code/feature",
},
],
],
},
});

const result = yield* runStackedAction(manager, {
cwd: repoDir,
action: "commit_push_pr",
});

expect(result.pr.status).toBe("created");
expect(yield* readRemoteDevelop).toBe(remoteBefore);
expect(
created(providerCalls).some(
(call) => call.baseRefName === "develop" && call.headSelector === "t3code/feature",
),
).toBe(true);
}),
);

it.effect("create_pr reports a created PR when the lookup after creation fails", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down Expand Up @@ -4355,6 +4474,74 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);

it.effect("commit_push_pr publishes the default branch to the push remote's fork", () =>
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, ["push", "origin", "main"]);
yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
yield* runGit(repoDir, ["push", "fork", "main"]);
yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]);
yield* configureVisibleRemoteUrlWithLocalRewrite(
repoDir,
"origin",
"git@github.com:pingdotgg/codething-mvp.git",
originDir,
);
yield* configureVisibleRemoteUrlWithLocalRewrite(
repoDir,
"fork",
"git@github.com:octocat/codething-mvp.git",
forkDir,
);
const readOriginMain = runGit(repoDir, ["ls-remote", "origin", "refs/heads/main"]).pipe(
Effect.map((output) => output.stdout),
);
const originBefore = yield* readOriginMain;
NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n");

const { manager, providerCalls } = yield* makeManager({
providerScenario: {
defaultBranch: "main",
prListSequenceByHeadSelector: {
"octocat:main": [
[],
[
{
number: 189,
title: "Contribute from main",
url: "https://github.com/pingdotgg/codething-mvp/pull/189",
baseRefName: "main",
headRefName: "main",
state: "open",
isCrossRepository: true,
headRepositoryNameWithOwner: "octocat/codething-mvp",
headRepositoryOwnerLogin: "octocat",
},
],
],
},
},
});

const result = yield* runStackedAction(manager, {
cwd: repoDir,
action: "commit_push_pr",
});

expect(result.pr.status).toBe("created");
expect(yield* readOriginMain).toBe(originBefore);
expect(
created(providerCalls).some(
(call) => call.baseRefName === "main" && call.headSelector === "octocat:main",
),
).toBe(true);
}),
);

it.effect("rejects push/pr actions from detached HEAD", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
37 changes: 37 additions & 0 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2282,6 +2282,34 @@ export const make = Effect.gen(function* () {
};
});

const ensurePrHeadIsNotBase = Effect.fn("ensurePrHeadIsNotBase")(function* (
cwd: string,
status: { branch: string; upstreamRef: string | null; hasUpstream: boolean },
changeRequestLabel: string,
) {
const target = yield* gitCore.resolvePushTarget(cwd, status);
if (!target) return;
const headContext = yield* resolveBranchHeadContext(cwd, {
branch: status.branch,
upstreamRef: `${target.remoteName}/${target.branchName}`,
remoteName: target.remoteName,
});
if (headContext.isCrossRepository) return;
const { baseBranch } = yield* resolvePrBase(
cwd,
status.branch,
status.upstreamRef,
headContext,
);
if (baseBranch !== target.branchName) return;
if (yield* findOpenPr(cwd, headContext)) return;
return yield* new GitManagerError({
operation: "runStackedAction",
cwd,
detail: `${baseBranch} is the base branch. Check out a feature branch to create ${changeRequestLabel}.`,
});
});

const runPrStep = Effect.fn("runPrStep")(function* (
settings: SourceControlTextGenerationSettings,
cwd: string,
Expand Down Expand Up @@ -3251,6 +3279,15 @@ export const make = Effect.gen(function* () {
const currentBranch = branchStep.name ?? initialStatus.branch;
const commitAction = isCommitAction(input.action) ? input.action : null;
const changeRequestTerms = wantsPr ? yield* changeRequestTermsFor(input.cwd) : null;
// A stale or missing origin/HEAD hides the default branch from status, so check here
// before anything is committed or pushed to it.
if (changeRequestTerms && !input.featureBranch && initialStatus.branch) {
yield* ensurePrHeadIsNotBase(
input.cwd,
{ ...initialStatus, branch: initialStatus.branch },
changeRequestTerms.shortLabelWithArticle,
);
}

const commit = commitAction
? yield* Ref.set(currentPhase, Option.some("commit")).pipe(
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ export interface GitPushResult {
setUpstream?: boolean | undefined;
}

export interface GitPushTarget {
remoteName: string;
branchName: string;
}

export interface GitRangeContext {
commitSummary: string;
diffSummary: string;
Expand Down Expand Up @@ -339,6 +344,11 @@ export class GitVcsDriver extends Context.Service<
fallbackBranch: string | null,
options?: { readonly remoteName?: string | null },
) => Effect.Effect<GitPushResult, GitCommandError>;
/** Where `pushCurrentBranch` would push the checked-out branch; null with no remote or when Git picks. */
readonly resolvePushTarget: (
cwd: string,
current: { readonly branch: string; readonly hasUpstream: boolean },
) => Effect.Effect<GitPushTarget | null, GitCommandError>;
readonly readRangeContext: (
cwd: string,
baseRef: string,
Expand Down
Loading
Loading