mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Untracked line counts, and the untracked share of the branch line total, come
from direct lstat/open calls rather than from git. When git executes inside a
WSL distro the worktree path can be a guest path, which Win32 reads as
drive-relative (`/home/me/repo`) or as a literal `C:\mnt\c\repo`. Every lstat
then fails, countFileAdditions swallows the error into `{}`, the file renders
with no +N, and the branch total silently undercounts it.
Route only those two filesystem reads through resolveWorktreeFilesystemPath,
which translates a guest-rooted path via the shared resolveGitMetadataPath.
Both call sites produce the same string, so the stat-keyed untracked cache
still hits across them. resolveGitMetadataPath itself is not modified, so its
other callers are untouched.
The wrapper translates only when the host is win32 and the path is a guest
spelling: exactly one leading slash, and unchanged by trim(). Each condition
is load-bearing.
- `//wsl.localhost/...` and `//wsl$/...` are UNC spellings that also start with
`/`, and translating one prepends the distro root a second time, so a
worktree that reads fine today would ENOENT on every untracked file. The same
single-leading-slash guard is used, for the same reason, by
resolveWslRepoWorktreeBasePath in src/shared/wsl-paths.ts.
- resolveGitMetadataPath returns `rawPath.trim()`, so a worktree directory name
with leading or trailing whitespace (legal on ext4) would be re-spelt onto a
different directory. Leaving those verbatim keeps them exactly as they read
today.
- Off win32 the resolver is already an identity for these inputs; the platform
check makes the macOS/Linux no-op structural rather than derived.
Per platform:
- Windows + WSL, worktree spelled as a guest path: untracked +N appears and the
branch total includes it.
- Windows + WSL, worktree already spelled `\\wsl.localhost\...`,
`//wsl.localhost/...` or `//wsl$/...`: unchanged, returned verbatim.
- Native Windows: `C:\...` is unchanged. One shape does change: a
`/mnt/<drive>/...` worktree now resolves to `<Drive>:\...` instead of being
passed through to a guaranteed lstat failure. Native Windows does not produce
that spelling, and if it were reached the new result is the correct file.
- macOS / Linux: unchanged; not win32, returned verbatim, whitespace included.
- SSH / relay: unchanged. Remote status runs in the relay, which builds its own
branch-total input and does not pass filesystemWorktreePath.
- Folder workspaces / GitLab: unaffected, no workspace-kind or provider
behavior is touched.
No fail-closed degradation: attachLineStats still returns
`stagedStats !== null && unstagedStats !== null`, and createBranchLineTotalInput
still has no early return. The wrapper returns `string`, never null, so an
unmappable worktree keeps its old spelling and its old (missing) untracked
counts rather than dropping the staged/unstaged counts as well.
The `filesystemWorktreePath` field on computeGitBranchLineTotal is optional and
does not touch the lease key, so the coalescing/cooldown identity is unchanged.
Co-authored-by: Neil <neil@orca.local>
243 lines
9.3 KiB
TypeScript
243 lines
9.3 KiB
TypeScript
import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner'
|
|
import type { GitBranchLineTotal } from './git-status-types'
|
|
import {
|
|
collectUntrackedAdditions,
|
|
parseNumstat,
|
|
type GitLineStats
|
|
} from './git-uncommitted-line-stats'
|
|
import { isGeneratedCodePath } from './generated-code-path'
|
|
import { isTestCodePath } from './test-code-path'
|
|
|
|
export type { GitBranchLineTotal }
|
|
|
|
// Why: a mergeBase→worktree diff on a very large branch over SSH is unbounded
|
|
// work. Past this budget the chip is dropped rather than the status response
|
|
// blocked — an absent total is the documented "not known exact" rendering.
|
|
export const GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS = 15_000
|
|
|
|
// Why: the hard timeout bounds the git process, not the wait. The status
|
|
// response carries the file list and staging state, so it must never sit behind
|
|
// a ranged diff — past this budget the pass publishes without the total and the
|
|
// diff keeps running to backfill the cache for the next pass.
|
|
export const GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS = 500
|
|
|
|
const BRANCH_LINE_TOTAL_PENDING = Symbol('branch-line-total-pending')
|
|
|
|
/**
|
|
* Resolves with the total only if it lands inside the soft budget; otherwise
|
|
* resolves undefined and hands the still-running diff to `onLateArrival`.
|
|
*/
|
|
export async function settleGitBranchLineTotalWithinSoftDeadline(input: {
|
|
total: Promise<GitBranchLineTotal | undefined>
|
|
onLateArrival: (total: GitBranchLineTotal) => void
|
|
softDeadlineMs?: number
|
|
}): Promise<GitBranchLineTotal | undefined> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
const deadline = new Promise<typeof BRANCH_LINE_TOTAL_PENDING>((resolve) => {
|
|
timer = setTimeout(
|
|
() => resolve(BRANCH_LINE_TOTAL_PENDING),
|
|
input.softDeadlineMs ?? GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS
|
|
)
|
|
// Why: a pending status timer must not hold the process open at shutdown.
|
|
timer.unref?.()
|
|
})
|
|
const settled = await Promise.race([input.total, deadline])
|
|
clearTimeout(timer)
|
|
if (settled !== BRANCH_LINE_TOTAL_PENDING) {
|
|
return settled
|
|
}
|
|
void input.total.then((late) => {
|
|
if (late) {
|
|
input.onLateArrival(late)
|
|
}
|
|
})
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* `git diff <mergeBase>` with no second rev and no `--cached` compares that
|
|
* commit to the working tree, so committed + staged + unstaged collapse into one
|
|
* correctly-deduplicated result. Summing the per-area status rows instead would
|
|
* double-count any line touched in two areas.
|
|
*
|
|
* `-M` only (not `-M -C`): matches the working-tree numstat the CHANGES rows are
|
|
* built from, and avoids `-C`'s cost on wide diffs. The trailing `--` keeps the
|
|
* OID parsed as a rev even if a path of the same name exists.
|
|
*/
|
|
export function buildGitBranchLineTotalDiffArgs(mergeBase: string): string[] {
|
|
return ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--']
|
|
}
|
|
|
|
/**
|
|
* The merge base reaches the host as untrusted RPC input and is spliced into a
|
|
* git argv. Only an object name shape is ever legitimate here, so anything else
|
|
* (notably a leading `-`) is rejected before it can act as a flag.
|
|
*/
|
|
export function isGitBranchLineTotalMergeBase(value: unknown): value is string {
|
|
return typeof value === 'string' && /^[0-9a-f]{7,64}$/.test(value)
|
|
}
|
|
|
|
/** Reads the request param without trusting its type; undefined disables the work entirely. */
|
|
export function readGitBranchLineTotalMergeBaseParam(value: unknown): string | undefined {
|
|
return isGitBranchLineTotalMergeBase(value) ? value : undefined
|
|
}
|
|
|
|
export function sumGitBranchLineTotal(input: {
|
|
mergeBase: string
|
|
tracked: ReadonlyMap<string, GitLineStats>
|
|
untracked: ReadonlyMap<string, GitLineStats>
|
|
}): GitBranchLineTotal {
|
|
let added = 0
|
|
let removed = 0
|
|
let testAdded = 0
|
|
let testRemoved = 0
|
|
let generatedAdded = 0
|
|
let generatedRemoved = 0
|
|
for (const source of [input.tracked, input.untracked]) {
|
|
for (const [filePath, stats] of source) {
|
|
// Binary files parse to undefined in numstat and contribute nothing, matching
|
|
// the per-file rows.
|
|
const fileAdded = stats.added ?? 0
|
|
const fileRemoved = stats.removed ?? 0
|
|
added += fileAdded
|
|
removed += fileRemoved
|
|
// Generated wins the overlap (a snapshot is both): the point of the bucket
|
|
// is to separate authored lines from churn, and a regenerated file is churn
|
|
// wherever it lives.
|
|
if (isGeneratedCodePath(filePath)) {
|
|
generatedAdded += fileAdded
|
|
generatedRemoved += fileRemoved
|
|
} else if (isTestCodePath(filePath)) {
|
|
testAdded += fileAdded
|
|
testRemoved += fileRemoved
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
added,
|
|
removed,
|
|
mergeBase: input.mergeBase,
|
|
test: { added: testAdded, removed: testRemoved },
|
|
generated: { added: generatedAdded, removed: generatedRemoved }
|
|
}
|
|
}
|
|
|
|
// Why: one shared exec per (host, worktree, mergeBase). The renderer's own
|
|
// in-flight refs only dedupe a single renderer's calls; a second window or an
|
|
// fs-watcher burst would otherwise run the ranged diff twice concurrently.
|
|
const rangedNumstatLeaseOwner = new GitStatusReadLeaseOwner<Map<string, GitLineStats> | null>()
|
|
|
|
// Why: the soft deadline hides this diff's cost from the poller's duration-aware
|
|
// backoff, so an overrunning one would restart the moment it finished. Make it
|
|
// wait out its own measured cost; the cache carry-forward keeps the chip up.
|
|
const GIT_BRANCH_LINE_TOTAL_MAX_COOLDOWN_MS = 30_000
|
|
const GIT_BRANCH_LINE_TOTAL_COOLDOWN_MAX_KEYS = 256
|
|
const rangedNumstatCooldownUntilMs = new Map<string, number>()
|
|
|
|
const monotonicNowMs = (): number => performance.now()
|
|
|
|
function isRangedNumstatCoolingDown(leaseKey: string, nowMs: number): boolean {
|
|
const readyAtMs = rangedNumstatCooldownUntilMs.get(leaseKey)
|
|
if (readyAtMs === undefined) {
|
|
return false
|
|
}
|
|
if (nowMs >= readyAtMs) {
|
|
rangedNumstatCooldownUntilMs.delete(leaseKey)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function recordRangedNumstatDuration(leaseKey: string, durationMs: number, nowMs: number): void {
|
|
if (durationMs <= GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS) {
|
|
rangedNumstatCooldownUntilMs.delete(leaseKey)
|
|
return
|
|
}
|
|
rangedNumstatCooldownUntilMs.delete(leaseKey)
|
|
rangedNumstatCooldownUntilMs.set(
|
|
leaseKey,
|
|
nowMs + Math.min(durationMs, GIT_BRANCH_LINE_TOTAL_MAX_COOLDOWN_MS)
|
|
)
|
|
while (rangedNumstatCooldownUntilMs.size > GIT_BRANCH_LINE_TOTAL_COOLDOWN_MAX_KEYS) {
|
|
const oldestKey = rangedNumstatCooldownUntilMs.keys().next().value
|
|
if (oldestKey === undefined) {
|
|
return
|
|
}
|
|
rangedNumstatCooldownUntilMs.delete(oldestKey)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call from every path that clears the other git read caches: without it a pass
|
|
* beginning after a discard/stash/checkout joins the pre-mutation lease.
|
|
*/
|
|
export function invalidateGitBranchLineTotalInFlight(): void {
|
|
rangedNumstatLeaseOwner.invalidate()
|
|
rangedNumstatCooldownUntilMs.clear()
|
|
}
|
|
|
|
/**
|
|
* Computes the branch total, or undefined when it cannot be known exact.
|
|
* Rejects only on abort — every other failure hides the chip instead of
|
|
* publishing a partial number.
|
|
*/
|
|
export async function computeGitBranchLineTotal(input: {
|
|
worktreePath: string
|
|
/** Spelling for the direct untracked-file reads when the git host's differs; defaults to worktreePath. */
|
|
filesystemWorktreePath?: string
|
|
/** Distinguishes hosts that can map the same path to different filesystems (WSL distro, relay). */
|
|
hostKey: string
|
|
mergeBase: string
|
|
untrackedPaths: readonly string[]
|
|
runDiffNumstat: (args: string[], signal: AbortSignal) => Promise<string>
|
|
signal?: AbortSignal
|
|
}): Promise<GitBranchLineTotal | undefined> {
|
|
if (!isGitBranchLineTotalMergeBase(input.mergeBase)) {
|
|
return undefined
|
|
}
|
|
const leaseKey = `${input.hostKey}\0${input.worktreePath}\0${input.mergeBase}`
|
|
// Safe before the lease: a cooldown is only armed once a diff settled, so there
|
|
// is never an in-flight one to join while it is active.
|
|
if (isRangedNumstatCoolingDown(leaseKey, monotonicNowMs())) {
|
|
return undefined
|
|
}
|
|
const [tracked, untracked] = await Promise.all([
|
|
rangedNumstatLeaseOwner.lease(leaseKey, input.signal, async (sharedSignal) => {
|
|
const startedAtMs = monotonicNowMs()
|
|
try {
|
|
const stdout = await input.runDiffNumstat(
|
|
buildGitBranchLineTotalDiffArgs(input.mergeBase),
|
|
sharedSignal
|
|
)
|
|
return parseNumstat(stdout)
|
|
} catch (error) {
|
|
// Why: an aborted pass must reject so a cancelled scan is never treated
|
|
// as completed. Everything else — a bad merge base, a timeout, a
|
|
// detached worktree — yields null so the field is omitted, not zeroed.
|
|
if (sharedSignal.aborted) {
|
|
throw error
|
|
}
|
|
return null
|
|
} finally {
|
|
// `finally`, not the success path: a timeout is the costliest outcome.
|
|
if (!sharedSignal.aborted) {
|
|
const settledAtMs = monotonicNowMs()
|
|
recordRangedNumstatDuration(leaseKey, settledAtMs - startedAtMs, settledAtMs)
|
|
}
|
|
}
|
|
}),
|
|
// Untracked files are invisible to a ranged diff but already render as
|
|
// CHANGES rows, so they are added on top. Stat-keyed caching inside makes
|
|
// this near-free when attachLineStats just read the same paths.
|
|
collectUntrackedAdditions(
|
|
input.filesystemWorktreePath ?? input.worktreePath,
|
|
input.untrackedPaths,
|
|
input.signal
|
|
)
|
|
])
|
|
if (tracked === null) {
|
|
return undefined
|
|
}
|
|
return sumGitBranchLineTotal({ mergeBase: input.mergeBase, tracked, untracked })
|
|
}
|