Commita164a6dunknown_key
feat(pr): squash/merge-commit/ff merge strategies with worktree
feat(pr): squash/merge-commit/ff merge strategies with worktree Replace fast-forward-only git update-ref with a full worktree-based merge engine that supports three strategies selectable from the UI: - Merge commit (--no-ff): always creates a merge commit - Squash and merge (--squash + commit): collapses PR commits to one - Fast-forward (--ff-only): strict FF or fails cleanly Adds a compact strategy selector dropdown next to the Merge button. Form data is parsed in the POST handler and routed to the appropriate git operation. Worktree is created, merge committed, then cleaned up. https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
1 file changed+139−24a164a6d67071e6471a8819fc9b3c3d3f38024b46
1 changed file+139−24
Modifiedsrc/routes/pulls.tsx+139−24View fileUnifiedSplit
@@ -744,6 +744,32 @@ const PRS_DETAIL_STYLES = `
744744 }
745745 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
746746
747 /* Merge strategy selector */
748 .prs-merge-strategy-wrap {
749 display: inline-flex; align-items: center;
750 background: var(--bg-elevated);
751 border: 1px solid var(--border);
752 border-radius: 10px;
753 overflow: hidden;
754 }
755 .prs-merge-strategy-label {
756 font-size: 11.5px; font-weight: 600;
757 color: var(--text-muted);
758 padding: 0 10px 0 12px;
759 white-space: nowrap;
760 }
761 .prs-merge-strategy-select {
762 background: transparent;
763 border: none;
764 color: var(--text);
765 font-size: 13px;
766 padding: 7px 10px 7px 4px;
767 cursor: pointer;
768 outline: none;
769 appearance: auto;
770 }
771 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
772
747773 /* Review summary banner */
748774 .prs-review-summary {
749775 display: flex; flex-direction: column; gap: 6px;
@@ -3482,19 +3508,29 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
34823508 Ready for review
34833509 </button>
34843510 ) : (
3485 <button
3486 type="submit"
3487 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
3488 formnovalidate
3489 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
3490 title={
3491 mergeBlocked
3492 ? "Failing gate checks must be resolved before this PR can merge."
3493 : "Merge pull request"
3494 }
3495 >
3496 {"✔"} Merge pull request
3497 </button>
3511 <>
3512 <div class="prs-merge-strategy-wrap">
3513 <span class="prs-merge-strategy-label">Strategy</span>
3514 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
3515 <option value="merge">Merge commit</option>
3516 <option value="squash">Squash and merge</option>
3517 <option value="ff">Fast-forward</option>
3518 </select>
3519 </div>
3520 <button
3521 type="submit"
3522 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
3523 formnovalidate
3524 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
3525 title={
3526 mergeBlocked
3527 ? "Failing gate checks must be resolved before this PR can merge."
3528 : "Merge pull request"
3529 }
3530 >
3531 {"✔"} Merge pull request
3532 </button>
3533 </>
34983534 )}
34993535 {!pr.isDraft && (
35003536 <button
@@ -3904,6 +3940,14 @@ pulls.post(
39043940 const prNum = parseInt(c.req.param("number"), 10);
39053941 const user = c.get("user")!;
39063942
3943 // Read merge strategy from form (default: merge commit)
3944 let mergeStrategy = "merge";
3945 try {
3946 const body = await c.req.parseBody();
3947 const s = body.merge_strategy;
3948 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
3949 } catch { /* ignore parse errors — default to merge commit */ }
3950
39073951 const resolved = await resolveRepo(ownerName, repoName);
39083952 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
39093953
@@ -4042,21 +4086,92 @@ pulls.post(
40424086 });
40434087 }
40444088 } else {
4045 // Standard merge — fast-forward or clean merge
4046 const ffProc = Bun.spawn(
4047 [
4048 "git",
4049 "update-ref",
4050 `refs/heads/${pr.baseBranch}`,
4051 `refs/heads/${pr.headBranch}`,
4052 ],
4089 // Worktree-based merge: supports merge-commit, squash, and fast-forward
4090 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
4091 const gitEnv = {
4092 ...process.env,
4093 GIT_AUTHOR_NAME: user.displayName || user.username,
4094 GIT_AUTHOR_EMAIL: user.email,
4095 GIT_COMMITTER_NAME: user.displayName || user.username,
4096 GIT_COMMITTER_EMAIL: user.email,
4097 };
4098
4099 // Create linked worktree on the base branch
4100 const addWt = Bun.spawn(
4101 ["git", "worktree", "add", wt, pr.baseBranch],
40534102 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
40544103 );
4055 const ffExit = await ffProc.exited;
4104 if (await addWt.exited !== 0) {
4105 return c.redirect(
4106 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
4107 );
4108 }
4109
4110 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
4111 let mergeOk = false;
4112
4113 try {
4114 if (mergeStrategy === "squash") {
4115 // Squash: stage all changes without committing
4116 const squashProc = Bun.spawn(
4117 ["git", "merge", "--squash", headSha],
4118 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4119 );
4120 if (await squashProc.exited !== 0) {
4121 const errTxt = await new Response(squashProc.stderr).text();
4122 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
4123 }
4124 // Commit the squashed changes
4125 const commitProc = Bun.spawn(
4126 ["git", "commit", "-m", commitMsg],
4127 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4128 );
4129 if (await commitProc.exited !== 0) {
4130 const errTxt = await new Response(commitProc.stderr).text();
4131 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
4132 }
4133 mergeOk = true;
4134 } else if (mergeStrategy === "ff") {
4135 // Fast-forward only — fail if FF is not possible
4136 const ffProc = Bun.spawn(
4137 ["git", "merge", "--ff-only", headSha],
4138 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4139 );
4140 if (await ffProc.exited !== 0) {
4141 const errTxt = await new Response(ffProc.stderr).text();
4142 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
4143 }
4144 mergeOk = true;
4145 } else {
4146 // Default: merge commit (--no-ff always creates a merge commit)
4147 const mergeProc = Bun.spawn(
4148 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
4149 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4150 );
4151 if (await mergeProc.exited !== 0) {
4152 const errTxt = await new Response(mergeProc.stderr).text();
4153 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
4154 }
4155 mergeOk = true;
4156 }
4157 } catch (err) {
4158 // Always clean up the worktree before redirecting
4159 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
4160 const msg = err instanceof Error ? err.message : "Merge failed";
4161 return c.redirect(
4162 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
4163 );
4164 }
4165
4166 // Clean up worktree (changes are now in the bare repo via linked worktree)
4167 await Bun.spawn(
4168 ["git", "worktree", "remove", "--force", wt],
4169 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4170 ).exited.catch(() => {});
40564171
4057 if (ffExit !== 0) {
4172 if (!mergeOk) {
40584173 return c.redirect(
4059 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
4174 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
40604175 );
40614176 }
40624177 }
40634178