authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-12 14:19:24-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 14:19:24-05:00
logb821fa04f1b9fe9c6fdb8d19ab7e4197b94c934b
treef115d9d8822368fdab6674b4e318c2e52e11e95c
parent9e552285bae9c9b0707e7ebeb77bf126f0c0a16b
signature Signed by PGP key B5690EEEBB952194

fix(server): resolve configured PR bases to branch names (#18754)


5 files changed, 234 insertions(+), 12 deletions(-)

apps/server/src/git/GitManager.test.ts+95
......@@ -4822,6 +4822,101 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
48224822 }),
48234823 );
48244824
4825 it.effect.each(["commit", "short-commit", "tag", "revision"] as const)(
4826 "creates a PR against the default branch with a legacy %s base setting",
4827 (kind) =>
4828 Effect.gen(function* () {
4829 const repoDir = yield* makeTempDir("t3code-git-manager-");
4830 yield* initRepo(repoDir);
4831 yield* runGit(repoDir, ["branch", "-m", "main", "trunk"]);
4832 const remoteDir = yield* createBareRemote();
4833 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4834 yield* runGit(repoDir, ["push", "-u", "origin", "trunk"]);
4835 yield* runGit(repoDir, ["checkout", "-b", "feature/legacy-base"]);
4836 NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n");
4837 yield* runGit(repoDir, ["add", "feature.txt"]);
4838 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
4839 const commit = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
4840 yield* runGit(repoDir, ["tag", "v1"]);
4841 const baseRef =
4842 kind === "commit"
4843 ? commit
4844 : kind === "short-commit"
4845 ? commit.slice(0, 12)
4846 : kind === "tag"
4847 ? "v1"
4848 : "HEAD~0";
4849 yield* runGit(repoDir, ["config", "branch.feature/legacy-base.gh-merge-base", baseRef]);
4850 yield* runGit(repoDir, ["push", "-u", "origin", "feature/legacy-base"]);
4851 let generatedBase = "";
4852 let generatedSummary = "";
4853 const { manager, ghCalls } = yield* makeManager({
4854 ghScenario: { defaultBranch: "trunk", prListSequence: ["[]", "[]"] },
4855 textGeneration: {
4856 generatePrContent: (input) => {
4857 generatedBase = input.baseBranch;
4858 generatedSummary = input.commitSummary;
4859 return Effect.succeed({ title: "Feature PR", body: "Feature body" });
4860 },
4861 },
4862 });
4863
4864 const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" });
4865
4866 expect(result.pr.status).toBe("created");
4867 expect(generatedBase).toBe("trunk");
4868 expect(generatedSummary).toContain("Feature commit");
4869 expect(
4870 ghCalls.some((call) =>
4871 call.includes("pr create --base trunk --head feature/legacy-base"),
4872 ),
4873 ).toBe(true);
4874 }),
4875 );
4876
4877 it.effect.each([
4878 "release/v2",
4879 "refs/heads/release/v2",
4880 "origin/release/v2",
4881 "deadbeef",
4882 "remote-only",
4883 ])("preserves the configured PR target branch %s", (baseRef) =>
4884 Effect.gen(function* () {
4885 const repoDir = yield* makeTempDir("t3code-git-manager-");
4886 yield* initRepo(repoDir);
4887 const baseBranch = baseRef === "deadbeef" ? baseRef : "release/v2";
4888 const remoteDir = yield* createBareRemote();
4889 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4890 yield* runGit(repoDir, ["checkout", "-b", baseBranch]);
4891 yield* runGit(repoDir, ["push", "-u", "origin", baseBranch]);
4892 yield* runGit(repoDir, ["checkout", "-b", "feature/configured-base"]);
4893 if (baseRef === "remote-only") {
4894 yield* runGit(repoDir, ["branch", "-D", baseBranch]);
4895 }
4896 NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n");
4897 yield* runGit(repoDir, ["add", "feature.txt"]);
4898 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
4899 yield* runGit(repoDir, ["push", "-u", "origin", "feature/configured-base"]);
4900 yield* runGit(repoDir, [
4901 "config",
4902 "branch.feature/configured-base.gh-merge-base",
4903 baseRef === "remote-only" ? baseBranch : baseRef,
4904 ]);
4905 const { manager, ghCalls } = yield* makeManager({
4906 ghScenario: { prListSequence: ["[]", "[]"] },
4907 });
4908
4909 const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" });
4910
4911 expect(result.pr.status).toBe("created");
4912 expect(
4913 ghCalls.some((call) =>
4914 call.includes(`pr create --base ${baseBranch} --head feature/configured-base`),
4915 ),
4916 ).toBe(true);
4917 }),
4918 );
4919
48254920 it.effect("generates PR content from branch changes when the remote base advances", () =>
48264921 Effect.gen(function* () {
48274922 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/GitManager.ts+5-1
......@@ -1993,7 +1993,11 @@ export const make = Effect.gen(function* () {
19931993 headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">,
19941994 ) {
19951995 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
1996 if (configured) return configured;
1996 if (configured) {
1997 // Older worktree handoffs recorded checkout revisions as PR target branches.
1998 const configuredBranch = yield* gitCore.resolveBranchName(cwd, configured);
1999 if (configuredBranch) return configuredBranch;
2000 }
19972001
19982002 if (upstreamRef && !headContext.isCrossRepository) {
19992003 const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, {
apps/server/src/vcs/GitVcsDriver.ts+5
......@@ -346,6 +346,11 @@ export class GitVcsDriver extends Context.Service<
346346 cwd: string,
347347 key: string,
348348 ) => Effect.Effect<string | null, GitCommandError>;
349 /** Returns the branch name of a local or remote-tracking ref, excluding tags and commits. */
350 readonly resolveBranchName: (
351 cwd: string,
352 refName: string,
353 ) => Effect.Effect<string | null, GitCommandError>;
349354 readonly listRefs: (
350355 input: VcsListRefsInput,
351356 ) => Effect.Effect<VcsListRefsResult, GitCommandError>;
apps/server/src/vcs/GitVcsDriverCore.test.ts+88
......@@ -2960,6 +2960,94 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
29602960 }
29612961 }),
29622962 );
2963 it.effect.each(["commit", "short-commit", "tag", "revision"] as const)(
2964 "creates a worktree from a %s without recording it as a PR base",
2965 (kind) =>
2966 Effect.gen(function* () {
2967 const cwd = yield* makeTmpDir();
2968 yield* initRepoWithCommit(cwd);
2969 const commit = yield* git(cwd, ["rev-parse", "HEAD"]);
2970 yield* git(cwd, ["tag", "v1"]);
2971 const baseRef =
2972 kind === "commit"
2973 ? commit
2974 : kind === "short-commit"
2975 ? commit.slice(0, 12)
2976 : kind === "tag"
2977 ? "v1"
2978 : "HEAD~0";
2979 const path = yield* Path.Path;
2980 const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "revision");
2981 const driver = yield* GitVcsDriver.GitVcsDriver;
2982 yield* driver.createWorktree({
2983 cwd,
2984 path: worktreePath,
2985 refName: baseRef,
2986 newRefName: "feature/revision",
2987 baseRefName: baseRef,
2988 });
2989
2990 assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), commit);
2991 assert.equal(
2992 yield* driver.readConfigValue(cwd, "branch.feature/revision.gh-merge-base"),
2993 null,
2994 );
2995 }),
2996 );
2997
2998 it.effect.each(["release/v2", "refs/heads/release/v2", "deadbeef"])(
2999 "records the branch name when creating a worktree from %s",
3000 (baseRef) =>
3001 Effect.gen(function* () {
3002 const cwd = yield* makeTmpDir();
3003 yield* initRepoWithCommit(cwd);
3004 const baseBranch = baseRef.replace(/^refs\/heads\//, "");
3005 yield* git(cwd, ["branch", baseBranch]);
3006 const path = yield* Path.Path;
3007 const driver = yield* GitVcsDriver.GitVcsDriver;
3008 yield* driver.createWorktree({
3009 cwd,
3010 path: path.join(yield* makeTmpDir("git-worktrees-"), "branch"),
3011 refName: baseRef,
3012 newRefName: "feature/branch",
3013 baseRefName: baseRef,
3014 });
3015
3016 assert.equal(
3017 yield* driver.readConfigValue(cwd, "branch.feature/branch.gh-merge-base"),
3018 baseBranch,
3019 );
3020 }),
3021 );
3022
3023 it.effect.each(["release/v2", "origin/release/v2", "refs/remotes/origin/release/v2"])(
3024 "records a remote-only PR base when creating a worktree from %s",
3025 (baseRef) =>
3026 Effect.gen(function* () {
3027 const cwd = yield* makeTmpDir();
3028 yield* initRepoWithCommit(cwd);
3029 const remote = yield* makeTmpDir("git-remote-");
3030 yield* git(remote, ["init", "--bare"]);
3031 yield* git(cwd, ["remote", "add", "origin", remote]);
3032 yield* git(cwd, ["push", "origin", "HEAD:refs/heads/release/v2"]);
3033 const commit = yield* git(cwd, ["rev-parse", "origin/release/v2"]);
3034 const path = yield* Path.Path;
3035 const driver = yield* GitVcsDriver.GitVcsDriver;
3036 yield* driver.createWorktree({
3037 cwd,
3038 path: path.join(yield* makeTmpDir("git-worktrees-"), "remote"),
3039 refName: commit,
3040 newRefName: "feature/remote",
3041 baseRefName: baseRef,
3042 });
3043
3044 assert.equal(
3045 yield* driver.readConfigValue(cwd, "branch.feature/remote.gh-merge-base"),
3046 "release/v2",
3047 );
3048 }),
3049 );
3050
29633051 it("parses checkout progress lines from git's stderr", () => {
29643052 assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), {
29653053 percent: 78,
apps/server/src/vcs/GitVcsDriverCore.ts+41-11
......@@ -3101,6 +3101,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
31013101 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
31023102 );
31033103
3104 const resolveBranchName = Effect.fn("GitVcsDriver.resolveBranchName")(function* (
3105 cwd: string,
3106 refName: string,
3107 ) {
3108 const result = yield* executeGit(
3109 "GitVcsDriver.resolveBranchName.resolveRef",
3110 cwd,
3111 ["rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", refName],
3112 { allowNonZeroExit: true },
3113 );
3114 if (result.exitCode !== 0) {
3115 // Remote-only bases are often recorded by their unqualified branch name.
3116 const remoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
3117 Effect.orElseSucceed(() => null),
3118 );
3119 return remoteName && (yield* remoteBranchExists({ cwd, remoteName, refName }))
3120 ? refName
3121 : null;
3122 }
3123 const ref = result.stdout.trim();
3124 if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length);
3125 if (!ref.startsWith("refs/remotes/")) return null;
3126 const remoteNames = yield* listRemoteNames(cwd);
3127 return (
3128 parseRemoteRefWithRemoteNames(
3129 ref.slice("refs/remotes/".length),
3130 remoteNames.toSorted((left, right) => right.length - left.length),
3131 )?.branchName ?? null
3132 );
3133 });
3134
31043135 const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) {
31053136 const fetchCwd =
31063137 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
......@@ -3532,17 +3563,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
35323563 }
35333564
35343565 if (input.newRefName && input.baseRefName) {
3535 const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => []));
3536 const parsedBaseRef = parseRemoteRefWithRemoteNames(
3537 input.baseRefName,
3538 remoteNames.toSorted((left, right) => right.length - left.length),
3539 );
3540 const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName;
3541 yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [
3542 "config",
3543 `branch.${input.newRefName}.gh-merge-base`,
3544 baseBranch,
3545 ]);
3566 // A checkout can start at a commit or tag, but a PR must target a branch.
3567 const baseBranch = yield* resolveBranchName(input.cwd, input.baseRefName);
3568 if (baseBranch) {
3569 yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [
3570 "config",
3571 `branch.${input.newRefName}.gh-merge-base`,
3572 baseBranch,
3573 ]);
3574 }
35463575 }
35473576
35483577 return {
......@@ -4075,6 +4104,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
40754104 getReviewDiffPreview,
40764105 getReviewDiffFileContents,
40774106 readConfigValue,
4107 resolveBranchName,
40784108 listRefs,
40794109 createWorktree: (input, options) =>
40804110 withListRefsInvalidation(input.cwd, createWorktree(input, options)),