/* eslint-disable max-lines */ import { existsSync } from 'node:fs' import { readFile, stat } from 'node:fs/promises' import * as path from 'node:path' import type { GitBranchChangeEntry, GitBranchChangeStatus, GitBranchCompareResult, GitBranchCompareSummary, GitCommitCompareResult, GitConflictKind, GitConflictOperation, GitDiffResult, GitFileStatus, GitStatusEntry, GitStatusResult, GitUpstreamStatus } from '../../shared/types' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import { getEffectiveGitUpstreamStatus, getGitUpstreamStatusForUpstreamName, splitRemoteBranchName } from '../../shared/git-effective-upstream' import { createGitConfigSnapshotRunner } from '../../shared/git-config-snapshot-runner' import { isBinaryBuffer } from '../../shared/binary-buffer' import { applyLineStats, collectUntrackedAdditions, parseNumstat, type GitLineStats } from '../../shared/git-uncommitted-line-stats' import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' import { gitExecFileAsync, gitExecFileAsyncBuffer, gitOptionalLocksDisabledEnv, gitStreamStdout } from './runner' import { StatusPorcelainParser } from '../../shared/git-status-porcelain-parser' import { findExistingWorktreeSymlinkPaths } from './worktree-symlink-detection' import { capGitStatusEntries, resolveGitStatusLimit } from '../../shared/git-status-limit' import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from './max-buffer-overflow' import { removeSafeUntrackedDiscardTarget, removeSafeUntrackedDiscardTargets } from '../../shared/git-discard-path-safety' import { readBranchCompareHead } from '../../shared/git-branch-compare-head' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe' import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit' import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' import { beginGitStatusLineStatsCacheWrite, clearGitStatusLineStatsCache, clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../../shared/git-status-line-stats-cache' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES const BULK_CHUNK_SIZE = 100 const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512 type EffectiveUpstreamStatusCacheEntry = { expiresAt: number status: GitUpstreamStatus } const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } const submodulePathsCache = new Map() let submodulePathsCacheGeneration = 0 // Why: cache the upstream name to skip its 4-5-spawn resolution chain each poll; revalidate via one rev-list (issue #7576). const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 type ResolvedUpstreamNameCacheEntry = { upstreamName: string expiresAt: number } const resolvedUpstreamNameCache = new Map() const effectiveUpstreamStatusCache = new Map() const effectiveUpstreamStatusInFlight = new Map>() const retiredEffectiveUpstreamStatusInFlight = new Map>() const gitDiffReadDedupe = new InFlightPromiseDedupe() const effectiveUpstreamStatusWriteGeneration = new Map() const statusReadLeaseOwner = new GitStatusReadLeaseOwner() // Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadLeaseOwner.invalidate() clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() } export async function runWithGitReadCacheInvalidation(run: () => Promise): Promise { invalidateGitReadCaches() try { return await run() } finally { // Why: a read that started mid-mutation can be stale too, so invalidate again after. invalidateGitReadCaches() } } export function clearSubmodulePathsCacheForTests(): void { clearSubmodulePathsCache() } function clearSubmodulePathsCache(): void { submodulePathsCache.clear() // Why: bump the generation so a pre-mutation read can't repopulate the invalidated cache. submodulePathsCacheGeneration += 1 } export function getSubmodulePathsCacheCountForTests(): number { return submodulePathsCache.size } function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] { return [options.wslDistro ?? null] } function getSubmodulePathsCacheKey(worktreePath: string, options: GitRuntimeOptions): string { // Why: the same path can map to different WSL-distro filesystems, so key the cache by runtime routing. return [worktreePath, ...gitRuntimeOptionsKey(options)].join('\0') } function pruneExpiredSubmodulePathsCache(now: number): void { for (const [cacheKey, entry] of submodulePathsCache) { if (entry.expiresAt <= now) { submodulePathsCache.delete(cacheKey) } } } function trimSubmodulePathsCache(): void { while (submodulePathsCache.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { const oldestKey = submodulePathsCache.keys().next().value if (oldestKey === undefined) { break } submodulePathsCache.delete(oldestKey) } } function getCachedSubmodulePaths(cacheKey: string, now: number): string[] | null { const cached = submodulePathsCache.get(cacheKey) if (!cached) { return null } if (cached.expiresAt <= now) { submodulePathsCache.delete(cacheKey) return null } submodulePathsCache.delete(cacheKey) submodulePathsCache.set(cacheKey, cached) return cached.paths } function rememberSubmodulePaths(cacheKey: string, paths: string[], now: number): void { submodulePathsCache.delete(cacheKey) submodulePathsCache.set(cacheKey, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) trimSubmodulePathsCache() } // Why: tests reuse this hook, so every memoization layer resets together despite the upstream-only name. export function clearEffectiveUpstreamStatusCacheForTests(): void { effectiveUpstreamStatusCache.clear() effectiveUpstreamStatusInFlight.clear() retiredEffectiveUpstreamStatusInFlight.clear() effectiveUpstreamStatusWriteGeneration.clear() invalidateGitReadCaches() } export function getEffectiveUpstreamStatusCacheCountForTests(): number { return effectiveUpstreamStatusCache.size } export function getEffectiveUpstreamStatusGenerationCountForTests(): number { return effectiveUpstreamStatusWriteGeneration.size } export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean reuseLineStats?: boolean /** * Max changed-file entries before git is stopped and the result is marked * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. */ limit?: number bypassEffectiveUpstreamNegativeCache?: boolean /** Paths Orca may have symlinked into this worktree (per-user shared paths * plus `orca.yaml` shared directories). Untracked entries that are one of * these *and* really symlinks are dropped: Git cannot ignore them when the * repo's rule is directory-only (`node_modules/`), but they are Orca's own * artifacts, not user work. */ sharedLinkPaths?: readonly string[] } /** * Parse `git status --porcelain=v2` output into structured entries. */ export async function getStatus( worktreePath: string, options: GetStatusOptions = {} ): Promise { gitDiffReadDedupe.clear() // Why: dedupe only concurrent identical reads; after settle, callers must run a fresh read. 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.includeIgnored === true, options.reuseLineStats === true, 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[], sharedLinkPaths: readonly string[] ): Promise { // 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)) 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 { const lineStatsCacheKey = getStatusLineStatsCacheKey(worktreePath, options) const lineStatsWriteToken = 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) // 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 const conflictOperation = await conflictPromise try { const { stoppedEarly } = await gitStreamStdout(statusArgs, { cwd: worktreePath, wslDistro: options.wslDistro, // 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 (!stoppedEarly) { parser.finish() } didHitLimit = 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: 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(worktreePath, record.line) if (unmergedEntry) { entries.push(unmergedEntry) } } } await dropSharedSymlinkUntrackedEntries(worktreePath, entries, options.sharedLinkPaths ?? []) 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. if (!didHitLimit) { await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: options.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, recompute: () => attachLineStats(worktreePath, entries, options) }) } else { 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 } : {}), ...(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 }) } : {}) } } function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host. return `${options.wslDistro ?? 'native'}\0${worktreePath}` } /** * Resolve a submodule's own worktree path from a parent worktree + relative * submodule path, rejecting anything that escapes the parent. */ export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath: string): string { if (!submodulePath || submodulePath.includes('\0') || path.isAbsolute(submodulePath)) { throw new Error('Access denied: invalid submodule path') } const resolved = path.resolve(worktreePath, submodulePath) const rel = path.relative(worktreePath, resolved) if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { throw new Error('Access denied: submodule path escapes the selected worktree') } return resolved } /** * Run a plain status inside a submodule's own worktree (lazy "expand submodule" * flow). Entry paths are relative to the submodule root; the renderer prefixes them. */ export async function getSubmoduleStatus( worktreePath: string, submodulePath: string, options: GetStatusOptions & { staged?: boolean } = {} ): Promise { const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) const limit = resolveGitStatusLimit(options.limit) // Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work. const workingResult = options.staged ? ({ entries: [], conflictOperation: 'unknown' } satisfies GitStatusResult) : await getStatus(submoduleWorktreePath, options) // Why: a moved gitlink (clean worktree) has no status rows; surface the parent-commit→checkout range as inner rows. const fromOid = options.staged ? await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) : (await readGitlinkOidFromIndex(worktreePath, submodulePath, options)) || (await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)) const toOid = options.staged ? await readGitlinkOidFromIndex(worktreePath, submodulePath, options) : await readWorkingSubmoduleHead(submoduleWorktreePath, options) if (fromOid && toOid && fromOid !== toOid) { const rangeEntries = await computeSubmoduleRangeEntries( submoduleWorktreePath, fromOid, toOid, options ) if (options.staged) { return { ...workingResult, ...capGitStatusEntries(rangeEntries, limit) } } const rangePaths = new Set(rangeEntries.map((entry) => entry.path)) // Range rows win on overlap so the diff matches getDiff's commit-range route. const entries = [ ...rangeEntries, ...workingResult.entries.filter((entry) => !rangePaths.has(entry.path)) ] return { ...workingResult, ...capGitStatusEntries(entries, limit, workingResult) } } if (options.staged) { return { ...workingResult, entries: [] } } return workingResult } /** * List files changed between two submodule commits as status rows — used when a * gitlink pointer moved so the expanded submodule shows committed changes. */ async function computeSubmoduleRangeEntries( submoduleWorktreePath: string, fromOid: string, toOid: string, options: GitRuntimeOptions = {} ): Promise { const gitOptions = { ...gitOptionsForWorktree(submoduleWorktreePath, options), env: gitOptionalLocksDisabledEnv() } let nameStatus = '' let numstat = '' try { const [statusResult, numstatResult] = await Promise.all([ gitExecFileAsync( ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', fromOid, toOid], gitOptions ), gitExecFileAsync( ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', fromOid, toOid], gitOptions ) ]) nameStatus = statusResult.stdout numstat = numstatResult.stdout } catch { return [] } const statsByPath = parseNumstat(numstat) const entries: GitStatusEntry[] = [] for (const line of nameStatus.split(/\r?\n/)) { if (!line) { continue } const change = parseBranchChangeLine(line) if (!change) { continue } entries.push({ path: change.path, status: change.status, area: 'unstaged', ...(change.oldPath ? { oldPath: change.oldPath } : {}), ...statsByPath.get(change.path) }) } return entries } async function runNumstat( worktreePath: string, cached: boolean, options: GitRuntimeOptions = {} ): Promise | null> { try { const { stdout } = await gitExecFileAsync( [ '-c', 'core.quotePath=false', 'diff', '-z', ...(cached ? ['--cached'] : []), '--numstat', '-M' ], { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } ) return parseNumstat(stdout) } catch (error) { // Why: an aborted pass must reject; only a genuine numstat failure degrades to uncounted rows. if (options.signal?.aborted) { throw error } // Why: a numstat failure leaves rows uncounted; null (not empty map) flags the pass incomplete and uncacheable. return null } } /** Returns false when a numstat pass failed, so callers skip caching it. */ async function attachLineStats( worktreePath: string, entries: GitStatusEntry[], options: GitRuntimeOptions = {} ): Promise { if (entries.length === 0) { return true } const hasStaged = entries.some((entry) => entry.area === 'staged') const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') const untrackedPaths = entries .filter((entry) => entry.area === 'untracked') .map((entry) => entry.path) const emptyStats = new Map() const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats), hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats), collectUntrackedAdditions(worktreePath, untrackedPaths, options.signal) ]) for (const entry of entries) { applyLineStats( entry, entry.area === 'staged' ? (stagedStats ?? emptyStats).get(entry.path) : entry.area === 'unstaged' ? (unstagedStats ?? emptyStats).get(entry.path) : untrackedStats.get(entry.path) ) } 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 getEffectiveUpstreamStatusCacheKey( worktreePath: string, branchName: string, upstreamName: string | undefined, options: GitRuntimeOptions = {} ): string { return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0') } export function clearEffectiveUpstreamNegativeStatusCache(identity: { worktreePath: string branchName: string upstreamName?: string options?: GitRuntimeOptions }): void { const cacheKey = getEffectiveUpstreamStatusCacheKey( identity.worktreePath, identity.branchName, identity.upstreamName, identity.options ) retireEffectiveUpstreamStatusProbe(cacheKey) effectiveUpstreamStatusCache.delete(cacheKey) effectiveUpstreamStatusInFlight.delete(cacheKey) resolvedUpstreamNameCache.delete(cacheKey) effectiveUpstreamStatusWriteGeneration.set( cacheKey, (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 ) } function retireEffectiveUpstreamStatusProbe(cacheKey: string): void { const retiredProbe = effectiveUpstreamStatusInFlight.get(cacheKey) if (!retiredProbe) { return } retiredEffectiveUpstreamStatusInFlight.set(cacheKey, retiredProbe) void retiredProbe .finally(() => { if (retiredEffectiveUpstreamStatusInFlight.get(cacheKey) === retiredProbe) { retiredEffectiveUpstreamStatusInFlight.delete(cacheKey) trimEffectiveUpstreamStatusGeneration() } }) .catch(() => undefined) } function hasPendingEffectiveUpstreamStatusProbe(cacheKey: string): boolean { return ( effectiveUpstreamStatusInFlight.has(cacheKey) || retiredEffectiveUpstreamStatusInFlight.has(cacheKey) ) } function trimEffectiveUpstreamStatusGeneration(): void { for (const cacheKey of effectiveUpstreamStatusWriteGeneration.keys()) { if ( effectiveUpstreamStatusWriteGeneration.size <= MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES ) { break } if (hasPendingEffectiveUpstreamStatusProbe(cacheKey)) { continue } effectiveUpstreamStatusWriteGeneration.delete(cacheKey) } } function readCachedEffectiveUpstreamStatus( cacheKey: string, now: number ): GitUpstreamStatus | undefined { const entry = effectiveUpstreamStatusCache.get(cacheKey) if (!entry) { return undefined } if (entry.expiresAt <= now) { effectiveUpstreamStatusCache.delete(cacheKey) return undefined } return entry.status } function rememberEffectiveUpstreamStatus( cacheKey: string, status: GitUpstreamStatus, now: number, probedSameNameOriginRef: boolean, writeGeneration: number ): void { // Why: hasConfiguredPushTarget gates a write action; re-probe each poll rather than cache a stale positive. if (status.hasUpstream || status.hasConfiguredPushTarget) { effectiveUpstreamStatusCache.delete(cacheKey) effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1) trimEffectiveUpstreamStatusGeneration() return } if ((effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { return } if (!probedSameNameOriginRef) { return } // Why: cache the negative so a stable no-upstream branch doesn't re-probe every poll (TTL lets push/fetch refs appear). effectiveUpstreamStatusCache.set(cacheKey, { status, expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS }) while (effectiveUpstreamStatusCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { const oldest = effectiveUpstreamStatusCache.keys().next() if (oldest.done) { break } effectiveUpstreamStatusCache.delete(oldest.value) effectiveUpstreamStatusWriteGeneration.delete(oldest.value) } trimEffectiveUpstreamStatusGeneration() } async function readOrProbeEffectiveUpstreamStatus( cacheKey: string, worktreePath: string, branchName: string, options: GitRuntimeOptions = {}, bypassCache = false ): Promise { if (!bypassCache) { const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) if (cached) { return cached } const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey) if (inFlight) { return inFlight } } // Why: overlapping refreshes at startup — coalesce the upstream probe so a stable missing ref fails once. const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 const probe = probeOrRevalidateEffectiveUpstreamStatus( cacheKey, worktreePath, branchName, options, bypassCache ).then((result) => { rememberEffectiveUpstreamStatus( cacheKey, result.status, Date.now(), result.probedSameNameOriginRef, writeGeneration ) return result.status }) if (!bypassCache) { effectiveUpstreamStatusInFlight.set(cacheKey, probe) } try { return await probe } finally { if (effectiveUpstreamStatusInFlight.get(cacheKey) === probe) { effectiveUpstreamStatusInFlight.delete(cacheKey) trimEffectiveUpstreamStatusGeneration() } } } async function probeOrRevalidateEffectiveUpstreamStatus( cacheKey: string, worktreePath: string, branchName: string, options: GitRuntimeOptions = {}, bypassCache = false ): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { const now = Date.now() const cached = resolvedUpstreamNameCache.get(cacheKey) if (cached && (bypassCache || cached.expiresAt <= now)) { resolvedUpstreamNameCache.delete(cacheKey) } else if (cached) { try { const status = await getGitUpstreamStatusForUpstreamName( (args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)), cached.upstreamName ) return { status, probedSameNameOriginRef: false } } catch (error) { // Why: an aborted probe says nothing about the ref; don't evict the warm name cache. if (options.signal?.aborted) { throw error } // Ref deleted or repo state changed — fall through to a full re-resolve. resolvedUpstreamNameCache.delete(cacheKey) } } const result = await probeEffectiveUpstreamStatus(worktreePath, branchName, options) if (result.status.hasUpstream && result.status.upstreamName) { resolvedUpstreamNameCache.set(cacheKey, { upstreamName: result.status.upstreamName, expiresAt: Date.now() + RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS }) while (resolvedUpstreamNameCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { const oldest = resolvedUpstreamNameCache.keys().next() if (oldest.done) { break } resolvedUpstreamNameCache.delete(oldest.value) } } return result } async function probeEffectiveUpstreamStatus( worktreePath: string, branchName: string, options: GitRuntimeOptions = {} ): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { let probedSameNameOriginRef = false const snapshotRunner = createGitConfigSnapshotRunner((args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) ) const status = await getEffectiveGitUpstreamStatus((args) => { if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { probedSameNameOriginRef = true } return snapshotRunner(args) }) return { status, probedSameNameOriginRef } } 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 } function parseBranchStatusChar(char: string): GitBranchChangeStatus { switch (char) { case 'M': return 'modified' case 'A': return 'added' case 'D': return 'deleted' case 'R': return 'renamed' case 'C': return 'copied' default: return 'modified' } } async function parseUnmergedEntry( worktreePath: string, line: string ): Promise { // Why: porcelain v2 `u` records are space-separated (not tab); path is field 10+ and may contain spaces, so join the tail. const parts = line.split(' ') const xy = parts[1] const modeStage1 = parts[3] const modeStage2 = parts[4] const modeStage3 = parts[5] const filePath = decodeGitCQuotedPath(parts.slice(10).join(' ')) if (!filePath) { return null } // Why: submodule conflicts (mode 160000) are out of scope for v1 — they need different resolution UX. if ([modeStage1, modeStage2, modeStage3].some((mode) => mode === '160000')) { return null } const conflictKind = parseConflictKind(xy) if (!conflictKind) { return null } // Why: porcelain v2 `u` records lack rename-origin metadata, so oldPath is intentionally omitted. return { path: filePath, area: 'unstaged', status: await getConflictCompatibilityStatus(worktreePath, filePath, conflictKind), conflictKind, conflictStatus: 'unresolved' } } function parseConflictKind(xy: string): GitConflictKind | null { switch (xy) { case 'UU': return 'both_modified' case 'AA': return 'both_added' case 'DD': return 'both_deleted' case 'AU': return 'added_by_us' case 'UA': return 'added_by_them' case 'DU': return 'deleted_by_us' case 'UD': return 'deleted_by_them' default: return null } } // Why: `status` here is a rendering-compat choice for icon/color plumbing, not semantic; the conflict badge carries the real meaning. // Why: for deleted_by_*/added_by_* variants Git's result depends on merge strategy, so check the filesystem. async function getConflictCompatibilityStatus( worktreePath: string, filePath: string, conflictKind: GitConflictKind ): Promise { if (conflictKind === 'both_modified' || conflictKind === 'both_added') { return 'modified' } if (conflictKind === 'both_deleted') { return 'deleted' } try { return existsSync(path.join(worktreePath, filePath)) ? 'modified' : 'deleted' } catch { // Why: on an fs check failure, 'modified' is safer — it keeps the row visible rather than falsely showing 'deleted'. return 'modified' } } // Why: the git-status → existsSync race can miss a transient HEAD; fall back to 'unknown' for one poll cycle. // Why: detect rebase from rebase-merge/ or rebase-apply/ dirs (persist all steps), not REBASE_HEAD (partial, lingers → stale badge). export async function detectConflictOperation(worktreePath: string): Promise { const gitDir = await resolveGitDir(worktreePath) const mergeHead = path.join(gitDir, 'MERGE_HEAD') const cherryPickHead = path.join(gitDir, 'CHERRY_PICK_HEAD') const rebaseMergeDir = path.join(gitDir, 'rebase-merge') const rebaseApplyDir = path.join(gitDir, 'rebase-apply') let hasMergeHead = false let hasCherryPickHead = false let hasRebaseDir = false try { hasMergeHead = existsSync(mergeHead) hasCherryPickHead = existsSync(cherryPickHead) hasRebaseDir = existsSync(rebaseMergeDir) || existsSync(rebaseApplyDir) } catch { return 'unknown' } if (hasMergeHead) { return 'merge' } if (hasRebaseDir) { return 'rebase' } if (hasCherryPickHead) { return 'cherry-pick' } return 'unknown' } export async function abortMerge( worktreePath: string, options: GitRuntimeOptions = {} ): Promise { await runWithGitReadCacheInvalidation(() => gitExecFileAsync(['merge', '--abort'], gitOptionsForWorktree(worktreePath, options)) ) } export async function abortRebase( worktreePath: string, options: GitRuntimeOptions = {} ): Promise { await runWithGitReadCacheInvalidation(() => gitExecFileAsync(['rebase', '--abort'], gitOptionsForWorktree(worktreePath, options)) ) } export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') try { const dotGitContents = await readFile(dotGitPath, 'utf-8') const match = dotGitContents.match(/^gitdir:\s*(.+)\s*$/m) if (match) { return path.resolve(worktreePath, match[1]) } } catch { // `.git` is likely a directory in a non-worktree checkout. } return dotGitPath } /** * List configured submodule paths (relative, forward-slash) for a worktree, cached * briefly. Read from `.gitmodules` to avoid an index-wide `ls-files` scan. */ export async function listSubmodulePaths( worktreePath: string, options: GitRuntimeOptions = {} ): Promise { const now = Date.now() const cacheKey = getSubmodulePathsCacheKey(worktreePath, options) const cached = getCachedSubmodulePaths(cacheKey, now) if (cached) { return cached } // Why: prune on misses so removed worktrees don't accumulate; hot hits stay O(1). pruneExpiredSubmodulePathsCache(now) const cacheGeneration = submodulePathsCacheGeneration let paths: string[] = [] try { const { stdout } = await gitExecFileAsync( ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } ) paths = stdout .split(/\r?\n/) .map((line) => { const spaceIndex = line.indexOf(' ') return spaceIndex === -1 ? '' : line .slice(spaceIndex + 1) .trim() .replace(/\/+$/, '') }) .filter((value) => value.length > 0) } catch { // No .gitmodules (or git config failure) — treat as a repo without submodules. paths = [] } if (cacheGeneration === submodulePathsCacheGeneration) { rememberSubmodulePaths(cacheKey, paths, Date.now()) } return paths } /** * Find the submodule whose root equals or contains `filePath`. Returns the * submodule path (forward-slash) or null when the path is not in a submodule. */ function findContainingSubmodule(submodulePaths: string[], filePath: string): string | null { const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '') let best: string | null = null for (const sub of submodulePaths) { if (normalized === sub || normalized.startsWith(`${sub}/`)) { // Prefer the longest match to support nested submodule roots. if (!best || sub.length > best.length) { best = sub } } } return best } async function readGitlinkOidFromTree( worktreePath: string, ref: string, submodulePath: string, options: GitRuntimeOptions ): Promise { try { const { stdout } = await gitExecFileAsync(['ls-tree', ref, '--', submodulePath], { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() }) return stdout.match(/^160000 commit ([0-9a-f]+)\t/m)?.[1] ?? '' } catch { return '' } } async function readGitlinkOidFromIndex( worktreePath: string, submodulePath: string, options: GitRuntimeOptions ): Promise { try { const { stdout } = await gitExecFileAsync(['ls-files', '-s', '--', submodulePath], { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() }) return stdout.match(/^160000 ([0-9a-f]+) /m)?.[1] ?? '' } catch { return '' } } async function readWorkingSubmoduleHead( submoduleWorktreePath: string, options: GitRuntimeOptions ): Promise { try { const { stdout } = await gitExecFileAsync(['rev-parse', 'HEAD'], { ...gitOptionsForWorktree(submoduleWorktreePath, options), env: gitOptionalLocksDisabledEnv() }) return stdout.trim() } catch { return '' } } /** * Synthesize a gitlink pointer diff: Git represents submodule commit changes as a * one-line `Subproject commit ` swap, so the old/new oids feed the text differ. */ async function buildSubmodulePointerDiff( worktreePath: string, submodulePath: string, staged: boolean, compareAgainstHead: boolean, options: GitRuntimeOptions, // Why: default to the validated resolver so every caller is guarded against path escape. submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) ): Promise { let leftOid = '' let rightOid = '' if (staged) { leftOid = await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) rightOid = await readGitlinkOidFromIndex(worktreePath, submodulePath, options) } else if (compareAgainstHead) { leftOid = await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) rightOid = await readWorkingSubmoduleHead(submoduleWorktreePath, options) } else { leftOid = (await readGitlinkOidFromIndex(worktreePath, submodulePath, options)) || (await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)) rightOid = await readWorkingSubmoduleHead(submoduleWorktreePath, options) } return buildDiffResult( leftOid ? `Subproject commit ${leftOid}\n` : '', rightOid ? `Subproject commit ${rightOid}\n` : '', false, false, submodulePath ) } /** * Diff a file inside a submodule across two of its commits — used when the parent * gitlink moved but the submodule worktree is clean (change is committed). */ async function buildSubmoduleInnerCommitRangeDiff( submoduleWorktreePath: string, innerPath: string, fromOid: string, toOid: string, options: GitRuntimeOptions ): Promise { let originalContent = '' let modifiedContent = '' let originalIsBinary = false let modifiedIsBinary = false try { const left = await readGitBlobAtOidPath(submoduleWorktreePath, fromOid, innerPath, options) originalContent = left.content originalIsBinary = left.isBinary const right = await readGitBlobAtOidPath(submoduleWorktreePath, toOid, innerPath, options) modifiedContent = right.content modifiedIsBinary = right.isBinary } catch { // Fallback to empty content; a missing blob (add/delete) reads as one side. } return buildDiffResult( originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, innerPath ) } /** * Get original and modified content for diffing a file. */ export async function getDiff( worktreePath: string, filePath: string, staged: boolean, compareAgainstHead = false, options: GitRuntimeOptions = {} ): Promise { // Why: register the dedupe synchronously (before any await) so concurrent identical reads coalesce. return gitDiffReadDedupe.run( stableInFlightKey([ 'diff', worktreePath, filePath, staged, compareAgainstHead, ...gitRuntimeOptionsKey(options) ]), () => loadDiff(worktreePath, filePath, staged, compareAgainstHead, options) ) } async function loadDiff( worktreePath: string, filePath: string, staged: boolean, compareAgainstHead: boolean, options: GitRuntimeOptions ): Promise { // Why: gitlink paths can't be read as blobs, so route submodule diffs explicitly (root → pointer, inner → recurse). const submodulePaths = await listSubmodulePaths(worktreePath, options) if (submodulePaths.length > 0) { const matchedSubmodule = findContainingSubmodule(submodulePaths, filePath) if (matchedSubmodule) { // Why: validate the .gitmodules-derived path against the worktree boundary so a crafted one can't escape the repo. const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, matchedSubmodule) const normalizedFilePath = filePath.replace(/\\/g, '/').replace(/\/+$/, '') if (normalizedFilePath === matchedSubmodule) { return buildSubmodulePointerDiff( worktreePath, matchedSubmodule, staged, compareAgainstHead, options, submoduleWorktreePath ) } const innerPath = normalizedFilePath.slice(matchedSubmodule.length + 1) const fromOid = staged ? await readGitlinkOidFromTree(worktreePath, 'HEAD', matchedSubmodule, options) : (await readGitlinkOidFromIndex(worktreePath, matchedSubmodule, options)) || (await readGitlinkOidFromTree(worktreePath, 'HEAD', matchedSubmodule, options)) const toOid = staged ? await readGitlinkOidFromIndex(worktreePath, matchedSubmodule, options) : await readWorkingSubmoduleHead(submoduleWorktreePath, options) // Why: a moved gitlink with a clean submodule worktree means the change is committed — diff the two commits. if (fromOid && toOid && fromOid !== toOid) { return buildSubmoduleInnerCommitRangeDiff( submoduleWorktreePath, innerPath, fromOid, toOid, options ) } return getDiff(submoduleWorktreePath, innerPath, staged, compareAgainstHead, options) } } let originalContent = '' let modifiedContent = '' let originalIsBinary = false let modifiedIsBinary = false let modifiedDeleted = false try { if (staged) { // Why concurrent: HEAD and the index are independent `git show` spawns. // Only this branch qualifies — the unstaged left read chains index→HEAD. const [leftBlob, rightBlob] = await Promise.all([ readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options), readGitBlobAtIndexPath(worktreePath, filePath, options) ]) originalContent = leftBlob.content originalIsBinary = leftBlob.isBinary modifiedContent = rightBlob.content modifiedIsBinary = rightBlob.isBinary modifiedDeleted = !rightBlob.exists } else { // The left chain (index→HEAD) is sequential within itself, but the working // tree read is a plain fs read that does not depend on it. const [leftBlob, workingTreeBlob] = await Promise.all([ compareAgainstHead ? readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) : readUnstagedLeftBlob(worktreePath, filePath, options), readWorkingTreeFile(path.join(worktreePath, filePath)) ]) originalContent = leftBlob.content originalIsBinary = leftBlob.isBinary modifiedContent = workingTreeBlob.content modifiedIsBinary = workingTreeBlob.isBinary modifiedDeleted = !workingTreeBlob.exists } } catch { // Fallback } const result = buildDiffResult( originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, filePath ) // Why: mark a proven deletion so previewers don't mistake a read failure's empty side for one. if (result.kind === 'binary' && modifiedDeleted) { return { ...result, modifiedDeleted: true } } return result } export async function getBranchCompare( worktreePath: string, baseRef: string, options: GitRuntimeOptions = {} ): Promise { const summary: GitBranchCompareSummary = { baseRef, baseOid: null, compareRef: 'HEAD', headOid: null, mergeBase: null, changedFiles: 0, status: 'loading' } // The base-ref probe peels to a commit. Only branch refs are guaranteed to store // commits; remote-tracking refs may store annotated tags whose raw oid must be preserved. const reusableProbedOidByRef = new Map() const { compareRef, headOidResult, baseOidResult } = await readBranchCompareHead({ readCompareRef: () => resolveCompareRef(worktreePath, options), resolveBaseRef: () => // Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref. resolveWorktreeAddBaseRef(baseRef, async (qualifiedRef) => { const oid = await resolveWorktreeBaseCommitOid(worktreePath, qualifiedRef, options) if (oid !== null && qualifiedRef.startsWith('refs/heads/')) { reusableProbedOidByRef.set(qualifiedRef, oid) } return oid !== null }), readHeadOid: () => resolveRefOid(worktreePath, 'HEAD', options), readBaseOid: (ref) => { const reusableOid = reusableProbedOidByRef.get(ref) return reusableOid === undefined ? resolveRefOid(worktreePath, ref, options) : Promise.resolve(reusableOid) } }) summary.compareRef = compareRef let headOid = '' let baseOid = '' if (headOidResult.ok) { headOid = headOidResult.oid summary.headOid = headOid } else { if (baseOidResult.ok) { baseOid = baseOidResult.oid summary.baseOid = baseOid // Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken. summary.changedFiles = 0 summary.commitsAhead = 0 summary.status = 'ready' return { summary, entries: [] } } summary.status = 'unborn-head' summary.errorMessage = 'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.' return { summary, entries: [] } } if (baseOidResult.ok) { baseOid = baseOidResult.oid summary.baseOid = baseOid } else { summary.status = 'invalid-base' summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.` return { summary, entries: [] } } let mergeBase = '' try { mergeBase = await resolveMergeBase(worktreePath, baseOid, headOid, options) summary.mergeBase = mergeBase } catch { summary.status = 'no-merge-base' summary.errorMessage = `This branch and ${baseRef} do not share a merge base, so compare-to-base is unavailable.` return { summary, entries: [] } } try { const [entries, commitsAhead] = await Promise.all([ loadBranchChanges(worktreePath, mergeBase, headOid, options), countAheadCommits(worktreePath, baseOid, headOid, options) ]) summary.changedFiles = entries.length summary.commitsAhead = commitsAhead summary.status = 'ready' return { summary, entries } } catch (error) { summary.status = 'error' summary.errorMessage = error instanceof Error ? error.message : 'Failed to load branch compare' return { summary, entries: [] } } } export async function getBranchDiff( worktreePath: string, args: { headOid: string mergeBase: string filePath: string oldPath?: string }, options: GitRuntimeOptions = {} ): Promise { return gitDiffReadDedupe.run( stableInFlightKey([ 'branchDiff', worktreePath, args.headOid, args.mergeBase, args.filePath, args.oldPath ?? null, ...gitRuntimeOptionsKey(options) ]), () => loadBranchDiff(worktreePath, args, options) ) } async function loadBranchDiff( worktreePath: string, args: { headOid: string mergeBase: string filePath: string oldPath?: string }, options: GitRuntimeOptions ): Promise { try { const leftPath = args.oldPath ?? args.filePath // Why concurrent: the two sides are independent `git show` spawns, so awaiting // them in series doubles the latency of every diff the review panel opens. const [leftBlob, rightBlob] = await Promise.all([ readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options), readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath, options) ]) return buildDiffResult( leftBlob.content, rightBlob.content, leftBlob.isBinary, rightBlob.isBinary, args.filePath ) } catch { return { kind: 'text', originalContent: '', modifiedContent: '', originalIsBinary: false, modifiedIsBinary: false } } } export async function getCommitCompare( worktreePath: string, commitId: string, options: GitRuntimeOptions = {} ): Promise { let commitOid = '' try { commitOid = await resolveRefOid(worktreePath, `${commitId}^{commit}`, options) } catch { return { summary: { commitOid: '', parentOid: null, compareRef: commitId, baseRef: 'parent', changedFiles: 0, status: 'invalid-commit', errorMessage: `Commit ${commitId} could not be resolved in this repository.` }, entries: [] } } const summary = { commitOid, parentOid: null as string | null, compareRef: commitOid.slice(0, 7), baseRef: 'empty tree', changedFiles: 0, status: 'ready' as const } try { const { stdout } = await gitExecFileAsync( ['rev-list', '--parents', '-n', '1', commitOid], gitOptionsForWorktree(worktreePath, options) ) const firstParent = parseGitRevListFirstParentOid(stdout) summary.parentOid = firstParent summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' const entries = await loadCommitChanges(worktreePath, summary.parentOid, commitOid, options) summary.changedFiles = entries.length return { summary, entries } } catch (error) { return { summary: { ...summary, status: 'error', errorMessage: error instanceof Error ? error.message : 'Failed to load commit diff' }, entries: [] } } } export async function getCommitDiff( worktreePath: string, args: { commitOid: string parentOid?: string | null filePath: string oldPath?: string }, options: GitRuntimeOptions = {} ): Promise { return gitDiffReadDedupe.run( stableInFlightKey([ 'commitDiff', worktreePath, args.commitOid, args.parentOid ?? null, args.filePath, args.oldPath ?? null, ...gitRuntimeOptionsKey(options) ]), () => loadCommitDiff(worktreePath, args, options) ) } async function loadCommitDiff( worktreePath: string, args: { commitOid: string parentOid?: string | null filePath: string oldPath?: string }, options: GitRuntimeOptions ): Promise { try { const leftPath = args.oldPath ?? args.filePath // Why concurrent: the two sides are independent `git show` spawns. A root // commit has no parent to read, so that side resolves without a spawn. const [leftBlob, rightBlob] = await Promise.all([ args.parentOid ? readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options) : Promise.resolve({ content: '', isBinary: false }), readGitBlobAtOidPath(worktreePath, args.commitOid, args.filePath, options) ]) return buildDiffResult( leftBlob.content, rightBlob.content, leftBlob.isBinary, rightBlob.isBinary, args.filePath ) } catch { return { kind: 'text', originalContent: '', modifiedContent: '', originalIsBinary: false, modifiedIsBinary: false } } } async function loadBranchChanges( worktreePath: string, mergeBase: string, headOid: string, options: GitRuntimeOptions = {} ): Promise { // Why: core.quotePath=false keeps real UTF-8 paths — see getStatus rationale. const gitOptions = { ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES } // Why: both diffs are independent, so run them concurrently instead of serializing. const [{ stdout }, { stdout: numstat }] = await Promise.all([ gitExecFileAsync( ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], gitOptions ), gitExecFileAsync( ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', mergeBase, headOid], gitOptions ) ]) const statsByPath = parseNumstat(numstat) const entries: GitBranchChangeEntry[] = [] // Why: split on /\r?\n/ so Git's CRLF output on Windows leaves no trailing \r in paths. for (const line of stdout.split(/\r?\n/)) { if (!line) { continue } const entry = parseBranchChangeLine(line) if (entry) { entries.push({ ...entry, ...statsByPath.get(entry.path) }) } } return entries } async function loadCommitChanges( worktreePath: string, parentOid: string | null, commitOid: string, options: GitRuntimeOptions = {} ): Promise { // Why: root commits have no parent tree; diff-tree --root uses git's empty tree, avoiding a hardcoded hash-format-specific oid. const args = parentOid ? ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', parentOid, commitOid] : [ '-c', 'core.quotePath=false', 'diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-M', '-C', commitOid ] const numstatArgs = parentOid ? ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', parentOid, commitOid] : [ '-c', 'core.quotePath=false', 'diff-tree', '-z', '--root', '--no-commit-id', '--numstat', '-r', '-M', '-C', commitOid ] const gitOptions = { ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES } // Why: the two git queries are independent, so run them in parallel. const [{ stdout }, { stdout: numstat }] = await Promise.all([ gitExecFileAsync(args, gitOptions), gitExecFileAsync(numstatArgs, gitOptions) ]) const statsByPath = parseNumstat(numstat) const entries: GitBranchChangeEntry[] = [] for (const line of stdout.split(/\r?\n/)) { if (!line) { continue } const entry = parseBranchChangeLine(line) if (entry) { entries.push({ ...entry, ...statsByPath.get(entry.path) }) } } return entries } function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { const parts = line.split('\t') const rawStatus = parts[0] ?? '' const status = parseBranchStatusChar(rawStatus[0] ?? 'M') if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { const oldPath = decodeGitCQuotedPath(parts[1] ?? '') const path = decodeGitCQuotedPath(parts[2] ?? '') if (!path) { return null } return { path, oldPath, status } } const path = decodeGitCQuotedPath(parts[1] ?? '') if (!path) { return null } return { path, status } } async function resolveCompareRef( worktreePath: string, options: GitRuntimeOptions = {} ): Promise { try { const { stdout } = await gitExecFileAsync(['branch', '--show-current'], { ...gitOptionsForWorktree(worktreePath, options) }) const branch = stdout.trim() return branch || 'HEAD' } catch { return 'HEAD' } } async function resolveRefOid( worktreePath: string, ref: string, options: GitRuntimeOptions = {} ): Promise { const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', '--end-of-options', ref], { ...gitOptionsForWorktree(worktreePath, options) }) return stdout.trim() } async function resolveMergeBase( worktreePath: string, baseOid: string, headOid: string, options: GitRuntimeOptions = {} ): Promise { const { stdout } = await gitExecFileAsync(['merge-base', baseOid, headOid], { ...gitOptionsForWorktree(worktreePath, options) }) return stdout.trim() } async function countAheadCommits( worktreePath: string, baseOid: string, headOid: string, options: GitRuntimeOptions = {} ): Promise { const { stdout } = await gitExecFileAsync(['rev-list', '--count', `${baseOid}..${headOid}`], { ...gitOptionsForWorktree(worktreePath, options) }) return Number.parseInt(stdout.trim(), 10) || 0 } async function readUnstagedLeftBlob( worktreePath: string, filePath: string, options: GitRuntimeOptions = {} ): Promise { const indexBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) if (indexBlob.exists) { return indexBlob } return readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) } async function readGitBlobAtIndexPath( worktreePath: string, filePath: string, options: GitRuntimeOptions = {} ): Promise { // Why: Git's `:` syntax expects forward slashes even on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const { stdout } = await gitExecFileAsyncBuffer(['show', `:${gitPath}`], { ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES }) return { ...bufferToBlob(stdout, filePath), exists: true } } catch (error) { if (isMaxBufferOverflowError(error)) { return { content: '', isBinary: true, exists: true } } return { content: '', isBinary: false, exists: false } } } async function readGitBlobAtOidPath( worktreePath: string, oid: string, filePath: string, options: GitRuntimeOptions = {} ): Promise { // Why: Git's `:` syntax expects forward slashes even on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const { stdout } = await gitExecFileAsyncBuffer( ['show', '--end-of-options', `${oid}:${gitPath}`], { ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES } ) return { ...bufferToBlob(stdout, filePath), exists: true } } catch (error) { if (isMaxBufferOverflowError(error)) { return { content: '', isBinary: true, exists: true } } return { content: '', isBinary: false, exists: false } } } async function readWorkingTreeFile(filePath: string): Promise { let fileStat try { fileStat = await stat(filePath) } catch (error) { // Why: only ENOENT is a real deletion; other stat errors are read failures, not absence. return { content: '', isBinary: false, exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT' } } if (!fileStat.isFile()) { return { content: '', isBinary: false, exists: false } } if (fileStat.size > MAX_GIT_SHOW_BYTES) { // Why: mirror git's maxBuffer cap for working-tree reads so readFile can't pull in huge assets. return { content: '', isBinary: true, exists: true } } try { const buffer = await readFile(filePath) return bufferToBlob(buffer, filePath) } catch { // Why: the file exists but could not be read — a read failure, not a deletion. return { content: '', isBinary: false, exists: true } } } function bufferToBlob(buffer: Buffer, filePath?: string): GitBlobReadResult { const isBinary = isBinaryBuffer(buffer) // Return base64 for recognized image formats so the renderer can display them const isPreviewableBinary = filePath ? !!PREVIEWABLE_BINARY_MIME_TYPES[path.extname(filePath).toLowerCase()] : false return { content: isBinary ? isPreviewableBinary ? buffer.toString('base64') : '' : buffer.toString('utf-8'), isBinary, exists: true } } function buildDiffResult( originalContent: string, modifiedContent: string, originalIsBinary: boolean, modifiedIsBinary: boolean, filePath?: string ): GitDiffResult { if (originalIsBinary || modifiedIsBinary) { const mimeType = filePath ? PREVIEWABLE_BINARY_MIME_TYPES[path.extname(filePath).toLowerCase()] : undefined return { kind: 'binary', originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, // Why: renderer still checks legacy `isImage` before previewing, so set it for PDFs too until the contract is renamed. ...(mimeType ? { isImage: true, mimeType } : {}) } as GitDiffResult } // Why: over the render limit, return metadata instead of huge text so the renderer can show fallback UI. const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent }) if (largeDiffRenderLimit.limited) { return { kind: 'text', originalContent: '', modifiedContent: '', originalIsBinary: false, modifiedIsBinary: false, largeDiffRenderLimit } } return { kind: 'text', originalContent, modifiedContent, originalIsBinary: false, modifiedIsBinary: false } } type GitBlobReadResult = { content: string isBinary: boolean exists: boolean } const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.pdf': 'application/pdf' } /** * Stage a file. */ export async function stageFile( worktreePath: string, filePath: string, options: GitRuntimeOptions = {} ): Promise { invalidateGitReadCaches() try { await gitExecFileAsync( ['add', '--', literalPathspec(filePath, options)], gitOptionsForWorktree(worktreePath, options) ) } finally { invalidateGitReadCaches() } } /** * Unstage a file. */ export async function unstageFile( worktreePath: string, filePath: string, options: GitRuntimeOptions = {} ): Promise { invalidateGitReadCaches() try { await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) }) } finally { invalidateGitReadCaches() } } export async function getStagedCommitContext( worktreePath: string, options: GitRuntimeOptions = {} ): Promise { const branchPromise = gitExecFileAsync(['branch', '--show-current'], { ...gitOptionsForWorktree(worktreePath, options) }).catch(() => ({ stdout: '' })) const summaryPromise = gitExecFileAsync(['diff', '--cached', '--name-status'], { ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES }) const [branchResult, summaryResult] = await Promise.all([branchPromise, summaryPromise]) const stagedSummary = summaryResult.stdout.trim() if (!stagedSummary) { return null } let stagedPatch = '' try { const patchResult = await gitExecFileAsync( ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], { ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES } ) stagedPatch = patchResult.stdout } catch (error) { if (!isMaxBufferOverflowError(error)) { throw error } // Why: staged patch is optional context (truncated later anyway); degrade to file-name summary rather than fail. console.warn( '[git] Staged patch too large to read; using file summary only:', describeMaxBufferOverflowError(error) ) } return { branch: branchResult.stdout.trim() || null, stagedSummary, stagedPatch } } export async function commitChanges( worktreePath: string, message: string, options: GitRuntimeOptions = {} ): Promise<{ success: boolean; error?: string }> { invalidateGitReadCaches() try { await gitExecFileAsync(['commit', '-m', message], gitOptionsForWorktree(worktreePath, options)) return { success: true } } catch (error) { // Why: useful message may be on stderr (hook/GPG failures) or stdout ("nothing to commit"), so try both then message. const readStringField = (field: string): string | null => { if (typeof error === 'object' && error && field in error) { const v = (error as Record)[field] if (typeof v === 'string' && v.length > 0) { return v } } return null } const errorMessage = readStringField('stderr') ?? readStringField('stdout') ?? (error instanceof Error ? error.message : 'Commit failed') return { success: false, error: errorMessage } } finally { invalidateGitReadCaches() } } /** * Discard working tree changes for a file. */ export async function discardChanges( worktreePath: string, filePath: string, options: GitRuntimeOptions = {} ): Promise { invalidateGitReadCaches() const resolvedWorktree = path.resolve(worktreePath) const resolvedTarget = path.resolve(worktreePath, filePath) try { if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) { throw new Error(`Path "${filePath}" resolves outside the worktree`) } let tracked = false try { await gitExecFileAsync( ['ls-files', '--error-unmatch', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) } ) tracked = true } catch { // File is not tracked by git } if (tracked) { await gitExecFileAsync( ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) } ) return } await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => cleanUntrackedPaths(worktreePath, [targetPath], options) ) } finally { invalidateGitReadCaches() } } function normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } function literalPathspec(filePath: string, options: GitRuntimeOptions): string { // Why: Git inside WSL needs POSIX paths, but host paths must stay literal, so convert backslashes only for WSL. const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath return `:(literal)${runtimePath}` } function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { const normalized = normalizeGitPathForCompare(filePath) return trackedPaths.some((trackedPath) => { const normalizedTracked = normalizeGitPathForCompare(trackedPath) return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`) }) } async function listTrackedPathSpecs( worktreePath: string, filePaths: readonly string[], options: GitRuntimeOptions = {} ): Promise { const trackedPaths: string[] = [] for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) const { stdout } = await gitExecFileAsync( ['ls-files', '-z', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], { ...gitOptionsForWorktree(worktreePath, options) } ) // Why: a tracked directory can hold enough paths to exceed the JS argument limit. for (const trackedPath of stdout.split('\0')) { if (trackedPath) { trackedPaths.push(trackedPath) } } } return trackedPaths } async function cleanUntrackedPaths( worktreePath: string, filePaths: readonly string[], options: GitRuntimeOptions = {} ): Promise { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. await gitExecFileAsync( ['clean', '-ffdx', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], { ...gitOptionsForWorktree(worktreePath, options) } ) } } } /** * Discard working tree changes for many paths in a small number of subprocesses. */ export async function bulkDiscardChanges( worktreePath: string, filePaths: string[], options: GitRuntimeOptions = {} ): Promise { invalidateGitReadCaches() if (filePaths.length === 0) { return } try { const resolvedWorktree = path.resolve(worktreePath) for (const filePath of filePaths) { const resolvedTarget = path.resolve(worktreePath, filePath) if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) { throw new Error(`Path "${filePath}" resolves outside the worktree`) } } const trackedPathSpecs = await listTrackedPathSpecs(worktreePath, filePaths, options) const trackedPaths = filePaths.filter((filePath) => isTrackedPathSpec(filePath, trackedPathSpecs) ) const untrackedPaths = filePaths.filter( (filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs) ) await removeSafeUntrackedDiscardTargets( worktreePath, untrackedPaths, (targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths, options), async () => { for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( [ 'restore', '--worktree', '--source=HEAD', '--', ...chunk.map((filePath) => literalPathspec(filePath, options)) ], { ...gitOptionsForWorktree(worktreePath, options) } ) } } ) } finally { invalidateGitReadCaches() } } export function isWithinWorktree( pathApi: Pick, resolvedWorktree: string, resolvedTarget: string ): boolean { const relativeTarget = pathApi.relative(resolvedWorktree, resolvedTarget) return !( relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relativeTarget) ) } /** * Bulk stage files in batches to avoid E2BIG. */ export async function bulkStageFiles( worktreePath: string, filePaths: string[], options: GitRuntimeOptions = {} ): Promise { invalidateGitReadCaches() if (filePaths.length === 0) { return } try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( ['add', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], gitOptionsForWorktree(worktreePath, options) ) } } finally { invalidateGitReadCaches() } } /** * Bulk unstage files in batches to avoid E2BIG. */ export async function bulkUnstageFiles( worktreePath: string, filePaths: string[], options: GitRuntimeOptions = {} ): Promise { invalidateGitReadCaches() if (filePaths.length === 0) { return } try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( [ 'restore', '--staged', '--', ...chunk.map((filePath) => literalPathspec(filePath, options)) ], { ...gitOptionsForWorktree(worktreePath, options) } ) } } finally { invalidateGitReadCaches() } }