Files
orca/src/relay/git-handler-worktree-list.ts
T
6e2a4a824d fix(worktrees): stop surfacing prunable git worktrees as live workspaces (#8409)
* 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>
2026-07-15 15:24:15 -07:00

94 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}