fix: make worktree scan failures actionable

This commit is contained in:
Jinwoo-H
2026-09-17 17:21:11 -04:00
parent 8b2502fc92
commit d9ffc6baa3
9 changed files with 193 additions and 42 deletions
@@ -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
}
}
}
@@ -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<Record<WorktreeScanFailureKind, string>> = {
'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<void> => {
await window.api.ui.writeClipboardText(value)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-repo-header-action=""
className={cn(
'inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] text-destructive',
pending && 'opacity-60'
)}
aria-label={`${title}. ${retryLabel}`}
aria-busy={pending}
disabled={pending}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setPending(true)
void fetchWorktrees(repo.id, {
executionHostId: getRepoExecutionHostId(repo)
}).finally(() => setPending(false))
}}
>
<TriangleAlert className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-72">
<div className="space-y-1">
<div className="font-medium">{title}</div>
<div className="break-words text-muted-foreground">{detected.unavailableReason}</div>
<div className="text-muted-foreground">
{translate(
'auto.components.sidebar.RepoScanUnavailableIndicator.retained',
'Existing worktrees are kept until a scan succeeds. Click to retry.'
<TooltipProvider disableHoverableContent={false}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-repo-header-action=""
className={cn(
'inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] text-destructive',
pending && 'opacity-60'
)}
aria-label={`${title}. ${retryLabel}`}
aria-busy={pending}
disabled={pending}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setPending(true)
void fetchWorktrees(repo.id, {
executionHostId: getRepoExecutionHostId(repo)
}).finally(() => setPending(false))
}}
>
<TriangleAlert className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="pointer-events-auto max-w-72">
<div className="space-y-1">
<div className="font-medium">{title}</div>
<div className="break-words text-muted-foreground">{failureMessage}</div>
{fixCommand ? (
<div>
<div className="break-words font-mono text-xs text-muted-foreground">
{fixCommand}
</div>
</div>
) : null}
<div className="text-muted-foreground">
{translate(
'auto.components.sidebar.RepoScanUnavailableIndicator.retained',
'Existing worktrees are kept until a scan succeeds. Click to retry.'
)}
</div>
<div className="flex items-center justify-start gap-3 border-t border-border/60 pt-1">
{fixCommand ? (
<button
type="button"
className="text-xs underline"
onClick={() => void copyText(fixCommand)}
>
{translate(
'auto.components.sidebar.RepoScanUnavailableIndicator.copyCommand',
'Copy command'
)}
</button>
) : null}
<button
type="button"
className="text-xs underline"
onClick={() => void copyText(diagnosticText)}
>
{translate(
'auto.components.sidebar.RepoScanUnavailableIndicator.copyDiagnostics',
'Copy diagnostics'
)}
</button>
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+6 -1
View File
@@ -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": {
@@ -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
@@ -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))
)
}
@@ -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)
)
}
+28
View File
@@ -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')
})
})
+18
View File
@@ -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'
}
+3
View File
@@ -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
}