authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-09 23:48:05-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 23:48:05-03:00
log6e4ca6ce9d04009af290412b77a0acb9be9f8a18
tree26733d5aab81f7d59abd91d718967546c56ea4a5
parente08c31e1a1fd5e89e2d4130b9ad18b44d15f92cd
signature Signed by PGP key B5690EEEBB952194

fix(git): allow creating prs from dirty worktrees (#15625)


6 files changed, 42 insertions(+), 24 deletions(-)

apps/server/src/git/GitManager.test.ts+30-2
......@@ -3743,7 +3743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
37433743 }),
37443744 );
37453745
3746 it.effect("create_pr pushes a clean branch before creating the PR when needed", () =>
3746 it.effect("create_pr pushes committed changes while preserving a dirty worktree", () =>
37473747 Effect.gen(function* () {
37483748 const repoDir = yield* makeTempDir("t3code-git-manager-");
37493749 yield* initRepo(repoDir);
......@@ -3753,6 +3753,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
37533753 NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n");
37543754 yield* runGit(repoDir, ["add", "create-pr-only.txt"]);
37553755 yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]);
3756 const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
3757 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "uncommitted readme\n");
3758 NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n");
3759 const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout;
37563760
37573761 const { manager, ghCalls } = yield* makeManager({
37583762 ghScenario: {
......@@ -3781,6 +3785,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
37813785 expect(result.push.setUpstream).toBe(true);
37823786 expect(result.pr.status).toBe("created");
37833787 expect(result.pr.number).toBe(303);
3788 expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore);
3789 expect(
3790 (yield* runGit(remoteDir, ["rev-parse", "feature/create-pr-only"])).stdout.trim(),
3791 ).toBe(headBefore);
37843792 expect(
37853793 ghCalls.some((call) =>
37863794 call.includes("pr create --base main --head feature/create-pr-only"),
......@@ -6199,7 +6207,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
61996207 }),
62006208 );
62016209
6202 it.effect("create_pr emits only the PR phase when the branch is already pushed", () =>
6210 it.effect("create_pr preserves dirty work on an already pushed branch", () =>
62036211 Effect.gen(function* () {
62046212 const repoDir = yield* makeTempDir("t3code-git-manager-");
62056213 yield* initRepo(repoDir);
......@@ -6210,8 +6218,22 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
62106218 yield* runGit(repoDir, ["add", "pr-only.txt"]);
62116219 yield* runGit(repoDir, ["commit", "-m", "PR only branch"]);
62126220 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]);
6221 const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout;
6222 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "staged readme\n");
6223 yield* runGit(repoDir, ["add", "README.md"]);
6224 NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "unstaged feature work\n");
6225 NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n");
6226 const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout;
6227 const stagedBefore = (yield* runGit(repoDir, ["diff", "--cached"])).stdout;
6228 let generatedContent: TextGeneration.PrContentGenerationInput | undefined;
62136229
62146230 const { manager } = yield* makeManager({
6231 textGeneration: {
6232 generatePrContent: (input) => {
6233 generatedContent = input;
6234 return Effect.succeed({ title: "PR only branch", body: "Committed feature work" });
6235 },
6236 },
62156237 ghScenario: {
62166238 prListSequence: [
62176239 JSON.stringify([]),
......@@ -6251,6 +6273,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
62516273 expect(result.commit.status).toBe("skipped_not_requested");
62526274 expect(result.push.status).toBe("skipped_not_requested");
62536275 expect(result.pr.status).toBe("created");
6276 expect(generatedContent?.diffPatch).toContain("+pr only");
6277 expect(generatedContent?.diffPatch).not.toContain("staged readme");
6278 expect(generatedContent?.diffPatch).not.toContain("unstaged feature work");
6279 expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore);
6280 expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore);
6281 expect((yield* runGit(repoDir, ["diff", "--cached"])).stdout).toBe(stagedBefore);
62546282 expect(
62556283 events.filter(
62566284 (event): event is Extract<GitActionProgressEvent, { kind: "phase_started" }> =>
apps/server/src/git/GitManager.ts-8
......@@ -2730,14 +2730,6 @@ export const make = Effect.gen(function* () {
27302730 detail: "Feature-branch checkout is only supported for commit actions.",
27312731 });
27322732 }
2733 if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {
2734 return yield* new GitManagerError({
2735 operation: "runStackedAction",
2736 cwd: input.cwd,
2737 detail: "Commit local changes before creating a PR.",
2738 });
2739 }
2740
27412733 const phases: GitActionProgressPhase[] = [
27422734 ...(input.featureBranch ? (["branch"] as const) : []),
27432735 ...(wantsCommit ? (["commit"] as const) : []),
apps/web/src/components/GitActionsControl.logic.test.ts+9-3
......@@ -365,7 +365,7 @@ describe("when: source control provider uses merge requests", () => {
365365});
366366
367367describe("when: ref is clean, up to date, and has no open PR", () => {
368 it("enables create PR when synced with upstream but ahead of default", () => {
368 it("enables create PR when synced with upstream but ahead of default, even with local changes", () => {
369369 const syncedFeature = status({
370370 aheadCount: 0,
371371 behindCount: 0,
......@@ -383,6 +383,12 @@ describe("when: ref is clean, up to date, and has no open PR", () => {
383383
384384 const items = buildMenuItems(syncedFeature, false);
385385 assert.equal(items.find((item) => item.id === "pr")?.disabled, false);
386
387 const dirtyFeature = { ...syncedFeature, hasWorkingTreeChanges: true };
388 assert.equal(
389 buildMenuItems(dirtyFeature, false).find((item) => item.id === "pr")?.disabled,
390 false,
391 );
386392 });
387393
388394 it("resolveQuickAction returns disabled no-action state", () => {
......@@ -550,7 +556,7 @@ describe("when: working tree has local changes", () => {
550556 ]);
551557 });
552558
553 it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => {
559 it("buildMenuItems enables push and create PR for ahead commits while local changes remain uncommitted", () => {
554560 const items = buildMenuItems(
555561 status({
556562 refName: "feature/test",
......@@ -584,7 +590,7 @@ describe("when: working tree has local changes", () => {
584590 {
585591 id: "pr",
586592 label: "Create PR",
587 disabled: true,
593 disabled: false,
588594 icon: "pr",
589595 kind: "open_dialog",
590596 dialogAction: "create_pr",
apps/web/src/components/GitActionsControl.logic.ts-1
......@@ -184,7 +184,6 @@ export function buildMenuItems(
184184 const canCreatePr =
185185 !isBusy &&
186186 hasBranch &&
187 !hasChanges &&
188187 !hasOpenPr &&
189188 hasDefaultBranchDelta &&
190189 !isBehind &&
apps/web/src/components/GitActionsControl.tsx+1-4
......@@ -338,13 +338,10 @@ function getMenuActionDisabledReason({
338338 if (!hasBranch) {
339339 return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`;
340340 }
341 if (hasChanges) {
342 return `Commit local changes before creating a ${terminology.singular}.`;
343 }
344341 if (!gitStatus.hasUpstream && !hasPrimaryRemote) {
345342 return `Add an "origin" remote before creating a ${terminology.singular}.`;
346343 }
347 if (!isAhead) {
344 if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) {
348345 return `No local commits to include in a ${terminology.singular}.`;
349346 }
350347 if (isBehind) {
packages/client-runtime/src/state/gitActions.ts+2-6
......@@ -65,9 +65,8 @@ export function buildMenuItems(
6565 const canCreatePr =
6666 !isBusy &&
6767 hasBranch &&
68 !hasChanges &&
6968 !hasOpenPr &&
70 gitStatus.aheadCount > 0 &&
69 (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0 &&
7170 !isBehind &&
7271 (gitStatus.hasUpstream || canPushWithoutUpstream);
7372 const canOpenPr = !isBusy && hasOpenPr;
......@@ -292,13 +291,10 @@ export function getGitActionDisabledReason(input: {
292291 if (!hasBranch) {
293292 return "Detached HEAD: checkout a branch before creating a PR.";
294293 }
295 if (hasChanges) {
296 return "Commit local changes before creating a PR.";
297 }
298294 if (!gitStatus.hasUpstream && !hasOriginRemote) {
299295 return 'Add an "origin" remote before creating a PR.';
300296 }
301 if (!isAhead) {
297 if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) {
302298 return "No local commits to include in a PR.";
303299 }
304300 if (isBehind) {