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) => {
22392239 }),
22402240 );
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
22422298 it.effect(
22432299 "creates a new PR instead of reusing an unrelated fork PR with the same head branch",
22442300 () =>
apps/server/src/git/GitManager.ts+23-1
......@@ -1092,6 +1092,27 @@ export const make = Effect.gen(function* () {
10921092 return "main";
10931093 });
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
10951116 const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")(
10961117 function* (input: {
10971118 cwd: string;
......@@ -1298,7 +1319,8 @@ export const make = Effect.gen(function* () {
12981319 phase: "pr",
12991320 label: `Generating ${terms.shortLabel} content...`,
13001321 });
1301 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);
1322 const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch);
1323 const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
13021324
13031325 const generated = yield* textGeneration.generatePrContent({
13041326 cwd,
apps/server/src/server.test.ts+1
......@@ -6041,6 +6041,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
60416041 cwd: "/tmp/project",
60426042 refName: fetchedOriginCommit,
60436043 newRefName: "t3code/bootstrap-refName",
6044 baseRefName: "main",
60446045 path: null,
60456046 });
60466047 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) => {
568568 path: worktreePath,
569569 refName: resolvedBase.commitSha,
570570 newRefName: "t3code/fetched-origin",
571 baseRefName: resolvedBase.remoteRefName,
571572 });
572573
573574 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 );
574579 assert.equal(
575580 yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"),
576581 null,
577582 );
583 const status = yield* driver.statusDetails(worktreePath);
584 assert.equal(status.aheadCount, 0);
585 assert.equal(status.aheadOfDefaultCount, 0);
578586 }),
579587 );
580588
apps/server/src/vcs/GitVcsDriverCore.ts+18-4
......@@ -1130,16 +1130,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
11301130 continue;
11311131 }
11321132
1133 if (yield* branchExists(cwd, normalizedCandidate)) {
1134 return normalizedCandidate;
1135 }
1136
11371133 if (
11381134 primaryRemoteName &&
11391135 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
11401136 ) {
11411137 return `${primaryRemoteName}/${normalizedCandidate}`;
11421138 }
1139
1140 if (yield* branchExists(cwd, normalizedCandidate)) {
1141 return normalizedCandidate;
1142 }
11431143 }
11441144
11451145 return null;
......@@ -2178,6 +2178,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
21782178 fallbackErrorMessage: "git worktree add failed",
21792179 });
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
21812195 return {
21822196 worktree: {
21832197 path: worktreePath,
apps/server/src/ws.ts+1
......@@ -712,6 +712,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
712712 cwd: bootstrap.prepareWorktree.projectCwd,
713713 refName: worktreeBaseRef,
714714 newRefName: bootstrap.prepareWorktree.branch,
715 baseRefName: bootstrap.prepareWorktree.baseBranch,
715716 path: null,
716717 });
717718 targetWorktreePath = worktree.worktree.path;
packages/contracts/src/git.test.ts+12
......@@ -28,6 +28,18 @@ describe("VcsCreateWorktreeInput", () => {
2828 expect(parsed.newRefName).toBeUndefined();
2929 expect(parsed.refName).toBe("feature/existing");
3030 });
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 });
3143});
3244
3345describe("GitPreparePullRequestThreadInput", () => {
packages/contracts/src/git.ts+1
......@@ -137,6 +137,7 @@ export const VcsCreateWorktreeInput = Schema.Struct({
137137 cwd: TrimmedNonEmptyStringSchema,
138138 refName: TrimmedNonEmptyStringSchema,
139139 newRefName: Schema.optional(TrimmedNonEmptyStringSchema),
140 baseRefName: Schema.optional(TrimmedNonEmptyStringSchema),
140141 path: Schema.NullOr(TrimmedNonEmptyStringSchema),
141142});
142143export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type;