From 3f5487ecce9cf304185502cd74ae9822a4c16cc7 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:28:21 +1100 Subject: [PATCH] fix(web): a disabled Push names the real reason when there are local changes Co-Authored-By: Claude Opus 5.5 --- .../GitActionsControl.logic.test.ts | 12 ++++ .../src/components/GitActionsControl.logic.ts | 59 +++++++++++++++++ apps/web/src/components/GitActionsControl.tsx | 63 +------------------ 3 files changed, 72 insertions(+), 62 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 3c58675d6f6d9..1af0befa10bb4 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -9,6 +9,7 @@ import { resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, + getMenuActionDisabledReason, } from "./GitActionsControl.logic"; function status(overrides: Partial = {}): VcsStatusResult { @@ -31,6 +32,12 @@ function status(overrides: Partial = {}): VcsStatusResult { }; } +function pushDisabledReason(gitStatus: VcsStatusResult): string | null { + const item = buildMenuItems(gitStatus, false).find((menuItem) => menuItem.id === "push"); + assert.isDefined(item); + return getMenuActionDisabledReason({ item, gitStatus, isBusy: false, hasPrimaryRemote: true }); +} + describe("when: ref is clean, ahead, and has an open PR", () => { it("resolveQuickAction prefers push", () => { const quick = resolveQuickAction( @@ -76,6 +83,7 @@ describe("when: ref is clean, up to date, and has no open PR", () => { buildMenuItems(dirtyFeature, false).find((item) => item.id === "pr")?.disabled, false, ); + assert.equal(pushDisabledReason(dirtyFeature), "No local commits to push."); }); }); @@ -105,6 +113,10 @@ describe("when: local changes are behind upstream", () => { action: "commit", } as const; assert.deepEqual(resolveQuickAction(behind, false), commit); + assert.equal( + pushDisabledReason({ ...behind, aheadCount: 1 }), + "Branch is behind upstream. Pull/rebase before pushing.", + ); assert.deepEqual( resolveQuickAction( { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index ca396893accda..16d0396d62028 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -174,6 +174,65 @@ export function buildMenuItems( ]; } +export function getMenuActionDisabledReason({ + item, + gitStatus, + isBusy, + hasPrimaryRemote, +}: { + item: GitActionMenuItem; + gitStatus: VcsStatusResult | null; + isBusy: boolean; + hasPrimaryRemote: boolean; +}): string | null { + if (!item.disabled) return null; + if (isBusy) return "Git action in progress."; + if (!gitStatus) return "Git status is unavailable."; + + const hasBranch = gitStatus.refName !== null; + const hasChanges = gitStatus.hasWorkingTreeChanges; + const isAhead = gitStatus.aheadCount > 0; + const isBehind = gitStatus.behindCount > 0; + const terminology = changeRequestTerminologyFor(gitStatus); + + if (item.id === "commit") { + if (!hasChanges) { + return "Worktree is clean. Make changes before committing."; + } + return "Commit is currently unavailable."; + } + + if (item.id === "push") { + if (!hasBranch) { + return "Detached HEAD: check out a branch before pushing."; + } + if (isBehind) { + return "Branch is behind upstream. Pull/rebase before pushing."; + } + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + return 'Add an "origin" remote before pushing.'; + } + if (!isAhead) { + return "No local commits to push."; + } + return "Push is currently unavailable."; + } + + if (!hasBranch) { + return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; + } + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + return `Add an "origin" remote before creating a ${terminology.singular}.`; + } + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { + return `No local commits to include in a ${terminology.singular}.`; + } + if (isBehind) { + return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; + } + return `Create ${terminology.singular} is currently unavailable.`; +} + export function resolveQuickAction( gitStatus: VcsStatusResult | null, isBusy: boolean, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6afb76c3760e9..8c0da394d1628 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -58,6 +58,7 @@ import { resolveGitActionProgressPresentation, resolveGitActionResultToastTiming, resolveLiveThreadBranchUpdate, + getMenuActionDisabledReason, resolveThreadBranchMetadataPatch, resolveQuickAction, resolveThreadBranchUpdate, @@ -211,68 +212,6 @@ function getPublishProviderReadiness(input: { return { ready: true, hint: null }; } -function getMenuActionDisabledReason({ - item, - gitStatus, - isBusy, - hasPrimaryRemote, -}: { - item: GitActionMenuItem; - gitStatus: VcsStatusResult | null; - isBusy: boolean; - hasPrimaryRemote: boolean; -}): string | null { - if (!item.disabled) return null; - if (isBusy) return "Git action in progress."; - if (!gitStatus) return "Git status is unavailable."; - - const hasBranch = gitStatus.refName !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const isAhead = gitStatus.aheadCount > 0; - const isBehind = gitStatus.behindCount > 0; - const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; - - if (item.id === "commit") { - if (!hasChanges) { - return "Worktree is clean. Make changes before committing."; - } - return "Commit is currently unavailable."; - } - - if (item.id === "push") { - if (!hasBranch) { - return "Detached HEAD: check out a branch before pushing."; - } - if (hasChanges) { - return "Commit or stash local changes before pushing."; - } - if (isBehind) { - return "Branch is behind upstream. Pull/rebase before pushing."; - } - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return 'Add an "origin" remote before pushing.'; - } - if (!isAhead) { - return "No local commits to push."; - } - return "Push is currently unavailable."; - } - - if (!hasBranch) { - return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; - } - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return `Add an "origin" remote before creating a ${terminology.singular}.`; - } - if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { - return `No local commits to include in a ${terminology.singular}.`; - } - if (isBehind) { - return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; - } - return `Create ${terminology.singular} is currently unavailable.`; -} - const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one.";