/** * Status and conflict-detection operations extracted from git-handler.ts. * Why: split to satisfy oxlint max-lines (300); pure data ops on git state, no class coupling. */ import * as path from 'node:path' import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { parseUnmergedEntry } from './git-handler-utils' import type { GitExec } from './git-handler-ops' import type { RelayGitStreamExec } from './git-stdout-stream' import type { GitUpstreamStatus } from '../shared/git-status-types' import { StatusPorcelainParser } from '../shared/git-status-porcelain-parser' import { splitRemoteBranchName } from '../shared/git-effective-upstream' import { collectGitStatusLineStatInputs } from '../shared/git-status-line-stat-inputs' import { readOrProbeNoEffectiveUpstreamStatus } from './git-status-upstream-negative-cache' import { applyLineStats, collectUntrackedAdditions, parseNumstat, type GitLineStats } from '../shared/git-uncommitted-line-stats' import { resolveGitStatusLimit } from '../shared/git-status-limit' import { beginGitStatusLineStatsCacheWrite, clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../shared/git-status-line-stats-cache' import { readGitBranchLineTotalMergeBaseParam, type GitBranchLineTotal } from '../shared/git-branch-line-total' import { buildBranchLineTotalInput } from './git-status-branch-line-total' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') try { const contents = await readFile(dotGitPath, 'utf-8') const match = contents.match(/^gitdir:\s*(.+)\s*$/m) if (match) { return path.resolve(worktreePath, match[1]) } } catch { // .git is a directory, not a file } return dotGitPath } export async function detectConflictOperation(worktreePath: string): Promise { const gitDir = await resolveGitDir(worktreePath) try { if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) { return 'merge' } if ( existsSync(path.join(gitDir, 'rebase-merge')) || existsSync(path.join(gitDir, 'rebase-apply')) ) { return 'rebase' } if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) { return 'cherry-pick' } } catch { // fs error — treat as no conflict operation } return 'unknown' } export async function getStatusOp( git: GitExec, streamGit: RelayGitStreamExec, params: Record, options: { signal?: AbortSignal } = {} ): Promise<{ entries: Record[] conflictOperation: string head?: string branch?: string upstreamStatus?: GitUpstreamStatus ignoredPaths?: string[] didHitLimit?: boolean statusLength?: number branchLineTotal?: GitBranchLineTotal }> { const worktreePath = params.worktreePath as string const lineStatsCacheKey = `relay\0${worktreePath}` const lineStatsWriteToken = params.includeLineStats === false ? null : beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true // Why: untrusted RPC input spliced into a git argv — only an OID shape may pass. const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam( params.branchLineTotalMergeBase ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) const conflictPromise = detectConflictOperation(worktreePath) // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. const statusArgs = [ '-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all' ] if (includeIgnored) { statusArgs.push('--ignored=matching') } // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. const statusSettlementPromise = Promise.allSettled([ (async () => { const parser = new StatusPorcelainParser() const result = await streamGit(statusArgs, worktreePath, { // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. disableOptionalLocks: true, signal: options.signal, onStdout: (chunk) => parser.update(chunk, limit) }) if (!result.stoppedEarly) { parser.finish() } return { parser, stoppedEarly: result.stoppedEarly } })() ]) const conflictOperation = await conflictPromise const entries: Record[] = [] let head: string | undefined let branch: string | undefined let upstreamStatus: GitUpstreamStatus | undefined let ignoredPaths: string[] = [] let didHitLimit = false let statusLength = 0 let statusSucceeded = false let branchLineTotal: GitBranchLineTotal | undefined try { const [statusResult] = await statusSettlementPromise if (statusResult.status === 'rejected') { throw statusResult.reason } const { parser, stoppedEarly } = statusResult.value head = parser.branch.head branch = parser.branch.branch ignoredPaths = parser.ignoredPaths statusLength = parser.statusLength didHitLimit = stoppedEarly statusSucceeded = true const { upstreamName, upstreamAheadBehind } = parser.branch upstreamStatus = upstreamName ? { hasUpstream: true, upstreamName, ahead: upstreamAheadBehind?.ahead ?? 0, behind: upstreamAheadBehind?.behind ?? 0 } : { hasUpstream: false, ahead: 0, behind: 0 } if (!didHitLimit) { if (shouldProbeEffectiveUpstreamStatus(branch, upstreamStatus?.upstreamName)) { const branchName = getShortBranchName(branch) if (branchName) { try { // Why: one request's abort must not reject this shared status probe. upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, (args) => git(args, worktreePath), { bypassCache: params.bypassEffectiveUpstreamNegativeCache === true } ) } catch { // Why: keep returning working-tree entries even if the upstream probe hits a transient SSH/git ref error. } } } } // 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 as Record) } else { const entry = parseUnmergedEntry(worktreePath, record.line) if (entry) { entries.push(entry) } } } } catch (error) { // Why: an aborted scan must reject, not resolve as a completed (empty) status result. if (options.signal?.aborted) { throw error } // not a git repo or git not available } // Why: skip numstat after the limit to avoid reintroducing its cost. if (!didHitLimit && lineStatsWriteToken !== null) { const branchLineTotalInput = buildBranchLineTotalInput( git, worktreePath, entries, // Why: a failed scan leaves the untracked list untrustworthy, so the total // would under-count — omit it rather than publish a confident wrong number. statusSucceeded ? branchLineTotalMergeBase : undefined, options.signal ) // Why: passed in so the ranged diff runs alongside the per-area numstats, not after them. ;({ branchLineTotal } = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: params.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, recompute: () => attachLineStats(git, worktreePath, entries, options.signal), ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) })) } else if (lineStatsWriteToken !== null) { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } // Why: a late abort (during unmerged/upstream/line-stats work) must still reject, not resolve as completed. if (options.signal?.aborted) { const error = new Error('The operation was aborted.') error.name = 'AbortError' throw error } return { entries, conflictOperation, head, branch, upstreamStatus, ...(includeIgnored ? { ignoredPaths } : {}), ...(didHitLimit ? { didHitLimit: true, statusLength } : {}), ...(branchLineTotal ? { branchLineTotal } : {}) } } async function runNumstat( git: GitExec, worktreePath: string, cached: boolean, signal?: AbortSignal ): Promise | null> { try { const { stdout } = await git( ['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'], worktreePath, { disableOptionalLocks: true, signal } ) return parseNumstat(stdout) } catch (error) { // Why: an aborted pass must reject so a cancelled scan is never treated as completed. if (signal?.aborted) { throw error } // Why: null (vs an empty map) tells the caller the pass is incomplete and must not be cached. return null } } /** Returns false when a numstat pass failed, so callers skip caching it. */ async function attachLineStats( git: GitExec, worktreePath: string, entries: Record[], signal?: AbortSignal ): Promise { if (entries.length === 0) { return true } const { hasStaged, hasUnstaged, untrackedPaths } = collectGitStatusLineStatInputs(entries) const emptyStats = new Map() const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ hasStaged ? runNumstat(git, worktreePath, true, signal) : Promise.resolve(emptyStats), hasUnstaged ? runNumstat(git, worktreePath, false, signal) : Promise.resolve(emptyStats), collectUntrackedAdditions(worktreePath, untrackedPaths, signal) ]) for (const entry of entries) { const filePath = entry.path as string const area = entry.area applyLineStats( entry as { added?: number; removed?: number }, area === 'staged' ? (stagedStats ?? emptyStats).get(filePath) : area === 'unstaged' ? (unstagedStats ?? emptyStats).get(filePath) : untrackedStats.get(filePath) ) } return stagedStats !== null && unstagedStats !== null } function getShortBranchName(branch: string | undefined): string | null { const prefix = 'refs/heads/' return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null } function shouldProbeEffectiveUpstreamStatus( branch: string | undefined, upstreamName: string | undefined ): boolean { const branchName = getShortBranchName(branch) if (!branchName) { return false } if (!upstreamName) { return true } const parsed = splitRemoteBranchName(upstreamName) return parsed?.remoteName === 'origin' && parsed.branchName !== branchName }