History

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

Julius Marminge committed 8 months ago

Commit 1d346561 parent 206eb6a3 files · +191 −32Browse files

Unified · Split

3 files
  1. apps/web/src/components/GitActionsControl.logic.test.ts +18 −0
  2. apps/web/src/components/GitActionsControl.logic.ts +28 −0
  3. apps/web/src/components/GitActionsControl.tsx +145 −32
apps/web/src/components/GitActionsControl.logic.test.ts+18−0
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
44 buildGitActionProgressStages,
55 buildMenuItems,
66 requiresDefaultBranchConfirmation,
7+ resolveAutoFeatureBranchName,
78 resolveQuickAction,
89 summarizeGitResult,
910 } from "./GitActionsControl.logic";
@@ -732,3 +733,20 @@ describe("summarizeGitResult", () => { · 722 unmodified lines
732733 });
733734 });
734735 });
736+
737+describe("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−0
@@ -27,6 +27,7 @@ export interface GitQuickAction { · 26 unmodified lines
2727
2828 const SHORT_SHA_LENGTH = 7;
2929 const TOAST_DESCRIPTION_MAX = 72;
30+const AUTO_FEATURE_BRANCH_PREFIX = "feature/stacked";
3031
3132 function shortenSha(sha: string | undefined): string | null {
3233 if (!sha) return null;
@@ -252,3 +253,30 @@ export function requiresDefaultBranchConfirmation( · 219 unmodified lines
252253 if (!isDefaultBranch) return false;
253254 return action === "commit_push" || action === "commit_push_pr";
254255 }
256+
257+function 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+
264+export 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 { · 10 unmodified lines
1111 type GitDialogAction,
1212 type GitQuickAction,
1313 requiresDefaultBranchConfirmation,
14+ resolveAutoFeatureBranchName,
1415 resolveQuickAction,
1516 summarizeGitResult,
1617 } from "./GitActionsControl.logic";
@@ -32,6 +33,7 @@ import { Textarea } from "~/components/ui/textarea"; · 15 unmodified lines
3233 import { toastManager } from "~/components/ui/toast";
3334 import {
3435 gitBranchesQueryOptions,
36+ gitCreateBranchAndCheckoutMutationOptions,
3537 gitInitMutationOptions,
3638 gitMutationKeys,
3739 gitPullMutationOptions,
@@ -47,6 +49,13 @@ interface GitActionsControlProps { · 9 unmodified lines
4749 activeThreadId: ThreadId | null;
4850 }
4951
52+interface PendingDefaultBranchAction {
53+ action: GitStackedAction;
54+ commitMessage?: string;
55+ forcePushOnlyProgress: boolean;
56+ onConfirmed?: () => void;
57+}
58+
5059 function getMenuActionDisabledReason(
5160 item: GitActionMenuItem,
5261 gitStatus: GitStatusResult | null,
@@ -118,6 +127,12 @@ const DIALOG_DESCRIPTION_BY_ACTION = { · 65 unmodified lines
118127 create_pr: "Create a pull request using generated title/body content.",
119128 };
120129
130+const 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+
121136 function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {
122137 if (icon === "commit") return <GitCommitIcon />;
123138 if (icon === "push") return <CloudUploadIcon />;
@@ -145,6 +160,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 21 unmodified lines
145160 const queryClient = useQueryClient();
146161 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);
147162 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
163+ const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
164+ useState<PendingDefaultBranchAction | null>(null);
148165
149166 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
150167
@@ -167,12 +184,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 16 unmodified lines
167184 const runImmediateGitActionMutation = useMutation(
168185 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
169186 );
187+ const createBranchAndCheckoutMutation = useMutation(
188+ gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }),
189+ );
170190 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
171191
172192 const isRunStackedActionRunning =
173193 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
194+ const isCreateBranchAndCheckoutRunning =
195+ useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0;
174196 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
175− const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
197+ const isGitActionRunning =
198+ isRunStackedActionRunning || isCreateBranchAndCheckoutRunning || isPullRunning;
176199 const isDefaultBranch = useMemo(() => {
177200 const branchName = gitStatusForActions?.branch;
178201 if (!branchName) return false;
@@ -192,23 +215,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 13 unmodified lines
192215 ? (quickAction.hint ?? "This action is currently unavailable.")
193216 : 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−
212218 const openExistingPr = useCallback(async () => {
213219 const api = readNativeApi();
214220 if (!api) {
@@ -237,29 +243,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 22 unmodified lines
237243 });
238244 });
239245 }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);
246+
240247 const runGitActionWithToast = useCallback(
241248 async ({
242249 action,
243250 commitMessage,
244251 forcePushOnlyProgress = false,
245252 onConfirmed,
253+ skipDefaultBranchPrompt = false,
254+ statusOverride,
255+ isDefaultBranchOverride,
246256 }: {
247257 action: GitStackedAction;
248258 commitMessage?: string;
249259 forcePushOnlyProgress?: boolean;
250260 onConfirmed?: () => void;
261+ skipDefaultBranchPrompt?: boolean;
262+ statusOverride?: GitStatusResult | null;
263+ isDefaultBranchOverride?: boolean;
251264 }) => {
252− const confirmed = await maybeConfirmPushToDefaultBranch(action);
253− if (!confirmed) return;
265+ const actionStatus = statusOverride ?? gitStatusForActions;
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+ }
254281 onConfirmed?.();
255282
256− const pushTarget = gitStatusForActions?.branch
257− ? `origin/${gitStatusForActions.branch}`
258− : undefined;
283+ const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined;
259284 const progressStages = buildGitActionProgressStages({
260285 action,
261286 hasCustomCommitMessage: !!commitMessage?.trim(),
262− hasWorkingTreeChanges: !!gitStatusForActions?.hasWorkingTreeChanges,
287+ hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,
263288 forcePushOnly: forcePushOnlyProgress,
264289 ...(pushTarget ? { pushTarget } : {}),
265290 });
@@ -295,14 +320,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 29 unmodified lines
295320 stopProgressUpdates();
296321 const resultToast = summarizeGitResult(result);
297322
298− const existingOpenPrUrl =
299− gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined;
323+ const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined;
300324 const prUrl = result.pr.url ?? existingOpenPrUrl;
301325 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
302326 const shouldOfferOpenPrCta =
303− (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;
327+ (action === "commit_push" || action === "commit_push_pr") &&
328+ !!prUrl &&
329+ !actionIsDefaultBranch;
304330 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;
306335 const closeResultToast = () => {
307336 toastManager.close(progressToastId);
308337 };
@@ -365,17 +394,67 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 56 unmodified lines
365394 },
366395
367396 [
368− gitStatusForActions?.branch,
369− gitStatusForActions?.hasWorkingTreeChanges,
370− gitStatusForActions?.pr,
371397 isDefaultBranch,
372− maybeConfirmPushToDefaultBranch,
373398 runImmediateGitActionMutation,
374399 setActiveDialogAction,
400+ setPendingDefaultBranchAction,
375401 threadToastData,
402+ gitStatusForActions,
376403 ],
377404 );
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+
379458 const runQuickAction = useCallback(() => {
380459 if (quickAction.kind === "open_pr") {
381460 void openExistingPr();
@@ -740,6 +819,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 358 unmodified lines
740819 </DialogFooter>
741820 </DialogPopup>
742821 </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>
743856 </>
744857 );
745858 }