diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx
index 74354407e3b..2eaf6dc4a1f 100644
--- a/mobile/src/source-control/MobileSourceControlContent.tsx
+++ b/mobile/src/source-control/MobileSourceControlContent.tsx
@@ -41,9 +41,9 @@ export function MobileSourceControlContent({ state, hostId, worktreeId, name }:
unstagedCount,
branchLabel,
syncLabel,
+ primaryAction,
stageAll,
unstageAll,
- commit,
generateCommitMessage,
cancelGenerateCommitMessage,
abortConflictOperation,
@@ -224,7 +224,7 @@ export function MobileSourceControlContent({ state, hostId, worktreeId, name }:
placeholderTextColor={colors.textMuted}
editable={busyAction === null && openingPath === null && openingBranchPath === null}
returnKeyType="done"
- onSubmitEditing={() => void commit()}
+ onSubmitEditing={primaryAction.onPress}
/>
)}
[
styles.commitButton,
- (!commitMessage.trim() || stagedCount === 0 || ioBusy) && styles.commitButtonDisabled,
+ primaryAction.disabled && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
- onPress={() => void commit()}
- disabled={!commitMessage.trim() || stagedCount === 0 || ioBusy}
+ onPress={primaryAction.onPress}
+ disabled={primaryAction.disabled}
+ accessibilityLabel={primaryAction.accessibilityLabel}
+ accessibilityHint={primaryAction.accessibilityHint}
>
- {busyAction === 'commit' ? (
+ {primaryAction.loading ? (
) : (
- Commit
+ {primaryAction.label}
)}
diff --git a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts b/mobile/src/source-control/mobile-source-control-primary-action-decision.ts
new file mode 100644
index 00000000000..eb0927bcff6
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-primary-action-decision.ts
@@ -0,0 +1,268 @@
+import type { MobileGitUpstreamStatus } from './mobile-git-status'
+
+export type MobileSourceControlPrimaryActionKind =
+ | 'commit'
+ | 'stage'
+ | 'push'
+ | 'pull'
+ | 'sync'
+ | 'publish'
+
+export type MobileSourceControlRemoteOpKind =
+ | 'push'
+ | 'force_push'
+ | 'pull'
+ | 'sync'
+ | 'fetch'
+ | 'fast_forward'
+ | 'publish'
+ | 'rebase'
+
+export type MobileSourceControlPrimaryActionTitleIntent =
+ | 'commit_in_progress'
+ | 'force_push_in_progress'
+ | 'action_in_progress'
+ | 'remote_operation_in_progress'
+ | 'remote_operation_blocks_commit'
+ | 'resolve_conflicts_before_commit'
+ | 'commit_staged_changes'
+ | 'enter_commit_message'
+ | 'stage_all_changes'
+ | 'stage_file_to_commit'
+ | 'checkout_branch_before_publish'
+ | 'publish_branch'
+ | 'force_push_with_lease'
+ | 'sync_counts'
+ | 'pull_count'
+ | 'push_count'
+ | 'nothing_to_commit_up_to_date'
+
+export type MobileSourceControlPrimaryActionDecision = {
+ kind: MobileSourceControlPrimaryActionKind
+ disabled: boolean
+ labelIntent: MobileSourceControlPrimaryActionKind | 'force_push'
+ titleIntent: MobileSourceControlPrimaryActionTitleIntent
+ count?: number
+ ahead?: number
+ behind?: number
+ upstreamName?: string
+ requiresForceWithLease?: boolean
+}
+
+export type MobileSourceControlPrimaryActionDecisionInputs = {
+ stagedCount: number
+ hasUnstagedChanges: boolean
+ hasStageableChanges: boolean
+ hasPartiallyStagedChanges: boolean
+ hasMessage: boolean
+ hasUnresolvedConflicts: boolean
+ isCommitting: boolean
+ isRemoteOperationActive: boolean
+ upstreamStatus: MobileGitUpstreamStatus | undefined
+ inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null
+ branchCommitsAhead?: number
+ hasCurrentBranch?: boolean
+}
+
+// Why: Metro cannot load runtime modules from the desktop/root `src/shared`
+// tree. Keep this mobile mirror narrow and parity-tested against the shared
+// commit-area decision core so the semantic ladder cannot drift silently.
+export function resolveMobileSourceControlCommitAreaPrimaryActionDecision(
+ inputs: MobileSourceControlPrimaryActionDecisionInputs
+): MobileSourceControlPrimaryActionDecision {
+ const {
+ stagedCount,
+ hasUnstagedChanges,
+ hasStageableChanges,
+ hasMessage,
+ hasUnresolvedConflicts,
+ isCommitting,
+ isRemoteOperationActive,
+ upstreamStatus,
+ branchCommitsAhead,
+ hasCurrentBranch = true
+ } = inputs
+
+ if (isCommitting) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'commit_in_progress',
+ disabled: true
+ }
+ }
+
+ if (isRemoteOperationActive) {
+ return resolveMobilePrimaryActionDuringRemoteOp(inputs)
+ }
+
+ if (hasUnresolvedConflicts) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'resolve_conflicts_before_commit',
+ disabled: true
+ }
+ }
+
+ const hasStaged = stagedCount > 0
+
+ if (hasStaged && hasMessage) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'commit_staged_changes',
+ disabled: false
+ }
+ }
+
+ if (hasStaged && !hasMessage) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'enter_commit_message',
+ disabled: true
+ }
+ }
+
+ if (!hasStaged && hasStageableChanges) {
+ return {
+ kind: 'stage',
+ labelIntent: 'stage',
+ titleIntent: 'stage_all_changes',
+ disabled: false
+ }
+ }
+
+ if (!upstreamStatus) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'stage_file_to_commit',
+ disabled: true
+ }
+ }
+
+ if (!upstreamStatus.hasUpstream) {
+ if (!hasCurrentBranch) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'checkout_branch_before_publish',
+ disabled: true
+ }
+ }
+ return {
+ kind: 'publish',
+ labelIntent: 'publish',
+ titleIntent: 'publish_branch',
+ disabled: false
+ }
+ }
+
+ if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
+ if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) {
+ return {
+ kind: 'push',
+ labelIntent: 'force_push',
+ titleIntent: 'force_push_with_lease',
+ disabled: false,
+ count: branchCommitsAhead,
+ upstreamName: upstreamStatus.upstreamName,
+ requiresForceWithLease: true
+ }
+ }
+ return {
+ kind: 'sync',
+ labelIntent: 'sync',
+ titleIntent: 'sync_counts',
+ disabled: false,
+ ahead: upstreamStatus.ahead,
+ behind: upstreamStatus.behind
+ }
+ }
+
+ if (upstreamStatus.behind > 0) {
+ return {
+ kind: 'pull',
+ labelIntent: 'pull',
+ titleIntent: 'pull_count',
+ disabled: false,
+ count: upstreamStatus.behind
+ }
+ }
+
+ if (upstreamStatus.ahead > 0) {
+ return {
+ kind: 'push',
+ labelIntent: 'push',
+ titleIntent: 'push_count',
+ disabled: false,
+ count: upstreamStatus.ahead
+ }
+ }
+
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date',
+ disabled: true
+ }
+}
+
+function resolveMobilePrimaryActionDuringRemoteOp(
+ inputs: MobileSourceControlPrimaryActionDecisionInputs
+): MobileSourceControlPrimaryActionDecision {
+ const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs
+ const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
+ ...inputs,
+ isRemoteOperationActive: false
+ })
+ const inFlightIsPrimaryKind =
+ inFlightRemoteOpKind === 'push' ||
+ inFlightRemoteOpKind === 'pull' ||
+ inFlightRemoteOpKind === 'sync' ||
+ inFlightRemoteOpKind === 'publish'
+
+ if (inFlightRemoteOpKind === 'force_push') {
+ return {
+ kind: 'push',
+ labelIntent: 'force_push',
+ titleIntent: 'force_push_in_progress',
+ disabled: true,
+ requiresForceWithLease: true
+ }
+ }
+
+ if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) {
+ return {
+ kind: inFlightRemoteOpKind,
+ labelIntent: inFlightRemoteOpKind,
+ titleIntent: 'action_in_progress',
+ disabled: true
+ }
+ }
+
+ const titleIntent = hasUnresolvedConflicts
+ ? 'resolve_conflicts_before_commit'
+ : candidate.kind === 'commit'
+ ? 'remote_operation_blocks_commit'
+ : 'remote_operation_in_progress'
+
+ return {
+ ...candidate,
+ titleIntent,
+ disabled: true
+ }
+}
+
+function shouldForcePushWithLeaseForMobileUpstream(
+ status: MobileGitUpstreamStatus | undefined
+): boolean {
+ return (
+ status?.hasUpstream === true &&
+ status.ahead > 0 &&
+ status.behind > 0 &&
+ status.behindCommitsArePatchEquivalent === true
+ )
+}
diff --git a/mobile/src/source-control/mobile-source-control-primary-action.test.ts b/mobile/src/source-control/mobile-source-control-primary-action.test.ts
new file mode 100644
index 00000000000..39229161525
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-primary-action.test.ts
@@ -0,0 +1,254 @@
+import { describe, expect, it, vi } from 'vitest'
+import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision'
+import {
+ resolveMobileSourceControlCommitAreaPrimaryActionDecision,
+ type MobileSourceControlPrimaryActionDecisionInputs
+} from './mobile-source-control-primary-action-decision'
+import {
+ buildMobileSourceControlPrimaryAction,
+ type MobileSourceControlPrimaryActionArgs,
+ type MobileSourceControlPrimaryActionHandlers
+} from './mobile-source-control-primary-action'
+import type { MobileGitStatusResult } from './mobile-git-status'
+
+function handlers(): MobileSourceControlPrimaryActionHandlers {
+ return {
+ commit: vi.fn(async () => true),
+ stageAll: vi.fn(async () => undefined),
+ runActionSheetGitSequence: vi.fn(async () => undefined),
+ runActionSheetGitSync: vi.fn(async () => undefined)
+ }
+}
+
+function status(overrides: Partial = {}): MobileGitStatusResult {
+ return {
+ entries: [],
+ conflictOperation: 'unknown',
+ branch: 'feature',
+ head: 'abc123',
+ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
+ ...overrides
+ }
+}
+
+function args(overrides: Partial = {}) {
+ return {
+ status: status(),
+ hasUnresolvedConflicts: false,
+ stageablePaths: [],
+ stagedCount: 0,
+ unstagedCount: 0,
+ commitMessage: '',
+ busyAction: null,
+ openingPath: null,
+ openingBranchPath: null,
+ branchCompareResult: null,
+ handlers: handlers(),
+ ...overrides
+ }
+}
+
+describe('buildMobileSourceControlPrimaryAction', () => {
+ it('selects Stage All for unstaged work and dispatches the stage runner', () => {
+ const h = handlers()
+ const action = buildMobileSourceControlPrimaryAction(
+ args({
+ stageablePaths: ['a.ts'],
+ unstagedCount: 1,
+ handlers: h
+ })
+ )
+ expect(action.label).toBe('Stage All')
+ expect(action.disabled).toBe(false)
+ action.onPress()
+ expect(h.stageAll).toHaveBeenCalledTimes(1)
+ })
+
+ it('selects Commit for staged work with a message and dispatches commit', () => {
+ const h = handlers()
+ const action = buildMobileSourceControlPrimaryAction(
+ args({
+ stagedCount: 1,
+ commitMessage: 'Ship it',
+ handlers: h
+ })
+ )
+ expect(action.label).toBe('Commit')
+ expect(action.disabled).toBe(false)
+ action.onPress()
+ expect(h.commit).toHaveBeenCalledTimes(1)
+ })
+
+ it('selects Publish Branch only when a current branch exists', () => {
+ expect(
+ buildMobileSourceControlPrimaryAction(
+ args({ status: status({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } }) })
+ ).label
+ ).toBe('Publish Branch')
+ const detached = buildMobileSourceControlPrimaryAction(
+ args({
+ status: status({
+ branch: undefined,
+ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
+ })
+ })
+ )
+ expect(detached.label).toBe('Commit')
+ expect(detached.disabled).toBe(true)
+ })
+
+ it('dispatches force push with lease when the shared decision requires it', () => {
+ const h = handlers()
+ const action = buildMobileSourceControlPrimaryAction(
+ args({
+ status: status({
+ upstreamStatus: {
+ hasUpstream: true,
+ ahead: 10,
+ behind: 2,
+ behindCommitsArePatchEquivalent: true
+ }
+ }),
+ branchCompareResult: {
+ entries: [],
+ summary: {
+ status: 'ready',
+ baseRef: 'main',
+ baseOid: 'base',
+ compareRef: 'HEAD',
+ changedFiles: 0,
+ commitsAhead: 3,
+ headOid: 'abc',
+ mergeBase: 'def'
+ }
+ },
+ handlers: h
+ })
+ )
+ expect(action.label).toBe('Force Push')
+ expect(action.requiresForceWithLease).toBe(true)
+ action.onPress()
+ expect(h.runActionSheetGitSequence).toHaveBeenCalledWith('force-push', [
+ { method: 'git.push', params: { forceWithLease: true } }
+ ])
+ })
+
+ it('disables the button for unresolved entries even during a conflict operation', () => {
+ const action = buildMobileSourceControlPrimaryAction(
+ args({
+ status: status({ conflictOperation: 'merge' }),
+ hasUnresolvedConflicts: true,
+ stagedCount: 1,
+ commitMessage: 'Resolve'
+ })
+ )
+ expect(action.label).toBe('Commit')
+ expect(action.disabled).toBe(true)
+ expect(action.accessibilityHint).toBe('Resolve conflicts before committing.')
+ })
+
+ it('does not block solely because a conflict operation exists without unresolved entries', () => {
+ const action = buildMobileSourceControlPrimaryAction(
+ args({
+ status: status({ conflictOperation: 'merge' }),
+ hasUnresolvedConflicts: false,
+ stagedCount: 1,
+ commitMessage: 'Resolve'
+ })
+ )
+ expect(action.disabled).toBe(false)
+ })
+})
+
+function decisionInputs(
+ overrides: Partial = {}
+): MobileSourceControlPrimaryActionDecisionInputs {
+ return {
+ stagedCount: 0,
+ hasUnstagedChanges: false,
+ hasStageableChanges: false,
+ hasPartiallyStagedChanges: false,
+ hasMessage: false,
+ hasUnresolvedConflicts: false,
+ isCommitting: false,
+ isRemoteOperationActive: false,
+ upstreamStatus: undefined,
+ ...overrides
+ }
+}
+
+describe('mobile source-control primary action decision parity', () => {
+ it.each([
+ {
+ name: 'dirty tree stages first',
+ input: decisionInputs({
+ hasUnstagedChanges: true,
+ hasStageableChanges: true,
+ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 }
+ })
+ },
+ {
+ name: 'staged message commits',
+ input: decisionInputs({ stagedCount: 1, hasMessage: true })
+ },
+ {
+ name: 'staged without message blocks commit',
+ input: decisionInputs({ stagedCount: 1, hasMessage: false })
+ },
+ {
+ name: 'unresolved conflicts block commit',
+ input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true })
+ },
+ {
+ name: 'unpublished branch publishes',
+ input: decisionInputs({
+ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
+ hasCurrentBranch: true
+ })
+ },
+ {
+ name: 'detached head blocks publish',
+ input: decisionInputs({
+ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
+ hasCurrentBranch: false
+ })
+ },
+ {
+ name: 'tracked ahead pushes',
+ input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } })
+ },
+ {
+ name: 'tracked behind pulls',
+ input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } })
+ },
+ {
+ name: 'tracked diverged syncs',
+ input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } })
+ },
+ {
+ name: 'patch-equivalent diverged force-pushes with lease',
+ input: decisionInputs({
+ branchCommitsAhead: 4,
+ upstreamStatus: {
+ hasUpstream: true,
+ upstreamName: 'origin/feature',
+ ahead: 10,
+ behind: 2,
+ behindCommitsArePatchEquivalent: true
+ }
+ })
+ },
+ {
+ name: 'in-flight pull mirrors pull',
+ input: decisionInputs({
+ isRemoteOperationActive: true,
+ inFlightRemoteOpKind: 'pull',
+ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
+ })
+ }
+ ])('matches the shared commit-area decision for $name', ({ input }) => {
+ expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual(
+ resolveSourceControlCommitAreaPrimaryActionDecision(input)
+ )
+ })
+})
diff --git a/mobile/src/source-control/mobile-source-control-primary-action.ts b/mobile/src/source-control/mobile-source-control-primary-action.ts
new file mode 100644
index 00000000000..f62a7770d16
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-primary-action.ts
@@ -0,0 +1,241 @@
+import {
+ resolveMobileSourceControlCommitAreaPrimaryActionDecision,
+ type MobileSourceControlPrimaryActionDecision,
+ type MobileSourceControlPrimaryActionKind,
+ type MobileSourceControlRemoteOpKind
+} from './mobile-source-control-primary-action-decision'
+import type { MobileGitBranchCompareResult } from './mobile-branch-compare'
+import type { MobileGitStatusResult } from './mobile-git-status'
+
+type GitStep = { method: string; params?: Record }
+
+export type MobileSourceControlPrimaryAction = {
+ kind: MobileSourceControlPrimaryActionKind
+ label: string
+ accessibilityLabel: string
+ accessibilityHint: string
+ disabled: boolean
+ loading: boolean
+ requiresForceWithLease?: boolean
+ onPress: () => void
+}
+
+export type MobileSourceControlPrimaryActionHandlers = {
+ commit: () => Promise
+ stageAll: () => Promise
+ runActionSheetGitSequence: (actionId: string, steps: GitStep[]) => Promise
+ runActionSheetGitSync: () => Promise
+}
+
+export type MobileSourceControlPrimaryActionArgs = {
+ status: MobileGitStatusResult | null
+ hasUnresolvedConflicts: boolean
+ stageablePaths: readonly string[]
+ stagedCount: number
+ unstagedCount: number
+ commitMessage: string
+ busyAction: string | null
+ openingPath: string | null
+ openingBranchPath: string | null
+ branchCompareResult: MobileGitBranchCompareResult | null
+ handlers: MobileSourceControlPrimaryActionHandlers
+}
+
+export function buildMobileSourceControlPrimaryAction(
+ args: MobileSourceControlPrimaryActionArgs
+): MobileSourceControlPrimaryAction {
+ const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
+ stagedCount: args.stagedCount,
+ hasUnstagedChanges: args.unstagedCount > 0,
+ hasStageableChanges: args.stageablePaths.length > 0,
+ // Why: the commit-area decision keeps the desktop input shape, but partial
+ // staging only matters to commit eligibility/dropdowns. Avoid an extra entry scan.
+ hasPartiallyStagedChanges: false,
+ hasMessage: args.commitMessage.trim().length > 0,
+ hasUnresolvedConflicts: args.hasUnresolvedConflicts,
+ isCommitting: args.busyAction === 'commit',
+ isRemoteOperationActive: isMobileRemoteOperationActive(args.busyAction),
+ inFlightRemoteOpKind: getInFlightRemoteOpKind(args.busyAction),
+ upstreamStatus: args.status?.upstreamStatus,
+ branchCommitsAhead: getMobileBranchCommitsAhead(args),
+ hasCurrentBranch: Boolean(args.status?.branch)
+ })
+ const ioBusy =
+ args.busyAction !== null || args.openingPath !== null || args.openingBranchPath !== null
+ const disabled = decision.disabled || ioBusy
+
+ return {
+ kind: decision.kind,
+ label: getMobilePrimaryActionLabel(decision),
+ accessibilityLabel: getMobilePrimaryActionLabel(decision),
+ accessibilityHint: getMobilePrimaryActionHint(decision),
+ disabled,
+ loading: isLoadingDecision(decision, args.busyAction),
+ requiresForceWithLease: decision.requiresForceWithLease,
+ onPress: () => {
+ if (disabled) {
+ return
+ }
+ void runMobilePrimaryAction(decision, args.handlers)
+ }
+ }
+}
+
+function isMobileRemoteOperationActive(busyAction: string | null): boolean {
+ return getInFlightRemoteOpKind(busyAction) !== null
+}
+
+function getInFlightRemoteOpKind(
+ busyAction: string | null
+): MobileSourceControlRemoteOpKind | null {
+ switch (busyAction) {
+ case 'push':
+ case 'commit-push':
+ case 'push-create-pr':
+ return 'push'
+ case 'force-push':
+ return 'force_push'
+ case 'pull':
+ return 'pull'
+ case 'sync':
+ case 'commit-sync':
+ return 'sync'
+ case 'fetch':
+ return 'fetch'
+ case 'publish':
+ return 'publish'
+ case 'fast-forward':
+ return 'fast_forward'
+ case 'rebase':
+ return 'rebase'
+ default:
+ return null
+ }
+}
+
+function getMobileBranchCommitsAhead(
+ args: MobileSourceControlPrimaryActionArgs
+): number | undefined {
+ const summary = args.branchCompareResult?.summary
+ if (summary?.status === 'ready' && summary.commitsAhead !== undefined) {
+ return summary.commitsAhead
+ }
+ const upstream = args.status?.upstreamStatus
+ return upstream?.hasUpstream ? upstream.ahead : undefined
+}
+
+function getMobilePrimaryActionLabel(decision: MobileSourceControlPrimaryActionDecision): string {
+ if (decision.requiresForceWithLease) {
+ return 'Force Push'
+ }
+ switch (decision.kind) {
+ case 'commit':
+ return 'Commit'
+ case 'stage':
+ return 'Stage All'
+ case 'push':
+ return 'Push'
+ case 'pull':
+ return 'Pull'
+ case 'sync':
+ return 'Sync'
+ case 'publish':
+ return 'Publish Branch'
+ }
+}
+
+function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDecision): string {
+ switch (decision.titleIntent) {
+ case 'commit_in_progress':
+ return 'Commit in progress.'
+ case 'force_push_in_progress':
+ return 'Force push in progress.'
+ case 'action_in_progress':
+ case 'remote_operation_in_progress':
+ return 'Remote operation in progress.'
+ case 'remote_operation_blocks_commit':
+ return 'Try again once the remote operation finishes.'
+ case 'resolve_conflicts_before_commit':
+ return 'Resolve conflicts before committing.'
+ case 'commit_staged_changes':
+ return 'Commit staged changes.'
+ case 'enter_commit_message':
+ return 'Enter a commit message to commit.'
+ case 'stage_all_changes':
+ return 'Stage all changes.'
+ case 'stage_file_to_commit':
+ return 'Stage at least one file to commit.'
+ case 'checkout_branch_before_publish':
+ return 'Check out a branch before publishing commits.'
+ case 'publish_branch':
+ return 'Publish this branch to origin.'
+ case 'force_push_with_lease':
+ return 'Force push with lease to update the remote branch.'
+ case 'sync_counts':
+ return `Pull ${decision.behind ?? 0}, push ${decision.ahead ?? 0}.`
+ case 'pull_count':
+ return `Pull ${decision.count ?? 0} commit${decision.count === 1 ? '' : 's'}.`
+ case 'push_count':
+ return `Push ${decision.count ?? 0} commit${decision.count === 1 ? '' : 's'}.`
+ case 'nothing_to_commit_up_to_date':
+ return 'Nothing to commit. Branch is up to date.'
+ }
+}
+
+function isLoadingDecision(
+ decision: MobileSourceControlPrimaryActionDecision,
+ busyAction: string | null
+): boolean {
+ switch (decision.kind) {
+ case 'commit':
+ return busyAction === 'commit'
+ case 'stage':
+ return busyAction === 'stage-all'
+ case 'push':
+ return (
+ busyAction === 'push' ||
+ busyAction === 'force-push' ||
+ busyAction === 'commit-push' ||
+ busyAction === 'push-create-pr'
+ )
+ case 'pull':
+ return busyAction === 'pull'
+ case 'sync':
+ return busyAction === 'sync' || busyAction === 'commit-sync'
+ case 'publish':
+ return busyAction === 'publish'
+ }
+}
+
+async function runMobilePrimaryAction(
+ decision: MobileSourceControlPrimaryActionDecision,
+ handlers: MobileSourceControlPrimaryActionHandlers
+): Promise {
+ switch (decision.kind) {
+ case 'commit':
+ await handlers.commit()
+ return
+ case 'stage':
+ await handlers.stageAll()
+ return
+ case 'push': {
+ const params = decision.requiresForceWithLease ? { forceWithLease: true } : undefined
+ await handlers.runActionSheetGitSequence(
+ decision.requiresForceWithLease ? 'force-push' : 'push',
+ [{ method: 'git.push', params }]
+ )
+ return
+ }
+ case 'pull':
+ await handlers.runActionSheetGitSequence('pull', [{ method: 'git.pull' }])
+ return
+ case 'sync':
+ await handlers.runActionSheetGitSync()
+ return
+ case 'publish':
+ await handlers.runActionSheetGitSequence('publish', [
+ { method: 'git.push', params: { publish: true } }
+ ])
+ return
+ }
+}
diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts
index 875715ad9b0..ecac9892b9f 100644
--- a/mobile/src/source-control/use-mobile-source-control-state.ts
+++ b/mobile/src/source-control/use-mobile-source-control-state.ts
@@ -7,6 +7,7 @@ import type { MobilePrPrefill } from './mobile-pr-create'
import { useMobileGitRequests } from './use-mobile-git-requests'
import { useMobileSourceControlLoaders } from './use-mobile-source-control-loaders'
import { useMobileSourceControlOpeners } from './use-mobile-source-control-openers'
+import { buildMobileSourceControlPrimaryAction } from './mobile-source-control-primary-action'
import { useMobileSourceControlRunners } from './use-mobile-source-control-runners'
import type { RuntimeGitLocalBranches } from '../../../src/shared/runtime-types'
import {
@@ -157,6 +158,10 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries])
const stagedCount = useMemo(() => countStagedEntries(entries), [entries])
const unstagedCount = useMemo(() => countUnstagedEntries(entries), [entries])
+ const hasUnresolvedConflicts = useMemo(
+ () => entries.some((entry) => entry.conflictStatus === 'unresolved'),
+ [entries]
+ )
const branchLabel = formatBranchLabel(status?.branch, status?.head)
const upstream = status?.upstreamStatus
const upstreamKnown = upstream !== undefined
@@ -200,6 +205,43 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
setPrPrefill,
setShowPrSheet
})
+ const primaryAction = useMemo(
+ () =>
+ buildMobileSourceControlPrimaryAction({
+ status,
+ hasUnresolvedConflicts,
+ stageablePaths,
+ stagedCount,
+ unstagedCount,
+ commitMessage,
+ busyAction,
+ openingPath,
+ openingBranchPath,
+ branchCompareResult,
+ handlers: {
+ commit: runners.commit,
+ stageAll: runners.stageAll,
+ runActionSheetGitSequence: runners.runActionSheetGitSequence,
+ runActionSheetGitSync: runners.runActionSheetGitSync
+ }
+ }),
+ [
+ branchCompareResult,
+ busyAction,
+ commitMessage,
+ hasUnresolvedConflicts,
+ openingBranchPath,
+ openingPath,
+ runners.commit,
+ runners.runActionSheetGitSequence,
+ runners.runActionSheetGitSync,
+ runners.stageAll,
+ stageablePaths,
+ stagedCount,
+ status,
+ unstagedCount
+ ]
+ )
return {
client,
@@ -253,6 +295,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
upstream,
upstreamKnown,
syncLabel,
+ primaryAction,
// actions
loadStatus,
openFile,
diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts
new file mode 100644
index 00000000000..a3963ef68b2
--- /dev/null
+++ b/mobile/vitest.config.ts
@@ -0,0 +1,28 @@
+import { defineConfig } from 'vitest/config'
+import { fileURLToPath } from 'node:url'
+
+const tsconfigRaw = JSON.stringify({
+ compilerOptions: {
+ jsx: 'react-jsx',
+ module: 'esnext',
+ moduleResolution: 'bundler',
+ strict: true,
+ target: 'es2022'
+ }
+})
+
+export default defineConfig({
+ root: fileURLToPath(new URL('.', import.meta.url)),
+ esbuild: {
+ tsconfigRaw
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ tsconfigRaw
+ }
+ },
+ test: {
+ environment: 'node',
+ include: ['src/**/*.test.ts']
+ }
+})
diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts
index dfe3d2437dd..2a8236dc686 100644
--- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts
@@ -1,78 +1,14 @@
-import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
-import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
-import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers'
-import type { GitUpstreamStatus } from '../../../../shared/types'
import type { PrimaryAction } from './source-control-primary-action-types'
+import {
+ resolveCreateReviewIntentEligibility,
+ type CreateReviewIntentEligibility,
+ type CreateReviewIntentKind
+} from '../../../../shared/source-control-create-review-intent'
-export type CreatePrIntentKind =
- | 'dirty'
- | 'message_required'
- | 'no_upstream'
- | 'needs_push'
- | 'force_push'
+export type CreatePrIntentKind = CreateReviewIntentKind
+export type CreatePrIntentEligibility = CreateReviewIntentEligibility
-export type CreatePrIntentEligibility = {
- eligible: boolean
- kind: CreatePrIntentKind | null
-}
-
-export function resolveCreatePrIntentEligibility({
- stagedCount,
- hasStageableChanges,
- hasMessage,
- hasUnresolvedConflicts,
- upstreamStatus,
- hostedReviewCreation,
- branchCommitsAhead,
- hasCurrentBranch = true
-}: {
- stagedCount: number
- hasStageableChanges: boolean
- hasMessage: boolean
- hasUnresolvedConflicts: boolean
- upstreamStatus: GitUpstreamStatus | undefined
- hostedReviewCreation?: HostedReviewCreationEligibility | null
- branchCommitsAhead?: number
- hasCurrentBranch?: boolean
-}): CreatePrIntentEligibility {
- if (
- hasUnresolvedConflicts ||
- !hasCurrentBranch ||
- !hostedReviewCreation ||
- hostedReviewCreation.canCreate ||
- !supportsHostedReviewCreation(hostedReviewCreation.provider)
- ) {
- return { eligible: false, kind: null }
- }
-
- if (hostedReviewCreation.blockedReason === 'dirty') {
- if (stagedCount > 0 && !hasMessage) {
- return { eligible: true, kind: 'message_required' }
- }
- return { eligible: stagedCount > 0 || hasStageableChanges, kind: 'dirty' }
- }
-
- if (hostedReviewCreation.blockedReason === 'no_upstream') {
- const hasPublishableCommits = branchCommitsAhead === undefined ? false : branchCommitsAhead > 0
- return {
- eligible: hasPublishableCommits || stagedCount > 0 || hasStageableChanges,
- kind: 'no_upstream'
- }
- }
-
- if (hostedReviewCreation.blockedReason === 'needs_push') {
- return { eligible: true, kind: 'needs_push' }
- }
-
- if (
- hostedReviewCreation.blockedReason === 'needs_sync' &&
- shouldForcePushWithLeaseForUpstream(upstreamStatus)
- ) {
- return { eligible: true, kind: 'force_push' }
- }
-
- return { eligible: false, kind: null }
-}
+export const resolveCreatePrIntentEligibility = resolveCreateReviewIntentEligibility
export function resolveVisibleCreatePrHeaderAction({
createPrHeaderAction
diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts
index 2694fc82efc..b3f79e8a69e 100644
--- a/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts
@@ -1,5 +1,9 @@
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import type { GitUpstreamStatus, PRState } from '../../../../shared/types'
+import type {
+ SourceControlPrimaryActionKind,
+ SourceControlRemoteOpKind
+} from '../../../../shared/source-control-primary-action-decision-types'
// Why: the primary button collapses to one-label-per-action. Compound
// kinds ('commit_push', 'commit_sync', 'commit_publish') live in
@@ -8,15 +12,7 @@ import type { GitUpstreamStatus, PRState } from '../../../../shared/types'
// `handlePrimaryClick` switch exhaustively over only the kinds the
// primary can actually emit, and it kills the compound-commit branch in
// the isRemoteOperationActive tooltip below at compile time.
-export type PrimaryActionKind =
- | 'commit'
- | 'stage'
- | 'push'
- | 'pull'
- | 'sync'
- | 'publish'
- | 'create_pr_intent'
- | 'create_pr'
+export type PrimaryActionKind = SourceControlPrimaryActionKind
// Why: the in-flight remote op tracker stores which action the user actually
// triggered, so the primary button can mirror that label/spinner instead of
@@ -24,15 +20,7 @@ export type PrimaryActionKind =
// kinds are included because they participate in the busy flag, but they are
// intentionally NOT in PrimaryActionKind — when Fetch is in flight the primary
// keeps its natural label, while Force Push maps back to the push icon/slot.
-export type RemoteOpKind =
- | 'push'
- | 'force_push'
- | 'pull'
- | 'sync'
- | 'fetch'
- | 'fast_forward'
- | 'publish'
- | 'rebase'
+export type RemoteOpKind = SourceControlRemoteOpKind
export type PrimaryAction = {
kind: PrimaryActionKind
diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts
index ad89186d569..1eee270ee09 100644
--- a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts
@@ -1,25 +1,22 @@
// Why: split from the combined primary+dropdown module because the primary and dropdown are independent derivations with different priority ladders; together they exceed the max-lines budget and tangle unrelated concerns.
-import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
+import {
+ resolveSourceControlCommitAreaPrimaryActionDecision,
+ resolveSourceControlPrimaryActionDecision
+} from '../../../../shared/source-control-primary-action-decision'
+import type { SourceControlPrimaryActionDecision } from '../../../../shared/source-control-primary-action-decision-types'
import { translate } from '@/i18n/i18n'
import {
localizedHostedReviewCopy,
resolveSupportedHostedReviewCopyProvider
} from '@/i18n/hosted-review-localized-copy'
import { type PrimaryAction, type PrimaryActionInputs } from './source-control-primary-action-types'
-import { resolvePrimaryActionDuringRemoteOp } from './source-control-primary-action-in-flight'
import {
describeForcePushWithLease,
describePullCount,
describePushCount,
describeSyncCounts
} from './source-control-primary-action-titles'
-import { resolveLinkedReviewPrimaryAction } from './source-control-linked-review-primary-action'
-import {
- resolveCreatePrIntentInFlightPrimaryAction,
- resolveCreatePrIntentPrimaryAction
-} from './source-control-primary-create-pr-intent-action'
-import { resolveUnpublishedPrimaryAction } from './source-control-primary-unpublished-action'
export type {
PrimaryActionKind,
@@ -28,10 +25,8 @@ export type {
PrimaryActionInputs
} from './source-control-primary-action-types'
-// Why: this module owns the pure state-machine logic for the Source Control
-// primary action (split button). Keeping the logic outside the React component
-// makes it straightforward to unit-test each row of the priority table without
-// spinning up a renderer.
+// Why: the shared module owns the pure state-machine logic; this renderer
+// adapter keeps localized copy and the historical exported shape in place.
/**
* Resolve the primary split-button action.
@@ -54,250 +49,200 @@ export type {
* through "Publish Branch" on every worktree switch.
*/
export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction {
- const {
- stagedCount,
- hasUnstagedChanges,
- hasStageableChanges,
- hasMessage,
- hasUnresolvedConflicts,
- isCommitting,
- isRemoteOperationActive,
- upstreamStatus,
- prState,
- isPRStateLoading,
- hostedReviewCreation,
- branchCommitsAhead,
- hasCurrentBranch = true,
- canPushLinkedReviewWithoutUpstream = false,
- isPrIntentInFlight = false
- } = inputs
-
- if (isPrIntentInFlight) {
- return resolveCreatePrIntentInFlightPrimaryAction(inputs)
- }
-
- // 1. Commit in flight — lock the primary no matter what else is true.
- if (isCommitting) {
- return {
- kind: 'commit',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
- 'Commit'
- ),
- title: translate(
- 'auto.components.right.sidebar.source.control.primary.action.16aee3a5c1',
- 'Commit in progress…'
- ),
- disabled: true
- }
- }
-
- if (isRemoteOperationActive) {
- return resolvePrimaryActionDuringRemoteOp(inputs, resolvePrimaryAction)
- }
-
- // 3. Unresolved conflicts block any commit path.
- if (hasUnresolvedConflicts) {
- return {
- kind: 'commit',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
- 'Commit'
- ),
- title: translate(
- 'auto.components.right.sidebar.source.control.primary.action.a6457b46a7',
- 'Resolve conflicts before committing'
- ),
- disabled: true
- }
- }
-
- const createPrIntent = resolveCreatePrIntentPrimaryAction(inputs)
- if (createPrIntent) {
- return createPrIntent
- }
-
- const hasStaged = stagedCount > 0
- const hasOpenHostedReview = prState === 'open' || prState === 'draft'
-
- // 5. Has staged files + message → plain Commit. The primary button never
- // compounds ("Commit & Push" etc.) — after the commit lands, the primary
- // naturally rotates to the appropriate remote action (Push / Sync /
- // Publish Branch) via step 7 below. Users who want the one-click
- // compound flow can still reach it from the dropdown.
- if (hasStaged && hasMessage) {
- return {
- kind: 'commit',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
- 'Commit'
- ),
- title: translate(
- 'auto.components.right.sidebar.source.control.primary.action.ab41fb926b',
- 'Commit staged changes'
- ),
- disabled: false
- }
- }
-
- // 6. Has staged files but no message — user just needs to type something.
- if (hasStaged && !hasMessage) {
- return {
- kind: 'commit',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
- 'Commit'
- ),
- title: translate(
- 'auto.components.right.sidebar.source.control.primary.action.f01f16d77f',
- 'Enter a commit message to commit'
- ),
- disabled: true
- }
- }
-
- // 6b. Nothing staged but local changes exist — surface staging as the
- // primary so dirty trees don't invite a remote op (pull/sync would fail
- // with uncommitted changes; push/publish skips the actual user need).
- // Sits before the upstream-status checks so it works regardless of
- // whether upstream has resolved yet.
- if (!hasStaged && hasStageableChanges) {
- return {
- kind: 'stage',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.18a0fca877',
- 'Stage All'
- ),
- title: translate(
- 'auto.components.right.sidebar.source.control.primary.action.5a477d80cb',
- 'Stage all changes'
- ),
- disabled: false
- }
- }
-
- // 7. Clean tree + no staged files → adaptive remote action.
- if (!upstreamStatus) {
- return {
- kind: 'commit',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
- 'Commit'
- ),
- title: translate(
- 'auto.components.right.sidebar.source.control.primary.action.fa3bd4f40c',
- 'Stage at least one file to commit'
- ),
- disabled: true
- }
- }
-
- if (!upstreamStatus.hasUpstream) {
- const unpublishedAction = resolveUnpublishedPrimaryAction({
- hasCurrentBranch,
- isPRStateLoading,
- prState
- })
-
- if (unpublishedAction.kind === 'publish') {
- const linkedReviewAction = resolveLinkedReviewPrimaryAction({
- hasOpenHostedReview,
- canPushLinkedReviewWithoutUpstream
- })
- if (linkedReviewAction) {
- return linkedReviewAction
- }
- }
-
- return unpublishedAction
- }
-
- if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
- if (shouldForcePushWithLeaseForUpstream(upstreamStatus)) {
- return {
- kind: 'push',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.390abeab93',
- 'Force Push'
- ),
- title: describeForcePushWithLease(branchCommitsAhead, upstreamStatus.upstreamName),
- disabled: false
- }
- }
- return {
- kind: 'sync',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.795f1509c5',
- 'Sync'
- ),
- title: describeSyncCounts(upstreamStatus.ahead, upstreamStatus.behind),
- disabled: false
- }
- }
- if (upstreamStatus.behind > 0) {
- return {
- kind: 'pull',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.d64292a938',
- 'Pull'
- ),
- title: describePullCount(upstreamStatus.behind),
- disabled: false
- }
- }
- if (upstreamStatus.ahead > 0) {
- return {
- kind: 'push',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.95550cff15',
- 'Push'
- ),
- title: describePushCount(upstreamStatus.ahead),
- disabled: false
- }
- }
-
- if (hostedReviewCreation?.canCreate) {
- const copy = localizedHostedReviewCopy(
- resolveSupportedHostedReviewCopyProvider(hostedReviewCreation.provider)
- )
- return {
- kind: 'create_pr',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
- 'Create {{value0}}',
- { value0: copy.shortLabel }
- ),
- title: translate(
- 'auto.components.right.sidebar.source.control.primary.action.946a8a05ea',
- 'Create a {{value0}} for this branch',
- { value0: copy.reviewLabel }
- ),
- disabled: false
- }
- }
-
- // Clean + tracked + in sync — distinguish truly clean from work that still
- // needs staging before commit can proceed.
- return {
- kind: 'commit',
- label: translate(
- 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
- 'Commit'
- ),
- title: hasUnstagedChanges
- ? 'Stage at least one file to commit'
- : 'Nothing to commit. Branch is up to date.',
- disabled: true
- }
+ return toRendererPrimaryAction(resolveSourceControlPrimaryActionDecision(inputs), inputs)
}
export function resolveCommitAreaPrimaryAction(inputs: PrimaryActionInputs): PrimaryAction {
- // Why: review creation is additive chrome. The commit area should keep the
- // same local/remote primary action it would have without review eligibility.
- return resolvePrimaryAction({
- ...inputs,
- hostedReviewCreation: null,
- isPrIntentInFlight: false
- })
+ return toRendererPrimaryAction(
+ resolveSourceControlCommitAreaPrimaryActionDecision(inputs),
+ inputs
+ )
+}
+
+function toRendererPrimaryAction(
+ decision: SourceControlPrimaryActionDecision,
+ inputs: PrimaryActionInputs
+): PrimaryAction {
+ return {
+ kind: decision.kind,
+ label: resolvePrimaryActionLabel(decision, inputs),
+ title: resolvePrimaryActionTitle(decision, inputs),
+ disabled: decision.disabled
+ }
+}
+
+function resolvePrimaryActionLabel(
+ decision: SourceControlPrimaryActionDecision,
+ inputs: PrimaryActionInputs
+): string {
+ if (decision.labelIntent === 'force_push') {
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.390abeab93',
+ 'Force Push'
+ )
+ }
+ if (decision.labelIntent === 'create_pr') {
+ const copy = localizedHostedReviewCopy(
+ resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation?.provider)
+ )
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
+ 'Create {{value0}}',
+ { value0: copy.shortLabel }
+ )
+ }
+ switch (decision.labelIntent) {
+ case 'commit':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
+ 'Commit'
+ )
+ case 'stage':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.18a0fca877',
+ 'Stage All'
+ )
+ case 'push':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.95550cff15',
+ 'Push'
+ )
+ case 'pull':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.d64292a938',
+ 'Pull'
+ )
+ case 'sync':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.795f1509c5',
+ 'Sync'
+ )
+ case 'publish':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8',
+ 'Publish Branch'
+ )
+ case 'create_pr_intent':
+ return resolvePrimaryActionLabel({ ...decision, labelIntent: 'create_pr' }, inputs)
+ }
+}
+
+function resolvePrimaryActionTitle(
+ decision: SourceControlPrimaryActionDecision,
+ inputs: PrimaryActionInputs
+): string {
+ const copy = localizedHostedReviewCopy(
+ resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation?.provider)
+ )
+ switch (decision.titleIntent) {
+ case 'commit_in_progress':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.16aee3a5c1',
+ 'Commit in progress…'
+ )
+ case 'force_push_in_progress':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.74fc171e99',
+ 'Force Push in progress…'
+ )
+ case 'action_in_progress':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.484f45c439',
+ '{{value0}} in progress…',
+ { value0: resolvePrimaryActionLabel(decision, inputs) }
+ )
+ case 'remote_operation_in_progress':
+ return 'Remote operation in progress…'
+ case 'remote_operation_blocks_commit':
+ return 'Remote operation in progress — try again once it finishes'
+ case 'resolve_conflicts_before_commit':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.a6457b46a7',
+ 'Resolve conflicts before committing'
+ )
+ case 'prepare_review':
+ if (decision.disabled) {
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.d37e68f61d',
+ 'Preparing branch for review…'
+ )
+ }
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.c72e5e65d1',
+ 'Prepare this branch and create a {{value0}}',
+ { value0: copy.reviewLabel }
+ )
+ case 'commit_staged_changes':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.ab41fb926b',
+ 'Commit staged changes'
+ )
+ case 'enter_commit_message':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.f01f16d77f',
+ 'Enter a commit message to commit'
+ )
+ case 'stage_all_changes':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.5a477d80cb',
+ 'Stage all changes'
+ )
+ case 'stage_file_to_commit':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.fa3bd4f40c',
+ 'Stage at least one file to commit'
+ )
+ case 'checkout_branch_before_publish':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c',
+ 'Check out a branch before publishing commits.'
+ )
+ case 'checking_review_status':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.41d4bcf157',
+ 'Checking PR status…'
+ )
+ case 'review_already_merged':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b',
+ 'Nothing to commit. PR is already merged.'
+ )
+ case 'publish_branch':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.1884cf34af',
+ 'Publish this branch to origin'
+ )
+ case 'push_linked_review':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.1d47e850cf',
+ 'Push updates to the linked review branch'
+ )
+ case 'linked_review_target_unavailable':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.c39d0c75c3',
+ 'Linked review branch target is unavailable.'
+ )
+ case 'force_push_with_lease':
+ return describeForcePushWithLease(decision.count, decision.upstreamName)
+ case 'sync_counts':
+ return describeSyncCounts(decision.ahead ?? 0, decision.behind ?? 0)
+ case 'pull_count':
+ return describePullCount(decision.count ?? 0)
+ case 'push_count':
+ return describePushCount(decision.count ?? 0)
+ case 'create_review':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.946a8a05ea',
+ 'Create a {{value0}} for this branch',
+ { value0: copy.reviewLabel }
+ )
+ case 'nothing_to_commit_up_to_date':
+ return 'Nothing to commit. Branch is up to date.'
+ case 'checking_review_creation':
+ return translate(
+ 'auto.components.right.sidebar.source.control.primary.action.h3i4j5k607',
+ 'Checking whether this branch can create a {{value0}}…',
+ { value0: copy.reviewLabel }
+ )
+ }
}
diff --git a/src/shared/source-control-create-review-intent.ts b/src/shared/source-control-create-review-intent.ts
new file mode 100644
index 00000000000..2ebb932aca4
--- /dev/null
+++ b/src/shared/source-control-create-review-intent.ts
@@ -0,0 +1,85 @@
+import { shouldForcePushWithLeaseForUpstream } from './git-upstream-status'
+import type { HostedReviewCreationEligibility } from './hosted-review'
+import { supportsHostedReviewCreation } from './hosted-review-creation-providers'
+import type { GitUpstreamStatus } from './git-status-types'
+import type { SourceControlPrimaryActionDecision } from './source-control-primary-action-decision-types'
+
+export type CreateReviewIntentKind =
+ | 'dirty'
+ | 'message_required'
+ | 'no_upstream'
+ | 'needs_push'
+ | 'force_push'
+
+export type CreateReviewIntentEligibility = {
+ eligible: boolean
+ kind: CreateReviewIntentKind | null
+}
+
+export function resolveCreateReviewIntentEligibility({
+ stagedCount,
+ hasStageableChanges,
+ hasMessage,
+ hasUnresolvedConflicts,
+ upstreamStatus,
+ hostedReviewCreation,
+ branchCommitsAhead,
+ hasCurrentBranch = true
+}: {
+ stagedCount: number
+ hasStageableChanges: boolean
+ hasMessage: boolean
+ hasUnresolvedConflicts: boolean
+ upstreamStatus: GitUpstreamStatus | undefined
+ hostedReviewCreation?: HostedReviewCreationEligibility | null
+ branchCommitsAhead?: number
+ hasCurrentBranch?: boolean
+}): CreateReviewIntentEligibility {
+ if (
+ hasUnresolvedConflicts ||
+ !hasCurrentBranch ||
+ !hostedReviewCreation ||
+ hostedReviewCreation.canCreate ||
+ !supportsHostedReviewCreation(hostedReviewCreation.provider)
+ ) {
+ return { eligible: false, kind: null }
+ }
+
+ if (hostedReviewCreation.blockedReason === 'dirty') {
+ if (stagedCount > 0 && !hasMessage) {
+ return { eligible: true, kind: 'message_required' }
+ }
+ return { eligible: stagedCount > 0 || hasStageableChanges, kind: 'dirty' }
+ }
+
+ if (hostedReviewCreation.blockedReason === 'no_upstream') {
+ const hasPublishableCommits = branchCommitsAhead === undefined ? false : branchCommitsAhead > 0
+ return {
+ eligible: hasPublishableCommits || stagedCount > 0 || hasStageableChanges,
+ kind: 'no_upstream'
+ }
+ }
+
+ if (hostedReviewCreation.blockedReason === 'needs_push') {
+ return { eligible: true, kind: 'needs_push' }
+ }
+
+ if (
+ hostedReviewCreation.blockedReason === 'needs_sync' &&
+ shouldForcePushWithLeaseForUpstream(upstreamStatus)
+ ) {
+ return { eligible: true, kind: 'force_push' }
+ }
+
+ return { eligible: false, kind: null }
+}
+
+export function resolveVisibleCreateReviewHeaderAction({
+ createPrHeaderAction
+}: {
+ createPrHeaderAction: SourceControlPrimaryActionDecision | null
+}): SourceControlPrimaryActionDecision | null {
+ // Why: keep a stable header anchor; disable Create Review when the branch is
+ // not ready instead of hiding it and shifting toolbar layout.
+ return createPrHeaderAction
+}
diff --git a/src/shared/source-control-primary-action-decision-types.ts b/src/shared/source-control-primary-action-decision-types.ts
new file mode 100644
index 00000000000..1769dbe0958
--- /dev/null
+++ b/src/shared/source-control-primary-action-decision-types.ts
@@ -0,0 +1,82 @@
+import type { HostedReviewCreationEligibility } from './hosted-review'
+import type { GitUpstreamStatus } from './git-status-types'
+import type { PRState } from './types'
+
+export type SourceControlPrimaryActionKind =
+ | 'commit'
+ | 'stage'
+ | 'push'
+ | 'pull'
+ | 'sync'
+ | 'publish'
+ | 'create_pr_intent'
+ | 'create_pr'
+
+export type SourceControlRemoteOpKind =
+ | 'push'
+ | 'force_push'
+ | 'pull'
+ | 'sync'
+ | 'fetch'
+ | 'fast_forward'
+ | 'publish'
+ | 'rebase'
+
+export type SourceControlPrimaryActionTitleIntent =
+ | 'commit_in_progress'
+ | 'force_push_in_progress'
+ | 'action_in_progress'
+ | 'remote_operation_in_progress'
+ | 'remote_operation_blocks_commit'
+ | 'resolve_conflicts_before_commit'
+ | 'prepare_review'
+ | 'commit_staged_changes'
+ | 'enter_commit_message'
+ | 'stage_all_changes'
+ | 'stage_file_to_commit'
+ | 'checkout_branch_before_publish'
+ | 'checking_review_status'
+ | 'review_already_merged'
+ | 'publish_branch'
+ | 'push_linked_review'
+ | 'linked_review_target_unavailable'
+ | 'force_push_with_lease'
+ | 'sync_counts'
+ | 'pull_count'
+ | 'push_count'
+ | 'create_review'
+ | 'nothing_to_commit_up_to_date'
+ | 'checking_review_creation'
+
+export type SourceControlPrimaryActionDecision = {
+ kind: SourceControlPrimaryActionKind
+ disabled: boolean
+ labelIntent: SourceControlPrimaryActionKind | 'force_push'
+ titleIntent: SourceControlPrimaryActionTitleIntent
+ count?: number
+ ahead?: number
+ behind?: number
+ upstreamName?: string
+ requiresForceWithLease?: boolean
+}
+
+export type SourceControlPrimaryActionDecisionInputs = {
+ stagedCount: number
+ hasUnstagedChanges: boolean
+ hasStageableChanges: boolean
+ hasPartiallyStagedChanges: boolean
+ hasMessage: boolean
+ hasUnresolvedConflicts: boolean
+ isCommitting: boolean
+ isRemoteOperationActive: boolean
+ upstreamStatus: GitUpstreamStatus | undefined
+ prState?: PRState | null
+ isPRStateLoading?: boolean
+ inFlightRemoteOpKind?: SourceControlRemoteOpKind | null
+ hostedReviewCreation?: HostedReviewCreationEligibility | null
+ branchCommitsAhead?: number
+ hasCurrentBranch?: boolean
+ canPushLinkedReviewWithoutUpstream?: boolean
+ isPrIntentInFlight?: boolean
+ isHostedReviewCreationLoading?: boolean
+}
diff --git a/src/shared/source-control-primary-action-decision.test.ts b/src/shared/source-control-primary-action-decision.test.ts
new file mode 100644
index 00000000000..dad1ee40e3f
--- /dev/null
+++ b/src/shared/source-control-primary-action-decision.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, it } from 'vitest'
+import {
+ resolveSourceControlCommitAreaPrimaryActionDecision,
+ resolveSourceControlPrimaryActionDecision,
+ type SourceControlPrimaryActionDecisionInputs
+} from './source-control-primary-action-decision'
+
+function inputs(
+ overrides: Partial = {}
+): SourceControlPrimaryActionDecisionInputs {
+ return {
+ stagedCount: 0,
+ hasUnstagedChanges: false,
+ hasStageableChanges: false,
+ hasPartiallyStagedChanges: false,
+ hasMessage: false,
+ hasUnresolvedConflicts: false,
+ isCommitting: false,
+ isRemoteOperationActive: false,
+ upstreamStatus: undefined,
+ ...overrides
+ }
+}
+
+describe('source-control primary action decision', () => {
+ it('returns Stage All for a dirty tree before remote actions', () => {
+ const result = resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({
+ hasUnstagedChanges: true,
+ hasStageableChanges: true,
+ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 4 }
+ })
+ )
+ expect(result.kind).toBe('stage')
+ expect(result.titleIntent).toBe('stage_all_changes')
+ expect(result.disabled).toBe(false)
+ })
+
+ it('returns enabled Commit for staged changes with a message', () => {
+ const result = resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({ stagedCount: 2, hasMessage: true })
+ )
+ expect(result).toMatchObject({
+ kind: 'commit',
+ titleIntent: 'commit_staged_changes',
+ disabled: false
+ })
+ })
+
+ it('blocks commits while unresolved conflicts exist', () => {
+ const result = resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true })
+ )
+ expect(result).toMatchObject({
+ kind: 'commit',
+ titleIntent: 'resolve_conflicts_before_commit',
+ disabled: true
+ })
+ })
+
+ it('returns remote push, pull, and sync decisions for clean tracked branches', () => {
+ expect(
+ resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } })
+ )
+ ).toMatchObject({ kind: 'push', titleIntent: 'push_count', count: 2 })
+ expect(
+ resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } })
+ )
+ ).toMatchObject({ kind: 'pull', titleIntent: 'pull_count', count: 3 })
+ expect(
+ resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } })
+ )
+ ).toMatchObject({ kind: 'sync', titleIntent: 'sync_counts', ahead: 2, behind: 3 })
+ })
+
+ it('returns Publish Branch for clean unpublished branches with a current branch', () => {
+ const result = resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({
+ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
+ hasCurrentBranch: true
+ })
+ )
+ expect(result).toMatchObject({
+ kind: 'publish',
+ titleIntent: 'publish_branch',
+ disabled: false
+ })
+ })
+
+ it('blocks unpublished branch publishing when HEAD is detached', () => {
+ const result = resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({
+ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
+ hasCurrentBranch: false
+ })
+ )
+ expect(result).toMatchObject({
+ kind: 'commit',
+ titleIntent: 'checkout_branch_before_publish',
+ disabled: true
+ })
+ })
+
+ it('mirrors in-flight remote operation semantics', () => {
+ const result = resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({
+ isRemoteOperationActive: true,
+ inFlightRemoteOpKind: 'pull',
+ upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 }
+ })
+ )
+ expect(result).toMatchObject({
+ kind: 'pull',
+ labelIntent: 'pull',
+ titleIntent: 'action_in_progress',
+ disabled: true
+ })
+ })
+
+ it('marks patch-equivalent diverged branches as force-push-with-lease decisions', () => {
+ const result = resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs({
+ branchCommitsAhead: 4,
+ upstreamStatus: {
+ hasUpstream: true,
+ upstreamName: 'origin/feature',
+ ahead: 14,
+ behind: 3,
+ behindCommitsArePatchEquivalent: true
+ }
+ })
+ )
+ expect(result).toMatchObject({
+ kind: 'push',
+ labelIntent: 'force_push',
+ titleIntent: 'force_push_with_lease',
+ requiresForceWithLease: true,
+ count: 4,
+ upstreamName: 'origin/feature'
+ })
+ })
+
+ it('keeps review creation out of commit-area decisions', () => {
+ const input = inputs({
+ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
+ hostedReviewCreation: {
+ provider: 'gitlab',
+ review: null,
+ canCreate: true,
+ blockedReason: null,
+ nextAction: null
+ }
+ })
+ expect(resolveSourceControlPrimaryActionDecision(input).kind).toBe('create_pr')
+ expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit')
+ })
+})
diff --git a/src/shared/source-control-primary-action-decision.ts b/src/shared/source-control-primary-action-decision.ts
new file mode 100644
index 00000000000..e51cbdd2e05
--- /dev/null
+++ b/src/shared/source-control-primary-action-decision.ts
@@ -0,0 +1,252 @@
+import { shouldForcePushWithLeaseForUpstream } from './git-upstream-status'
+import { resolveCreateReviewIntentEligibility } from './source-control-create-review-intent'
+import { resolveSourceControlPrimaryActionDuringRemoteOp } from './source-control-primary-action-in-flight'
+import type {
+ SourceControlPrimaryActionDecision,
+ SourceControlPrimaryActionDecisionInputs
+} from './source-control-primary-action-decision-types'
+import { resolveUnpublishedSourceControlPrimaryAction } from './source-control-primary-unpublished-action'
+
+export type {
+ SourceControlPrimaryActionKind,
+ SourceControlRemoteOpKind,
+ SourceControlPrimaryActionDecision,
+ SourceControlPrimaryActionDecisionInputs
+} from './source-control-primary-action-decision-types'
+
+export function resolveSourceControlPrimaryActionDecision(
+ inputs: SourceControlPrimaryActionDecisionInputs
+): SourceControlPrimaryActionDecision {
+ const {
+ stagedCount,
+ hasUnstagedChanges,
+ hasStageableChanges,
+ hasMessage,
+ hasUnresolvedConflicts,
+ isCommitting,
+ isRemoteOperationActive,
+ upstreamStatus,
+ prState,
+ isPRStateLoading,
+ hostedReviewCreation,
+ branchCommitsAhead,
+ hasCurrentBranch = true,
+ canPushLinkedReviewWithoutUpstream = false,
+ isPrIntentInFlight = false
+ } = inputs
+
+ if (isPrIntentInFlight) {
+ return {
+ kind: 'create_pr_intent',
+ labelIntent: 'create_pr',
+ titleIntent: 'prepare_review',
+ disabled: true
+ }
+ }
+
+ if (isCommitting) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'commit_in_progress',
+ disabled: true
+ }
+ }
+
+ if (isRemoteOperationActive) {
+ return resolveSourceControlPrimaryActionDuringRemoteOp(
+ inputs,
+ resolveSourceControlPrimaryActionDecision
+ )
+ }
+
+ if (hasUnresolvedConflicts) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'resolve_conflicts_before_commit',
+ disabled: true
+ }
+ }
+
+ const createPrIntent = resolveCreatePrIntentDecision(inputs)
+ if (createPrIntent) {
+ return createPrIntent
+ }
+
+ const hasStaged = stagedCount > 0
+ const hasOpenHostedReview = prState === 'open' || prState === 'draft'
+
+ if (hasStaged && hasMessage) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'commit_staged_changes',
+ disabled: false
+ }
+ }
+
+ if (hasStaged && !hasMessage) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'enter_commit_message',
+ disabled: true
+ }
+ }
+
+ if (!hasStaged && hasStageableChanges) {
+ return {
+ kind: 'stage',
+ labelIntent: 'stage',
+ titleIntent: 'stage_all_changes',
+ disabled: false
+ }
+ }
+
+ if (!upstreamStatus) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'stage_file_to_commit',
+ disabled: true
+ }
+ }
+
+ if (!upstreamStatus.hasUpstream) {
+ const unpublishedAction = resolveUnpublishedSourceControlPrimaryAction({
+ hasCurrentBranch,
+ isPRStateLoading,
+ prState
+ })
+
+ if (unpublishedAction.kind === 'publish') {
+ const linkedReviewAction = resolveLinkedReviewSourceControlPrimaryAction({
+ hasOpenHostedReview,
+ canPushLinkedReviewWithoutUpstream
+ })
+ if (linkedReviewAction) {
+ return linkedReviewAction
+ }
+ }
+
+ return unpublishedAction
+ }
+
+ if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
+ if (shouldForcePushWithLeaseForUpstream(upstreamStatus)) {
+ return {
+ kind: 'push',
+ labelIntent: 'force_push',
+ titleIntent: 'force_push_with_lease',
+ disabled: false,
+ count: branchCommitsAhead,
+ upstreamName: upstreamStatus.upstreamName,
+ requiresForceWithLease: true
+ }
+ }
+ return {
+ kind: 'sync',
+ labelIntent: 'sync',
+ titleIntent: 'sync_counts',
+ disabled: false,
+ ahead: upstreamStatus.ahead,
+ behind: upstreamStatus.behind
+ }
+ }
+
+ if (upstreamStatus.behind > 0) {
+ return {
+ kind: 'pull',
+ labelIntent: 'pull',
+ titleIntent: 'pull_count',
+ disabled: false,
+ count: upstreamStatus.behind
+ }
+ }
+
+ if (upstreamStatus.ahead > 0) {
+ return {
+ kind: 'push',
+ labelIntent: 'push',
+ titleIntent: 'push_count',
+ disabled: false,
+ count: upstreamStatus.ahead
+ }
+ }
+
+ if (hostedReviewCreation?.canCreate) {
+ return {
+ kind: 'create_pr',
+ labelIntent: 'create_pr',
+ titleIntent: 'create_review',
+ disabled: false
+ }
+ }
+
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date',
+ disabled: true
+ }
+}
+
+export function resolveSourceControlCommitAreaPrimaryActionDecision(
+ inputs: SourceControlPrimaryActionDecisionInputs
+): SourceControlPrimaryActionDecision {
+ // Why: review creation is additive chrome. Commit/mobile bottom areas keep
+ // the local/remote action they would have without review eligibility.
+ return resolveSourceControlPrimaryActionDecision({
+ ...inputs,
+ hostedReviewCreation: null,
+ isPrIntentInFlight: false
+ })
+}
+
+function resolveCreatePrIntentDecision(
+ inputs: SourceControlPrimaryActionDecisionInputs
+): SourceControlPrimaryActionDecision | null {
+ const createPrIntent = resolveCreateReviewIntentEligibility({
+ stagedCount: inputs.stagedCount,
+ hasStageableChanges: inputs.hasStageableChanges,
+ hasMessage: inputs.hasMessage,
+ hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
+ upstreamStatus: inputs.upstreamStatus,
+ hostedReviewCreation: inputs.hostedReviewCreation,
+ branchCommitsAhead: inputs.branchCommitsAhead,
+ hasCurrentBranch: inputs.hasCurrentBranch
+ })
+ if (!createPrIntent.eligible) {
+ return null
+ }
+ return {
+ kind: 'create_pr_intent',
+ labelIntent: 'create_pr',
+ titleIntent: 'prepare_review',
+ disabled: false
+ }
+}
+
+function resolveLinkedReviewSourceControlPrimaryAction(args: {
+ hasOpenHostedReview: boolean
+ canPushLinkedReviewWithoutUpstream: boolean
+}): SourceControlPrimaryActionDecision | null {
+ if (!args.hasOpenHostedReview) {
+ return null
+ }
+ if (args.canPushLinkedReviewWithoutUpstream) {
+ return {
+ kind: 'push',
+ labelIntent: 'push',
+ titleIntent: 'push_linked_review',
+ disabled: false
+ }
+ }
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'linked_review_target_unavailable',
+ disabled: true
+ }
+}
diff --git a/src/shared/source-control-primary-action-in-flight.ts b/src/shared/source-control-primary-action-in-flight.ts
new file mode 100644
index 00000000000..55d06935e4c
--- /dev/null
+++ b/src/shared/source-control-primary-action-in-flight.ts
@@ -0,0 +1,50 @@
+import type {
+ SourceControlPrimaryActionDecision,
+ SourceControlPrimaryActionDecisionInputs
+} from './source-control-primary-action-decision-types'
+
+export function resolveSourceControlPrimaryActionDuringRemoteOp(
+ inputs: SourceControlPrimaryActionDecisionInputs,
+ resolveWithoutRemoteOp: (
+ inputs: SourceControlPrimaryActionDecisionInputs
+ ) => SourceControlPrimaryActionDecision
+): SourceControlPrimaryActionDecision {
+ const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs
+ const candidate = resolveWithoutRemoteOp({ ...inputs, isRemoteOperationActive: false })
+ const inFlightIsPrimaryKind =
+ inFlightRemoteOpKind === 'push' ||
+ inFlightRemoteOpKind === 'pull' ||
+ inFlightRemoteOpKind === 'sync' ||
+ inFlightRemoteOpKind === 'publish'
+
+ if (inFlightRemoteOpKind === 'force_push') {
+ return {
+ kind: 'push',
+ labelIntent: 'force_push',
+ titleIntent: 'force_push_in_progress',
+ disabled: true,
+ requiresForceWithLease: true
+ }
+ }
+
+ if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) {
+ return {
+ kind: inFlightRemoteOpKind,
+ labelIntent: inFlightRemoteOpKind,
+ titleIntent: 'action_in_progress',
+ disabled: true
+ }
+ }
+
+ const titleIntent = hasUnresolvedConflicts
+ ? 'resolve_conflicts_before_commit'
+ : candidate.kind === 'commit'
+ ? 'remote_operation_blocks_commit'
+ : 'remote_operation_in_progress'
+
+ return {
+ ...candidate,
+ titleIntent,
+ disabled: true
+ }
+}
diff --git a/src/shared/source-control-primary-unpublished-action.ts b/src/shared/source-control-primary-unpublished-action.ts
new file mode 100644
index 00000000000..ccc71c321c0
--- /dev/null
+++ b/src/shared/source-control-primary-unpublished-action.ts
@@ -0,0 +1,47 @@
+import type {
+ SourceControlPrimaryActionDecision,
+ SourceControlPrimaryActionDecisionInputs
+} from './source-control-primary-action-decision-types'
+
+export function resolveUnpublishedSourceControlPrimaryAction({
+ hasCurrentBranch,
+ isPRStateLoading,
+ prState
+}: Pick<
+ SourceControlPrimaryActionDecisionInputs,
+ 'hasCurrentBranch' | 'isPRStateLoading' | 'prState'
+>): SourceControlPrimaryActionDecision {
+ if (!hasCurrentBranch) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'checkout_branch_before_publish',
+ disabled: true
+ }
+ }
+
+ if (isPRStateLoading) {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'checking_review_status',
+ disabled: true
+ }
+ }
+
+ if (prState === 'merged') {
+ return {
+ kind: 'commit',
+ labelIntent: 'commit',
+ titleIntent: 'review_already_merged',
+ disabled: true
+ }
+ }
+
+ return {
+ kind: 'publish',
+ labelIntent: 'publish',
+ titleIntent: 'publish_branch',
+ disabled: false
+ }
+}