| author | |
| committer | |
| log | b821fa04f1b9fe9c6fdb8d19ab7e4197b94c934b |
| tree | f115d9d8822368fdab6674b4e318c2e52e11e95c |
| parent | 9e552285bae9c9b0707e7ebeb77bf126f0c0a16b |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 234 insertions(+), 12 deletions(-)
apps/server/src/git/GitManager.test.ts+95| ... | ... | @@ -4822,6 +4822,101 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 4822 | 4822 | }), |
| 4823 | 4823 | ); |
| 4824 | 4824 | |
| 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 | ||
| 4825 | 4920 | it.effect("generates PR content from branch changes when the remote base advances", () => |
| 4826 | 4921 | Effect.gen(function* () { |
| 4827 | 4922 | 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* () { |
| 1993 | 1993 | headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">, |
| 1994 | 1994 | ) { |
| 1995 | 1995 | 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 | } | |
| 1997 | 2001 | |
| 1998 | 2002 | if (upstreamRef && !headContext.isCrossRepository) { |
| 1999 | 2003 | const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { |
apps/server/src/vcs/GitVcsDriver.ts+5| ... | ... | @@ -346,6 +346,11 @@ export class GitVcsDriver extends Context.Service< |
| 346 | 346 | cwd: string, |
| 347 | 347 | key: string, |
| 348 | 348 | ) => 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>; | |
| 349 | 354 | readonly listRefs: ( |
| 350 | 355 | input: VcsListRefsInput, |
| 351 | 356 | ) => Effect.Effect<VcsListRefsResult, GitCommandError>; |
apps/server/src/vcs/GitVcsDriverCore.test.ts+88| ... | ... | @@ -2960,6 +2960,94 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { |
| 2960 | 2960 | } |
| 2961 | 2961 | }), |
| 2962 | 2962 | ); |
| 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 | ||
| 2963 | 3051 | it("parses checkout progress lines from git's stderr", () => { |
| 2964 | 3052 | assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { |
| 2965 | 3053 | percent: 78, |
apps/server/src/vcs/GitVcsDriverCore.ts+41-11| ... | ... | @@ -3101,6 +3101,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3101 | 3101 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), |
| 3102 | 3102 | ); |
| 3103 | 3103 | |
| 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 | ||
| 3104 | 3135 | const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { |
| 3105 | 3136 | const fetchCwd = |
| 3106 | 3137 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; |
| ... | ... | @@ -3532,17 +3563,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3532 | 3563 | } |
| 3533 | 3564 | |
| 3534 | 3565 | 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 | } | |
| 3546 | 3575 | } |
| 3547 | 3576 | |
| 3548 | 3577 | return { |
| ... | ... | @@ -4075,6 +4104,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 4075 | 4104 | getReviewDiffPreview, |
| 4076 | 4105 | getReviewDiffFileContents, |
| 4077 | 4106 | readConfigValue, |
| 4107 | resolveBranchName, | |
| 4078 | 4108 | listRefs, |
| 4079 | 4109 | createWorktree: (input, options) => |
| 4080 | 4110 | withListRefsInvalidation(input.cwd, createWorktree(input, options)), |