mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
* fix(worktrees): stop surfacing prunable git worktrees as live workspaces A worktree still registered in git but whose directory was deleted (git's `prunable` state) was enumerated as a normal workspace, producing repeated pty:spawn DaemonProtocolError / fs:readDir ENOENT loops and a blank pane. - Parse the `prunable` porcelain field (Git >= 2.36) in both the main and relay worktree-list parsers. - For Git < 2.36 (no `prunable` field), probe each linked worktree path for existence on the fallback line-block path, skipping locked registrations to mirror git's own prunable rules. - Omit prunable worktrees from the detected-workspace enumeration only; removal/cleanup flows keep seeing them. - Extend the real-binary compatibility contract with the 2.36 `prunable` boundary. Fixes #8389 Claude-Session: https://claude.ai/code/session_018Rg1Bpq4GGwmz613hq6RSD * fix(worktrees): pin the prunable/locked porcelain annotations to their real Git 2.31 boundary The prunable and locked annotations landed in Git 2.31, five releases before `worktree list -z` (2.36); only -z defines the capability fallback boundary. Correct the compatibility contract so a future matrix entry in the 2.31-2.35 range passes, and reword the fallback comments: on 2.31-2.35 the annotations still parse and the existence probe is a backstop; only Git <2.31 relies on it outright. * fix(worktrees): omit prunable registrations from the Space scan A prunable registration has no directory to size or reclaim, so Space rendered it as a dead "Missing" row whose checkbox stayed disabled with no prune/remove affordance (reported on macOS after a reboot cleared /private/tmp under 16 registrations). Skip prunable entries in the scan, matching the workspace enumeration; removal flows list worktrees separately and still see them. --------- Co-authored-by: kaynan <kaynan.camargo@terceiro-sky.com.br> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
94 lines
3.5 KiB
TypeScript
94 lines
3.5 KiB
TypeScript
import { stat } from 'node:fs/promises'
|
||
import type { GitCapabilityCache } from '../shared/git-capability-cache'
|
||
import type { GitExec } from './git-handler-ops'
|
||
import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils'
|
||
|
||
export type RelayWorktreeInfo = {
|
||
path: string
|
||
branch?: string
|
||
head?: string
|
||
locked?: boolean
|
||
lockReason?: string
|
||
}
|
||
|
||
export async function readRelayWorktreeList(
|
||
git: GitExec,
|
||
repoPath: string,
|
||
capabilities: GitCapabilityCache
|
||
): Promise<RelayWorktreeInfo[]> {
|
||
return capabilities.runWithFallback(
|
||
'worktree-list-z',
|
||
async () => {
|
||
const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath)
|
||
return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true }))
|
||
},
|
||
async () => {
|
||
// Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible.
|
||
const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath)
|
||
return normalizeRelayWorktrees(parseWorktreeList(stdout))
|
||
},
|
||
isUnsupportedWorktreeListZError
|
||
)
|
||
}
|
||
|
||
const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
|
||
|
||
/** Why: Git <2.31 does not emit the `prunable` porcelain annotation, so probe
|
||
* each linked worktree path directly instead of treating a stale registration
|
||
* as a live workspace (issue #8389). Runs on the `-z`-unsupported fallback
|
||
* (Git <2.36); on Git 2.31–2.35 the annotation is already parsed, so this is a
|
||
* harmless backstop. The relay owns the filesystem, so a plain stat is
|
||
* authoritative. */
|
||
export async function annotatePrunableWorktreesByExistence(
|
||
worktrees: Record<string, unknown>[]
|
||
): Promise<Record<string, unknown>[]> {
|
||
const annotated = [...worktrees]
|
||
let nextIndex = 0
|
||
|
||
async function probeNext(): Promise<void> {
|
||
while (nextIndex < worktrees.length) {
|
||
const index = nextIndex
|
||
nextIndex += 1
|
||
const worktree = worktrees[index]
|
||
const worktreePath = typeof worktree?.path === 'string' ? worktree.path : ''
|
||
// Git only marks linked worktrees prunable, and never locked ones (a
|
||
// lock shields the registration even when the directory is missing). The
|
||
// `locked` annotation is only parsed on Git >=2.31, so on older Git a
|
||
// locked+missing worktree cannot be shielded here. A missing main
|
||
// worktree is surfaced by the repo-level failure paths.
|
||
if (
|
||
!worktreePath ||
|
||
worktree.isMainWorktree === true ||
|
||
worktree.isBare === true ||
|
||
worktree.locked === true ||
|
||
worktree.prunable === true
|
||
) {
|
||
continue
|
||
}
|
||
try {
|
||
await stat(worktreePath)
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
|
||
annotated[index] = { ...worktree, prunable: true }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length)
|
||
await Promise.all(Array.from({ length: workerCount }, () => probeNext()))
|
||
return annotated
|
||
}
|
||
|
||
function normalizeRelayWorktrees(worktrees: Record<string, unknown>[]): RelayWorktreeInfo[] {
|
||
return worktrees
|
||
.map((worktree) => ({
|
||
path: typeof worktree.path === 'string' ? worktree.path : '',
|
||
head: typeof worktree.head === 'string' ? worktree.head : undefined,
|
||
branch: typeof worktree.branch === 'string' ? worktree.branch : undefined,
|
||
locked: worktree.locked === true ? true : undefined,
|
||
lockReason: typeof worktree.lockReason === 'string' ? worktree.lockReason : undefined
|
||
}))
|
||
.filter((worktree) => worktree.path.length > 0)
|
||
}
|