mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
#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.
157 lines
5.5 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|