Files
orca/src/main/providers/ssh-git-worktree-provider.ts
T
Neil fbcd4d3f76 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.
2026-08-31 23:37:19 -07:00

157 lines
5.5 KiB
TypeScript

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'
const WORKTREE_IS_CLEAN_CAPABILITY = 'git.worktreeIsClean' as const
function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined {
if (entries.length === 0) {
return undefined
}
return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n')
}
function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined {
const trackedLines = (stdout ?? '')
.split(/\r?\n/)
.filter((line) => line.trim().length > 0 && !line.startsWith('?? '))
return trackedLines.length > 0 ? trackedLines.join('\n') : undefined
}
export class SshGitWorktreeProvider extends SshGitReviewHeadProvider {
private loggedWorktreeIsCleanFallback = false
// Why: reconnect replaces this provider, so an upgraded relay is naturally re-probed.
private readonly worktreeIsCleanCapabilityCache = new CapabilityProbeCache<
typeof WORKTREE_IS_CLEAN_CAPABILITY
>(Number.POSITIVE_INFINITY)
async listWorktrees(
repoPath: string,
options?: { signal?: AbortSignal }
): Promise<GitWorktreeInfo[]> {
const response = await this.mux.request(
'git.listWorktrees',
{ repoPath },
{ signal: options?.signal }
)
// 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(
repoPath: string,
branchName: string,
targetDir: string,
options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean }
): Promise<void> {
await this.runWithGitReadInvalidation(async () => {
await this.mux.request('git.addWorktree', {
repoPath,
branchName,
targetDir,
...options
})
})
}
async removeWorktree(
worktreePath: string,
force?: boolean,
options?: { deleteBranch?: boolean; forceBranchDelete?: boolean }
): Promise<RemoveWorktreeResult> {
return this.runWithGitReadInvalidation(
async () =>
((await this.mux.request('git.removeWorktree', {
worktreePath,
force,
...options
})) ?? {}) as RemoveWorktreeResult
)
}
async worktreeIsClean(
worktreePath: string,
options: { includeUntracked?: boolean } = {}
): Promise<{ clean: boolean; stdout?: string }> {
return this.worktreeIsCleanCapabilityCache.runWithFallback(
WORKTREE_IS_CLEAN_CAPABILITY,
async () => {
const result = (await this.mux.request('git.worktreeIsClean', {
worktreePath,
...(options.includeUntracked === false ? { includeUntracked: false } : {})
})) as { clean: boolean; stdout?: string }
if (options.includeUntracked === false) {
if (!result.clean && result.stdout === undefined) {
return result
}
const trackedStdout = filterUntrackedPorcelainStatus(result.stdout)
return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) }
}
return result
},
async () => {
if (!this.loggedWorktreeIsCleanFallback) {
this.loggedWorktreeIsCleanFallback = true
console.warn(
'[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check'
)
}
const status = await this.getStatus(worktreePath)
const entries =
options.includeUntracked === false
? status.entries.filter((entry) => entry.area !== 'untracked')
: status.entries
const clean = entries.length === 0
return { clean, stdout: formatStatusEntriesForCleanCheck(entries) }
},
isJsonRpcMethodNotFoundError
)
}
async refreshLocalBaseRefForWorktreeCreate(args: {
repoPath: string
fullRef: string
remoteTrackingRef: string
ownerWorktreePath?: string
checkOnly?: boolean
}): Promise<void> {
await this.runWithGitReadInvalidation(async () => {
await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args)
})
}
async renameCurrentBranch(worktreePath: string, newBranch: string): Promise<void> {
await this.runWithGitReadInvalidation(async () => {
await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch })
})
}
async forceDeletePreservedBranch(
repoPath: string,
branchName: string,
expectedHead: string
): Promise<void> {
try {
await this.runWithGitReadInvalidation(async () => {
await this.mux.request('git.forceDeletePreservedBranch', {
repoPath,
branchName,
expectedHead
})
})
} catch (error) {
if (isJsonRpcMethodNotFoundError(error)) {
throw new Error(
'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.'
)
}
throw error
}
}
}