mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Every porcelain-v2 `u` record already carries `mW`, the working-tree mode Git stat'ed for that row: `000000` means the conflicted path is absent. Reading it replaces the per-conflict `fs.access`, so the bounded-concurrency resolver, its `= 8` cap, and the order/error-precedence invariant are unnecessary rather than cheaper. `access()` stays only as a fallback for a malformed `mW`, so `parseUnmergedEntry` keeps its signature and neither status-read.ts nor the relay loop changes. Also corrects two fixtures that encoded `mW=100644` for a file that does not exist, which real Git never emits.
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
import type {
|
|
GitStatusEntry,
|
|
GitStatusResult,
|
|
GitUpstreamStatus
|
|
} from '../../../shared/git-status-types'
|
|
import { StatusPorcelainParser } from '../../../shared/git-status-porcelain-parser'
|
|
import { resolveGitStatusLimit } from '../../../shared/git-status-limit'
|
|
import { stableInFlightKey } from '../../../shared/in-flight-promise-dedupe'
|
|
import type { GitBranchLineTotal } from '../../../shared/git-branch-line-total'
|
|
import {
|
|
beginGitStatusLineStatsCacheWrite,
|
|
clearGitStatusLineStatsCacheKey,
|
|
reuseOrRecomputeGitStatusLineStats
|
|
} from '../../../shared/git-status-line-stats-cache'
|
|
import { resolveWorktreeHostPath } from '../../../shared/git-metadata-path'
|
|
import { gitOptionalLocksDisabledEnv, gitStreamStdout } from '../runner'
|
|
import { findExistingWorktreeSymlinkPaths } from '../worktree-symlink-detection'
|
|
import type { GetStatusOptions } from './get-status-options'
|
|
import { statusReadLeaseOwner } from './git-read-cache-invalidation'
|
|
import { detectConflictOperation } from './git-conflict-operation'
|
|
import { parseUnmergedEntry } from '../../../shared/git-status-conflict-entries'
|
|
import { getEffectiveUpstreamStatusCacheKey } from './effective-upstream-status-cache'
|
|
import {
|
|
getShortBranchName,
|
|
readOrProbeEffectiveUpstreamStatus,
|
|
shouldProbeEffectiveUpstreamStatus
|
|
} from './effective-upstream-status-probe'
|
|
import { attachLineStats } from './status-line-stats'
|
|
import {
|
|
createBranchLineTotalInput,
|
|
getStatusLineStatsCacheKey
|
|
} from './status-branch-line-total-input'
|
|
|
|
/**
|
|
* Parse `git status --porcelain=v2` output into structured entries.
|
|
*/
|
|
export async function getStatus(
|
|
worktreePath: string,
|
|
options: GetStatusOptions = {}
|
|
): Promise<GitStatusResult> {
|
|
// Why nothing is cleared here: a status poll is a read. Dropping the in-flight diff entry
|
|
// mid-read only made a concurrent identical request start duplicate git work, and the
|
|
// settled diff cache is keyed on stamped git state, which a read cannot change anyway.
|
|
// Mutations invalidate both, through invalidateGitReadCaches.
|
|
const cacheKey = getStatusReadKey(worktreePath, options)
|
|
return statusReadLeaseOwner.lease(cacheKey, options.signal, (sharedSignal) =>
|
|
runGetStatus(worktreePath, { ...options, signal: sharedSignal })
|
|
)
|
|
}
|
|
|
|
function getStatusReadKey(worktreePath: string, options: GetStatusOptions): string {
|
|
// Why: each key part can change the output shape or runtime routing.
|
|
const limit = resolveGitStatusLimit(options.limit)
|
|
return stableInFlightKey([
|
|
worktreePath,
|
|
options.wslDistro ?? '',
|
|
options.admissionTier ?? 'status',
|
|
options.includeIgnored === true,
|
|
options.includeLineStats !== false,
|
|
options.reuseLineStats === true,
|
|
// Why: the result carries a total only for callers who asked, and only for
|
|
// this fork point, so a shared lease must never serve one to the other.
|
|
options.branchLineTotalMergeBase ?? '',
|
|
options.bypassEffectiveUpstreamNegativeCache === true,
|
|
limit,
|
|
// Why: this changes which entries survive, so it must not share a cache slot.
|
|
options.sharedLinkPaths ?? []
|
|
])
|
|
}
|
|
|
|
/** Remove untracked entries that are shared symlinks Orca created.
|
|
*
|
|
* Why this can't be left to Git: a directory-only ignore rule (`node_modules/`)
|
|
* matches the primary checkout's real directory but never the worktree's
|
|
* symlink, so Git reports it untracked forever — a phantom row in the diff and
|
|
* a permanently "dirty" worktree.
|
|
*
|
|
* Tight on both axes: an entry must be configured as shared *and* actually be a
|
|
* symlink. A regular file the user created at a configured name still shows up,
|
|
* and so does a symlink at a path nobody declared shared. Mutates `entries`. */
|
|
async function dropSharedSymlinkUntrackedEntries(
|
|
worktreePath: string,
|
|
entries: GitStatusEntry[],
|
|
options: GetStatusOptions
|
|
): Promise<void> {
|
|
const sharedLinkPaths = options.sharedLinkPaths ?? []
|
|
// Why: a clean tree has no untracked entries, so this costs nothing on the
|
|
// common status-poll path — no syscall, no config read, no subprocess.
|
|
if (sharedLinkPaths.length === 0 || !entries.some((entry) => entry.area === 'untracked')) {
|
|
return
|
|
}
|
|
const sharedLinks = new Set(
|
|
await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths, {
|
|
wslDistro: options.wslDistro
|
|
})
|
|
)
|
|
if (sharedLinks.size === 0) {
|
|
return
|
|
}
|
|
for (let index = entries.length - 1; index >= 0; index--) {
|
|
const entry = entries[index]
|
|
if (entry.area === 'untracked' && sharedLinks.has(entry.path)) {
|
|
entries.splice(index, 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runGetStatus(
|
|
worktreePath: string,
|
|
options: GetStatusOptions = {}
|
|
): Promise<GitStatusResult> {
|
|
const lineStatsCacheKey = getStatusLineStatsCacheKey(worktreePath, options)
|
|
const lineStatsWriteToken =
|
|
options.includeLineStats === false ? null : beginGitStatusLineStatsCacheWrite(lineStatsCacheKey)
|
|
let effectiveUpstreamStatus: GitUpstreamStatus | undefined
|
|
let statusSucceeded = false
|
|
// Why: a bad limit (negative/fractional/NaN) breaks early-stop; require a valid non-negative int (0 disables the cap).
|
|
const limit = resolveGitStatusLimit(options.limit)
|
|
|
|
// Why: detectConflictOperation and git status are independent, so run them concurrently to save I/O latency.
|
|
const conflictPromise = detectConflictOperation(worktreePath, options)
|
|
// Why: core.quotePath=false keeps non-ASCII paths as raw UTF-8, not octal escapes, so entry.path is readable and lookups match.
|
|
const statusArgs = [
|
|
'-c',
|
|
'core.quotePath=false',
|
|
'status',
|
|
'--porcelain=v2',
|
|
'--branch',
|
|
'--untracked-files=all'
|
|
]
|
|
if (options.includeIgnored) {
|
|
statusArgs.push('--ignored=matching')
|
|
}
|
|
|
|
// Why: stream + parse and stop at `limit` so a huge un-ignored folder can't buffer enough to crash the process.
|
|
const parser = new StatusPorcelainParser()
|
|
let didHitLimit = false
|
|
// Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled.
|
|
const statusSettlementPromise = Promise.allSettled([
|
|
(async () => {
|
|
const result = await gitStreamStdout(statusArgs, {
|
|
cwd: worktreePath,
|
|
wslDistro: options.wslDistro,
|
|
admissionTier: options.admissionTier,
|
|
preferWslDirectGit: true,
|
|
// Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock.
|
|
env: gitOptionalLocksDisabledEnv(),
|
|
signal: options.signal,
|
|
onStdout: (chunk) => parser.update(chunk, limit)
|
|
})
|
|
if (!result.stoppedEarly) {
|
|
parser.finish()
|
|
}
|
|
return result
|
|
})()
|
|
])
|
|
const conflictOperation = await conflictPromise
|
|
|
|
try {
|
|
const [statusResult] = await statusSettlementPromise
|
|
if (statusResult.status === 'rejected') {
|
|
throw statusResult.reason
|
|
}
|
|
didHitLimit = statusResult.value.stoppedEarly
|
|
statusSucceeded = true
|
|
} catch (error) {
|
|
// Why: an aborted scan must reject, not resolve as an empty result.
|
|
if (options.signal?.aborted) {
|
|
throw error
|
|
}
|
|
// Not a git repo or git not available
|
|
}
|
|
|
|
const entries: GitStatusEntry[] = []
|
|
const { head, branch, upstreamName, upstreamAheadBehind } = parser.branch
|
|
// Why: git runs in the distro and answers in its namespace; the working-tree probes below run here.
|
|
const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) ?? worktreePath
|
|
|
|
// Why: resolve deferred conflicts in Git's output order so the cap cannot hide
|
|
// an early conflict behind ordinary rows that appeared later in the stream.
|
|
for (const record of parser.statusRecords) {
|
|
if (didHitLimit && entries.length >= limit) {
|
|
break
|
|
}
|
|
if (record.type === 'entry') {
|
|
entries.push(record.entry)
|
|
} else {
|
|
const unmergedEntry = await parseUnmergedEntry(hostWorktreePath, record.line)
|
|
if (unmergedEntry) {
|
|
entries.push(unmergedEntry)
|
|
}
|
|
}
|
|
}
|
|
|
|
await dropSharedSymlinkUntrackedEntries(worktreePath, entries, options)
|
|
|
|
if (statusSucceeded && !didHitLimit && shouldProbeEffectiveUpstreamStatus(branch, upstreamName)) {
|
|
const branchName = getShortBranchName(branch)
|
|
if (branchName) {
|
|
const cacheKey = getEffectiveUpstreamStatusCacheKey(
|
|
worktreePath,
|
|
branchName,
|
|
upstreamName,
|
|
options
|
|
)
|
|
try {
|
|
// Why: the shared probe/caches serve concurrent reads, so run it unbound from this signal — one abort mustn't reject it for others.
|
|
const { signal: _requestSignal, ...sharedProbeOptions } = options
|
|
effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus(
|
|
cacheKey,
|
|
worktreePath,
|
|
branchName,
|
|
sharedProbeOptions,
|
|
options.bypassEffectiveUpstreamNegativeCache === true
|
|
)
|
|
} catch {
|
|
// Why: don't fail status polling on a transient upstream-probe error; the explicit upstream path surfaces those.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set.
|
|
let branchLineTotal: GitBranchLineTotal | undefined
|
|
if (!didHitLimit && lineStatsWriteToken !== null) {
|
|
const branchLineTotalInput = createBranchLineTotalInput(
|
|
worktreePath,
|
|
entries,
|
|
options,
|
|
statusSucceeded
|
|
)
|
|
const lineStats = await reuseOrRecomputeGitStatusLineStats({
|
|
cacheKey: lineStatsCacheKey,
|
|
head,
|
|
entries,
|
|
writeToken: lineStatsWriteToken,
|
|
reuse: options.reuseLineStats === true,
|
|
isAborted: () => options.signal?.aborted === true,
|
|
recompute: () => attachLineStats(worktreePath, entries, options),
|
|
...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {})
|
|
})
|
|
branchLineTotal = lineStats.branchLineTotal
|
|
} else if (lineStatsWriteToken !== null) {
|
|
clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken)
|
|
}
|
|
|
|
// Why: an abort after the stream (unmerged/upstream/line-stats work) must still reject, not resolve.
|
|
if (options.signal?.aborted) {
|
|
const error = new Error('The operation was aborted.')
|
|
error.name = 'AbortError'
|
|
throw error
|
|
}
|
|
|
|
return {
|
|
entries,
|
|
conflictOperation,
|
|
head,
|
|
branch,
|
|
...(options.includeIgnored ? { ignoredPaths: parser.ignoredPaths } : {}),
|
|
...(branchLineTotal ? { branchLineTotal } : {}),
|
|
...(didHitLimit ? { didHitLimit: true, statusLength: parser.statusLength } : {}),
|
|
...(statusSucceeded
|
|
? {
|
|
upstreamStatus:
|
|
effectiveUpstreamStatus ??
|
|
(upstreamName
|
|
? {
|
|
hasUpstream: true,
|
|
upstreamName,
|
|
ahead: upstreamAheadBehind?.ahead ?? 0,
|
|
behind: upstreamAheadBehind?.behind ?? 0
|
|
}
|
|
: { hasUpstream: false, ahead: 0, behind: 0 })
|
|
}
|
|
: {})
|
|
}
|
|
}
|