authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 10:58:38-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:01-08:00
log02eca40c3b854377ec7bfff629c638379b04e030
treef64fd7dd752c1ed4c8998388b85197a387668d64
parentc6ee3aee9fbe97baeea08593b1fdf9a5b9173466

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

3 files changed, 125 insertions(+), 113 deletions(-)

apps/web/src/components/GitActionsControl.logic.test.ts+13-11
...@@ -515,19 +515,20 @@ describe("when: HEAD is detached and there are no local changes", () => {...@@ -515,19 +515,20 @@ describe("when: HEAD is detached and there are no local changes", () => {
515});515});
516516
517describe("when: branch has no upstream configured", () => {517describe("when: branch has no upstream configured", () => {
518 it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => {518 it("resolveQuickAction runs push and create PR when clean, no upstream, and no local commits are ahead", () => {
519 const quick = resolveQuickAction(519 const quick = resolveQuickAction(
520 status({ hasUpstream: false, pr: null, aheadCount: 0 }),520 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
521 false,521 false,
522 );522 );
523 assert.deepInclude(quick, {523 assert.deepInclude(quick, {
524 kind: "show_hint",524 kind: "run_action",
525 label: "Commit",525 action: "commit_push_pr",
526 disabled: true,526 label: "Push & create PR",
527 disabled: false,
527 });528 });
528 });529 });
529530
530 it("resolveQuickAction remains disabled when clean, no upstream, and no local commits ahead", () => {531 it("resolveQuickAction runs push when clean and no upstream even if PR metadata exists", () => {
531 const quick = resolveQuickAction(532 const quick = resolveQuickAction(
532 status({533 status({
533 hasUpstream: false,534 hasUpstream: false,
...@@ -544,9 +545,10 @@ describe("when: branch has no upstream configured", () => {...@@ -544,9 +545,10 @@ describe("when: branch has no upstream configured", () => {
544 false,545 false,
545 );546 );
546 assert.deepInclude(quick, {547 assert.deepInclude(quick, {
547 kind: "show_hint",548 kind: "run_action",
548 label: "Commit",549 action: "commit_push",
549 disabled: true,550 label: "Push",
551 disabled: false,
550 });552 });
551 });553 });
552554
...@@ -574,7 +576,7 @@ describe("when: branch has no upstream configured", () => {...@@ -574,7 +576,7 @@ describe("when: branch has no upstream configured", () => {
574 });576 });
575 });577 });
576578
577 it("buildMenuItems disables push and create PR when no commits are ahead", () => {579 it("buildMenuItems enables push and create PR to publish branch when no upstream exists", () => {
578 const items = buildMenuItems(580 const items = buildMenuItems(
579 status({ hasUpstream: false, pr: null, aheadCount: 0 }),581 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
580 false,582 false,
...@@ -591,7 +593,7 @@ describe("when: branch has no upstream configured", () => {...@@ -591,7 +593,7 @@ describe("when: branch has no upstream configured", () => {
591 {593 {
592 id: "push",594 id: "push",
593 label: "Push",595 label: "Push",
594 disabled: true,596 disabled: false,
595 icon: "push",597 icon: "push",
596 kind: "open_dialog",598 kind: "open_dialog",
597 dialogAction: "push",599 dialogAction: "push",
...@@ -599,7 +601,7 @@ describe("when: branch has no upstream configured", () => {...@@ -599,7 +601,7 @@ describe("when: branch has no upstream configured", () => {
599 {601 {
600 id: "pr",602 id: "pr",
601 label: "Create PR",603 label: "Create PR",
602 disabled: true,604 disabled: false,
603 icon: "pr",605 icon: "pr",
604 kind: "open_dialog",606 kind: "open_dialog",
605 dialogAction: "create_pr",607 dialogAction: "create_pr",
apps/web/src/components/GitActionsControl.logic.ts+20-3
...@@ -111,15 +111,20 @@ export function buildMenuItems(...@@ -111,15 +111,20 @@ export function buildMenuItems(
111 const hasChanges = gitStatus.hasWorkingTreeChanges;111 const hasChanges = gitStatus.hasWorkingTreeChanges;
112 const hasOpenPr = gitStatus.pr?.state === "open";112 const hasOpenPr = gitStatus.pr?.state === "open";
113 const isBehind = gitStatus.behindCount > 0;113 const isBehind = gitStatus.behindCount > 0;
114 const canPublishUntrackedBranch = !gitStatus.hasUpstream;
114 const canCommit = !isBusy && hasChanges;115 const canCommit = !isBusy && hasChanges;
115 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;116 const canPush =
117 !isBusy &&
118 hasBranch &&
119 !hasChanges &&
120 !isBehind &&
121 (gitStatus.aheadCount > 0 || canPublishUntrackedBranch);
116 const canCreatePr =122 const canCreatePr =
117 !isBusy &&123 !isBusy &&
118 hasBranch &&124 hasBranch &&
119 !hasChanges &&125 !hasChanges &&
120 !hasOpenPr &&126 !hasOpenPr &&
121 gitStatus.aheadCount > 0 &&127 (gitStatus.aheadCount > 0 || canPublishUntrackedBranch) &&
122 gitStatus.hasUpstream &&
123 !isBehind;128 !isBehind;
124 const canOpenPr = !isBusy && hasOpenPr;129 const canOpenPr = !isBusy && hasOpenPr;
125130
...@@ -205,6 +210,18 @@ export function resolveQuickAction(...@@ -205,6 +210,18 @@ export function resolveQuickAction(
205 };210 };
206 }211 }
207212
213 if (!gitStatus.hasUpstream) {
214 if (hasOpenPr || isDefaultBranch) {
215 return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" };
216 }
217 return {
218 label: "Push & create PR",
219 disabled: false,
220 kind: "run_action",
221 action: "commit_push_pr",
222 };
223 }
224
208 if (isDiverged) {225 if (isDiverged) {
209 return {226 return {
210 label: "Sync branch",227 label: "Sync branch",
apps/web/src/components/GitActionsControl.tsx+92-99
...@@ -8,7 +8,6 @@ import {...@@ -8,7 +8,6 @@ import {
8 buildMenuItems,8 buildMenuItems,
9 type GitActionIconName,9 type GitActionIconName,
10 type GitActionMenuItem,10 type GitActionMenuItem,
11 type GitDialogAction,
12 type GitQuickAction,11 type GitQuickAction,
13 requiresDefaultBranchConfirmation,12 requiresDefaultBranchConfirmation,
14 resolveAutoFeatureBranchName,13 resolveAutoFeatureBranchName,
...@@ -88,6 +87,9 @@ function getMenuActionDisabledReason(...@@ -88,6 +87,9 @@ function getMenuActionDisabledReason(
88 if (isBehind) {87 if (isBehind) {
89 return "Branch is behind upstream. Pull/rebase before pushing.";88 return "Branch is behind upstream. Pull/rebase before pushing.";
90 }89 }
90 if (!gitStatus.hasUpstream) {
91 return "Branch has no upstream. Push to publish it.";
92 }
91 if (!isAhead) {93 if (!isAhead) {
92 return "No local commits to push.";94 return "No local commits to push.";
93 }95 }
...@@ -103,29 +105,21 @@ function getMenuActionDisabledReason(...@@ -103,29 +105,21 @@ function getMenuActionDisabledReason(
103 if (hasChanges) {105 if (hasChanges) {
104 return "Commit local changes before creating a PR.";106 return "Commit local changes before creating a PR.";
105 }107 }
106 if (!isAhead) {
107 return "No local commits to include in a PR.";
108 }
109 if (!gitStatus.hasUpstream) {108 if (!gitStatus.hasUpstream) {
110 return "Set an upstream branch before creating a PR.";109 return "Set an upstream branch before creating a PR.";
111 }110 }
111 if (!isAhead) {
112 return "No local commits to include in a PR.";
113 }
112 if (isBehind) {114 if (isBehind) {
113 return "Branch is behind upstream. Pull/rebase before creating a PR.";115 return "Branch is behind upstream. Pull/rebase before creating a PR.";
114 }116 }
115 return "Create PR is currently unavailable.";117 return "Create PR is currently unavailable.";
116}118}
117119
118const DIALOG_TITLE_BY_ACTION = {120const COMMIT_DIALOG_TITLE = "Commit changes";
119 commit: "Commit changes",121const COMMIT_DIALOG_DESCRIPTION =
120 push: "Push branch",122 "Review and confirm your commit. Leave the message blank to auto-generate one.";
121 create_pr: "Create pull request",
122};
123
124const DIALOG_DESCRIPTION_BY_ACTION = {
125 commit: "Review and confirm your commit. Leave the message blank to auto-generate one.",
126 push: "Push this branch now.",
127 create_pr: "Create a pull request using generated title/body content.",
128};
129123
130const DEFAULT_BRANCH_ACTION_DESCRIPTION: Record<GitStackedAction, string> = {124const DEFAULT_BRANCH_ACTION_DESCRIPTION: Record<GitStackedAction, string> = {
131 commit: "commit changes",125 commit: "commit changes",
...@@ -158,7 +152,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -158,7 +152,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
158 [activeThreadId],152 [activeThreadId],
159 );153 );
160 const queryClient = useQueryClient();154 const queryClient = useQueryClient();
161 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);155 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
162 const [dialogCommitMessage, setDialogCommitMessage] = useState("");156 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
163 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =157 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
164 useState<PendingDefaultBranchAction | null>(null);158 useState<PendingDefaultBranchAction | null>(null);
...@@ -376,7 +370,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -376,7 +370,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
376 children: "Create PR",370 children: "Create PR",
377 onClick: () => {371 onClick: () => {
378 closeResultToast();372 closeResultToast();
379 setActiveDialogAction("create_pr");373 void runGitActionWithToast({ action: "commit_push_pr" });
380 },374 },
381 },375 },
382 }376 }
...@@ -396,7 +390,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -396,7 +390,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
396 [390 [
397 isDefaultBranch,391 isDefaultBranch,
398 runImmediateGitActionMutation,392 runImmediateGitActionMutation,
399 setActiveDialogAction,
400 setPendingDefaultBranchAction,393 setPendingDefaultBranchAction,
401 threadToastData,394 threadToastData,
402 gitStatusForActions,395 gitStatusForActions,
...@@ -455,6 +448,54 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -455,6 +448,54 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
455 threadToastData,448 threadToastData,
456 ]);449 ]);
457450
451 const runDialogActionOnNewBranch = useCallback(() => {
452 if (!isCommitDialogOpen) return;
453 const action: GitStackedAction = "commit";
454 const commitMessage = dialogCommitMessage.trim();
455 const branchName = resolveAutoFeatureBranchName(branchList?.branches.map((branch) => branch.name) ?? []);
456
457 setIsCommitDialogOpen(false);
458 setDialogCommitMessage("");
459
460 const checkoutPromise = createBranchAndCheckoutMutation.mutateAsync(branchName);
461 toastManager.promise(checkoutPromise, {
462 loading: { title: `Creating ${branchName}...`, data: threadToastData },
463 success: () => ({
464 title: `Checked out ${branchName}`,
465 data: threadToastData,
466 }),
467 error: (error) => ({
468 title: "Failed to checkout feature branch",
469 description: error instanceof Error ? error.message : "An error occurred.",
470 data: threadToastData,
471 }),
472 });
473
474 void checkoutPromise
475 .then(() => {
476 const statusOverride = gitStatusForActions
477 ? { ...gitStatusForActions, branch: branchName, pr: null }
478 : null;
479 return runGitActionWithToast({
480 action,
481 ...(commitMessage ? { commitMessage } : {}),
482 skipDefaultBranchPrompt: true,
483 statusOverride,
484 isDefaultBranchOverride: false,
485 });
486 })
487 .catch(() => undefined);
488 }, [
489 branchList?.branches,
490 createBranchAndCheckoutMutation,
491 dialogCommitMessage,
492 gitStatusForActions,
493 isCommitDialogOpen,
494 runGitActionWithToast,
495 setIsCommitDialogOpen,
496 threadToastData,
497 ]);
498
458 const runQuickAction = useCallback(() => {499 const runQuickAction = useCallback(() => {
459 if (quickAction.kind === "open_pr") {500 if (quickAction.kind === "open_pr") {
460 void openExistingPr();501 void openExistingPr();
...@@ -506,30 +547,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -506,30 +547,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
506 void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true });547 void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true });
507 return;548 return;
508 }549 }
509 if (item.dialogAction) {550 if (item.dialogAction === "create_pr") {
510 setActiveDialogAction(item.dialogAction);551 void runGitActionWithToast({ action: "commit_push_pr" });
552 return;
511 }553 }
554 setIsCommitDialogOpen(true);
512 },555 },
513 [openExistingPr, runGitActionWithToast],556 [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen],
514 );557 );
515558
516 const runDialogAction = useCallback(() => {559 const runDialogAction = useCallback(() => {
517 if (!activeDialogAction) return;560 if (!isCommitDialogOpen) return;
518 const action: GitStackedAction =561 const commitMessage = dialogCommitMessage.trim();
519 activeDialogAction === "commit"562 setIsCommitDialogOpen(false);
520 ? "commit"
521 : activeDialogAction === "push"
522 ? "commit_push"
523 : "commit_push_pr";
524 const commitMessage = activeDialogAction === "commit" ? dialogCommitMessage.trim() : "";
525 const forcePushOnlyProgress = activeDialogAction === "push";
526 setActiveDialogAction(null);
527 void runGitActionWithToast({563 void runGitActionWithToast({
528 action,564 action: "commit",
529 ...(commitMessage ? { commitMessage } : {}),565 ...(commitMessage ? { commitMessage } : {}),
530 ...(forcePushOnlyProgress ? { forcePushOnlyProgress } : {}),
531 });566 });
532 }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]);567 }, [dialogCommitMessage, isCommitDialogOpen, runGitActionWithToast, setIsCommitDialogOpen]);
533568
534 const openChangedFileInEditor = useCallback(569 const openChangedFileInEditor = useCallback(
535 (filePath: string) => {570 (filePath: string) => {
...@@ -685,68 +720,29 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -685,68 +720,29 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
685 )}720 )}
686721
687 <Dialog722 <Dialog
688 open={activeDialogAction !== null}723 open={isCommitDialogOpen}
689 onOpenChange={(open) => {724 onOpenChange={(open) => {
690 if (!open) {725 if (!open) {
691 setActiveDialogAction(null);726 setIsCommitDialogOpen(false);
692 setDialogCommitMessage("");727 setDialogCommitMessage("");
693 }728 }
694 }}729 }}
695 >730 >
696 <DialogPopup>731 <DialogPopup>
697 <DialogHeader>732 <DialogHeader>
698 <DialogTitle>{DIALOG_TITLE_BY_ACTION[activeDialogAction ?? "commit"]}</DialogTitle>733 <DialogTitle>{COMMIT_DIALOG_TITLE}</DialogTitle>
699 <DialogDescription>734 <DialogDescription>{COMMIT_DIALOG_DESCRIPTION}</DialogDescription>
700 {DIALOG_DESCRIPTION_BY_ACTION[activeDialogAction ?? "commit"]}
701 </DialogDescription>
702 </DialogHeader>735 </DialogHeader>
703 <DialogPanel className="space-y-4">736 <DialogPanel className="space-y-4">
704 <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs">737 <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs">
705 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">738 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
706 <span className="text-muted-foreground">Branch</span>739 <span className="text-muted-foreground">Branch</span>
707 <span className="font-medium">740 <span className="flex items-center justify-between gap-2">
708 {gitStatusForActions?.branch ?? "(detached HEAD)"}741 <span className="font-medium">{gitStatusForActions?.branch ?? "(detached HEAD)"}</span>
742 {isDefaultBranch && (
743 <span className="text-right text-warning text-xs">Warning: default branch</span>
744 )}
709 </span>745 </span>
710 {activeDialogAction !== "commit" && (
711 <>
712 <span className="text-muted-foreground">Upstream</span>
713 <span className="font-medium">
714 {!gitStatusForActions || !gitStatusForActions.hasUpstream
715 ? "No upstream configured"
716 : gitStatusForActions.aheadCount === 0 &&
717 gitStatusForActions.behindCount === 0
718 ? "Up to date"
719 : gitStatusForActions.aheadCount > 0 &&
720 gitStatusForActions.behindCount > 0
721 ? `Diverged (+${gitStatusForActions.aheadCount} / -${gitStatusForActions.behindCount})`
722 : gitStatusForActions.aheadCount > 0
723 ? `Ahead by ${gitStatusForActions.aheadCount}`
724 : `Behind by ${gitStatusForActions.behindCount}`}
725 </span>
726 <span className="text-muted-foreground">Working tree</span>
727 <span className="font-medium">
728 {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges
729 ? "Clean"
730 : `${gitStatusForActions.workingTree.files.length} file(s)`}
731 </span>
732 <span className="text-muted-foreground">Diff</span>
733 <span className="font-mono">
734 {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges ? (
735 "none"
736 ) : (
737 <>
738 <span className="text-success">
739 +{gitStatusForActions.workingTree.insertions}
740 </span>
741 <span className="text-muted-foreground"> / </span>
742 <span className="text-destructive">
743 -{gitStatusForActions.workingTree.deletions}
744 </span>
745 </>
746 )}
747 </span>
748 </>
749 )}
750 </div>746 </div>
751 <div className="space-y-1">747 <div className="space-y-1">
752 <p className="text-muted-foreground">Files</p>748 <p className="text-muted-foreground">Files</p>
...@@ -786,35 +782,32 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -786,35 +782,32 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
786 )}782 )}
787 </div>783 </div>
788 </div>784 </div>
789 {activeDialogAction === "commit" && (785 <div className="space-y-1">
790 <div className="space-y-1">786 <p className="text-xs font-medium">Commit message (optional)</p>
791 <p className="text-xs font-medium">Commit message (optional)</p>787 <Textarea
792 <Textarea788 value={dialogCommitMessage}
793 value={dialogCommitMessage}789 onChange={(event) => setDialogCommitMessage(event.target.value)}
794 onChange={(event) => setDialogCommitMessage(event.target.value)}790 placeholder="Leave empty to auto-generate"
795 placeholder="Leave empty to auto-generate"791 size="sm"
796 size="sm"792 />
797 />793 </div>
798 </div>
799 )}
800 </DialogPanel>794 </DialogPanel>
801 <DialogFooter>795 <DialogFooter>
802 <Button796 <Button
803 variant="outline"797 variant="outline"
804 size="sm"798 size="sm"
805 onClick={() => {799 onClick={() => {
806 setActiveDialogAction(null);800 setIsCommitDialogOpen(false);
807 setDialogCommitMessage("");801 setDialogCommitMessage("");
808 }}802 }}
809 >803 >
810 Cancel804 Cancel
811 </Button>805 </Button>
806 <Button variant="outline" size="sm" onClick={runDialogActionOnNewBranch}>
807 Commit on new branch
808 </Button>
812 <Button size="sm" onClick={runDialogAction}>809 <Button size="sm" onClick={runDialogAction}>
813 {activeDialogAction === "commit"810 Commit
814 ? "Commit"
815 : activeDialogAction === "push"
816 ? "Push"
817 : "Create PR"}
818 </Button>811 </Button>
819 </DialogFooter>812 </DialogFooter>
820 </DialogPopup>813 </DialogPopup>