From c6ee3aee9fbe97baeea08593b1fdf9a5b9173466 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 23:28:29 -0800 Subject: [PATCH] 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 --- .../GitActionsControl.logic.test.ts | 18 ++ .../src/components/GitActionsControl.logic.ts | 28 +++ apps/web/src/components/GitActionsControl.tsx | 177 ++++++++++++++---- 3 files changed, 191 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index ba8cbdad4485a9b7967075bfbddef6ac4cbf297a..c10ca58918e1f625a45b8c493088a95a24528dc0 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -4,6 +4,7 @@ import { buildGitActionProgressStages, buildMenuItems, requiresDefaultBranchConfirmation, + resolveAutoFeatureBranchName, resolveQuickAction, summarizeGitResult, } from "./GitActionsControl.logic"; @@ -732,3 +733,20 @@ describe("summarizeGitResult", () => { }); }); }); + +describe("resolveAutoFeatureBranchName", () => { + const day = new Date("2026-03-01T12:00:00.000Z"); + + it("returns the base auto feature branch name when available", () => { + const branch = resolveAutoFeatureBranchName(["main", "feature/other"], day); + assert.equal(branch, "feature/stacked-20260301"); + }); + + it("increments suffix when the base name already exists", () => { + const branch = resolveAutoFeatureBranchName( + ["main", "feature/stacked-20260301", "feature/stacked-20260301-2"], + day, + ); + assert.equal(branch, "feature/stacked-20260301-3"); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 9b39904c60bee4544798558d619b52133f00abd2..5389bdbb07561008610e42769f40de9a81471186 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -27,6 +27,7 @@ export interface GitQuickAction { const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; +const AUTO_FEATURE_BRANCH_PREFIX = "feature/stacked"; function shortenSha(sha: string | undefined): string | null { if (!sha) return null; @@ -252,3 +253,30 @@ export function requiresDefaultBranchConfirmation( if (!isDefaultBranch) return false; return action === "commit_push" || action === "commit_push_pr"; } + +function formatDateForBranch(now: Date): string { + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, "0"); + const day = String(now.getUTCDate()).padStart(2, "0"); + return `${year}${month}${day}`; +} + +export function resolveAutoFeatureBranchName( + existingBranchNames: readonly string[], + now = new Date(), +): string { + const daySlug = formatDateForBranch(now); + const base = `${AUTO_FEATURE_BRANCH_PREFIX}-${daySlug}`; + const existingNames = new Set(existingBranchNames); + + if (!existingNames.has(base)) { + return base; + } + + let suffix = 2; + while (existingNames.has(`${base}-${suffix}`)) { + suffix += 1; + } + + return `${base}-${suffix}`; +} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 694ecbc0553a6a6da96dceedff9d6170140066aa..818c21a386a5805947d50b1a5826d981100d2c35 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -11,6 +11,7 @@ import { type GitDialogAction, type GitQuickAction, requiresDefaultBranchConfirmation, + resolveAutoFeatureBranchName, resolveQuickAction, summarizeGitResult, } from "./GitActionsControl.logic"; @@ -32,6 +33,7 @@ import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; import { gitBranchesQueryOptions, + gitCreateBranchAndCheckoutMutationOptions, gitInitMutationOptions, gitMutationKeys, gitPullMutationOptions, @@ -47,6 +49,13 @@ interface GitActionsControlProps { activeThreadId: ThreadId | null; } +interface PendingDefaultBranchAction { + action: GitStackedAction; + commitMessage?: string; + forcePushOnlyProgress: boolean; + onConfirmed?: () => void; +} + function getMenuActionDisabledReason( item: GitActionMenuItem, gitStatus: GitStatusResult | null, @@ -118,6 +127,12 @@ const DIALOG_DESCRIPTION_BY_ACTION = { create_pr: "Create a pull request using generated title/body content.", }; +const DEFAULT_BRANCH_ACTION_DESCRIPTION: Record = { + commit: "commit changes", + commit_push: "commit and push changes", + commit_push_pr: "commit, push, and create a PR", +}; + function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return ; if (icon === "push") return ; @@ -145,6 +160,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const queryClient = useQueryClient(); const [activeDialogAction, setActiveDialogAction] = useState(null); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); + const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = + useState(null); const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); @@ -167,12 +184,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), ); + const createBranchAndCheckoutMutation = useMutation( + gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }), + ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); const isRunStackedActionRunning = useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; + const isCreateBranchAndCheckoutRunning = + useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; - const isGitActionRunning = isRunStackedActionRunning || isPullRunning; + const isGitActionRunning = + isRunStackedActionRunning || isCreateBranchAndCheckoutRunning || isPullRunning; const isDefaultBranch = useMemo(() => { const branchName = gitStatusForActions?.branch; if (!branchName) return false; @@ -192,23 +215,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions ? (quickAction.hint ?? "This action is currently unavailable.") : null; - const maybeConfirmPushToDefaultBranch = useCallback( - async (action: GitStackedAction): Promise => { - const api = readNativeApi(); - if (!api) return false; - if ( - !requiresDefaultBranchConfirmation(action, isDefaultBranch) || - !gitStatusForActions?.branch - ) { - return true; - } - return api.dialogs.confirm( - `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`, - ); - }, - [gitStatusForActions?.branch, isDefaultBranch], - ); - const openExistingPr = useCallback(async () => { const api = readNativeApi(); if (!api) { @@ -237,29 +243,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); }); }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); + const runGitActionWithToast = useCallback( async ({ action, commitMessage, forcePushOnlyProgress = false, onConfirmed, + skipDefaultBranchPrompt = false, + statusOverride, + isDefaultBranchOverride, }: { action: GitStackedAction; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; + skipDefaultBranchPrompt?: boolean; + statusOverride?: GitStatusResult | null; + isDefaultBranchOverride?: boolean; }) => { - const confirmed = await maybeConfirmPushToDefaultBranch(action); - if (!confirmed) return; + const actionStatus = statusOverride ?? gitStatusForActions; + const actionIsDefaultBranch = isDefaultBranchOverride ?? isDefaultBranch; + const actionBranch = actionStatus?.branch ?? null; + if ( + !skipDefaultBranchPrompt && + requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && + actionBranch + ) { + setPendingDefaultBranchAction({ + action, + ...(commitMessage ? { commitMessage } : {}), + forcePushOnlyProgress, + ...(onConfirmed ? { onConfirmed } : {}), + }); + return; + } onConfirmed?.(); - const pushTarget = gitStatusForActions?.branch - ? `origin/${gitStatusForActions.branch}` - : undefined; + const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined; const progressStages = buildGitActionProgressStages({ action, hasCustomCommitMessage: !!commitMessage?.trim(), - hasWorkingTreeChanges: !!gitStatusForActions?.hasWorkingTreeChanges, + hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, forcePushOnly: forcePushOnlyProgress, ...(pushTarget ? { pushTarget } : {}), }); @@ -295,14 +320,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions stopProgressUpdates(); const resultToast = summarizeGitResult(result); - const existingOpenPrUrl = - gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined; + const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; const prUrl = result.pr.url ?? existingOpenPrUrl; const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; const shouldOfferOpenPrCta = - (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; + (action === "commit_push" || action === "commit_push_pr") && + !!prUrl && + !actionIsDefaultBranch; const shouldOfferCreatePrCta = - action === "commit_push" && !prUrl && result.push.status === "pushed" && !isDefaultBranch; + action === "commit_push" && + !prUrl && + result.push.status === "pushed" && + !actionIsDefaultBranch; const closeResultToast = () => { toastManager.close(progressToastId); }; @@ -365,17 +394,67 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [ - gitStatusForActions?.branch, - gitStatusForActions?.hasWorkingTreeChanges, - gitStatusForActions?.pr, isDefaultBranch, - maybeConfirmPushToDefaultBranch, runImmediateGitActionMutation, setActiveDialogAction, + setPendingDefaultBranchAction, threadToastData, + gitStatusForActions, ], ); + const continuePendingDefaultBranchAction = useCallback(() => { + if (!pendingDefaultBranchAction) return; + const pendingAction = pendingDefaultBranchAction; + setPendingDefaultBranchAction(null); + void runGitActionWithToast({ + ...pendingAction, + skipDefaultBranchPrompt: true, + }); + }, [pendingDefaultBranchAction, runGitActionWithToast]); + + const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { + if (!pendingDefaultBranchAction) return; + const pendingAction = pendingDefaultBranchAction; + const branchName = resolveAutoFeatureBranchName(branchList?.branches.map((branch) => branch.name) ?? []); + setPendingDefaultBranchAction(null); + + 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({ + ...pendingAction, + skipDefaultBranchPrompt: true, + statusOverride, + isDefaultBranchOverride: false, + }); + }) + .catch(() => undefined); + }, [ + branchList?.branches, + createBranchAndCheckoutMutation, + gitStatusForActions, + pendingDefaultBranchAction, + runGitActionWithToast, + threadToastData, + ]); + const runQuickAction = useCallback(() => { if (quickAction.kind === "open_pr") { void openExistingPr(); @@ -740,6 +819,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions + + { + if (!open) { + setPendingDefaultBranchAction(null); + } + }} + > + + + Commit to default branch? + + This action will{" "} + {pendingDefaultBranchAction + ? DEFAULT_BRANCH_ACTION_DESCRIPTION[pendingDefaultBranchAction.action] + : "push changes"}{" "} + on "{gitStatusForActions?.branch ?? "default"}". You can continue on this branch or + create a feature branch and run the same action there. + + + + + + + + + ); } -- 2.54.0