From 60111b2b912e8ebc9c21a6bac836b60cf8b75122 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 00:59:30 -0800 Subject: [PATCH] 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 --- .../GitActionsControl.logic.test.ts | 12 ++ .../src/components/GitActionsControl.logic.ts | 10 ++ apps/web/src/components/GitActionsControl.tsx | 118 +++++++++++------- 3 files changed, 96 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 85e020178d40df42759db7331e8323b12800e28a..b764fac276bca59c6cd29fc2979df70b611d3bf7 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -320,6 +320,18 @@ describe("when: branch is behind upstream", () => { }); }); +describe("when: branch has diverged from upstream", () => { + it("resolveQuickAction returns a disabled sync hint", () => { + const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); + assert.deepEqual(quick, { + label: "Sync branch", + disabled: true, + kind: "show_hint", + hint: "Branch has diverged from upstream. Rebase/merge first.", + }); + }); +}); + describe("when: working tree has local changes", () => { it("resolveQuickAction returns commit, push, and create PR", () => { const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 2da201d64a63466c7581dd12cda9b80b018b536b..50de7b6c5751294e9e3a05b6c8de7402b9589151 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -181,6 +181,7 @@ export function resolveQuickAction( const hasOpenPr = gitStatus.openPr !== null; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; + const isDiverged = isAhead && isBehind; if (!hasBranch) { return { @@ -203,6 +204,15 @@ export function resolveQuickAction( }; } + if (isDiverged) { + return { + label: "Sync branch", + disabled: true, + kind: "show_hint", + hint: "Branch has diverged from upstream. Rebase/merge first.", + }; + } + if (isBehind) { return { label: "Pull", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6d8f6fe1f1aa2a29f87423ca971768b20531b739..83c74e5f5807715970002a1ecfb30f2fb6d2adc7 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,6 +1,6 @@ import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; import { @@ -154,6 +154,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd)); // Default to true while loading so we don't flash init controls. const isRepo = branchList?.isRepo ?? true; + const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; + const isGitStatusOutOfSync = + !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; + + useEffect(() => { + if (!isGitStatusOutOfSync) return; + void invalidateGitQueries(queryClient); + }, [isGitStatusOutOfSync, queryClient]); + + const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient })); @@ -164,19 +174,19 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; const isDefaultBranch = useMemo(() => { - const currentBranch = gitStatus?.branch; - if (!currentBranch) return false; - const current = branchList?.branches.find((branch) => branch.name === currentBranch); - return current?.isDefault ?? (currentBranch === "main" || currentBranch === "master"); - }, [branchList?.branches, gitStatus?.branch]); + const branchName = gitStatusForActions?.branch; + if (!branchName) return false; + const current = branchList?.branches.find((branch) => branch.name === branchName); + return current?.isDefault ?? (branchName === "main" || branchName === "master"); + }, [branchList?.branches, gitStatusForActions?.branch]); const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatus, isGitActionRunning), - [gitStatus, isGitActionRunning], + () => buildMenuItems(gitStatusForActions, isGitActionRunning), + [gitStatusForActions, isGitActionRunning], ); const quickAction = useMemo( - () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch), - [gitStatus, isDefaultBranch, isGitActionRunning], + () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch), + [gitStatusForActions, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -185,14 +195,17 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp const maybeConfirmPushToDefaultBranch = useCallback( async (action: GitStackedAction): Promise => { if (!api) return false; - if (!requiresDefaultBranchConfirmation(action, isDefaultBranch) || !gitStatus?.branch) { + if ( + !requiresDefaultBranchConfirmation(action, isDefaultBranch) || + !gitStatusForActions?.branch + ) { return true; } return api.dialogs.confirm( - `You're about to push to the default branch "${gitStatus.branch}". Continue?`, + `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`, ); }, - [api, gitStatus?.branch, isDefaultBranch], + [api, gitStatusForActions?.branch, isDefaultBranch], ); const openExistingPr = useCallback(async () => { @@ -204,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }); return; } - const prUrl = gitStatus?.openPr?.url ?? null; + const prUrl = gitStatusForActions?.openPr?.url ?? null; if (!prUrl) { toastManager.add({ type: "error", @@ -224,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }), }); void promise.catch(() => undefined); - }, [api, gitStatus?.openPr?.url, threadToastData]); + }, [api, gitStatusForActions?.openPr?.url, threadToastData]); const runGitActionWithToast = useCallback( async ({ @@ -242,11 +255,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp if (!confirmed) return; onConfirmed?.(); - const pushTarget = gitStatus?.branch ? `origin/${gitStatus.branch}` : undefined; + const pushTarget = gitStatusForActions?.branch + ? `origin/${gitStatusForActions.branch}` + : undefined; const progressStages = buildGitActionProgressStages({ action, hasCustomCommitMessage: !!commitMessage?.trim(), - hasWorkingTreeChanges: !!gitStatus?.hasWorkingTreeChanges, + hasWorkingTreeChanges: !!gitStatusForActions?.hasWorkingTreeChanges, forcePushOnly: forcePushOnlyProgress, ...(pushTarget ? { pushTarget } : {}), }); @@ -282,7 +297,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp stopProgressUpdates(); const resultToast = summarizeGitResult(result); - const prUrl = result.pr.url ?? gitStatus?.openPr?.url; + const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url; const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; const shouldOfferOpenPrCta = (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; @@ -345,9 +360,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }, [ api, - gitStatus?.branch, - gitStatus?.hasWorkingTreeChanges, - gitStatus?.openPr?.url, + gitStatusForActions?.branch, + gitStatusForActions?.hasWorkingTreeChanges, + gitStatusForActions?.openPr?.url, isDefaultBranch, maybeConfirmPushToDefaultBranch, runImmediateGitActionMutation, @@ -517,7 +532,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp {gitActionMenuItems.map((item) => { const disabledReason = getMenuActionDisabledReason( item, - gitStatus, + gitStatusForActions, isGitActionRunning, ); if (item.disabled && disabledReason) { @@ -553,19 +568,25 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp ); })} - {gitStatus?.branch === null && ( + {gitStatusForActions?.branch === null && (

Detached HEAD: create and checkout a branch to enable push and PR actions.

)} - {gitStatus && - gitStatus.branch !== null && - !gitStatus.hasWorkingTreeChanges && - gitStatus.behindCount > 0 && ( + {gitStatusForActions && + gitStatusForActions.branch !== null && + !gitStatusForActions.hasWorkingTreeChanges && + gitStatusForActions.behindCount > 0 && + gitStatusForActions.aheadCount === 0 && (

Behind upstream. Pull/rebase first.

)} + {isGitStatusOutOfSync && ( +

+ Refreshing git status... +

+ )} {gitStatusError && (

{gitStatusError.message}

)} @@ -594,37 +615,42 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
Branch - {gitStatus?.branch ?? "(detached HEAD)"} + + {gitStatusForActions?.branch ?? "(detached HEAD)"} + {activeDialogAction !== "commit" && ( <> Upstream - {!gitStatus || !gitStatus.hasUpstream + {!gitStatusForActions || !gitStatusForActions.hasUpstream ? "No upstream configured" - : gitStatus.aheadCount === 0 && gitStatus.behindCount === 0 + : gitStatusForActions.aheadCount === 0 && + gitStatusForActions.behindCount === 0 ? "Up to date" - : gitStatus.aheadCount > 0 && gitStatus.behindCount > 0 - ? `Diverged (+${gitStatus.aheadCount} / -${gitStatus.behindCount})` - : gitStatus.aheadCount > 0 - ? `Ahead by ${gitStatus.aheadCount}` - : `Behind by ${gitStatus.behindCount}`} + : gitStatusForActions.aheadCount > 0 && gitStatusForActions.behindCount > 0 + ? `Diverged (+${gitStatusForActions.aheadCount} / -${gitStatusForActions.behindCount})` + : gitStatusForActions.aheadCount > 0 + ? `Ahead by ${gitStatusForActions.aheadCount}` + : `Behind by ${gitStatusForActions.behindCount}`} Working tree - {!gitStatus || !gitStatus.hasWorkingTreeChanges + {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges ? "Clean" - : `${gitStatus.workingTree.files.length} file(s)`} + : `${gitStatusForActions.workingTree.files.length} file(s)`} Diff - {!gitStatus || !gitStatus.hasWorkingTreeChanges ? ( + {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges ? ( "none" ) : ( <> - +{gitStatus.workingTree.insertions} + + +{gitStatusForActions.workingTree.insertions} + / - -{gitStatus.workingTree.deletions} + -{gitStatusForActions.workingTree.deletions} )} @@ -634,13 +660,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp

Files

- {!gitStatus || gitStatus.workingTree.files.length === 0 ? ( + {!gitStatusForActions || gitStatusForActions.workingTree.files.length === 0 ? (

none

) : (
- {gitStatus.workingTree.files.map((file) => ( + {gitStatusForActions.workingTree.files.map((file) => (
)} -- 2.54.0