mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
* fix(worktrees): stop a failed worktree scan from pruning as an authoritative empty listing A `git worktree list` that could not run at all — a WSL distro that stopped resolving, a hung mount, a git binary that errored — was softened to `[]` by the lenient listing path, so the detected scan published it as `authoritative: true, worktrees: []`. That disables the #1158 retention guard, drops every persisted tab for the repo, and the pruned session is written back to disk on the next launch. The loss is permanent, not a transient glitch. Route the detected scan through a strict listing that still reports the two genuinely empty states (repo path gone, not a Git repo) as `[]`. Everything else rejects, so the existing catch answers `authoritative: false` and the destructive halves (`rememberLocalWorktreeRoots`, `pruneLineageForMissingRepoWorktrees`) never see a failed scan. Also make the failure readable: wsl.exe reports its own launch failures as exit 0xFFFFFFFF with an EMPTY stderr and the `Wsl/Service/WSL_E_*` line on stdout as UTF-16LE, which is why the field bundle carried a git error with no text. Set WSL_UTF8 for WSL-routed git (matching the wsl runner, #9010) and attach that stdout diagnostic to the error so `git.exec` spans name the cause. * fix(worktrees): surface a failed worktree scan's cause on the repo header with a retry A failed scan now travels with its reason (optional unavailableReason on DetectedWorktreeListResult), the repo header shows it with click-to-retry, and the WSL deleted-guest-directory shape measured on a real Windows host is pinned as retained-not-pruned.
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
import { realpath, stat } from 'node:fs/promises'
|
|
import { join, posix } from 'node:path'
|
|
import { isWorktreeCreatePreparation } from '../../shared/worktree/create-preparation'
|
|
import { toWslExecutionSpace } from '../../shared/wsl-paths'
|
|
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
|
import {
|
|
readCheckedOutBranchRef,
|
|
readRepoCommonDirFromGit,
|
|
readRepoLocation,
|
|
readTranslatedWorktreeGraph,
|
|
readWorktreeHeadOid,
|
|
readWorktreeList
|
|
} from './worktree-list-reader'
|
|
import type { GitWorktreeExecOptions } from './worktree-operation-options'
|
|
import {
|
|
WORKTREE_LIST_TIMEOUT_MS,
|
|
getErrorCode,
|
|
isNotGitRepositoryError,
|
|
normalizeLocalBranchRef
|
|
} from './worktree-operation-options'
|
|
import { areWorktreePathsEqual, translateWorktreePath } from './worktree-path-comparison'
|
|
import { detectSparseCheckoutCached } from './worktree-sparse-checkout-cache'
|
|
import { resolveGitCommonDir } from './worktree-sparse-state'
|
|
import { resolveGitDir } from './source-control/resolve-git-dir'
|
|
|
|
const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
|
|
|
|
export async function listWorktreeGraph(
|
|
repoPath: string,
|
|
options: GitWorktreeExecOptions = {}
|
|
): Promise<GitWorktreeInfo[]> {
|
|
try {
|
|
const worktrees = await readTranslatedWorktreeGraph(repoPath, options)
|
|
return options.includeCreatePreparations
|
|
? worktrees
|
|
: worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree))
|
|
} catch (err) {
|
|
if (await isTrueEmptyWorktreeListing(repoPath, err)) {
|
|
return []
|
|
}
|
|
console.warn(`[git/worktree] listWorktreeGraph failed for ${repoPath}:`, err)
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function listWorktreesUnshared(
|
|
repoPath: string,
|
|
options: GitWorktreeExecOptions = {}
|
|
): Promise<GitWorktreeInfo[]> {
|
|
try {
|
|
const worktrees = await readTranslatedWorktreeGraph(repoPath, options)
|
|
const visibleWorktrees = options.includeCreatePreparations
|
|
? worktrees
|
|
: worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree))
|
|
return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options)
|
|
} catch (err) {
|
|
if (await isTrueEmptyWorktreeListing(repoPath, err)) {
|
|
return []
|
|
}
|
|
// Why: don't swallow git-compat/repo-state failures — else they resurface as opaque "created but not found in listing" errors.
|
|
console.warn(`[git/worktree] listWorktrees failed for ${repoPath}:`, err)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The two failures where an empty listing is the repo's true answer, not a broken scan: the repo
|
|
* path is gone, or it is not a Git repo. Every other failure means the scan could not read Git.
|
|
*/
|
|
async function isTrueEmptyWorktreeListing(repoPath: string, err: unknown): Promise<boolean> {
|
|
if (getErrorCode(err) === 'ENOENT') {
|
|
try {
|
|
await stat(repoPath)
|
|
} catch (statErr) {
|
|
if (getErrorCode(statErr) === 'ENOENT') {
|
|
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return isNotGitRepositoryError(err)
|
|
}
|
|
|
|
export async function listWorktreesStrict(
|
|
repoPath: string,
|
|
options: GitWorktreeExecOptions = {}
|
|
): Promise<GitWorktreeInfo[]> {
|
|
const worktrees = (await readWorktreeList(repoPath, options)).map((worktree) => {
|
|
const translatedPath = translateWorktreePath(worktree.path, repoPath, options)
|
|
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
|
|
})
|
|
const visibleWorktrees = options.includeCreatePreparations
|
|
? worktrees
|
|
: worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree))
|
|
return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options)
|
|
}
|
|
|
|
/**
|
|
* Strict except for the two true empties above.
|
|
*
|
|
* Why: a Git or host failure (dead WSL distro, hung mount) softened to `[]` reaches the detected
|
|
* listing as an *authoritative* empty scan, which then permanently prunes the repo's worktrees and
|
|
* the agent tabs attached to them. Rejecting keeps that listing non-authoritative, while a deleted
|
|
* repo still reports empty so real removals prune.
|
|
*/
|
|
export async function listWorktreesStrictAllowingTrueEmpty(
|
|
repoPath: string,
|
|
options: GitWorktreeExecOptions = {}
|
|
): Promise<GitWorktreeInfo[]> {
|
|
try {
|
|
return await listWorktreesStrict(repoPath, options)
|
|
} catch (err) {
|
|
if (await isTrueEmptyWorktreeListing(repoPath, err)) {
|
|
return []
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export async function annotateSparseCheckoutStatus(
|
|
repoPath: string,
|
|
worktrees: GitWorktreeInfo[],
|
|
options: GitWorktreeExecOptions = {}
|
|
): Promise<GitWorktreeInfo[]> {
|
|
const annotated = [...worktrees]
|
|
let nextIndex = 0
|
|
|
|
async function detectNext(): Promise<void> {
|
|
while (nextIndex < worktrees.length) {
|
|
const index = nextIndex
|
|
nextIndex += 1
|
|
const worktree = worktrees[index]
|
|
if (!worktree || worktree.isBare || worktree.isSparse) {
|
|
continue
|
|
}
|
|
const isSparse = await detectSparseCheckoutCached(repoPath, worktree.path, options)
|
|
if (isSparse) {
|
|
annotated[index] = { ...worktree, isSparse }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Why: cap concurrency so status-poll refreshes don't fan out many sparse-checkout filesystem probes at once.
|
|
const workerCount = Math.min(SPARSE_CHECKOUT_DETECTION_CONCURRENCY, worktrees.length)
|
|
await Promise.all(Array.from({ length: workerCount }, () => detectNext()))
|
|
return annotated
|
|
}
|
|
|
|
/**
|
|
* The repo's common dir from the filesystem, as a second opinion on Git's own reading.
|
|
*
|
|
* Deadlined because a `.git` on a hung mount (dead NFS/SSHFS, stalled WSL 9p) never rejects, and an
|
|
* unbounded read here would leave the whole create IPC pending instead of failing like it used to.
|
|
*/
|
|
async function readRepoCommonDirFromDisk(
|
|
repoPath: string,
|
|
timeoutMs: number
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const dotGit = join(repoPath, '.git')
|
|
// A bare repo has no `.git`, and resolveGitDir would fabricate one; offer no candidate instead.
|
|
await withDeadline(stat(dotGit), timeoutMs)
|
|
const commonDir = await withDeadline(
|
|
resolveGitDir(repoPath).then(resolveGitCommonDir),
|
|
timeoutMs
|
|
)
|
|
// Node answers in the caller's space, Git in the distro's. Without this the WSL candidate is a UNC
|
|
// path that can never equal Git's `/home/...`, leaving this witness inert on exactly the fallback
|
|
// path that needs it (realpath cannot bridge the two: a Linux path has no local inode).
|
|
return toWslExecutionSpace(commonDir)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
async function withDeadline<T>(work: Promise<T>, timeoutMs: number): Promise<T> {
|
|
let timer: NodeJS.Timeout | undefined
|
|
try {
|
|
return await Promise.race([
|
|
work,
|
|
new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error('deadline exceeded')), timeoutMs)
|
|
})
|
|
])
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async function canonicalizeLocalPath(pathValue: string): Promise<string> {
|
|
try {
|
|
return await realpath(pathValue)
|
|
} catch {
|
|
// A path Git reported from another execution space (WSL, SSH) has no local inode.
|
|
return pathValue
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the created worktree's common dir names the same object store as the repo.
|
|
*
|
|
* Why accept any candidate: the readings come from different spaces — Git resolves symlinks and,
|
|
* under WSL, answers in Linux paths, while the filesystem walk keeps the caller's (possibly UNC)
|
|
* spelling. Comparing only one rejected every symlinked-root, WSL, and bare-repo create (#16520).
|
|
*/
|
|
async function isSameRepoCommonDir(
|
|
createdCommonDir: string,
|
|
candidates: readonly (string | undefined)[]
|
|
): Promise<boolean> {
|
|
const present = candidates.filter((candidate): candidate is string => Boolean(candidate))
|
|
if (present.some((candidate) => isSameCommonDirPath(createdCommonDir, candidate))) {
|
|
return true
|
|
}
|
|
const [canonicalCreated, ...canonicalCandidates] = await Promise.all(
|
|
[createdCommonDir, ...present].map(canonicalizeLocalPath)
|
|
)
|
|
return canonicalCandidates.some((candidate) => isSameCommonDirPath(canonicalCreated, candidate))
|
|
}
|
|
|
|
/**
|
|
* Why not areWorktreePathsEqual alone: it folds case for every path once the host is Windows, so on
|
|
* a Windows desktop two WSL repos differing only in case would be accepted as the same object store.
|
|
*/
|
|
export function isSameCommonDirPath(
|
|
left: string,
|
|
right: string,
|
|
platform = process.platform
|
|
): boolean {
|
|
const leftIsPosix = isPosixAbsolutePath(left)
|
|
if (leftIsPosix || isPosixAbsolutePath(right)) {
|
|
return leftIsPosix && isPosixAbsolutePath(right) && posix.resolve(left) === posix.resolve(right)
|
|
}
|
|
return areWorktreePathsEqual(left, right, platform)
|
|
}
|
|
|
|
function isPosixAbsolutePath(pathValue: string): boolean {
|
|
return pathValue.startsWith('/') && !pathValue.startsWith('//')
|
|
}
|
|
|
|
/**
|
|
* Reconstruct the listing row for a worktree `git worktree add` just created, by asking Git about
|
|
* the worktree itself. Used when the listing fails or omits it, so a create does not abandon a
|
|
* worktree Git already wrote to disk (#16520). Returns undefined unless Git resolves the path into
|
|
* this repo's object store with the expected branch checked out.
|
|
*/
|
|
export async function describeCreatedWorktree(
|
|
repoPath: string,
|
|
worktreePath: string,
|
|
branch: string,
|
|
options: GitWorktreeExecOptions = {}
|
|
): Promise<GitWorktreeInfo | undefined> {
|
|
const expectedRef = `refs/heads/${normalizeLocalBranchRef(branch)}`
|
|
// Bound Git recovery after the bounded listing failed; filesystem canonicalization stays best effort.
|
|
const deadlined: GitWorktreeExecOptions = {
|
|
...options,
|
|
timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
|
|
}
|
|
const [created, repoGitCommonDir, checkedOutRef, head] = await Promise.all([
|
|
readRepoLocation(worktreePath, toWslExecutionSpace(worktreePath), deadlined),
|
|
readRepoCommonDirFromGit(repoPath, deadlined),
|
|
readCheckedOutBranchRef(worktreePath, deadlined),
|
|
readWorktreeHeadOid(worktreePath, deadlined)
|
|
])
|
|
// An unreadable HEAD means Git could not confirm the worktree, so report nothing rather than a blank OID.
|
|
if (!created || checkedOutRef !== expectedRef || !head) {
|
|
return undefined
|
|
}
|
|
if (!(await isSameRepoCommonDir(created.commonDir, [repoGitCommonDir]))) {
|
|
// Only now read the second opinion from disk: a `.git` on a hung mount pins a threadpool thread
|
|
// that no deadline can reclaim, so never pay that on the path where Git already agreed.
|
|
const repoDiskCommonDir = await readRepoCommonDirFromDisk(
|
|
repoPath,
|
|
deadlined.timeout ?? WORKTREE_LIST_TIMEOUT_MS
|
|
)
|
|
if (!(await isSameRepoCommonDir(created.commonDir, [repoDiskCommonDir]))) {
|
|
return undefined
|
|
}
|
|
}
|
|
const [described] = await annotateSparseCheckoutStatus(
|
|
repoPath,
|
|
[
|
|
{
|
|
path: translateWorktreePath(created.topLevel, repoPath, options),
|
|
head,
|
|
branch: expectedRef,
|
|
isBare: false,
|
|
// `git worktree add` only ever produces a linked worktree.
|
|
isMainWorktree: false
|
|
}
|
|
],
|
|
options
|
|
)
|
|
return described
|
|
}
|