mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
* 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
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
/**
|
|
* 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 })
|
|
})
|
|
})
|