authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 23:28:29-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:01-08:00
logc6ee3aee9fbe97baeea08593b1fdf9a5b9173466
tree6b5f66aa9349c00167648bfef14db6365a52c414
parent586e0ecea93228a0db7b3e6cf09166f5266d2707

Add default-branch safeguard with auto feature branch fallback

- Prompt before commit/push actions on the default branch - Allow one-click create+checkout of an auto-named feature branch and continue - Add logic/tests for date-based unique auto feature branch naming

3 files changed, 191 insertions(+), 32 deletions(-)

apps/web/src/components/GitActionsControl.logic.test.ts+18
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 buildGitActionProgressStages,4 buildGitActionProgressStages,
5 buildMenuItems,5 buildMenuItems,
6 requiresDefaultBranchConfirmation,6 requiresDefaultBranchConfirmation,
7 resolveAutoFeatureBranchName,
7 resolveQuickAction,8 resolveQuickAction,
8 summarizeGitResult,9 summarizeGitResult,
9} from "./GitActionsControl.logic";10} from "./GitActionsControl.logic";
...@@ -732,3 +733,20 @@ describe("summarizeGitResult", () => {...@@ -732,3 +733,20 @@ describe("summarizeGitResult", () => {
732 });733 });
733 });734 });
734});735});
736
737describe("resolveAutoFeatureBranchName", () => {
738 const day = new Date("2026-03-01T12:00:00.000Z");
739
740 it("returns the base auto feature branch name when available", () => {
741 const branch = resolveAutoFeatureBranchName(["main", "feature/other"], day);
742 assert.equal(branch, "feature/stacked-20260301");
743 });
744
745 it("increments suffix when the base name already exists", () => {
746 const branch = resolveAutoFeatureBranchName(
747 ["main", "feature/stacked-20260301", "feature/stacked-20260301-2"],
748 day,
749 );
750 assert.equal(branch, "feature/stacked-20260301-3");
751 });
752});
apps/web/src/components/GitActionsControl.logic.ts+28
...@@ -27,6 +27,7 @@ export interface GitQuickAction {...@@ -27,6 +27,7 @@ export interface GitQuickAction {
2727
28const SHORT_SHA_LENGTH = 7;28const SHORT_SHA_LENGTH = 7;
29const TOAST_DESCRIPTION_MAX = 72;29const TOAST_DESCRIPTION_MAX = 72;
30const AUTO_FEATURE_BRANCH_PREFIX = "feature/stacked";
3031
31function shortenSha(sha: string | undefined): string | null {32function shortenSha(sha: string | undefined): string | null {
32 if (!sha) return null;33 if (!sha) return null;
...@@ -252,3 +253,30 @@ export function requiresDefaultBranchConfirmation(...@@ -252,3 +253,30 @@ export function requiresDefaultBranchConfirmation(
252 if (!isDefaultBranch) return false;253 if (!isDefaultBranch) return false;
253 return action === "commit_push" || action === "commit_push_pr";254 return action === "commit_push" || action === "commit_push_pr";
254}255}
256
257function formatDateForBranch(now: Date): string {
258 const year = now.getUTCFullYear();
259 const month = String(now.getUTCMonth() + 1).padStart(2, "0");
260 const day = String(now.getUTCDate()).padStart(2, "0");
261 return `${year}${month}${day}`;
262}
263
264export function resolveAutoFeatureBranchName(
265 existingBranchNames: readonly string[],
266 now = new Date(),
267): string {
268 const daySlug = formatDateForBranch(now);
269 const base = `${AUTO_FEATURE_BRANCH_PREFIX}-${daySlug}`;
270 const existingNames = new Set(existingBranchNames);
271
272 if (!existingNames.has(base)) {
273 return base;
274 }
275
276 let suffix = 2;
277 while (existingNames.has(`${base}-${suffix}`)) {
278 suffix += 1;
279 }
280
281 return `${base}-${suffix}`;
282}
apps/web/src/components/GitActionsControl.tsx+145-32
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 type GitDialogAction,11 type GitDialogAction,
12 type GitQuickAction,12 type GitQuickAction,
13 requiresDefaultBranchConfirmation,13 requiresDefaultBranchConfirmation,
14 resolveAutoFeatureBranchName,
14 resolveQuickAction,15 resolveQuickAction,
15 summarizeGitResult,16 summarizeGitResult,
16} from "./GitActionsControl.logic";17} from "./GitActionsControl.logic";
...@@ -32,6 +33,7 @@ import { Textarea } from "~/components/ui/textarea";...@@ -32,6 +33,7 @@ import { Textarea } from "~/components/ui/textarea";
32import { toastManager } from "~/components/ui/toast";33import { toastManager } from "~/components/ui/toast";
33import {34import {
34 gitBranchesQueryOptions,35 gitBranchesQueryOptions,
36 gitCreateBranchAndCheckoutMutationOptions,
35 gitInitMutationOptions,37 gitInitMutationOptions,
36 gitMutationKeys,38 gitMutationKeys,
37 gitPullMutationOptions,39 gitPullMutationOptions,
...@@ -47,6 +49,13 @@ interface GitActionsControlProps {...@@ -47,6 +49,13 @@ interface GitActionsControlProps {
47 activeThreadId: ThreadId | null;49 activeThreadId: ThreadId | null;
48}50}
4951
52interface PendingDefaultBranchAction {
53 action: GitStackedAction;
54 commitMessage?: string;
55 forcePushOnlyProgress: boolean;
56 onConfirmed?: () => void;
57}
58
50function getMenuActionDisabledReason(59function getMenuActionDisabledReason(
51 item: GitActionMenuItem,60 item: GitActionMenuItem,
52 gitStatus: GitStatusResult | null,61 gitStatus: GitStatusResult | null,
...@@ -118,6 +127,12 @@ const DIALOG_DESCRIPTION_BY_ACTION = {...@@ -118,6 +127,12 @@ const DIALOG_DESCRIPTION_BY_ACTION = {
118 create_pr: "Create a pull request using generated title/body content.",127 create_pr: "Create a pull request using generated title/body content.",
119};128};
120129
130const DEFAULT_BRANCH_ACTION_DESCRIPTION: Record<GitStackedAction, string> = {
131 commit: "commit changes",
132 commit_push: "commit and push changes",
133 commit_push_pr: "commit, push, and create a PR",
134};
135
121function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {136function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {
122 if (icon === "commit") return <GitCommitIcon />;137 if (icon === "commit") return <GitCommitIcon />;
123 if (icon === "push") return <CloudUploadIcon />;138 if (icon === "push") return <CloudUploadIcon />;
...@@ -145,6 +160,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -145,6 +160,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
145 const queryClient = useQueryClient();160 const queryClient = useQueryClient();
146 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);161 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);
147 const [dialogCommitMessage, setDialogCommitMessage] = useState("");162 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
163 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
164 useState<PendingDefaultBranchAction | null>(null);
148165
149 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));166 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
150167
...@@ -167,12 +184,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -167,12 +184,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
167 const runImmediateGitActionMutation = useMutation(184 const runImmediateGitActionMutation = useMutation(
168 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),185 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
169 );186 );
187 const createBranchAndCheckoutMutation = useMutation(
188 gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }),
189 );
170 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));190 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
171191
172 const isRunStackedActionRunning =192 const isRunStackedActionRunning =
173 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;193 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
194 const isCreateBranchAndCheckoutRunning =
195 useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0;
174 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;196 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
175 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;197 const isGitActionRunning =
198 isRunStackedActionRunning || isCreateBranchAndCheckoutRunning || isPullRunning;
176 const isDefaultBranch = useMemo(() => {199 const isDefaultBranch = useMemo(() => {
177 const branchName = gitStatusForActions?.branch;200 const branchName = gitStatusForActions?.branch;
178 if (!branchName) return false;201 if (!branchName) return false;
...@@ -192,23 +215,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -192,23 +215,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
192 ? (quickAction.hint ?? "This action is currently unavailable.")215 ? (quickAction.hint ?? "This action is currently unavailable.")
193 : null;216 : null;
194217
195 const maybeConfirmPushToDefaultBranch = useCallback(
196 async (action: GitStackedAction): Promise<boolean> => {
197 const api = readNativeApi();
198 if (!api) return false;
199 if (
200 !requiresDefaultBranchConfirmation(action, isDefaultBranch) ||
201 !gitStatusForActions?.branch
202 ) {
203 return true;
204 }
205 return api.dialogs.confirm(
206 `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`,
207 );
208 },
209 [gitStatusForActions?.branch, isDefaultBranch],
210 );
211
212 const openExistingPr = useCallback(async () => {218 const openExistingPr = useCallback(async () => {
213 const api = readNativeApi();219 const api = readNativeApi();
214 if (!api) {220 if (!api) {
...@@ -237,29 +243,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -237,29 +243,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
237 });243 });
238 });244 });
239 }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);245 }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);
246
240 const runGitActionWithToast = useCallback(247 const runGitActionWithToast = useCallback(
241 async ({248 async ({
242 action,249 action,
243 commitMessage,250 commitMessage,
244 forcePushOnlyProgress = false,251 forcePushOnlyProgress = false,
245 onConfirmed,252 onConfirmed,
253 skipDefaultBranchPrompt = false,
254 statusOverride,
255 isDefaultBranchOverride,
246 }: {256 }: {
247 action: GitStackedAction;257 action: GitStackedAction;
248 commitMessage?: string;258 commitMessage?: string;
249 forcePushOnlyProgress?: boolean;259 forcePushOnlyProgress?: boolean;
250 onConfirmed?: () => void;260 onConfirmed?: () => void;
261 skipDefaultBranchPrompt?: boolean;
262 statusOverride?: GitStatusResult | null;
263 isDefaultBranchOverride?: boolean;
251 }) => {264 }) => {
252 const confirmed = await maybeConfirmPushToDefaultBranch(action);265 const actionStatus = statusOverride ?? gitStatusForActions;
253 if (!confirmed) return;266 const actionIsDefaultBranch = isDefaultBranchOverride ?? isDefaultBranch;
267 const actionBranch = actionStatus?.branch ?? null;
268 if (
269 !skipDefaultBranchPrompt &&
270 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&
271 actionBranch
272 ) {
273 setPendingDefaultBranchAction({
274 action,
275 ...(commitMessage ? { commitMessage } : {}),
276 forcePushOnlyProgress,
277 ...(onConfirmed ? { onConfirmed } : {}),
278 });
279 return;
280 }
254 onConfirmed?.();281 onConfirmed?.();
255282
256 const pushTarget = gitStatusForActions?.branch283 const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined;
257 ? `origin/${gitStatusForActions.branch}`
258 : undefined;
259 const progressStages = buildGitActionProgressStages({284 const progressStages = buildGitActionProgressStages({
260 action,285 action,
261 hasCustomCommitMessage: !!commitMessage?.trim(),286 hasCustomCommitMessage: !!commitMessage?.trim(),
262 hasWorkingTreeChanges: !!gitStatusForActions?.hasWorkingTreeChanges,287 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,
263 forcePushOnly: forcePushOnlyProgress,288 forcePushOnly: forcePushOnlyProgress,
264 ...(pushTarget ? { pushTarget } : {}),289 ...(pushTarget ? { pushTarget } : {}),
265 });290 });
...@@ -295,14 +320,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -295,14 +320,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
295 stopProgressUpdates();320 stopProgressUpdates();
296 const resultToast = summarizeGitResult(result);321 const resultToast = summarizeGitResult(result);
297322
298 const existingOpenPrUrl =323 const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined;
299 gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined;
300 const prUrl = result.pr.url ?? existingOpenPrUrl;324 const prUrl = result.pr.url ?? existingOpenPrUrl;
301 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";325 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
302 const shouldOfferOpenPrCta =326 const shouldOfferOpenPrCta =
303 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;327 (action === "commit_push" || action === "commit_push_pr") &&
328 !!prUrl &&
329 !actionIsDefaultBranch;
304 const shouldOfferCreatePrCta =330 const shouldOfferCreatePrCta =
305 action === "commit_push" && !prUrl && result.push.status === "pushed" && !isDefaultBranch;331 action === "commit_push" &&
332 !prUrl &&
333 result.push.status === "pushed" &&
334 !actionIsDefaultBranch;
306 const closeResultToast = () => {335 const closeResultToast = () => {
307 toastManager.close(progressToastId);336 toastManager.close(progressToastId);
308 };337 };
...@@ -365,17 +394,67 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -365,17 +394,67 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
365 },394 },
366395
367 [396 [
368 gitStatusForActions?.branch,
369 gitStatusForActions?.hasWorkingTreeChanges,
370 gitStatusForActions?.pr,
371 isDefaultBranch,397 isDefaultBranch,
372 maybeConfirmPushToDefaultBranch,
373 runImmediateGitActionMutation,398 runImmediateGitActionMutation,
374 setActiveDialogAction,399 setActiveDialogAction,
400 setPendingDefaultBranchAction,
375 threadToastData,401 threadToastData,
402 gitStatusForActions,
376 ],403 ],
377 );404 );
378405
406 const continuePendingDefaultBranchAction = useCallback(() => {
407 if (!pendingDefaultBranchAction) return;
408 const pendingAction = pendingDefaultBranchAction;
409 setPendingDefaultBranchAction(null);
410 void runGitActionWithToast({
411 ...pendingAction,
412 skipDefaultBranchPrompt: true,
413 });
414 }, [pendingDefaultBranchAction, runGitActionWithToast]);
415
416 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
417 if (!pendingDefaultBranchAction) return;
418 const pendingAction = pendingDefaultBranchAction;
419 const branchName = resolveAutoFeatureBranchName(branchList?.branches.map((branch) => branch.name) ?? []);
420 setPendingDefaultBranchAction(null);
421
422 const checkoutPromise = createBranchAndCheckoutMutation.mutateAsync(branchName);
423 toastManager.promise(checkoutPromise, {
424 loading: { title: `Creating ${branchName}...`, data: threadToastData },
425 success: () => ({
426 title: `Checked out ${branchName}`,
427 data: threadToastData,
428 }),
429 error: (error) => ({
430 title: "Failed to checkout feature branch",
431 description: error instanceof Error ? error.message : "An error occurred.",
432 data: threadToastData,
433 }),
434 });
435
436 void checkoutPromise
437 .then(() => {
438 const statusOverride = gitStatusForActions
439 ? { ...gitStatusForActions, branch: branchName, pr: null }
440 : null;
441 return runGitActionWithToast({
442 ...pendingAction,
443 skipDefaultBranchPrompt: true,
444 statusOverride,
445 isDefaultBranchOverride: false,
446 });
447 })
448 .catch(() => undefined);
449 }, [
450 branchList?.branches,
451 createBranchAndCheckoutMutation,
452 gitStatusForActions,
453 pendingDefaultBranchAction,
454 runGitActionWithToast,
455 threadToastData,
456 ]);
457
379 const runQuickAction = useCallback(() => {458 const runQuickAction = useCallback(() => {
380 if (quickAction.kind === "open_pr") {459 if (quickAction.kind === "open_pr") {
381 void openExistingPr();460 void openExistingPr();
...@@ -740,6 +819,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -740,6 +819,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
740 </DialogFooter>819 </DialogFooter>
741 </DialogPopup>820 </DialogPopup>
742 </Dialog>821 </Dialog>
822
823 <Dialog
824 open={pendingDefaultBranchAction !== null}
825 onOpenChange={(open) => {
826 if (!open) {
827 setPendingDefaultBranchAction(null);
828 }
829 }}
830 >
831 <DialogPopup>
832 <DialogHeader>
833 <DialogTitle>Commit to default branch?</DialogTitle>
834 <DialogDescription>
835 This action will{" "}
836 {pendingDefaultBranchAction
837 ? DEFAULT_BRANCH_ACTION_DESCRIPTION[pendingDefaultBranchAction.action]
838 : "push changes"}{" "}
839 on "{gitStatusForActions?.branch ?? "default"}". You can continue on this branch or
840 create a feature branch and run the same action there.
841 </DialogDescription>
842 </DialogHeader>
843 <DialogFooter>
844 <Button variant="outline" size="sm" onClick={() => setPendingDefaultBranchAction(null)}>
845 Abort
846 </Button>
847 <Button variant="outline" size="sm" onClick={continuePendingDefaultBranchAction}>
848 Commit to {gitStatusForActions?.branch ?? "default"}
849 </Button>
850 <Button size="sm" onClick={checkoutFeatureBranchAndContinuePendingAction}>
851 Checkout feature branch & continue
852 </Button>
853 </DialogFooter>
854 </DialogPopup>
855 </Dialog>
743 </>856 </>
744 );857 );
745}858}