| 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,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 | it.effect("generates PR content from branch changes when the remote base advances", () => | 4920 | it.effect("generates PR content from branch changes when the remote base advances", () => |
| 4826 | Effect.gen(function* () { | 4921 | Effect.gen(function* () { |
| 4827 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 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,7 +1993,11 @@ export const make = Effect.gen(function* () { |
| 1993 | headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">, | 1993 | headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">, |
| 1994 | ) { | 1994 | ) { |
| 1995 | const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); | 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 | if (upstreamRef && !headContext.isCrossRepository) { | 2002 | if (upstreamRef && !headContext.isCrossRepository) { |
| 1999 | const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { | 2003 | const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { |
apps/server/src/vcs/GitVcsDriver.ts+5| ... | @@ -346,6 +346,11 @@ export class GitVcsDriver extends Context.Service< | ... | @@ -346,6 +346,11 @@ export class GitVcsDriver extends Context.Service< |
| 346 | cwd: string, | 346 | cwd: string, |
| 347 | key: string, | 347 | key: string, |
| 348 | ) => Effect.Effect<string | null, GitCommandError>; | 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 | readonly listRefs: ( | 354 | readonly listRefs: ( |
| 350 | input: VcsListRefsInput, | 355 | input: VcsListRefsInput, |
| 351 | ) => Effect.Effect<VcsListRefsResult, GitCommandError>; | 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,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 | it("parses checkout progress lines from git's stderr", () => { | 3051 | it("parses checkout progress lines from git's stderr", () => { |
| 2964 | assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { | 3052 | assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { |
| 2965 | percent: 78, | 3053 | percent: 78, |
apps/server/src/vcs/GitVcsDriverCore.ts+41-11| ... | @@ -3101,6 +3101,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -3101,6 +3101,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3101 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), | 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 | const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { | 3135 | const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { |
| 3105 | const fetchCwd = | 3136 | const fetchCwd = |
| 3106 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; | 3137 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; |
| ... | @@ -3532,17 +3563,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -3532,17 +3563,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3532 | } | 3563 | } |
| 3533 | 3564 | ||
| 3534 | if (input.newRefName && input.baseRefName) { | 3565 | if (input.newRefName && input.baseRefName) { |
| 3535 | const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); | 3566 | // A checkout can start at a commit or tag, but a PR must target a branch. |
| 3536 | const parsedBaseRef = parseRemoteRefWithRemoteNames( | 3567 | const baseBranch = yield* resolveBranchName(input.cwd, input.baseRefName); |
| 3537 | input.baseRefName, | 3568 | if (baseBranch) { |
| 3538 | remoteNames.toSorted((left, right) => right.length - left.length), | 3569 | yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ |
| 3539 | ); | 3570 | "config", |
| 3540 | const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; | 3571 | `branch.${input.newRefName}.gh-merge-base`, |
| 3541 | yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ | 3572 | baseBranch, |
| 3542 | "config", | 3573 | ]); |
| 3543 | `branch.${input.newRefName}.gh-merge-base`, | 3574 | } |
| 3544 | baseBranch, | ||
| 3545 | ]); | ||
| 3546 | } | 3575 | } |
| 3547 | 3576 | ||
| 3548 | return { | 3577 | return { |
| ... | @@ -4075,6 +4104,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -4075,6 +4104,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 4075 | getReviewDiffPreview, | 4104 | getReviewDiffPreview, |
| 4076 | getReviewDiffFileContents, | 4105 | getReviewDiffFileContents, |
| 4077 | readConfigValue, | 4106 | readConfigValue, |
| 4107 | resolveBranchName, | ||
| 4078 | listRefs, | 4108 | listRefs, |
| 4079 | createWorktree: (input, options) => | 4109 | createWorktree: (input, options) => |
| 4080 | withListRefsInvalidation(input.cwd, createWorktree(input, options)), | 4110 | withListRefsInvalidation(input.cwd, createWorktree(input, options)), |