authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 00:59:30-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 01:23:10-08:00
log60111b2b912e8ebc9c21a6bac836b60cf8b75122
tree1aa613aa16ce1c767e5bbb52a0b5b7c36660ba6f
parent1f17e9c429c4dad56762c60137436923b61cc499

Guard git quick actions for diverged and stale branch status

- Disable the quick sync action when local and upstream branches have diverged - Treat mismatched branch/status data as out-of-sync and refresh before enabling actions - Update GitActionsControl logic tests for diverged branch behavior Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 96 insertions(+), 44 deletions(-)

apps/web/src/components/GitActionsControl.logic.test.ts+12
......@@ -320,6 +320,18 @@ describe("when: branch is behind upstream", () => {
320320 });
321321});
322322
323describe("when: branch has diverged from upstream", () => {
324 it("resolveQuickAction returns a disabled sync hint", () => {
325 const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false);
326 assert.deepEqual(quick, {
327 label: "Sync branch",
328 disabled: true,
329 kind: "show_hint",
330 hint: "Branch has diverged from upstream. Rebase/merge first.",
331 });
332 });
333});
334
323335describe("when: working tree has local changes", () => {
324336 it("resolveQuickAction returns commit, push, and create PR", () => {
325337 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false);
apps/web/src/components/GitActionsControl.logic.ts+10
......@@ -181,6 +181,7 @@ export function resolveQuickAction(
181181 const hasOpenPr = gitStatus.openPr !== null;
182182 const isAhead = gitStatus.aheadCount > 0;
183183 const isBehind = gitStatus.behindCount > 0;
184 const isDiverged = isAhead && isBehind;
184185
185186 if (!hasBranch) {
186187 return {
......@@ -203,6 +204,15 @@ export function resolveQuickAction(
203204 };
204205 }
205206
207 if (isDiverged) {
208 return {
209 label: "Sync branch",
210 disabled: true,
211 kind: "show_hint",
212 hint: "Branch has diverged from upstream. Rebase/merge first.",
213 };
214 }
215
206216 if (isBehind) {
207217 return {
208218 label: "Pull",
apps/web/src/components/GitActionsControl.tsx+74-44
......@@ -1,6 +1,6 @@
11import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useCallback, useMemo, useState } from "react";
3import { useCallback, useEffect, useMemo, useState } from "react";
44import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
55import { GitHubIcon } from "./Icons";
66import {
......@@ -154,6 +154,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
154154 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd));
155155 // Default to true while loading so we don't flash init controls.
156156 const isRepo = branchList?.isRepo ?? true;
157 const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;
158 const isGitStatusOutOfSync =
159 !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch;
160
161 useEffect(() => {
162 if (!isGitStatusOutOfSync) return;
163 void invalidateGitQueries(queryClient);
164 }, [isGitStatusOutOfSync, queryClient]);
165
166 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;
157167
158168 const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient }));
159169
......@@ -164,19 +174,19 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
164174
165175 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;
166176 const isDefaultBranch = useMemo(() => {
167 const currentBranch = gitStatus?.branch;
168 if (!currentBranch) return false;
169 const current = branchList?.branches.find((branch) => branch.name === currentBranch);
170 return current?.isDefault ?? (currentBranch === "main" || currentBranch === "master");
171 }, [branchList?.branches, gitStatus?.branch]);
177 const branchName = gitStatusForActions?.branch;
178 if (!branchName) return false;
179 const current = branchList?.branches.find((branch) => branch.name === branchName);
180 return current?.isDefault ?? (branchName === "main" || branchName === "master");
181 }, [branchList?.branches, gitStatusForActions?.branch]);
172182
173183 const gitActionMenuItems = useMemo(
174 () => buildMenuItems(gitStatus, isGitActionRunning),
175 [gitStatus, isGitActionRunning],
184 () => buildMenuItems(gitStatusForActions, isGitActionRunning),
185 [gitStatusForActions, isGitActionRunning],
176186 );
177187 const quickAction = useMemo(
178 () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch),
179 [gitStatus, isDefaultBranch, isGitActionRunning],
188 () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch),
189 [gitStatusForActions, isDefaultBranch, isGitActionRunning],
180190 );
181191 const quickActionDisabledReason = quickAction.disabled
182192 ? (quickAction.hint ?? "This action is currently unavailable.")
......@@ -185,14 +195,17 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
185195 const maybeConfirmPushToDefaultBranch = useCallback(
186196 async (action: GitStackedAction): Promise<boolean> => {
187197 if (!api) return false;
188 if (!requiresDefaultBranchConfirmation(action, isDefaultBranch) || !gitStatus?.branch) {
198 if (
199 !requiresDefaultBranchConfirmation(action, isDefaultBranch) ||
200 !gitStatusForActions?.branch
201 ) {
189202 return true;
190203 }
191204 return api.dialogs.confirm(
192 `You're about to push to the default branch "${gitStatus.branch}". Continue?`,
205 `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`,
193206 );
194207 },
195 [api, gitStatus?.branch, isDefaultBranch],
208 [api, gitStatusForActions?.branch, isDefaultBranch],
196209 );
197210
198211 const openExistingPr = useCallback(async () => {
......@@ -204,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
204217 });
205218 return;
206219 }
207 const prUrl = gitStatus?.openPr?.url ?? null;
220 const prUrl = gitStatusForActions?.openPr?.url ?? null;
208221 if (!prUrl) {
209222 toastManager.add({
210223 type: "error",
......@@ -224,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
224237 }),
225238 });
226239 void promise.catch(() => undefined);
227 }, [api, gitStatus?.openPr?.url, threadToastData]);
240 }, [api, gitStatusForActions?.openPr?.url, threadToastData]);
228241
229242 const runGitActionWithToast = useCallback(
230243 async ({
......@@ -242,11 +255,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
242255 if (!confirmed) return;
243256 onConfirmed?.();
244257
245 const pushTarget = gitStatus?.branch ? `origin/${gitStatus.branch}` : undefined;
258 const pushTarget = gitStatusForActions?.branch
259 ? `origin/${gitStatusForActions.branch}`
260 : undefined;
246261 const progressStages = buildGitActionProgressStages({
247262 action,
248263 hasCustomCommitMessage: !!commitMessage?.trim(),
249 hasWorkingTreeChanges: !!gitStatus?.hasWorkingTreeChanges,
264 hasWorkingTreeChanges: !!gitStatusForActions?.hasWorkingTreeChanges,
250265 forcePushOnly: forcePushOnlyProgress,
251266 ...(pushTarget ? { pushTarget } : {}),
252267 });
......@@ -282,7 +297,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
282297 stopProgressUpdates();
283298 const resultToast = summarizeGitResult(result);
284299
285 const prUrl = result.pr.url ?? gitStatus?.openPr?.url;
300 const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url;
286301 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
287302 const shouldOfferOpenPrCta =
288303 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;
......@@ -345,9 +360,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
345360 },
346361 [
347362 api,
348 gitStatus?.branch,
349 gitStatus?.hasWorkingTreeChanges,
350 gitStatus?.openPr?.url,
363 gitStatusForActions?.branch,
364 gitStatusForActions?.hasWorkingTreeChanges,
365 gitStatusForActions?.openPr?.url,
351366 isDefaultBranch,
352367 maybeConfirmPushToDefaultBranch,
353368 runImmediateGitActionMutation,
......@@ -517,7 +532,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
517532 {gitActionMenuItems.map((item) => {
518533 const disabledReason = getMenuActionDisabledReason(
519534 item,
520 gitStatus,
535 gitStatusForActions,
521536 isGitActionRunning,
522537 );
523538 if (item.disabled && disabledReason) {
......@@ -553,19 +568,25 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
553568 </MenuItem>
554569 );
555570 })}
556 {gitStatus?.branch === null && (
571 {gitStatusForActions?.branch === null && (
557572 <p className="px-2 py-1.5 text-xs text-warning">
558573 Detached HEAD: create and checkout a branch to enable push and PR actions.
559574 </p>
560575 )}
561 {gitStatus &&
562 gitStatus.branch !== null &&
563 !gitStatus.hasWorkingTreeChanges &&
564 gitStatus.behindCount > 0 && (
576 {gitStatusForActions &&
577 gitStatusForActions.branch !== null &&
578 !gitStatusForActions.hasWorkingTreeChanges &&
579 gitStatusForActions.behindCount > 0 &&
580 gitStatusForActions.aheadCount === 0 && (
565581 <p className="px-2 py-1.5 text-xs text-warning">
566582 Behind upstream. Pull/rebase first.
567583 </p>
568584 )}
585 {isGitStatusOutOfSync && (
586 <p className="px-2 py-1.5 text-xs text-muted-foreground">
587 Refreshing git status...
588 </p>
589 )}
569590 {gitStatusError && (
570591 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
571592 )}
......@@ -594,37 +615,42 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
594615 <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs">
595616 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
596617 <span className="text-muted-foreground">Branch</span>
597 <span className="font-medium">{gitStatus?.branch ?? "(detached HEAD)"}</span>
618 <span className="font-medium">
619 {gitStatusForActions?.branch ?? "(detached HEAD)"}
620 </span>
598621 {activeDialogAction !== "commit" && (
599622 <>
600623 <span className="text-muted-foreground">Upstream</span>
601624 <span className="font-medium">
602 {!gitStatus || !gitStatus.hasUpstream
625 {!gitStatusForActions || !gitStatusForActions.hasUpstream
603626 ? "No upstream configured"
604 : gitStatus.aheadCount === 0 && gitStatus.behindCount === 0
627 : gitStatusForActions.aheadCount === 0 &&
628 gitStatusForActions.behindCount === 0
605629 ? "Up to date"
606 : gitStatus.aheadCount > 0 && gitStatus.behindCount > 0
607 ? `Diverged (+${gitStatus.aheadCount} / -${gitStatus.behindCount})`
608 : gitStatus.aheadCount > 0
609 ? `Ahead by ${gitStatus.aheadCount}`
610 : `Behind by ${gitStatus.behindCount}`}
630 : gitStatusForActions.aheadCount > 0 && gitStatusForActions.behindCount > 0
631 ? `Diverged (+${gitStatusForActions.aheadCount} / -${gitStatusForActions.behindCount})`
632 : gitStatusForActions.aheadCount > 0
633 ? `Ahead by ${gitStatusForActions.aheadCount}`
634 : `Behind by ${gitStatusForActions.behindCount}`}
611635 </span>
612636 <span className="text-muted-foreground">Working tree</span>
613637 <span className="font-medium">
614 {!gitStatus || !gitStatus.hasWorkingTreeChanges
638 {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges
615639 ? "Clean"
616 : `${gitStatus.workingTree.files.length} file(s)`}
640 : `${gitStatusForActions.workingTree.files.length} file(s)`}
617641 </span>
618642 <span className="text-muted-foreground">Diff</span>
619643 <span className="font-mono">
620 {!gitStatus || !gitStatus.hasWorkingTreeChanges ? (
644 {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges ? (
621645 "none"
622646 ) : (
623647 <>
624 <span className="text-success">+{gitStatus.workingTree.insertions}</span>
648 <span className="text-success">
649 +{gitStatusForActions.workingTree.insertions}
650 </span>
625651 <span className="text-muted-foreground"> / </span>
626652 <span className="text-destructive">
627 -{gitStatus.workingTree.deletions}
653 -{gitStatusForActions.workingTree.deletions}
628654 </span>
629655 </>
630656 )}
......@@ -634,13 +660,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
634660 </div>
635661 <div className="space-y-1">
636662 <p className="text-muted-foreground">Files</p>
637 {!gitStatus || gitStatus.workingTree.files.length === 0 ? (
663 {!gitStatusForActions || gitStatusForActions.workingTree.files.length === 0 ? (
638664 <p className="font-medium">none</p>
639665 ) : (
640666 <div className="space-y-2">
641667 <ScrollArea className="h-44 rounded-md border border-input bg-background">
642668 <div className="space-y-1 p-1">
643 {gitStatus.workingTree.files.map((file) => (
669 {gitStatusForActions.workingTree.files.map((file) => (
644670 <button
645671 type="button"
646672 key={file.path}
......@@ -658,9 +684,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
658684 </div>
659685 </ScrollArea>
660686 <div className="flex justify-end font-mono">
661 <span className="text-success">+{gitStatus.workingTree.insertions}</span>
687 <span className="text-success">
688 +{gitStatusForActions.workingTree.insertions}
689 </span>
662690 <span className="text-muted-foreground"> / </span>
663 <span className="text-destructive">-{gitStatus.workingTree.deletions}</span>
691 <span className="text-destructive">
692 -{gitStatusForActions.workingTree.deletions}
693 </span>
664694 </div>
665695 </div>
666696 )}