mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
fix: make worktree scan failures actionable
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
-38
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-1
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user