From 02eca40c3b854377ec7bfff629c638379b04e030 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Mar 2026 10:58:38 -0800 Subject: [PATCH] Enable push/PR actions for branches without upstream - Treat no-upstream branches as publishable: enable Push/Create PR and update quick actions - Route Create PR menu action through commit+push+PR flow when appropriate - Simplify Git action dialog to commit-only and add "Commit on new branch" option --- .../GitActionsControl.logic.test.ts | 24 ++- .../src/components/GitActionsControl.logic.ts | 23 ++- apps/web/src/components/GitActionsControl.tsx | 191 +++++++++--------- 3 files changed, 125 insertions(+), 113 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index c10ca58918e1f625a45b8c493088a95a24528dc0..b31c18cec36f3029660b99f553092bd40d164c1b 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -515,19 +515,20 @@ describe("when: HEAD is detached and there are no local changes", () => { }); describe("when: branch has no upstream configured", () => { - it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => { + it("resolveQuickAction runs push and create PR when clean, no upstream, and no local commits are ahead", () => { const quick = resolveQuickAction( status({ hasUpstream: false, pr: null, aheadCount: 0 }), false, ); assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, + kind: "run_action", + action: "commit_push_pr", + label: "Push & create PR", + disabled: false, }); }); - it("resolveQuickAction remains disabled when clean, no upstream, and no local commits ahead", () => { + it("resolveQuickAction runs push when clean and no upstream even if PR metadata exists", () => { const quick = resolveQuickAction( status({ hasUpstream: false, @@ -544,9 +545,10 @@ describe("when: branch has no upstream configured", () => { false, ); assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, + kind: "run_action", + action: "commit_push", + label: "Push", + disabled: false, }); }); @@ -574,7 +576,7 @@ describe("when: branch has no upstream configured", () => { }); }); - it("buildMenuItems disables push and create PR when no commits are ahead", () => { + it("buildMenuItems enables push and create PR to publish branch when no upstream exists", () => { const items = buildMenuItems( status({ hasUpstream: false, pr: null, aheadCount: 0 }), false, @@ -591,7 +593,7 @@ describe("when: branch has no upstream configured", () => { { id: "push", label: "Push", - disabled: true, + disabled: false, icon: "push", kind: "open_dialog", dialogAction: "push", @@ -599,7 +601,7 @@ describe("when: branch has no upstream configured", () => { { 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 5389bdbb07561008610e42769f40de9a81471186..bebdd2e072bdd75d5e30620c4ad2156dd004cf65 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -111,15 +111,20 @@ export function buildMenuItems( const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; + const canPublishUntrackedBranch = !gitStatus.hasUpstream; const canCommit = !isBusy && hasChanges; - const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; + const canPush = + !isBusy && + hasBranch && + !hasChanges && + !isBehind && + (gitStatus.aheadCount > 0 || canPublishUntrackedBranch); const canCreatePr = !isBusy && hasBranch && !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && - gitStatus.hasUpstream && + (gitStatus.aheadCount > 0 || canPublishUntrackedBranch) && !isBehind; const canOpenPr = !isBusy && hasOpenPr; @@ -205,6 +210,18 @@ export function resolveQuickAction( }; } + if (!gitStatus.hasUpstream) { + if (hasOpenPr || isDefaultBranch) { + return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; + } + return { + label: "Push & create PR", + disabled: false, + kind: "run_action", + action: "commit_push_pr", + }; + } + if (isDiverged) { return { label: "Sync branch", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 818c21a386a5805947d50b1a5826d981100d2c35..908689044709107cc142706d9e00358bb59025bd 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -8,7 +8,6 @@ import { buildMenuItems, type GitActionIconName, type GitActionMenuItem, - type GitDialogAction, type GitQuickAction, requiresDefaultBranchConfirmation, resolveAutoFeatureBranchName, @@ -88,6 +87,9 @@ function getMenuActionDisabledReason( if (isBehind) { return "Branch is behind upstream. Pull/rebase before pushing."; } + if (!gitStatus.hasUpstream) { + return "Branch has no upstream. Push to publish it."; + } if (!isAhead) { return "No local commits to push."; } @@ -103,29 +105,21 @@ function getMenuActionDisabledReason( if (hasChanges) { return "Commit local changes before creating a PR."; } - if (!isAhead) { - return "No local commits to include in a PR."; - } if (!gitStatus.hasUpstream) { return "Set an upstream branch before creating a PR."; } + if (!isAhead) { + return "No local commits to include in a PR."; + } if (isBehind) { return "Branch is behind upstream. Pull/rebase before creating a PR."; } return "Create PR is currently unavailable."; } -const DIALOG_TITLE_BY_ACTION = { - commit: "Commit changes", - push: "Push branch", - create_pr: "Create pull request", -}; - -const DIALOG_DESCRIPTION_BY_ACTION = { - commit: "Review and confirm your commit. Leave the message blank to auto-generate one.", - push: "Push this branch now.", - create_pr: "Create a pull request using generated title/body content.", -}; +const COMMIT_DIALOG_TITLE = "Commit changes"; +const COMMIT_DIALOG_DESCRIPTION = + "Review and confirm your commit. Leave the message blank to auto-generate one."; const DEFAULT_BRANCH_ACTION_DESCRIPTION: Record = { commit: "commit changes", @@ -158,7 +152,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [activeThreadId], ); const queryClient = useQueryClient(); - const [activeDialogAction, setActiveDialogAction] = useState(null); + const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); @@ -376,7 +370,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions children: "Create PR", onClick: () => { closeResultToast(); - setActiveDialogAction("create_pr"); + void runGitActionWithToast({ action: "commit_push_pr" }); }, }, } @@ -396,7 +390,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [ isDefaultBranch, runImmediateGitActionMutation, - setActiveDialogAction, setPendingDefaultBranchAction, threadToastData, gitStatusForActions, @@ -455,6 +448,54 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions threadToastData, ]); + const runDialogActionOnNewBranch = useCallback(() => { + if (!isCommitDialogOpen) return; + const action: GitStackedAction = "commit"; + const commitMessage = dialogCommitMessage.trim(); + const branchName = resolveAutoFeatureBranchName(branchList?.branches.map((branch) => branch.name) ?? []); + + setIsCommitDialogOpen(false); + setDialogCommitMessage(""); + + const checkoutPromise = createBranchAndCheckoutMutation.mutateAsync(branchName); + toastManager.promise(checkoutPromise, { + loading: { title: `Creating ${branchName}...`, data: threadToastData }, + success: () => ({ + title: `Checked out ${branchName}`, + data: threadToastData, + }), + error: (error) => ({ + title: "Failed to checkout feature branch", + description: error instanceof Error ? error.message : "An error occurred.", + data: threadToastData, + }), + }); + + void checkoutPromise + .then(() => { + const statusOverride = gitStatusForActions + ? { ...gitStatusForActions, branch: branchName, pr: null } + : null; + return runGitActionWithToast({ + action, + ...(commitMessage ? { commitMessage } : {}), + skipDefaultBranchPrompt: true, + statusOverride, + isDefaultBranchOverride: false, + }); + }) + .catch(() => undefined); + }, [ + branchList?.branches, + createBranchAndCheckoutMutation, + dialogCommitMessage, + gitStatusForActions, + isCommitDialogOpen, + runGitActionWithToast, + setIsCommitDialogOpen, + threadToastData, + ]); + const runQuickAction = useCallback(() => { if (quickAction.kind === "open_pr") { void openExistingPr(); @@ -506,30 +547,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true }); return; } - if (item.dialogAction) { - setActiveDialogAction(item.dialogAction); + if (item.dialogAction === "create_pr") { + void runGitActionWithToast({ action: "commit_push_pr" }); + return; } + setIsCommitDialogOpen(true); }, - [openExistingPr, runGitActionWithToast], + [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen], ); const runDialogAction = useCallback(() => { - if (!activeDialogAction) return; - const action: GitStackedAction = - activeDialogAction === "commit" - ? "commit" - : activeDialogAction === "push" - ? "commit_push" - : "commit_push_pr"; - const commitMessage = activeDialogAction === "commit" ? dialogCommitMessage.trim() : ""; - const forcePushOnlyProgress = activeDialogAction === "push"; - setActiveDialogAction(null); + if (!isCommitDialogOpen) return; + const commitMessage = dialogCommitMessage.trim(); + setIsCommitDialogOpen(false); void runGitActionWithToast({ - action, + action: "commit", ...(commitMessage ? { commitMessage } : {}), - ...(forcePushOnlyProgress ? { forcePushOnlyProgress } : {}), }); - }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]); + }, [dialogCommitMessage, isCommitDialogOpen, runGitActionWithToast, setIsCommitDialogOpen]); const openChangedFileInEditor = useCallback( (filePath: string) => { @@ -685,68 +720,29 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions )} { if (!open) { - setActiveDialogAction(null); + setIsCommitDialogOpen(false); setDialogCommitMessage(""); } }} > - {DIALOG_TITLE_BY_ACTION[activeDialogAction ?? "commit"]} - - {DIALOG_DESCRIPTION_BY_ACTION[activeDialogAction ?? "commit"]} - + {COMMIT_DIALOG_TITLE} + {COMMIT_DIALOG_DESCRIPTION}
Branch - - {gitStatusForActions?.branch ?? "(detached HEAD)"} + + {gitStatusForActions?.branch ?? "(detached HEAD)"} + {isDefaultBranch && ( + Warning: default branch + )} - {activeDialogAction !== "commit" && ( - <> - Upstream - - {!gitStatusForActions || !gitStatusForActions.hasUpstream - ? "No upstream configured" - : gitStatusForActions.aheadCount === 0 && - gitStatusForActions.behindCount === 0 - ? "Up to date" - : gitStatusForActions.aheadCount > 0 && - gitStatusForActions.behindCount > 0 - ? `Diverged (+${gitStatusForActions.aheadCount} / -${gitStatusForActions.behindCount})` - : gitStatusForActions.aheadCount > 0 - ? `Ahead by ${gitStatusForActions.aheadCount}` - : `Behind by ${gitStatusForActions.behindCount}`} - - Working tree - - {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges - ? "Clean" - : `${gitStatusForActions.workingTree.files.length} file(s)`} - - Diff - - {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges ? ( - "none" - ) : ( - <> - - +{gitStatusForActions.workingTree.insertions} - - / - - -{gitStatusForActions.workingTree.deletions} - - - )} - - - )}

Files

@@ -786,35 +782,32 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions )}
- {activeDialogAction === "commit" && ( -
-

Commit message (optional)

-