diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index ec5e7606e45..4feeb42dd58 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -31,6 +31,7 @@ import { warnOnce } from './worktree-listing-diagnostics' import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta' +import { classifyWorktreeScanFailure } from '../../../../shared/worktree-scan-failure' export async function listDetectedWorktreesForCapturedRepo( store: Store, @@ -163,6 +164,7 @@ export async function listDetectedWorktreesForCapturedRepo( ) // Why: retention alone leaves inert rows with no explanation; the cause rides with the listing. const unavailableReason = describeWorktreeScanFailure(err) + const failureKind = classifyWorktreeScanFailure(unavailableReason) if (repo.connectionId) { const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex()) return { @@ -170,7 +172,8 @@ export async function listDetectedWorktreesForCapturedRepo( authoritative: false, source: 'metadata-fallback', worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees), - unavailableReason + unavailableReason, + failureKind } } return { @@ -178,7 +181,8 @@ export async function listDetectedWorktreesForCapturedRepo( authoritative: false, source: 'metadata-fallback', worktrees: [], - unavailableReason + unavailableReason, + failureKind } } } diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx index a956598140e..3d3b1dc91ea 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx @@ -1,16 +1,35 @@ import React from 'react' import { TriangleAlert } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { Repo } from '../../../../../../shared/repo-types' import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' +import { + classifyWorktreeScanFailure, + type WorktreeScanFailureKind +} from '../../../../../../shared/worktree-scan-failure' import { handleRepoHeaderActionPointerDown, stopRepoHeaderKeyboardToggle } from './header-event-guards' +const WORKTREE_SCAN_FIX_COMMANDS = { + 'xcode-license': 'sudo xcodebuild -license', + 'developer-tools': 'xcode-select --install' +} as const + +function fixCommandForFailureKind(kind: WorktreeScanFailureKind): string | undefined { + if (kind === 'xcode-license') { + return WORKTREE_SCAN_FIX_COMMANDS['xcode-license'] + } + if (kind === 'developer-tools') { + return WORKTREE_SCAN_FIX_COMMANDS['developer-tools'] + } + return undefined +} + /** * Marks a repo whose worktree scan failed, so its rows are retained but cannot be trusted. * Click re-runs the scan: the failure is otherwise re-tried only by the next incidental refresh. @@ -31,45 +50,109 @@ export function RepoScanUnavailableIndicator({ repo }: { repo: Repo }): React.JS 'auto.components.sidebar.RepoScanUnavailableIndicator.retry', 'Retry scan' ) + const isLocalHost = getRepoExecutionHostId(repo) === 'local' && !repo.connectionId + const isLocalMac = isLocalHost && navigator.userAgent.includes('Mac') + const failureKind: WorktreeScanFailureKind = isLocalMac + ? (detected.failureKind ?? classifyWorktreeScanFailure(detected.unavailableReason)) + : 'unknown' + const failureMessageByKind: Partial> = { + 'xcode-license': translate( + 'auto.components.sidebar.RepoScanUnavailableIndicator.xcodeLicense', + 'Apple developer tools require license acceptance before Git can run.' + ), + 'developer-tools': translate( + 'auto.components.sidebar.RepoScanUnavailableIndicator.developerTools', + 'Apple command-line developer tools are missing or unavailable.' + ), + 'architecture-mismatch': translate( + 'auto.components.sidebar.RepoScanUnavailableIndicator.architectureMismatch', + 'A Git-related executable could not run because its CPU architecture is incompatible with this execution host. Install Git and related tools for the host architecture.' + ) + } + const failureMessage = failureMessageByKind[failureKind] ?? detected.unavailableReason + const fixCommand = fixCommandForFailureKind(failureKind) + const diagnosticText = [ + `Repository: ${repo.displayName}`, + ...(isLocalMac + ? [`Path: ${repo.path}`, 'Client platform: macOS'] + : [`Execution host: ${getRepoExecutionHostId(repo)}`]), + `Failure: ${detected.unavailableReason}` + ].join('\n') + const copyText = async (value: string): Promise => { + await window.api.ui.writeClipboardText(value) + } return ( - - - - - -
-
{title}
-
{detected.unavailableReason}
-
- {translate( - 'auto.components.sidebar.RepoScanUnavailableIndicator.retained', - 'Existing worktrees are kept until a scan succeeds. Click to retry.' + + + + + + +
+
{title}
+
{failureMessage}
+ {fixCommand ? ( +
+
+ {fixCommand} +
+
+ ) : null} +
+ {translate( + 'auto.components.sidebar.RepoScanUnavailableIndicator.retained', + 'Existing worktrees are kept until a scan succeeds. Click to retry.' + )} +
+
+ {fixCommand ? ( + + ) : null} + +
-
- - + + + ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a0ce84c3559..87cdd2ab853 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6316,7 +6316,12 @@ "RepoScanUnavailableIndicator": { "title": "Worktree scan failed for {{value0}}", "retry": "Retry scan", - "retained": "Existing worktrees are kept until a scan succeeds. Click to retry." + "retained": "Existing worktrees are kept until a scan succeeds. Click to retry.", + "xcodeLicense": "Apple developer tools require license acceptance before Git can run.", + "developerTools": "Apple command-line developer tools are missing or unavailable.", + "architectureMismatch": "A Git-related executable could not run because its CPU architecture is incompatible with this execution host. Install Git and related tools for the host architecture.", + "copyCommand": "Copy command", + "copyDiagnostics": "Copy diagnostics" } }, "shared": { diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts index f8078a9925c..b1fb7f7bafb 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts @@ -23,6 +23,7 @@ export function mergeDetectedWorktreesForHost( current.authoritative === refreshed.authoritative && current.source === refreshed.source && current.unavailableReason === refreshed.unavailableReason && + current.failureKind === refreshed.failureKind && current.worktrees === worktrees ) { return current diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts index e85add4fc85..fe7bca3384c 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts @@ -17,6 +17,13 @@ import type { import { isRuntimeMethodNotFoundError } from './runtime-worktree-rpc-errors' import { toLegacyDetectedWorktreeResult } from './worktree-host-ownership' +const detectedWorktreeFailureKinds = new Set([ + 'xcode-license', + 'developer-tools', + 'architecture-mismatch', + 'unknown' +]) + export async function listDetectedWorktreesForRepo( settings: AppState['settings'], repoId: string, @@ -86,7 +93,8 @@ export function isDetectedWorktreeListResult(value: unknown): value is DetectedW (result.source === 'git' || result.source === 'metadata-fallback' || result.source === 'session-fallback') && - Array.isArray(result.worktrees) + Array.isArray(result.worktrees) && + (result.failureKind === undefined || detectedWorktreeFailureKinds.has(result.failureKind)) ) } diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts index c6d3a9636f7..2fcf8d51d28 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts @@ -15,6 +15,7 @@ export function areDetectedWorktreeResultsEqual( current.authoritative === next.authoritative && current.source === next.source && current.unavailableReason === next.unavailableReason && + current.failureKind === next.failureKind && catalogRowsEqual(current.worktrees, next.worktrees) ) } diff --git a/src/shared/worktree-scan-failure.test.ts b/src/shared/worktree-scan-failure.test.ts new file mode 100644 index 00000000000..0981344ec4d --- /dev/null +++ b/src/shared/worktree-scan-failure.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { classifyWorktreeScanFailure } from './worktree-scan-failure' + +describe('classifyWorktreeScanFailure', () => { + it('recognizes Xcode license failures', () => { + expect( + classifyWorktreeScanFailure('Agreeing to the Xcode/iOS license requires admin privileges') + ).toBe('xcode-license') + }) + it('recognizes missing developer tools', () => { + expect(classifyWorktreeScanFailure('xcode-select: error: no developer tools were found')).toBe( + 'developer-tools' + ) + }) + it('does not prescribe installation for an unspecified xcode-select path error', () => { + expect(classifyWorktreeScanFailure('xcode-select: error: invalid active developer path')).toBe( + 'unknown' + ) + }) + it('recognizes architecture spawn failures', () => { + expect(classifyWorktreeScanFailure('spawn Unknown system error -86')).toBe( + 'architecture-mismatch' + ) + }) + it('keeps unrecognized failures unknown', () => { + expect(classifyWorktreeScanFailure('git failed for an unspecified reason')).toBe('unknown') + }) +}) diff --git a/src/shared/worktree-scan-failure.ts b/src/shared/worktree-scan-failure.ts new file mode 100644 index 00000000000..3c33324d67a --- /dev/null +++ b/src/shared/worktree-scan-failure.ts @@ -0,0 +1,18 @@ +export type WorktreeScanFailureKind = + | 'xcode-license' + | 'developer-tools' + | 'architecture-mismatch' + | 'unknown' + +export function classifyWorktreeScanFailure(reason: string): WorktreeScanFailureKind { + if (/Agreeing to the Xcode\/iOS license requires admin privileges/i.test(reason)) { + return 'xcode-license' + } + if (/no developer tools were found/i.test(reason)) { + return 'developer-tools' + } + if (/Unknown system error -86|EBADARCH|Bad CPU type in executable/i.test(reason)) { + return 'architecture-mismatch' + } + return 'unknown' +} diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index e368716dc01..e07696016a9 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -6,6 +6,7 @@ import type { DiffComment, MobileDiffReviewState } from '../diff-comment-types' import type { EphemeralVmCheckoutMode } from '../orca-yaml-hook-types' import type { BuiltInWorktreeVisibilitySourceId } from '../repo-types' import type { WorktreeIdentity } from './identity' +import type { WorktreeScanFailureKind } from '../worktree-scan-failure' export type WorkspaceLinkedItem = { provider: 'github' | 'gitlab' | 'linear' | 'jira' @@ -223,4 +224,6 @@ export type DetectedWorktreeListResult = { worktrees: DetectedWorktree[] /** Why a non-authoritative listing could not be scanned; additive, older hosts omit it. */ unavailableReason?: string + /** Structured cause captured by the execution host when a scan fails. */ + failureKind?: WorktreeScanFailureKind }