authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 23:38:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 23:38:54-07:00
log335e0b59ea8a1bbc381402d1fbea268e05095f74
treed9963e730eca030ae1441b6112ce21ca024090b8
parent2fbbe68c24892c4de9bd50450e7363376475ad7d
signature Signed by PGP key B5690EEEBB952194

Fix PR creation from origin-based worktrees (#3218)

Co-authored-by: Julius Marminge <julius@mac.lan>

8 files changed, 120 insertions(+), 5 deletions(-)

apps/server/src/git/GitManager.test.ts+56
...@@ -2239,6 +2239,62 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2239,6 +2239,62 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2239 }),2239 }),
2240 );2240 );
22412241
2242 it.effect("generates PR content against the remote base when the local base is stale", () =>
2243 Effect.gen(function* () {
2244 const repoDir = yield* makeTempDir("t3code-git-manager-");
2245 yield* initRepo(repoDir);
2246 const remoteDir = yield* createBareRemote();
2247 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2248 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2249 yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
2250
2251 const peerDir = yield* makeTempDir("t3code-git-peer-");
2252 yield* runGit(peerDir, ["clone", remoteDir, "."]);
2253 yield* runGit(peerDir, ["config", "user.email", "peer@example.com"]);
2254 yield* runGit(peerDir, ["config", "user.name", "Peer User"]);
2255 fs.writeFileSync(path.join(peerDir, "remote.txt"), "remote\n");
2256 yield* runGit(peerDir, ["add", "remote.txt"]);
2257 yield* runGit(peerDir, ["commit", "-m", "Remote base commit"]);
2258 yield* runGit(peerDir, ["push", "origin", "main"]);
2259
2260 yield* runGit(repoDir, ["fetch", "origin"]);
2261 yield* runGit(repoDir, [
2262 "checkout",
2263 "--no-track",
2264 "-b",
2265 "feature/remote-base",
2266 "origin/main",
2267 ]);
2268 fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n");
2269 yield* runGit(repoDir, ["add", "feature.txt"]);
2270 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
2271 yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]);
2272 yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]);
2273
2274 let generatedCommitSummary = "";
2275 const { manager } = yield* makeManager({
2276 ghScenario: {
2277 prListSequence: ["[]", "[]"],
2278 },
2279 textGeneration: {
2280 generatePrContent: (input) => {
2281 generatedCommitSummary = input.commitSummary;
2282 return Effect.succeed({ title: "Feature PR", body: "Feature body" });
2283 },
2284 },
2285 });
2286
2287 const result = yield* runStackedAction(manager, {
2288 cwd: repoDir,
2289 action: "create_pr",
2290 });
2291
2292 expect(result.pr.status).toBe("created");
2293 expect(generatedCommitSummary).toContain("Feature commit");
2294 expect(generatedCommitSummary).not.toContain("Remote base commit");
2295 }),
2296 );
2297
2242 it.effect(2298 it.effect(
2243 "creates a new PR instead of reusing an unrelated fork PR with the same head branch",2299 "creates a new PR instead of reusing an unrelated fork PR with the same head branch",
2244 () =>2300 () =>
apps/server/src/git/GitManager.ts+23-1
...@@ -1092,6 +1092,27 @@ export const make = Effect.gen(function* () {...@@ -1092,6 +1092,27 @@ export const make = Effect.gen(function* () {
1092 return "main";1092 return "main";
1093 });1093 });
10941094
1095 const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* (
1096 cwd: string,
1097 baseBranch: string,
1098 ) {
1099 const remoteName = yield* gitCore
1100 .resolvePrimaryRemoteName(cwd)
1101 .pipe(Effect.orElseSucceed(() => null));
1102 if (!remoteName) return baseBranch;
1103
1104 return yield* gitCore
1105 .resolveRemoteTrackingCommit({
1106 cwd,
1107 refName: baseBranch,
1108 fallbackRemoteName: remoteName,
1109 })
1110 .pipe(
1111 Effect.map((resolved) => resolved.commitSha),
1112 Effect.orElseSucceed(() => baseBranch),
1113 );
1114 });
1115
1095 const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")(1116 const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")(
1096 function* (input: {1117 function* (input: {
1097 cwd: string;1118 cwd: string;
...@@ -1298,7 +1319,8 @@ export const make = Effect.gen(function* () {...@@ -1298,7 +1319,8 @@ export const make = Effect.gen(function* () {
1298 phase: "pr",1319 phase: "pr",
1299 label: `Generating ${terms.shortLabel} content...`,1320 label: `Generating ${terms.shortLabel} content...`,
1300 });1321 });
1301 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);1322 const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch);
1323 const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
13021324
1303 const generated = yield* textGeneration.generatePrContent({1325 const generated = yield* textGeneration.generatePrContent({
1304 cwd,1326 cwd,
apps/server/src/server.test.ts+1
...@@ -6041,6 +6041,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6041,6 +6041,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6041 cwd: "/tmp/project",6041 cwd: "/tmp/project",
6042 refName: fetchedOriginCommit,6042 refName: fetchedOriginCommit,
6043 newRefName: "t3code/bootstrap-refName",6043 newRefName: "t3code/bootstrap-refName",
6044 baseRefName: "main",
6044 path: null,6045 path: null,
6045 });6046 });
6046 assert.deepEqual(fetchRemote.mock.calls[0]?.[0], {6047 assert.deepEqual(fetchRemote.mock.calls[0]?.[0], {
apps/server/src/vcs/GitVcsDriverCore.test.ts+8
...@@ -568,13 +568,21 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -568,13 +568,21 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
568 path: worktreePath,568 path: worktreePath,
569 refName: resolvedBase.commitSha,569 refName: resolvedBase.commitSha,
570 newRefName: "t3code/fetched-origin",570 newRefName: "t3code/fetched-origin",
571 baseRefName: resolvedBase.remoteRefName,
571 });572 });
572573
573 assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead);574 assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead);
575 assert.equal(
576 yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.gh-merge-base"),
577 initialBranch,
578 );
574 assert.equal(579 assert.equal(
575 yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"),580 yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"),
576 null,581 null,
577 );582 );
583 const status = yield* driver.statusDetails(worktreePath);
584 assert.equal(status.aheadCount, 0);
585 assert.equal(status.aheadOfDefaultCount, 0);
578 }),586 }),
579 );587 );
580588
apps/server/src/vcs/GitVcsDriverCore.ts+18-4
...@@ -1130,16 +1130,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1130,16 +1130,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1130 continue;1130 continue;
1131 }1131 }
11321132
1133 if (yield* branchExists(cwd, normalizedCandidate)) {
1134 return normalizedCandidate;
1135 }
1136
1137 if (1133 if (
1138 primaryRemoteName &&1134 primaryRemoteName &&
1139 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))1135 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
1140 ) {1136 ) {
1141 return `${primaryRemoteName}/${normalizedCandidate}`;1137 return `${primaryRemoteName}/${normalizedCandidate}`;
1142 }1138 }
1139
1140 if (yield* branchExists(cwd, normalizedCandidate)) {
1141 return normalizedCandidate;
1142 }
1143 }1143 }
11441144
1145 return null;1145 return null;
...@@ -2178,6 +2178,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2178,6 +2178,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2178 fallbackErrorMessage: "git worktree add failed",2178 fallbackErrorMessage: "git worktree add failed",
2179 });2179 });
21802180
2181 if (input.newRefName && input.baseRefName) {
2182 const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => []));
2183 const parsedBaseRef = parseRemoteRefWithRemoteNames(
2184 input.baseRefName,
2185 remoteNames.toSorted((left, right) => right.length - left.length),
2186 );
2187 const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName;
2188 yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [
2189 "config",
2190 `branch.${input.newRefName}.gh-merge-base`,
2191 baseBranch,
2192 ]);
2193 }
2194
2181 return {2195 return {
2182 worktree: {2196 worktree: {
2183 path: worktreePath,2197 path: worktreePath,
apps/server/src/ws.ts+1
...@@ -712,6 +712,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -712,6 +712,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
712 cwd: bootstrap.prepareWorktree.projectCwd,712 cwd: bootstrap.prepareWorktree.projectCwd,
713 refName: worktreeBaseRef,713 refName: worktreeBaseRef,
714 newRefName: bootstrap.prepareWorktree.branch,714 newRefName: bootstrap.prepareWorktree.branch,
715 baseRefName: bootstrap.prepareWorktree.baseBranch,
715 path: null,716 path: null,
716 });717 });
717 targetWorktreePath = worktree.worktree.path;718 targetWorktreePath = worktree.worktree.path;
packages/contracts/src/git.test.ts+12
...@@ -28,6 +28,18 @@ describe("VcsCreateWorktreeInput", () => {...@@ -28,6 +28,18 @@ describe("VcsCreateWorktreeInput", () => {
28 expect(parsed.newRefName).toBeUndefined();28 expect(parsed.newRefName).toBeUndefined();
29 expect(parsed.refName).toBe("feature/existing");29 expect(parsed.refName).toBe("feature/existing");
30 });30 });
31
32 it("accepts baseRefName metadata for a new worktree ref", () => {
33 const parsed = decodeCreateWorktreeInput({
34 cwd: "/repo",
35 refName: "0123456789abcdef",
36 newRefName: "feature/new",
37 baseRefName: "origin/main",
38 path: "/tmp/worktree",
39 });
40
41 expect(parsed.baseRefName).toBe("origin/main");
42 });
31});43});
3244
33describe("GitPreparePullRequestThreadInput", () => {45describe("GitPreparePullRequestThreadInput", () => {
packages/contracts/src/git.ts+1
...@@ -137,6 +137,7 @@ export const VcsCreateWorktreeInput = Schema.Struct({...@@ -137,6 +137,7 @@ export const VcsCreateWorktreeInput = Schema.Struct({
137 cwd: TrimmedNonEmptyStringSchema,137 cwd: TrimmedNonEmptyStringSchema,
138 refName: TrimmedNonEmptyStringSchema,138 refName: TrimmedNonEmptyStringSchema,
139 newRefName: Schema.optional(TrimmedNonEmptyStringSchema),139 newRefName: Schema.optional(TrimmedNonEmptyStringSchema),
140 baseRefName: Schema.optional(TrimmedNonEmptyStringSchema),
140 path: Schema.NullOr(TrimmedNonEmptyStringSchema),141 path: Schema.NullOr(TrimmedNonEmptyStringSchema),
141});142});
142export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type;143export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type;