mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
* perf(git): reuse queries and stop canceled catalog scans * refactor(git): check relay filesystem error codes
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
|
import { readFile, realpath, stat } from 'node:fs/promises'
|
|
import { join, posix } from 'node:path'
|
|
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
|
|
import { resolveGitMetadataPath } from '../../shared/git-metadata-path'
|
|
import { parseGitdirMarkerPayload } from '../../shared/gitdir-marker-payload'
|
|
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'
|
|
|
|
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) {
|
|
throwIfSignalAborted(options.signal)
|
|
const index = nextIndex
|
|
nextIndex += 1
|
|
const worktree = worktrees[index]
|
|
if (!worktree || worktree.isBare || worktree.isSparse) {
|
|
continue
|
|
}
|
|
const isSparse = await detectSparseCheckoutCached(repoPath, worktree.path, options)
|
|
throwIfSignalAborted(options.signal)
|
|
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 waitForPromiseWithSignal(
|
|
Promise.all(Array.from({ length: workerCount }, () => detectNext())),
|
|
options.signal
|
|
)
|
|
throwIfSignalAborted(options.signal)
|
|
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.
|
|
*
|
|
* A missing `.git` is a real "no candidate"; every other read failure is unverifiable and rejects.
|
|
*/
|
|
async function readRepoCommonDirFromDisk(
|
|
repoPath: string,
|
|
timeoutMs: number
|
|
): Promise<string | undefined> {
|
|
const dotGit = join(repoPath, '.git')
|
|
try {
|
|
const commonDir = await withDeadline(resolveRepoCommonDirFromDisk(repoPath, dotGit), timeoutMs)
|
|
return commonDir ? toWslExecutionSpace(commonDir) : undefined
|
|
} catch (error) {
|
|
// A bare repo has no `.git`; do not fabricate a candidate for it.
|
|
if (isDefinitiveAbsence(error)) {
|
|
return undefined
|
|
}
|
|
const reason = error instanceof Error ? error.message : String(error)
|
|
throw new Error(`repo common dir unverifiable: could not read ${dotGit}: ${reason}`, {
|
|
cause: error
|
|
})
|
|
}
|
|
}
|
|
|
|
async function resolveRepoCommonDirFromDisk(
|
|
repoPath: string,
|
|
dotGit: string
|
|
): Promise<string | undefined> {
|
|
// The general metadata resolvers are intentionally best effort; a witness must preserve read failures.
|
|
const dotGitStats = await stat(dotGit)
|
|
let gitDir = dotGit
|
|
if (!dotGitStats.isDirectory()) {
|
|
const pointer = parseGitdirMarkerPayload(await readFile(dotGit, 'utf8'))
|
|
if (!pointer) {
|
|
return undefined
|
|
}
|
|
gitDir = resolveGitMetadataPath(repoPath, pointer) ?? dotGit
|
|
await assertGitDirIsDirectory(gitDir)
|
|
}
|
|
|
|
return readCommonDirMarker(gitDir)
|
|
}
|
|
|
|
/**
|
|
* A marker target that is missing or is not a directory is unverifiable, not an absent `.git`:
|
|
* without this, `commondir`'s own ENOENT/ENOTDIR would pass as absence and hand the caller the
|
|
* pointer target as a common dir it never proved exists.
|
|
*/
|
|
async function assertGitDirIsDirectory(gitDir: string): Promise<void> {
|
|
let gitDirStats
|
|
try {
|
|
gitDirStats = await stat(gitDir)
|
|
} catch (error) {
|
|
// Rewrapped so the outer absence check cannot read this errno as a bare repo's missing `.git`.
|
|
throw new Error(`gitdir marker target unreadable: ${gitDir}`, { cause: error })
|
|
}
|
|
if (!gitDirStats.isDirectory()) {
|
|
throw new Error(`gitdir marker target is not a directory: ${gitDir}`)
|
|
}
|
|
}
|
|
|
|
async function readCommonDirMarker(gitDir: string): Promise<string> {
|
|
try {
|
|
const pointer = await readFile(join(gitDir, 'commondir'), 'utf8')
|
|
return resolveGitMetadataPath(gitDir, pointer) ?? gitDir
|
|
} catch (error) {
|
|
if (!isDefinitiveAbsence(error)) {
|
|
throw error
|
|
}
|
|
return gitDir
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|