fix(ssh): stop a failed worktree scan from publishing authoritative emptiness (#17833)

* fix(ssh): keep an unreadable worktree catalog from authorizing teardown

#14004: the relay's worktree-list fallback caught every failure and returned
`[]`, so `SshGitProvider.listWorktrees` resolved as a success with an empty
list. Downstream reconciliation treats a resolved listing as authoritative,
which reaches `teardownMissingWorktreeTerminalsBestEffort` and the
unregistered-worktree removal paths — a data-loss path from a failed scan.

- relay: the `-z`-unsupported fallback lane propagates its failure instead of
  swallowing it to `[]`.
- provider: an empty or malformed `git.listWorktrees` response is refused as
  `WorktreeCatalogUnavailableError`. A Git repo always lists its own checkout,
  so a zero-row listing can only be a scan that never answered — this is the
  mixed-version guard against relays that still swallow.
- `listRepoWorktrees`: an unreachable SSH host reports unavailable instead of
  an empty catalog.

#12661: `ssh:terminateSessions` now returns `{ terminated, unverifiable }`, so
an offline sweep that only tore down local transport cannot be mistaken for a
remote kill. The Manage-hosts toast warns instead of claiming success.

* chore(i18n): register the unreachable-terminal terminate message
This commit is contained in:
Neil
2026-09-01 22:05:30 -07:00
committed by GitHub
parent bed9734a9d
commit 058e618bb4
14 changed files with 389 additions and 35 deletions
+10 -1
View File
@@ -1,5 +1,5 @@
import { ipcMain } from 'electron'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshTarget, SshTerminateSessionsResult } from '../../shared/ssh-types'
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
@@ -98,6 +98,9 @@ export function registerSshConnectionHandlers(): void {
ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => {
invalidateConnectAttempt(args.targetId)
// Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell
// "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue.
let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 }
await runTargetLifecycle(args.targetId, async () => {
const provider = getSshPtyProvider(args.targetId)
const leases = persistedStore!.getSshRemotePtyLeases(args.targetId)
@@ -142,6 +145,10 @@ export function registerSshConnectionHandlers(): void {
)
)
: []
if (!provider) {
// Nothing observed these remote shells, so their state is unknown — not "nothing to do".
outcome = { terminated: 0, unverifiable: ptyIds.length }
}
const shutdownFailures: string[] = []
for (const [index, result] of shutdownResults.entries()) {
const { appPtyId, relayPtyId } = ptyIds[index]
@@ -154,6 +161,7 @@ export function registerSshConnectionHandlers(): void {
clearProviderPtyState(appPtyId)
deletePtyOwnership(appPtyId)
persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
outcome = { ...outcome, terminated: outcome.terminated + 1 }
}
if (shutdownFailures.length > 0) {
// Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry.
@@ -161,6 +169,7 @@ export function registerSshConnectionHandlers(): void {
}
await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist())
})
return outcome
})
ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => {
+23 -3
View File
@@ -95,7 +95,9 @@ describe('SSH IPC handlers', () => {
mockPtyProvider.shutdown.mockResolvedValue(undefined)
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
await expect(
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
).resolves.toEqual({ terminated: 2, unverifiable: 0 })
expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-live', {
immediate: true,
@@ -168,7 +170,9 @@ describe('SSH IPC handlers', () => {
await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' })
})
it('ssh:terminateSessions cannot reach expired leases without a relay', async () => {
// Issue #12661: an offline sweep tears down local transport only. Reporting plain success would
// read as "the remote shells are gone" when nobody asked the host.
it('ssh:terminateSessions reports expired leases as unverifiable without a relay', async () => {
mockStore.getSshRemotePtyLeases.mockReturnValue([
{ targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' }
])
@@ -177,10 +181,26 @@ describe('SSH IPC handlers', () => {
await expect(
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
).resolves.toBeUndefined()
).resolves.toEqual({ terminated: 0, unverifiable: 1 })
expect(mockPtyProvider.shutdown).not.toHaveBeenCalled()
// Still no forced reconnect: an expired lease can name a host that is gone for good (#2626).
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'pty-expired',
'terminated'
)
})
it('ssh:terminateSessions reports nothing unverifiable when there is nothing to reach', async () => {
mockStore.getSshRemotePtyLeases.mockReturnValue([])
vi.mocked(getSshPtyProvider).mockReturnValue(undefined)
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
await expect(
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
).resolves.toEqual({ terminated: 0, unverifiable: 0 })
})
it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => {
@@ -2,6 +2,7 @@ import type { GitStatusResult } from '../../shared/git-status-types'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { CapabilityProbeCache } from '../../shared/capability-probe-cache'
import { assertAuthoritativeWorktreeCatalog } from '../../shared/worktree/worktree-catalog-availability'
import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors'
import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider'
@@ -32,11 +33,14 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider {
repoPath: string,
options?: { signal?: AbortSignal }
): Promise<GitWorktreeInfo[]> {
return (await this.mux.request(
const response = await this.mux.request(
'git.listWorktrees',
{ repoPath },
{ signal: options?.signal }
)) as GitWorktreeInfo[]
)
// Why (#14004): relays before this fix answered a failed worktree scan with `[]`. Mixed versions are
// normal, so refuse the shape here too — a Git repo always lists its own checkout.
return assertAuthoritativeWorktreeCatalog<GitWorktreeInfo>(response, repoPath)
}
async addWorktree(
@@ -0,0 +1,141 @@
/**
* Issue #14004: an SSH worktree catalog Orca could not read must never surface as an authoritative
* empty catalog. Covers the whole client-side chain — provider response guard, the repo-level
* listing, and the detected-worktree result whose `authoritative` flag gates renderer terminal
* teardown (`teardownMissingWorktreeTerminalsBestEffort`).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SshGitProvider } from './ssh-git-provider'
import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness'
import { isWorktreeCatalogUnavailableError } from '../../shared/worktree/worktree-catalog-availability'
import { listRepoWorktrees } from '../repo-worktrees'
import { listDetectedWorktreesForCapturedRepo } from '../ipc/worktrees/listing/detected-provider-listing'
import type { Repo } from '../../shared/repo-types'
import type { Store } from '../persistence/loading-store/store'
const { getSshGitProviderMock } = vi.hoisted(() => ({ getSshGitProviderMock: vi.fn() }))
vi.mock('./ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
requireSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: () => 1
}))
const CONNECTION_ID = 'conn-1'
const REPO_PATH = '/home/user/repo'
const WORKTREE_PATH = '/home/user/feature'
const repo: Repo = {
id: 'repo-1',
path: REPO_PATH,
displayName: 'repo',
connectionId: CONNECTION_ID
} as Repo
const worktreeId = `${repo.id}::${WORKTREE_PATH}`
function createStore(): Store {
const meta: Record<string, { hostId?: string; instanceId?: string }> = {
[worktreeId]: { instanceId: 'instance-1' }
}
return {
getRepos: () => [repo],
getRepo: () => repo,
getAllWorktreeMeta: () => meta,
getWorktreeMeta: (id: string) => meta[id],
setWorktreeMeta: vi.fn(),
getAllWorktreeLineage: () => ({}),
getProjectHostSetups: () => [],
getSettings: () => ({})
} as unknown as Store
}
describe('SSH worktree catalog authority (#14004)', () => {
let mux: MockMultiplexer
let provider: SshGitProvider
beforeEach(() => {
mux = createMockMux()
provider = new SshGitProvider(CONNECTION_ID, mux as never)
getSshGitProviderMock.mockReset()
getSshGitProviderMock.mockReturnValue(provider)
})
it('refuses an empty relay response instead of publishing an empty catalog', async () => {
// An older relay converted a failed `git worktree list` into `[]`; mixed versions are the normal state.
mux.request.mockResolvedValue([])
await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy(
isWorktreeCatalogUnavailableError
)
})
it('refuses a malformed relay response', async () => {
mux.request.mockResolvedValue(undefined)
await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy(
isWorktreeCatalogUnavailableError
)
})
it('reports an unreachable SSH host as unavailable, not as an empty repo listing', async () => {
getSshGitProviderMock.mockReturnValue(undefined)
await expect(listRepoWorktrees(repo)).rejects.toSatisfy(isWorktreeCatalogUnavailableError)
})
it('does not authorize missing-worktree teardown when the relay listing fails', async () => {
mux.request.mockRejectedValue(new Error('relay request failed'))
const result = await listDetectedWorktreesForCapturedRepo(
createStore(),
repo,
() => true,
provider
)
expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' })
// The persisted workspace survives the failed scan, so the renderer has nothing to reconcile away.
expect(
(result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id)
).toContain(worktreeId)
})
it('does not authorize missing-worktree teardown when the relay answers with an empty list', async () => {
mux.request.mockResolvedValue([])
const result = await listDetectedWorktreesForCapturedRepo(
createStore(),
repo,
() => true,
provider
)
expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' })
expect(
(result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id)
).toContain(worktreeId)
})
it('republishes an authoritative catalog once the relay answers again', async () => {
mux.request.mockResolvedValue([
{ path: REPO_PATH, head: 'abc123', branch: 'main', isBare: false, isMainWorktree: true },
{
path: WORKTREE_PATH,
head: 'def456',
branch: 'feature',
isBare: false,
isMainWorktree: false
}
])
const result = await listDetectedWorktreesForCapturedRepo(
createStore(),
repo,
() => true,
provider
)
expect(result).toMatchObject({ authoritative: true, source: 'git' })
})
})
+10 -4
View File
@@ -4,6 +4,7 @@ import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/wor
import { isFolderRepo } from '../shared/repo-kind'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { areWorktreePathsEqual } from './ipc/worktree-logic'
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
type LocalRepoWorktreeListOptions = {
wslDistro?: string
@@ -42,10 +43,15 @@ export async function listRepoWorktrees(
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
// Why: runtime worktree resolution can run before SSH providers have
// reattached during startup. Return empty instead of falling back to
// local git against a server path.
return provider ? await provider.listWorktrees(repo.path) : []
// Why: runtime worktree resolution can run before SSH providers have reattached during startup.
// Never fall back to local git against a server path, and never report the unreachable host as an
// empty catalog (#14004) — callers treat a resolved listing as authoritative.
if (!provider) {
throw new WorktreeCatalogUnavailableError(
`Worktree catalog unavailable for ${repo.path}: SSH connection "${repo.connectionId}" is not connected.`
)
}
return await provider.listWorktrees(repo.path)
}
return hasLocalRepoWorktreeListOptions(options)
? await listWorktrees(repo.path, options)
+3 -2
View File
@@ -9,7 +9,8 @@ import type {
SshTarget,
SshTargetAddResult,
SshTargetCreateInput,
SshTargetUpdateInput
SshTargetUpdateInput,
SshTerminateSessionsResult
} from '../../shared/ssh-types'
import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types'
@@ -25,7 +26,7 @@ export type SshApi = {
resolveConfigHost: (args: { alias: string }) => Promise<SshConfigHostResolution | null>
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
disconnect: (args: { targetId: string }) => Promise<void>
terminateSessions: (args: { targetId: string }) => Promise<void>
terminateSessions: (args: { targetId: string }) => Promise<SshTerminateSessionsResult>
resetRelay: (args: { targetId: string }) => Promise<void>
getState: (args: { targetId: string }) => Promise<SshConnectionState | null>
needsPassphrasePrompt: (args: { targetId: string }) => Promise<boolean>
@@ -0,0 +1,96 @@
/**
* Issue #14004: a relay-side worktree-list failure must stay a failure across the relay/provider
* boundary. Converting it to `[]` reports an unreadable catalog as an authoritative empty one, and
* downstream reconciliation uses that to authorize missing-worktree teardown.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayContext } from './context'
import { GitHandler } from './git-handler'
import {
createMockDispatcher,
type MockDispatcher,
type RelayDispatcher
} from './git-handler-test-setup'
type GitSpyTarget = {
git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }>
}
const WORKTREE_LIST_OUTPUT = `worktree /repo
HEAD abc123
branch refs/heads/main
`
/** Git <2.36 rejects `worktree list -z` with a usage error, which routes the handler to the fallback lane. */
function unsupportedZError(): Error {
return Object.assign(new Error('git usage error'), {
code: 129,
stderr: 'usage: git worktree list [<options>]\n'
})
}
describe('relay worktree-list authority (#14004)', () => {
let dispatcher: MockDispatcher
let handler: GitHandler
beforeEach(() => {
dispatcher = createMockDispatcher()
handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext())
})
it('rejects instead of reporting an empty catalog when the fallback listing fails', async () => {
vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) =>
args.includes('-z')
? Promise.reject(unsupportedZError())
: Promise.reject(
Object.assign(new Error('fatal: not a git repository'), { code: 128, stderr: '' })
)
)
await expect(
dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
).rejects.toThrow('not a git repository')
})
it('rejects a timed-out fallback listing on a host whose -z support is already known absent', async () => {
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockImplementation((args: string[]) =>
args.includes('-z')
? Promise.reject(unsupportedZError())
: Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' })
)
// Prime the capability cache so the probe is not repeated; later scans go straight to the fallback.
await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
gitSpy.mockRejectedValue(Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' }))
await expect(
dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
).rejects.toThrow('ETIMEDOUT')
expect(gitSpy.mock.calls.at(-1)?.[0]).toEqual(['worktree', 'list', '--porcelain'])
})
it('republishes the catalog when a later fallback listing succeeds', async () => {
let failListing = true
vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => {
if (args.includes('-z')) {
return Promise.reject(unsupportedZError())
}
return failListing
? Promise.reject(new Error('transient relay failure'))
: Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' })
})
await expect(
dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
).rejects.toThrow('transient relay failure')
failListing = false
const result = (await dispatcher.callRequest('git.listWorktrees', {
repoPath: '/repo'
})) as Record<string, unknown>[]
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ path: '/repo', isMainWorktree: true })
})
})
+8 -13
View File
@@ -132,19 +132,14 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
},
async () => {
// Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety).
try {
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
signal: context?.signal
})
const normalized = await this.normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout)
)
// Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389).
return annotatePrunableWorktreesByExistence(normalized)
} catch {
return []
}
// Why no catch (#14004): swallowing to `[]` would report an unreadable catalog as an authoritative
// empty one, and callers use that to authorize missing-worktree teardown. Let the failure propagate.
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
signal: context?.signal
})
const normalized = await this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout))
// Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389).
return annotatePrunableWorktreesByExistence(normalized)
},
isUnsupportedWorktreeListZError
)
@@ -6,7 +6,10 @@ import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { Button } from '../ui/button'
import { removeSshTargetWithBestEffortCleanup } from './ssh-target-remove'
import { terminateSshSessionsWithReconnect } from './ssh-session-termination'
import {
describeSshTerminateOutcome,
terminateSshSessionsWithReconnect
} from './ssh-session-termination'
import { SshTargetCard } from './SshTargetCard'
import { SshTargetDestructiveActions } from './SshTargetDestructiveActions'
import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm'
@@ -218,10 +221,8 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
const handleTerminateSessions = async (targetId: string): Promise<void> => {
try {
await terminateSshSessionsWithReconnect(targetId)
toast.success(
translate('auto.components.settings.SshPane.90e308c98b', 'Remote terminals ended')
)
const report = describeSshTerminateOutcome(await terminateSshSessionsWithReconnect(targetId))
toast[report.level](report.message)
} catch (err) {
toast.error(
err instanceof Error
@@ -1,8 +1,12 @@
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../../../shared/constants'
import type { SshTerminateSessionsResult } from '../../../../shared/ssh-types'
import { translate } from '../../i18n/i18n'
export async function terminateSshSessionsWithReconnect(targetId: string): Promise<void> {
export async function terminateSshSessionsWithReconnect(
targetId: string
): Promise<SshTerminateSessionsResult> {
try {
await window.api.ssh.terminateSessions({ targetId })
return await window.api.ssh.terminateSessions({ targetId })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!message.includes(SSH_TERMINATE_RECONNECT_REQUIRED)) {
@@ -11,6 +15,31 @@ export async function terminateSshSessionsWithReconnect(targetId: string): Promi
// Why: disconnect is now non-destructive, so preserved remote PTYs may
// require a fresh relay attachment before they can be explicitly killed.
await window.api.ssh.connect({ targetId })
await window.api.ssh.terminateSessions({ targetId })
return await window.api.ssh.terminateSessions({ targetId })
}
}
/**
* An offline sweep only tears down local transport, so its remote shells are `unverifiable`, never
* `exited` (docs/reference/ssh-execution-boundary.md). Reporting plain success there would announce
* a kill nobody delivered (issue #12661).
*/
export function describeSshTerminateOutcome(outcome: SshTerminateSessionsResult): {
level: 'success' | 'warning'
message: string
} {
if (outcome.unverifiable > 0) {
return {
level: 'warning',
message: translate(
'auto.components.settings.SshPane.terminateUnverifiable',
'{{terminals}} remote terminal(s) could not be reached. Reconnect to end them.',
{ terminals: outcome.unverifiable }
)
}
}
return {
level: 'success',
message: translate('auto.components.settings.SshPane.90e308c98b', 'Remote terminals ended')
}
}
+2 -1
View File
@@ -8265,7 +8265,8 @@
"4db9afce1c": "Port must be between 1 and 65535",
"0e5aa04161": "Host or SSH config alias is required",
"f1fc50dad2": "Failed to load SSH targets",
"0cda732f43": "Connection test failed"
"0cda732f43": "Connection test failed",
"terminateUnverifiable": "{{terminals}} remote terminal(s) could not be reached. Reconnect to end them."
},
"SshPassphraseDialog": {
"d5a234456f": "Cancel",
@@ -144,7 +144,7 @@ export function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
return state
},
disconnect: () => Promise.resolve(),
terminateSessions: () => Promise.resolve(),
terminateSessions: () => Promise.resolve({ terminated: 0, unverifiable: 0 }),
resetRelay: () => Promise.resolve(),
getState: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
+10
View File
@@ -266,3 +266,13 @@ export type EnrichedDetectedPort = DetectedPort & {
advertisedUrl?: string
advertisedProtocol?: 'http' | 'https'
}
/** Outcome of `ssh:terminateSessions`. Uses the fixed verdict vocabulary from
* docs/reference/ssh-execution-boundary.md: a host we could not reach yields `unverifiable`,
* never `exited`, so an offline sweep can never be read as a successful remote kill (issue #12661). */
export type SshTerminateSessionsResult = {
/** Remote PTYs the host acknowledged stopping. */
terminated: number
/** Leases whose remote shells were never reached because the relay was offline. */
unverifiable: number
}
@@ -0,0 +1,41 @@
/**
* "I could not ask" is not "there is nothing there".
*
* A worktree catalog that could not be read must stay distinguishable from one that
* genuinely lists no worktrees, or downstream reconciliation converts a transport or
* Git failure into authoritative emptiness and tears down live state
* (docs/reference/ssh-execution-boundary.md, issue #14004).
*/
export class WorktreeCatalogUnavailableError extends Error {
/** Structural marker: survives JSON-RPC re-wrapping better than `instanceof` across module copies. */
readonly worktreeCatalogUnavailable = true
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'WorktreeCatalogUnavailableError'
}
}
export function isWorktreeCatalogUnavailableError(error: unknown): boolean {
return (
error instanceof WorktreeCatalogUnavailableError ||
(typeof error === 'object' &&
error !== null &&
(error as { worktreeCatalogUnavailable?: unknown }).worktreeCatalogUnavailable === true)
)
}
/**
* Git always lists at least the repository's own checkout, so a zero-row listing for a Git repo
* can only mean the scan never produced an answer. Older relays converted worktree-list failures
* into `[]`, so a mixed-version client must reject that shape rather than publish it.
*/
export function assertAuthoritativeWorktreeCatalog<T>(worktrees: unknown, repoPath: string): T[] {
if (!Array.isArray(worktrees) || worktrees.length === 0) {
throw new WorktreeCatalogUnavailableError(
`Worktree catalog unavailable for ${repoPath}: the execution host returned no worktree listing. ` +
'Treating this as an empty catalog would authorize removing workspaces that still exist.'
)
}
return worktrees as T[]
}