diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 45f4ae96a1330fe6d968d126945232ec96f61be9..c9a08607bc087917bef390f3f8d43bb00f8645e1 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3743,7 +3743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr pushes a clean branch before creating the PR when needed", () => + it.effect("create_pr pushes committed changes while preserving a dirty worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -3753,6 +3753,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); yield* runGit(repoDir, ["add", "create-pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "uncommitted readme\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; const { manager, ghCalls } = yield* makeManager({ ghScenario: { @@ -3781,6 +3785,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.push.setUpstream).toBe(true); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(303); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect( + (yield* runGit(remoteDir, ["rev-parse", "feature/create-pr-only"])).stdout.trim(), + ).toBe(headBefore); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature/create-pr-only"), @@ -6199,7 +6207,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr emits only the PR phase when the branch is already pushed", () => + it.effect("create_pr preserves dirty work on an already pushed branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -6210,8 +6218,22 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout; + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "staged readme\n"); + yield* runGit(repoDir, ["add", "README.md"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "unstaged feature work\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; + const stagedBefore = (yield* runGit(repoDir, ["diff", "--cached"])).stdout; + let generatedContent: TextGeneration.PrContentGenerationInput | undefined; const { manager } = yield* makeManager({ + textGeneration: { + generatePrContent: (input) => { + generatedContent = input; + return Effect.succeed({ title: "PR only branch", body: "Committed feature work" }); + }, + }, ghScenario: { prListSequence: [ JSON.stringify([]), @@ -6251,6 +6273,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.commit.status).toBe("skipped_not_requested"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); + expect(generatedContent?.diffPatch).toContain("+pr only"); + expect(generatedContent?.diffPatch).not.toContain("staged readme"); + expect(generatedContent?.diffPatch).not.toContain("unstaged feature work"); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect((yield* runGit(repoDir, ["diff", "--cached"])).stdout).toBe(stagedBefore); expect( events.filter( (event): event is Extract => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8ca0b6b976a94138c55e50bd758f09cec39afb62..bd4ee0da7e9d2978a76ed844873da5f83feb6c51 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2730,14 +2730,6 @@ export const make = Effect.gen(function* () { detail: "Feature-branch checkout is only supported for commit actions.", }); } - if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "Commit local changes before creating a PR.", - }); - } - const phases: GitActionProgressPhase[] = [ ...(input.featureBranch ? (["branch"] as const) : []), ...(wantsCommit ? (["commit"] as const) : []), diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 6d33d412ec8c3be7ff419166a5f11d7317e1b365..b29a44be3702fd740beb27d034884b1711fb2690 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -365,7 +365,7 @@ describe("when: source control provider uses merge requests", () => { }); describe("when: ref is clean, up to date, and has no open PR", () => { - it("enables create PR when synced with upstream but ahead of default", () => { + it("enables create PR when synced with upstream but ahead of default, even with local changes", () => { const syncedFeature = status({ aheadCount: 0, behindCount: 0, @@ -383,6 +383,12 @@ describe("when: ref is clean, up to date, and has no open PR", () => { const items = buildMenuItems(syncedFeature, false); assert.equal(items.find((item) => item.id === "pr")?.disabled, false); + + const dirtyFeature = { ...syncedFeature, hasWorkingTreeChanges: true }; + assert.equal( + buildMenuItems(dirtyFeature, false).find((item) => item.id === "pr")?.disabled, + false, + ); }); it("resolveQuickAction returns disabled no-action state", () => { @@ -550,7 +556,7 @@ describe("when: working tree has local changes", () => { ]); }); - it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => { + it("buildMenuItems enables push and create PR for ahead commits while local changes remain uncommitted", () => { const items = buildMenuItems( status({ refName: "feature/test", @@ -584,7 +590,7 @@ describe("when: working tree has local changes", () => { { id: "pr", label: "Create PR", - disabled: true, + disabled: false, icon: "pr", kind: "open_dialog", dialogAction: "create_pr", diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 8d3b7bc3057803d403243fa6977bf7e3c3e25aff..5422628825a689198b6dce93cd43b0dbca95ea51 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -184,7 +184,6 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && hasDefaultBranchDelta && !isBehind && diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 5615c64ee450a9176b91fa84cad17fa017394de8..cfe45ff24cc4e60f25d3e07b0e70e594ff5994ac 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -338,13 +338,10 @@ function getMenuActionDisabledReason({ if (!hasBranch) { return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; } - if (hasChanges) { - return `Commit local changes before creating a ${terminology.singular}.`; - } if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return `Add an "origin" remote before creating a ${terminology.singular}.`; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index d3571e65d2e61d3238434b72f51d5e7dcc4d9e1a..f708e9ca9b98c3f42bdaf98dc65d5b6328eedf69 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -65,9 +65,8 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0 && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; @@ -292,13 +291,10 @@ export function getGitActionDisabledReason(input: { if (!hasBranch) { return "Detached HEAD: checkout a branch before creating a PR."; } - if (hasChanges) { - return "Commit local changes before creating a PR."; - } if (!gitStatus.hasUpstream && !hasOriginRemote) { return 'Add an "origin" remote before creating a PR.'; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return "No local commits to include in a PR."; } if (isBehind) {