mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +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
173 lines
6.8 KiB
TypeScript
173 lines
6.8 KiB
TypeScript
import * as path from 'node:path'
|
|
import type { RequestContext } from './dispatcher'
|
|
import { expandTilde } from './context'
|
|
import { GitHandlerOperationContext } from './git-handler-operation-context'
|
|
import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils'
|
|
import {
|
|
addWorktreeOp,
|
|
areRelayWorktreePathsEqual,
|
|
removeWorktreeOp,
|
|
worktreeIsCleanOp
|
|
} from './git-handler-worktree-ops'
|
|
import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list'
|
|
import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh'
|
|
import {
|
|
hasUnsupportedRevParsePathFormatEcho,
|
|
isUnsupportedRevParsePathFormatError
|
|
} from '../shared/git-worktree-command-capabilities'
|
|
|
|
function isWindowsAbsolutePath(value: string): boolean {
|
|
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
|
|
}
|
|
|
|
function resolveRelayPath(repoPath: string, value: string): string {
|
|
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
|
return value
|
|
}
|
|
// Old Git ignores `--path-format=absolute`; resolve relative paths against repoPath by path shape.
|
|
return isWindowsAbsolutePath(repoPath)
|
|
? path.win32.resolve(repoPath, value)
|
|
: path.posix.resolve(repoPath, value)
|
|
}
|
|
|
|
type RelayRepoLocation = { topLevel: string; commonDir: string }
|
|
|
|
function parseRelayRepoLocation(repoPath: string, output: string): RelayRepoLocation | undefined {
|
|
// Old git (pre `--path-format`) echoes the unknown flag and exits 0; drop `-`-prefixed lines, take the last two paths.
|
|
// Strip only the trailing CR, not surrounding spaces — git paths may legitimately start or end with a space.
|
|
const lines = output
|
|
.split('\n')
|
|
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
|
|
.filter((line) => line.length > 0 && !line.startsWith('-'))
|
|
if (lines.length < 2) {
|
|
return undefined
|
|
}
|
|
const [topLevel, commonDir] = lines.slice(-2)
|
|
return {
|
|
topLevel: resolveRelayPath(repoPath, topLevel),
|
|
commonDir: resolveRelayPath(repoPath, commonDir)
|
|
}
|
|
}
|
|
|
|
export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
|
|
async isGitRepo(params: Record<string, unknown>) {
|
|
const dirPath = params.dirPath as string
|
|
try {
|
|
const { stdout } = await this.git(['rev-parse', '--show-toplevel'], dirPath)
|
|
return { isRepo: true, rootPath: stdout.trim() }
|
|
} catch {
|
|
return { isRepo: false, rootPath: null }
|
|
}
|
|
}
|
|
|
|
private async readRepoLocation(repoPath: string): Promise<RelayRepoLocation | undefined> {
|
|
try {
|
|
return await this.gitCapabilities.runWithFallback(
|
|
'rev-parse-path-format',
|
|
async () => {
|
|
const { stdout } = await this.git(
|
|
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
|
|
repoPath
|
|
)
|
|
if (hasUnsupportedRevParsePathFormatEcho(stdout)) {
|
|
// Why: old Git echoes the unknown option and exits zero; remember the signal though the paths still parse.
|
|
this.gitCapabilities.rememberUnsupported('rev-parse-path-format')
|
|
}
|
|
return parseRelayRepoLocation(repoPath, stdout)
|
|
},
|
|
async () => {
|
|
const { stdout } = await this.git(
|
|
['rev-parse', '--show-toplevel', '--git-common-dir'],
|
|
repoPath
|
|
)
|
|
return parseRelayRepoLocation(repoPath, stdout)
|
|
},
|
|
isUnsupportedRevParsePathFormatError
|
|
)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
private async normalizeMainWorktreePath(
|
|
repoPath: string,
|
|
worktrees: Record<string, unknown>[]
|
|
): Promise<Record<string, unknown>[]> {
|
|
const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree === true)
|
|
const mainWorktree = worktrees[mainIndex]
|
|
const mainPath = typeof mainWorktree?.path === 'string' ? mainWorktree.path : ''
|
|
// Expand `~` so legacy tilde SSH repo paths match git's absolute path, sparing a rev-parse per poll.
|
|
const resolvedRepoPath = expandTilde(repoPath)
|
|
if (!mainPath || areRelayWorktreePathsEqual(mainPath, resolvedRepoPath)) {
|
|
return worktrees
|
|
}
|
|
|
|
const location = await this.readRepoLocation(resolvedRepoPath)
|
|
if (!location) {
|
|
return worktrees
|
|
}
|
|
|
|
// Why: only separate-git-dir/submodule repos have main entry == git-common-dir; gate on it so we don't clobber a linked worktree's real root.
|
|
if (!areRelayWorktreePathsEqual(mainPath, location.commonDir)) {
|
|
return worktrees
|
|
}
|
|
|
|
const normalized = [...worktrees]
|
|
normalized[mainIndex] = { ...mainWorktree, path: location.topLevel }
|
|
return normalized
|
|
}
|
|
|
|
async listWorktrees(params: Record<string, unknown>, context?: RequestContext) {
|
|
const repoPath = params.repoPath as string
|
|
return this.gitCapabilities.runWithFallback(
|
|
'worktree-list-z',
|
|
async () => {
|
|
const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, {
|
|
signal: context?.signal
|
|
})
|
|
return this.normalizeMainWorktreePath(
|
|
repoPath,
|
|
parseWorktreeList(stdout, { nulDelimited: true })
|
|
)
|
|
},
|
|
async () => {
|
|
// Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety).
|
|
// 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
|
|
)
|
|
}
|
|
|
|
async addWorktree(params: Record<string, unknown>) {
|
|
return this.runWithGitReadCacheClear(() => addWorktreeOp(this.git.bind(this), params))
|
|
}
|
|
|
|
async removeWorktree(params: Record<string, unknown>) {
|
|
const remove = () =>
|
|
this.runWithGitReadCacheClear(() =>
|
|
removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities)
|
|
)
|
|
const worktreePath = params.worktreePath
|
|
return this.watcherRegistry && typeof worktreePath === 'string'
|
|
? this.watcherRegistry.runWithRemovalFence(expandTilde(worktreePath), remove)
|
|
: remove()
|
|
}
|
|
|
|
async worktreeIsClean(params: Record<string, unknown>) {
|
|
return worktreeIsCleanOp(this.git.bind(this), params)
|
|
}
|
|
|
|
async refreshLocalBaseRefForWorktreeCreate(params: Record<string, unknown>) {
|
|
return this.runWithGitReadCacheClear(() =>
|
|
refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities)
|
|
)
|
|
}
|
|
}
|