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", () => {...@@ -320,6 +320,18 @@ describe("when: branch is behind upstream", () => {
320 });320 });
321});321});
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
323describe("when: working tree has local changes", () => {335describe("when: working tree has local changes", () => {
324 it("resolveQuickAction returns commit, push, and create PR", () => {336 it("resolveQuickAction returns commit, push, and create PR", () => {
325 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false);337 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false);
apps/web/src/components/GitActionsControl.logic.ts+10
...@@ -181,6 +181,7 @@ export function resolveQuickAction(...@@ -181,6 +181,7 @@ export function resolveQuickAction(
181 const hasOpenPr = gitStatus.openPr !== null;181 const hasOpenPr = gitStatus.openPr !== null;
182 const isAhead = gitStatus.aheadCount > 0;182 const isAhead = gitStatus.aheadCount > 0;
183 const isBehind = gitStatus.behindCount > 0;183 const isBehind = gitStatus.behindCount > 0;
184 const isDiverged = isAhead && isBehind;
184185
185 if (!hasBranch) {186 if (!hasBranch) {
186 return {187 return {
...@@ -203,6 +204,15 @@ export function resolveQuickAction(...@@ -203,6 +204,15 @@ export function resolveQuickAction(
203 };204 };
204 }205 }
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
206 if (isBehind) {216 if (isBehind) {
207 return {217 return {
208 label: "Pull",218 label: "Pull",
apps/web/src/components/GitActionsControl.tsx+74-44
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts";1import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useCallback, useMemo, useState } from "react";3import { useCallback, useEffect, useMemo, useState } from "react";
4import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";4import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
5import { GitHubIcon } from "./Icons";5import { GitHubIcon } from "./Icons";
6import {6import {
...@@ -154,6 +154,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -154,6 +154,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
154 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd));154 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd));
155 // Default to true while loading so we don't flash init controls.155 // Default to true while loading so we don't flash init controls.
156 const isRepo = branchList?.isRepo ?? true;156 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
158 const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient }));168 const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient }));
159169
...@@ -164,19 +174,19 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -164,19 +174,19 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
164174
165 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;175 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;
166 const isDefaultBranch = useMemo(() => {176 const isDefaultBranch = useMemo(() => {
167 const currentBranch = gitStatus?.branch;177 const branchName = gitStatusForActions?.branch;
168 if (!currentBranch) return false;178 if (!branchName) return false;
169 const current = branchList?.branches.find((branch) => branch.name === currentBranch);179 const current = branchList?.branches.find((branch) => branch.name === branchName);
170 return current?.isDefault ?? (currentBranch === "main" || currentBranch === "master");180 return current?.isDefault ?? (branchName === "main" || branchName === "master");
171 }, [branchList?.branches, gitStatus?.branch]);181 }, [branchList?.branches, gitStatusForActions?.branch]);
172182
173 const gitActionMenuItems = useMemo(183 const gitActionMenuItems = useMemo(
174 () => buildMenuItems(gitStatus, isGitActionRunning),184 () => buildMenuItems(gitStatusForActions, isGitActionRunning),
175 [gitStatus, isGitActionRunning],185 [gitStatusForActions, isGitActionRunning],
176 );186 );
177 const quickAction = useMemo(187 const quickAction = useMemo(
178 () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch),188 () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch),
179 [gitStatus, isDefaultBranch, isGitActionRunning],189 [gitStatusForActions, isDefaultBranch, isGitActionRunning],
180 );190 );
181 const quickActionDisabledReason = quickAction.disabled191 const quickActionDisabledReason = quickAction.disabled
182 ? (quickAction.hint ?? "This action is currently unavailable.")192 ? (quickAction.hint ?? "This action is currently unavailable.")
...@@ -185,14 +195,17 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -185,14 +195,17 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
185 const maybeConfirmPushToDefaultBranch = useCallback(195 const maybeConfirmPushToDefaultBranch = useCallback(
186 async (action: GitStackedAction): Promise<boolean> => {196 async (action: GitStackedAction): Promise<boolean> => {
187 if (!api) return false;197 if (!api) return false;
188 if (!requiresDefaultBranchConfirmation(action, isDefaultBranch) || !gitStatus?.branch) {198 if (
199 !requiresDefaultBranchConfirmation(action, isDefaultBranch) ||
200 !gitStatusForActions?.branch
201 ) {
189 return true;202 return true;
190 }203 }
191 return api.dialogs.confirm(204 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?`,
193 );206 );
194 },207 },
195 [api, gitStatus?.branch, isDefaultBranch],208 [api, gitStatusForActions?.branch, isDefaultBranch],
196 );209 );
197210
198 const openExistingPr = useCallback(async () => {211 const openExistingPr = useCallback(async () => {
...@@ -204,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -204,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
204 });217 });
205 return;218 return;
206 }219 }
207 const prUrl = gitStatus?.openPr?.url ?? null;220 const prUrl = gitStatusForActions?.openPr?.url ?? null;
208 if (!prUrl) {221 if (!prUrl) {
209 toastManager.add({222 toastManager.add({
210 type: "error",223 type: "error",
...@@ -224,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -224,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
224 }),237 }),
225 });238 });
226 void promise.catch(() => undefined);239 void promise.catch(() => undefined);
227 }, [api, gitStatus?.openPr?.url, threadToastData]);240 }, [api, gitStatusForActions?.openPr?.url, threadToastData]);
228241
229 const runGitActionWithToast = useCallback(242 const runGitActionWithToast = useCallback(
230 async ({243 async ({
...@@ -242,11 +255,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -242,11 +255,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
242 if (!confirmed) return;255 if (!confirmed) return;
243 onConfirmed?.();256 onConfirmed?.();
244257
245 const pushTarget = gitStatus?.branch ? `origin/${gitStatus.branch}` : undefined;258 const pushTarget = gitStatusForActions?.branch
259 ? `origin/${gitStatusForActions.branch}`
260 : undefined;
246 const progressStages = buildGitActionProgressStages({261 const progressStages = buildGitActionProgressStages({
247 action,262 action,
248 hasCustomCommitMessage: !!commitMessage?.trim(),263 hasCustomCommitMessage: !!commitMessage?.trim(),
249 hasWorkingTreeChanges: !!gitStatus?.hasWorkingTreeChanges,264 hasWorkingTreeChanges: !!gitStatusForActions?.hasWorkingTreeChanges,
250 forcePushOnly: forcePushOnlyProgress,265 forcePushOnly: forcePushOnlyProgress,
251 ...(pushTarget ? { pushTarget } : {}),266 ...(pushTarget ? { pushTarget } : {}),
252 });267 });
...@@ -282,7 +297,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -282,7 +297,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
282 stopProgressUpdates();297 stopProgressUpdates();
283 const resultToast = summarizeGitResult(result);298 const resultToast = summarizeGitResult(result);
284299
285 const prUrl = result.pr.url ?? gitStatus?.openPr?.url;300 const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url;
286 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";301 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
287 const shouldOfferOpenPrCta =302 const shouldOfferOpenPrCta =
288 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;303 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;
...@@ -345,9 +360,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -345,9 +360,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
345 },360 },
346 [361 [
347 api,362 api,
348 gitStatus?.branch,363 gitStatusForActions?.branch,
349 gitStatus?.hasWorkingTreeChanges,364 gitStatusForActions?.hasWorkingTreeChanges,
350 gitStatus?.openPr?.url,365 gitStatusForActions?.openPr?.url,
351 isDefaultBranch,366 isDefaultBranch,
352 maybeConfirmPushToDefaultBranch,367 maybeConfirmPushToDefaultBranch,
353 runImmediateGitActionMutation,368 runImmediateGitActionMutation,
...@@ -517,7 +532,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -517,7 +532,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
517 {gitActionMenuItems.map((item) => {532 {gitActionMenuItems.map((item) => {
518 const disabledReason = getMenuActionDisabledReason(533 const disabledReason = getMenuActionDisabledReason(
519 item,534 item,
520 gitStatus,535 gitStatusForActions,
521 isGitActionRunning,536 isGitActionRunning,
522 );537 );
523 if (item.disabled && disabledReason) {538 if (item.disabled && disabledReason) {
...@@ -553,19 +568,25 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -553,19 +568,25 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
553 </MenuItem>568 </MenuItem>
554 );569 );
555 })}570 })}
556 {gitStatus?.branch === null && (571 {gitStatusForActions?.branch === null && (
557 <p className="px-2 py-1.5 text-xs text-warning">572 <p className="px-2 py-1.5 text-xs text-warning">
558 Detached HEAD: create and checkout a branch to enable push and PR actions.573 Detached HEAD: create and checkout a branch to enable push and PR actions.
559 </p>574 </p>
560 )}575 )}
561 {gitStatus &&576 {gitStatusForActions &&
562 gitStatus.branch !== null &&577 gitStatusForActions.branch !== null &&
563 !gitStatus.hasWorkingTreeChanges &&578 !gitStatusForActions.hasWorkingTreeChanges &&
564 gitStatus.behindCount > 0 && (579 gitStatusForActions.behindCount > 0 &&
580 gitStatusForActions.aheadCount === 0 && (
565 <p className="px-2 py-1.5 text-xs text-warning">581 <p className="px-2 py-1.5 text-xs text-warning">
566 Behind upstream. Pull/rebase first.582 Behind upstream. Pull/rebase first.
567 </p>583 </p>
568 )}584 )}
585 {isGitStatusOutOfSync && (
586 <p className="px-2 py-1.5 text-xs text-muted-foreground">
587 Refreshing git status...
588 </p>
589 )}
569 {gitStatusError && (590 {gitStatusError && (
570 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>591 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
571 )}592 )}
...@@ -594,37 +615,42 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -594,37 +615,42 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
594 <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs">615 <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs">
595 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">616 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
596 <span className="text-muted-foreground">Branch</span>617 <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>
598 {activeDialogAction !== "commit" && (621 {activeDialogAction !== "commit" && (
599 <>622 <>
600 <span className="text-muted-foreground">Upstream</span>623 <span className="text-muted-foreground">Upstream</span>
601 <span className="font-medium">624 <span className="font-medium">
602 {!gitStatus || !gitStatus.hasUpstream625 {!gitStatusForActions || !gitStatusForActions.hasUpstream
603 ? "No upstream configured"626 ? "No upstream configured"
604 : gitStatus.aheadCount === 0 && gitStatus.behindCount === 0627 : gitStatusForActions.aheadCount === 0 &&
628 gitStatusForActions.behindCount === 0
605 ? "Up to date"629 ? "Up to date"
606 : gitStatus.aheadCount > 0 && gitStatus.behindCount > 0630 : gitStatusForActions.aheadCount > 0 && gitStatusForActions.behindCount > 0
607 ? `Diverged (+${gitStatus.aheadCount} / -${gitStatus.behindCount})`631 ? `Diverged (+${gitStatusForActions.aheadCount} / -${gitStatusForActions.behindCount})`
608 : gitStatus.aheadCount > 0632 : gitStatusForActions.aheadCount > 0
609 ? `Ahead by ${gitStatus.aheadCount}`633 ? `Ahead by ${gitStatusForActions.aheadCount}`
610 : `Behind by ${gitStatus.behindCount}`}634 : `Behind by ${gitStatusForActions.behindCount}`}
611 </span>635 </span>
612 <span className="text-muted-foreground">Working tree</span>636 <span className="text-muted-foreground">Working tree</span>
613 <span className="font-medium">637 <span className="font-medium">
614 {!gitStatus || !gitStatus.hasWorkingTreeChanges638 {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges
615 ? "Clean"639 ? "Clean"
616 : `${gitStatus.workingTree.files.length} file(s)`}640 : `${gitStatusForActions.workingTree.files.length} file(s)`}
617 </span>641 </span>
618 <span className="text-muted-foreground">Diff</span>642 <span className="text-muted-foreground">Diff</span>
619 <span className="font-mono">643 <span className="font-mono">
620 {!gitStatus || !gitStatus.hasWorkingTreeChanges ? (644 {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges ? (
621 "none"645 "none"
622 ) : (646 ) : (
623 <>647 <>
624 <span className="text-success">+{gitStatus.workingTree.insertions}</span>648 <span className="text-success">
649 +{gitStatusForActions.workingTree.insertions}
650 </span>
625 <span className="text-muted-foreground"> / </span>651 <span className="text-muted-foreground"> / </span>
626 <span className="text-destructive">652 <span className="text-destructive">
627 -{gitStatus.workingTree.deletions}653 -{gitStatusForActions.workingTree.deletions}
628 </span>654 </span>
629 </>655 </>
630 )}656 )}
...@@ -634,13 +660,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -634,13 +660,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
634 </div>660 </div>
635 <div className="space-y-1">661 <div className="space-y-1">
636 <p className="text-muted-foreground">Files</p>662 <p className="text-muted-foreground">Files</p>
637 {!gitStatus || gitStatus.workingTree.files.length === 0 ? (663 {!gitStatusForActions || gitStatusForActions.workingTree.files.length === 0 ? (
638 <p className="font-medium">none</p>664 <p className="font-medium">none</p>
639 ) : (665 ) : (
640 <div className="space-y-2">666 <div className="space-y-2">
641 <ScrollArea className="h-44 rounded-md border border-input bg-background">667 <ScrollArea className="h-44 rounded-md border border-input bg-background">
642 <div className="space-y-1 p-1">668 <div className="space-y-1 p-1">
643 {gitStatus.workingTree.files.map((file) => (669 {gitStatusForActions.workingTree.files.map((file) => (
644 <button670 <button
645 type="button"671 type="button"
646 key={file.path}672 key={file.path}
...@@ -658,9 +684,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -658,9 +684,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
658 </div>684 </div>
659 </ScrollArea>685 </ScrollArea>
660 <div className="flex justify-end font-mono">686 <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>
662 <span className="text-muted-foreground"> / </span>690 <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>
664 </div>694 </div>
665 </div>695 </div>
666 )}696 )}