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", () => {
515515});
516516
517517describe("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", () => {
519519 const quick = resolveQuickAction(
520520 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
521521 false,
522522 );
523523 assert.deepInclude(quick, {
524 kind: "show_hint",
525 label: "Commit",
526 disabled: true,
524 kind: "run_action",
525 action: "commit_push_pr",
526 label: "Push & create PR",
527 disabled: false,
527528 });
528529 });
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", () => {
531532 const quick = resolveQuickAction(
532533 status({
533534 hasUpstream: false,
......@@ -544,9 +545,10 @@ describe("when: branch has no upstream configured", () => {
544545 false,
545546 );
546547 assert.deepInclude(quick, {
547 kind: "show_hint",
548 label: "Commit",
549 disabled: true,
548 kind: "run_action",
549 action: "commit_push",
550 label: "Push",
551 disabled: false,
550552 });
551553 });
552554
......@@ -574,7 +576,7 @@ describe("when: branch has no upstream configured", () => {
574576 });
575577 });
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", () => {
578580 const items = buildMenuItems(
579581 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
580582 false,
......@@ -591,7 +593,7 @@ describe("when: branch has no upstream configured", () => {
591593 {
592594 id: "push",
593595 label: "Push",
594 disabled: true,
596 disabled: false,
595597 icon: "push",
596598 kind: "open_dialog",
597599 dialogAction: "push",
......@@ -599,7 +601,7 @@ describe("when: branch has no upstream configured", () => {
599601 {
600602 id: "pr",
601603 label: "Create PR",
602 disabled: true,
604 disabled: false,
603605 icon: "pr",
604606 kind: "open_dialog",
605607 dialogAction: "create_pr",
apps/web/src/components/GitActionsControl.logic.ts+20-3
......@@ -111,15 +111,20 @@ export function buildMenuItems(
111111 const hasChanges = gitStatus.hasWorkingTreeChanges;
112112 const hasOpenPr = gitStatus.pr?.state === "open";
113113 const isBehind = gitStatus.behindCount > 0;
114 const canPublishUntrackedBranch = !gitStatus.hasUpstream;
114115 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);
116122 const canCreatePr =
117123 !isBusy &&
118124 hasBranch &&
119125 !hasChanges &&
120126 !hasOpenPr &&
121 gitStatus.aheadCount > 0 &&
122 gitStatus.hasUpstream &&
127 (gitStatus.aheadCount > 0 || canPublishUntrackedBranch) &&
123128 !isBehind;
124129 const canOpenPr = !isBusy && hasOpenPr;
125130
......@@ -205,6 +210,18 @@ export function resolveQuickAction(
205210 };
206211 }
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
208225 if (isDiverged) {
209226 return {
210227 label: "Sync branch",
apps/web/src/components/GitActionsControl.tsx+92-99
......@@ -8,7 +8,6 @@ import {
88 buildMenuItems,
99 type GitActionIconName,
1010 type GitActionMenuItem,
11 type GitDialogAction,
1211 type GitQuickAction,
1312 requiresDefaultBranchConfirmation,
1413 resolveAutoFeatureBranchName,
......@@ -88,6 +87,9 @@ function getMenuActionDisabledReason(
8887 if (isBehind) {
8988 return "Branch is behind upstream. Pull/rebase before pushing.";
9089 }
90 if (!gitStatus.hasUpstream) {
91 return "Branch has no upstream. Push to publish it.";
92 }
9193 if (!isAhead) {
9294 return "No local commits to push.";
9395 }
......@@ -103,29 +105,21 @@ function getMenuActionDisabledReason(
103105 if (hasChanges) {
104106 return "Commit local changes before creating a PR.";
105107 }
106 if (!isAhead) {
107 return "No local commits to include in a PR.";
108 }
109108 if (!gitStatus.hasUpstream) {
110109 return "Set an upstream branch before creating a PR.";
111110 }
111 if (!isAhead) {
112 return "No local commits to include in a PR.";
113 }
112114 if (isBehind) {
113115 return "Branch is behind upstream. Pull/rebase before creating a PR.";
114116 }
115117 return "Create PR is currently unavailable.";
116118}
117119
118const DIALOG_TITLE_BY_ACTION = {
119 commit: "Commit changes",
120 push: "Push branch",
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};
120const COMMIT_DIALOG_TITLE = "Commit changes";
121const COMMIT_DIALOG_DESCRIPTION =
122 "Review and confirm your commit. Leave the message blank to auto-generate one.";
129123
130124const DEFAULT_BRANCH_ACTION_DESCRIPTION: Record<GitStackedAction, string> = {
131125 commit: "commit changes",
......@@ -158,7 +152,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
158152 [activeThreadId],
159153 );
160154 const queryClient = useQueryClient();
161 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);
155 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
162156 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
163157 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
164158 useState<PendingDefaultBranchAction | null>(null);
......@@ -376,7 +370,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
376370 children: "Create PR",
377371 onClick: () => {
378372 closeResultToast();
379 setActiveDialogAction("create_pr");
373 void runGitActionWithToast({ action: "commit_push_pr" });
380374 },
381375 },
382376 }
......@@ -396,7 +390,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
396390 [
397391 isDefaultBranch,
398392 runImmediateGitActionMutation,
399 setActiveDialogAction,
400393 setPendingDefaultBranchAction,
401394 threadToastData,
402395 gitStatusForActions,
......@@ -455,6 +448,54 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
455448 threadToastData,
456449 ]);
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
458499 const runQuickAction = useCallback(() => {
459500 if (quickAction.kind === "open_pr") {
460501 void openExistingPr();
......@@ -506,30 +547,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
506547 void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true });
507548 return;
508549 }
509 if (item.dialogAction) {
510 setActiveDialogAction(item.dialogAction);
550 if (item.dialogAction === "create_pr") {
551 void runGitActionWithToast({ action: "commit_push_pr" });
552 return;
511553 }
554 setIsCommitDialogOpen(true);
512555 },
513 [openExistingPr, runGitActionWithToast],
556 [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen],
514557 );
515558
516559 const runDialogAction = useCallback(() => {
517 if (!activeDialogAction) return;
518 const action: GitStackedAction =
519 activeDialogAction === "commit"
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);
560 if (!isCommitDialogOpen) return;
561 const commitMessage = dialogCommitMessage.trim();
562 setIsCommitDialogOpen(false);
527563 void runGitActionWithToast({
528 action,
564 action: "commit",
529565 ...(commitMessage ? { commitMessage } : {}),
530 ...(forcePushOnlyProgress ? { forcePushOnlyProgress } : {}),
531566 });
532 }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]);
567 }, [dialogCommitMessage, isCommitDialogOpen, runGitActionWithToast, setIsCommitDialogOpen]);
533568
534569 const openChangedFileInEditor = useCallback(
535570 (filePath: string) => {
......@@ -685,68 +720,29 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
685720 )}
686721
687722 <Dialog
688 open={activeDialogAction !== null}
723 open={isCommitDialogOpen}
689724 onOpenChange={(open) => {
690725 if (!open) {
691 setActiveDialogAction(null);
726 setIsCommitDialogOpen(false);
692727 setDialogCommitMessage("");
693728 }
694729 }}
695730 >
696731 <DialogPopup>
697732 <DialogHeader>
698 <DialogTitle>{DIALOG_TITLE_BY_ACTION[activeDialogAction ?? "commit"]}</DialogTitle>
699 <DialogDescription>
700 {DIALOG_DESCRIPTION_BY_ACTION[activeDialogAction ?? "commit"]}
701 </DialogDescription>
733 <DialogTitle>{COMMIT_DIALOG_TITLE}</DialogTitle>
734 <DialogDescription>{COMMIT_DIALOG_DESCRIPTION}</DialogDescription>
702735 </DialogHeader>
703736 <DialogPanel className="space-y-4">
704737 <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs">
705738 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
706739 <span className="text-muted-foreground">Branch</span>
707 <span className="font-medium">
708 {gitStatusForActions?.branch ?? "(detached HEAD)"}
740 <span className="flex items-center justify-between gap-2">
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 )}
709745 </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 )}
750746 </div>
751747 <div className="space-y-1">
752748 <p className="text-muted-foreground">Files</p>
......@@ -786,35 +782,32 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
786782 )}
787783 </div>
788784 </div>
789 {activeDialogAction === "commit" && (
790 <div className="space-y-1">
791 <p className="text-xs font-medium">Commit message (optional)</p>
792 <Textarea
793 value={dialogCommitMessage}
794 onChange={(event) => setDialogCommitMessage(event.target.value)}
795 placeholder="Leave empty to auto-generate"
796 size="sm"
797 />
798 </div>
799 )}
785 <div className="space-y-1">
786 <p className="text-xs font-medium">Commit message (optional)</p>
787 <Textarea
788 value={dialogCommitMessage}
789 onChange={(event) => setDialogCommitMessage(event.target.value)}
790 placeholder="Leave empty to auto-generate"
791 size="sm"
792 />
793 </div>
800794 </DialogPanel>
801795 <DialogFooter>
802796 <Button
803797 variant="outline"
804798 size="sm"
805799 onClick={() => {
806 setActiveDialogAction(null);
800 setIsCommitDialogOpen(false);
807801 setDialogCommitMessage("");
808802 }}
809803 >
810804 Cancel
811805 </Button>
806 <Button variant="outline" size="sm" onClick={runDialogActionOnNewBranch}>
807 Commit on new branch
808 </Button>
812809 <Button size="sm" onClick={runDialogAction}>
813 {activeDialogAction === "commit"
814 ? "Commit"
815 : activeDialogAction === "push"
816 ? "Push"
817 : "Create PR"}
810 Commit
818811 </Button>
819812 </DialogFooter>
820813 </DialogPopup>