From 33587fb77d369907b67051d2bac7097f3780a5fc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:24:11 -0700 Subject: [PATCH] refactor(git): split status.ts into source-control modules (#16393) --- config/max-lines-baseline.txt | 1 - .../source-control/branch-change-entries.ts | 145 + src/main/git/source-control/branch-compare.ts | 110 + src/main/git/source-control/branch-diff.ts | 68 + src/main/git/source-control/commit-changes.ts | 34 + src/main/git/source-control/commit-compare.ts | 63 + src/main/git/source-control/commit-diff.ts | 70 + .../git/source-control/compare-ref-oids.ts | 53 + src/main/git/source-control/diff-result.ts | 48 + .../git/source-control/discard-changes.ts | 170 ++ .../effective-upstream-status-cache.ts | 154 ++ .../effective-upstream-status-probe.ts | 154 ++ src/main/git/source-control/file-diff.ts | 147 + .../git/source-control/get-status-options.ts | 21 + src/main/git/source-control/git-blob-read.ts | 121 + .../source-control/git-conflict-operation.ts | 59 + src/main/git/source-control/git-pathspec.ts | 21 + .../git-read-cache-invalidation.ts | 35 + .../git-runtime-options-cache-key.ts | 5 + .../git/source-control/git-show-max-bytes.ts | 2 + .../previewable-binary-mime-types.ts | 11 + .../git/source-control/resolve-git-dir.ts | 18 + .../resolved-upstream-name-cache.ts | 9 + .../source-control/staged-commit-context.ts | 52 + src/main/git/source-control/staging.ts | 99 + .../status-branch-line-total-input.ts | 56 + .../source-control/status-conflict-entries.ts | 87 + .../git/source-control/status-line-stats.ts | 75 + src/main/git/source-control/status-read.ts | 262 ++ src/main/git/source-control/submodule-diff.ts | 80 + .../source-control/submodule-gitlink-oid.ts | 51 + .../git/source-control/submodule-paths.ts | 147 + .../git/source-control/submodule-status.ts | 118 + src/main/git/status.ts | 2404 +---------------- 34 files changed, 2587 insertions(+), 2363 deletions(-) create mode 100644 src/main/git/source-control/branch-change-entries.ts create mode 100644 src/main/git/source-control/branch-compare.ts create mode 100644 src/main/git/source-control/branch-diff.ts create mode 100644 src/main/git/source-control/commit-changes.ts create mode 100644 src/main/git/source-control/commit-compare.ts create mode 100644 src/main/git/source-control/commit-diff.ts create mode 100644 src/main/git/source-control/compare-ref-oids.ts create mode 100644 src/main/git/source-control/diff-result.ts create mode 100644 src/main/git/source-control/discard-changes.ts create mode 100644 src/main/git/source-control/effective-upstream-status-cache.ts create mode 100644 src/main/git/source-control/effective-upstream-status-probe.ts create mode 100644 src/main/git/source-control/file-diff.ts create mode 100644 src/main/git/source-control/get-status-options.ts create mode 100644 src/main/git/source-control/git-blob-read.ts create mode 100644 src/main/git/source-control/git-conflict-operation.ts create mode 100644 src/main/git/source-control/git-pathspec.ts create mode 100644 src/main/git/source-control/git-read-cache-invalidation.ts create mode 100644 src/main/git/source-control/git-runtime-options-cache-key.ts create mode 100644 src/main/git/source-control/git-show-max-bytes.ts create mode 100644 src/main/git/source-control/previewable-binary-mime-types.ts create mode 100644 src/main/git/source-control/resolve-git-dir.ts create mode 100644 src/main/git/source-control/resolved-upstream-name-cache.ts create mode 100644 src/main/git/source-control/staged-commit-context.ts create mode 100644 src/main/git/source-control/staging.ts create mode 100644 src/main/git/source-control/status-branch-line-total-input.ts create mode 100644 src/main/git/source-control/status-conflict-entries.ts create mode 100644 src/main/git/source-control/status-line-stats.ts create mode 100644 src/main/git/source-control/status-read.ts create mode 100644 src/main/git/source-control/submodule-diff.ts create mode 100644 src/main/git/source-control/submodule-gitlink-oid.ts create mode 100644 src/main/git/source-control/submodule-paths.ts create mode 100644 src/main/git/source-control/submodule-status.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 8e737854747..8df39288fe9 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -18,7 +18,6 @@ inline src/main/codex-accounts/service.ts inline src/main/codex/hook-service.ts inline src/main/daemon/daemon-init.ts inline src/main/git/runner.ts -inline src/main/git/status.ts inline src/main/git/worktree.ts inline src/main/github/project-view.ts inline src/main/index.ts diff --git a/src/main/git/source-control/branch-change-entries.ts b/src/main/git/source-control/branch-change-entries.ts new file mode 100644 index 00000000000..932bc9cba22 --- /dev/null +++ b/src/main/git/source-control/branch-change-entries.ts @@ -0,0 +1,145 @@ +import type { GitBranchChangeEntry } from '../../../shared/git-diff-compare-types' +import type { GitBranchChangeStatus } from '../../../shared/git-status-types' +import { parseNumstat } from '../../../shared/git-uncommitted-line-stats' +import { decodeGitCQuotedPath } from '../../../shared/git-cquoted-path' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync } from '../runner' +import { MAX_GIT_SHOW_BYTES } from './git-show-max-bytes' + +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' + } +} + +export 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 +} + +export 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 +} + +export 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 } +} diff --git a/src/main/git/source-control/branch-compare.ts b/src/main/git/source-control/branch-compare.ts new file mode 100644 index 00000000000..68f6fbea419 --- /dev/null +++ b/src/main/git/source-control/branch-compare.ts @@ -0,0 +1,110 @@ +import type { + GitBranchCompareResult, + GitBranchCompareSummary +} from '../../../shared/git-diff-compare-types' +import { readBranchCompareHead } from '../../../shared/git-branch-compare-head' +import { resolveWorktreeAddBaseRef } from '../../../shared/worktree/base-ref' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { resolveWorktreeBaseCommitOid } from '../worktree-base-ref-probe' +import { loadBranchChanges } from './branch-change-entries' +import { + countAheadCommits, + resolveCompareRef, + resolveMergeBase, + resolveRefOid +} from './compare-ref-oids' + +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: [] } + } +} diff --git a/src/main/git/source-control/branch-diff.ts b/src/main/git/source-control/branch-diff.ts new file mode 100644 index 00000000000..9dd2183ef29 --- /dev/null +++ b/src/main/git/source-control/branch-diff.ts @@ -0,0 +1,68 @@ +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { stableInFlightKey } from '../../../shared/in-flight-promise-dedupe' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitRuntimeOptionsKey } from './git-runtime-options-cache-key' +import { gitDiffReadDedupe } from './git-read-cache-invalidation' +import { buildDiffResult } from './diff-result' +import { readGitBlobAtOidPath } from './git-blob-read' + +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 + } + } +} diff --git a/src/main/git/source-control/commit-changes.ts b/src/main/git/source-control/commit-changes.ts new file mode 100644 index 00000000000..51624479755 --- /dev/null +++ b/src/main/git/source-control/commit-changes.ts @@ -0,0 +1,34 @@ +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync } from '../runner' +import { invalidateGitReadCaches } from './git-read-cache-invalidation' + +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() + } +} diff --git a/src/main/git/source-control/commit-compare.ts b/src/main/git/source-control/commit-compare.ts new file mode 100644 index 00000000000..60bbfdfa825 --- /dev/null +++ b/src/main/git/source-control/commit-compare.ts @@ -0,0 +1,63 @@ +import type { GitCommitCompareResult } from '../../../shared/git-diff-compare-types' +import { parseGitRevListFirstParentOid } from '../../../shared/git-rev-list-output' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync } from '../runner' +import { loadCommitChanges } from './branch-change-entries' +import { resolveRefOid } from './compare-ref-oids' + +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: [] + } + } +} diff --git a/src/main/git/source-control/commit-diff.ts b/src/main/git/source-control/commit-diff.ts new file mode 100644 index 00000000000..0c17f1c600d --- /dev/null +++ b/src/main/git/source-control/commit-diff.ts @@ -0,0 +1,70 @@ +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { stableInFlightKey } from '../../../shared/in-flight-promise-dedupe' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitRuntimeOptionsKey } from './git-runtime-options-cache-key' +import { gitDiffReadDedupe } from './git-read-cache-invalidation' +import { buildDiffResult } from './diff-result' +import { readGitBlobAtOidPath } from './git-blob-read' + +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 + } + } +} diff --git a/src/main/git/source-control/compare-ref-oids.ts b/src/main/git/source-control/compare-ref-oids.ts new file mode 100644 index 00000000000..f2172de1c50 --- /dev/null +++ b/src/main/git/source-control/compare-ref-oids.ts @@ -0,0 +1,53 @@ +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync } from '../runner' + +export 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' + } +} + +export 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() +} + +export 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() +} + +export 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 +} diff --git a/src/main/git/source-control/diff-result.ts b/src/main/git/source-control/diff-result.ts new file mode 100644 index 00000000000..745ed277a72 --- /dev/null +++ b/src/main/git/source-control/diff-result.ts @@ -0,0 +1,48 @@ +import * as path from 'node:path' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { getLargeDiffRenderLimit } from '../../../shared/large-diff-render-limit' +import { PREVIEWABLE_BINARY_MIME_TYPES } from './previewable-binary-mime-types' + +export 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 + } +} diff --git a/src/main/git/source-control/discard-changes.ts b/src/main/git/source-control/discard-changes.ts new file mode 100644 index 00000000000..33fd2f3f84a --- /dev/null +++ b/src/main/git/source-control/discard-changes.ts @@ -0,0 +1,170 @@ +import * as path from 'node:path' +import { + removeSafeUntrackedDiscardTarget, + removeSafeUntrackedDiscardTargets +} from '../../../shared/git-discard-path-safety' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync } from '../runner' +import { invalidateGitReadCaches } from './git-read-cache-invalidation' +import { BULK_CHUNK_SIZE, isTrackedPathSpec, literalPathspec } from './git-pathspec' + +/** + * 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() + } +} + +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) + ) +} diff --git a/src/main/git/source-control/effective-upstream-status-cache.ts b/src/main/git/source-control/effective-upstream-status-cache.ts new file mode 100644 index 00000000000..574b6413876 --- /dev/null +++ b/src/main/git/source-control/effective-upstream-status-cache.ts @@ -0,0 +1,154 @@ +import type { GitUpstreamStatus } from '../../../shared/git-status-types' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { invalidateGitReadCaches } from './git-read-cache-invalidation' +import { resolvedUpstreamNameCache } from './resolved-upstream-name-cache' + +const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 +export const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512 + +type EffectiveUpstreamStatusCacheEntry = { + expiresAt: number + status: GitUpstreamStatus +} + +const effectiveUpstreamStatusCache = new Map() +export const effectiveUpstreamStatusInFlight = new Map>() +const retiredEffectiveUpstreamStatusInFlight = new Map>() + +export const effectiveUpstreamStatusWriteGeneration = new Map() + +// 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 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) + ) +} + +export 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) + } +} + +export 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 +} + +export 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() +} diff --git a/src/main/git/source-control/effective-upstream-status-probe.ts b/src/main/git/source-control/effective-upstream-status-probe.ts new file mode 100644 index 00000000000..bea3dc93d79 --- /dev/null +++ b/src/main/git/source-control/effective-upstream-status-probe.ts @@ -0,0 +1,154 @@ +import type { GitUpstreamStatus } from '../../../shared/git-status-types' +import { + getEffectiveGitUpstreamStatus, + getGitUpstreamStatusForUpstreamName, + splitRemoteBranchName +} from '../../../shared/git-effective-upstream' +import { createGitConfigSnapshotRunner } from '../../../shared/git-config-snapshot-runner' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitStatusReadOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync } from '../runner' +import { + MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES, + effectiveUpstreamStatusInFlight, + effectiveUpstreamStatusWriteGeneration, + readCachedEffectiveUpstreamStatus, + rememberEffectiveUpstreamStatus, + trimEffectiveUpstreamStatusGeneration +} from './effective-upstream-status-cache' +import { + RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS, + resolvedUpstreamNameCache +} from './resolved-upstream-name-cache' + +export function getShortBranchName(branch: string | undefined): string | null { + const prefix = 'refs/heads/' + return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null +} + +export 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, gitStatusReadOptionsForWorktree(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, gitStatusReadOptionsForWorktree(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 } +} + +export 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 +} diff --git a/src/main/git/source-control/file-diff.ts b/src/main/git/source-control/file-diff.ts new file mode 100644 index 00000000000..a8fe78a6270 --- /dev/null +++ b/src/main/git/source-control/file-diff.ts @@ -0,0 +1,147 @@ +import * as path from 'node:path' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { stableInFlightKey } from '../../../shared/in-flight-promise-dedupe' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitRuntimeOptionsKey } from './git-runtime-options-cache-key' +import { gitDiffReadDedupe } from './git-read-cache-invalidation' +import { buildDiffResult } from './diff-result' +import { + readGitBlobAtIndexPath, + readGitBlobAtOidPath, + readUnstagedLeftBlob, + readWorkingTreeFile +} from './git-blob-read' +import { + findContainingSubmodule, + listSubmodulePaths, + resolveSubmoduleWorktreePath +} from './submodule-paths' +import { + readGitlinkOidFromIndex, + readGitlinkOidFromTree, + readWorkingSubmoduleHead +} from './submodule-gitlink-oid' +import { buildSubmoduleInnerCommitRangeDiff, buildSubmodulePointerDiff } from './submodule-diff' + +/** + * 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 +} diff --git a/src/main/git/source-control/get-status-options.ts b/src/main/git/source-control/get-status-options.ts new file mode 100644 index 00000000000..1b69cc3eae6 --- /dev/null +++ b/src/main/git/source-control/get-status-options.ts @@ -0,0 +1,21 @@ +import type { GitRuntimeOptions } from '../git-runtime-options' + +export type GetStatusOptions = GitRuntimeOptions & { + includeIgnored?: boolean + reuseLineStats?: boolean + /** Merge-base OID the caller wants the branch line total measured against; + * omitted means the chip is hidden, so no ranged diff runs at all. */ + branchLineTotalMergeBase?: string + /** + * 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[] +} diff --git a/src/main/git/source-control/git-blob-read.ts b/src/main/git/source-control/git-blob-read.ts new file mode 100644 index 00000000000..e9e9182939c --- /dev/null +++ b/src/main/git/source-control/git-blob-read.ts @@ -0,0 +1,121 @@ +import { readFile, stat } from 'node:fs/promises' +import * as path from 'node:path' +import { isBinaryBuffer } from '../../../shared/binary-buffer' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsyncBuffer } from '../runner' +import { isMaxBufferOverflowError } from '../max-buffer-overflow' +import { MAX_GIT_SHOW_BYTES } from './git-show-max-bytes' +import { PREVIEWABLE_BINARY_MIME_TYPES } from './previewable-binary-mime-types' + +export type GitBlobReadResult = { + content: string + isBinary: boolean + exists: boolean +} + +export 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) +} + +export 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 } + } +} + +export 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 } + } +} + +export 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 + } +} diff --git a/src/main/git/source-control/git-conflict-operation.ts b/src/main/git/source-control/git-conflict-operation.ts new file mode 100644 index 00000000000..de7d5604f16 --- /dev/null +++ b/src/main/git/source-control/git-conflict-operation.ts @@ -0,0 +1,59 @@ +import { existsSync } from 'node:fs' +import * as path from 'node:path' +import type { GitConflictOperation } from '../../../shared/git-status-types' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync } from '../runner' +import { runWithGitReadCacheInvalidation } from './git-read-cache-invalidation' +import { resolveGitDir } from './resolve-git-dir' + +// 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)) + ) +} diff --git a/src/main/git/source-control/git-pathspec.ts b/src/main/git/source-control/git-pathspec.ts new file mode 100644 index 00000000000..29ce04ade99 --- /dev/null +++ b/src/main/git/source-control/git-pathspec.ts @@ -0,0 +1,21 @@ +import type { GitRuntimeOptions } from '../git-runtime-options' + +export const BULK_CHUNK_SIZE = 100 + +function normalizeGitPathForCompare(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/\/+$/, '') +} + +export 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}` +} + +export 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}/`) + }) +} diff --git a/src/main/git/source-control/git-read-cache-invalidation.ts b/src/main/git/source-control/git-read-cache-invalidation.ts new file mode 100644 index 00000000000..7d4da0cc8ed --- /dev/null +++ b/src/main/git/source-control/git-read-cache-invalidation.ts @@ -0,0 +1,35 @@ +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import type { GitStatusResult } from '../../../shared/git-status-types' +import { InFlightPromiseDedupe } from '../../../shared/in-flight-promise-dedupe' +import { clearGitStatusLineStatsCache } from '../../../shared/git-status-line-stats-cache' +import { invalidateGitBranchLineTotalInFlight } from '../../../shared/git-branch-line-total' +import { GitStatusReadLeaseOwner } from '../git-status-read-lease-owner' +import { invalidateGitUpstreamStatusReads } from '../upstream' +import { clearSubmodulePathsCache } from './submodule-paths' +import { resolvedUpstreamNameCache } from './resolved-upstream-name-cache' + +export const gitDiffReadDedupe = new InFlightPromiseDedupe() + +export const statusReadLeaseOwner = new GitStatusReadLeaseOwner() + +// Why: clear every in-flight git read cache; clearing only some would let a post-mutation +// getStatus() join a pre-mutation read and publish it as current. +export function invalidateGitReadCaches(): void { + gitDiffReadDedupe.clear() + statusReadLeaseOwner.invalidate() + invalidateGitBranchLineTotalInFlight() + invalidateGitUpstreamStatusReads() + 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() + } +} diff --git a/src/main/git/source-control/git-runtime-options-cache-key.ts b/src/main/git/source-control/git-runtime-options-cache-key.ts new file mode 100644 index 00000000000..abba1e0e0a5 --- /dev/null +++ b/src/main/git/source-control/git-runtime-options-cache-key.ts @@ -0,0 +1,5 @@ +import type { GitRuntimeOptions } from '../git-runtime-options' + +export function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] { + return [options.wslDistro ?? null] +} diff --git a/src/main/git/source-control/git-show-max-bytes.ts b/src/main/git/source-control/git-show-max-bytes.ts new file mode 100644 index 00000000000..a9f5da3c92c --- /dev/null +++ b/src/main/git/source-control/git-show-max-bytes.ts @@ -0,0 +1,2 @@ +export const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 +export const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES diff --git a/src/main/git/source-control/previewable-binary-mime-types.ts b/src/main/git/source-control/previewable-binary-mime-types.ts new file mode 100644 index 00000000000..28ba602bc95 --- /dev/null +++ b/src/main/git/source-control/previewable-binary-mime-types.ts @@ -0,0 +1,11 @@ +export 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' +} diff --git a/src/main/git/source-control/resolve-git-dir.ts b/src/main/git/source-control/resolve-git-dir.ts new file mode 100644 index 00000000000..acafe96456b --- /dev/null +++ b/src/main/git/source-control/resolve-git-dir.ts @@ -0,0 +1,18 @@ +import { readFile } from 'node:fs/promises' +import * as path from 'node:path' + +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 +} diff --git a/src/main/git/source-control/resolved-upstream-name-cache.ts b/src/main/git/source-control/resolved-upstream-name-cache.ts new file mode 100644 index 00000000000..c83b97a6b87 --- /dev/null +++ b/src/main/git/source-control/resolved-upstream-name-cache.ts @@ -0,0 +1,9 @@ +// Why: cache the upstream name to skip its 4-5-spawn resolution chain each poll; revalidate via one rev-list (issue #7576). +export const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 + +type ResolvedUpstreamNameCacheEntry = { + upstreamName: string + expiresAt: number +} + +export const resolvedUpstreamNameCache = new Map() diff --git a/src/main/git/source-control/staged-commit-context.ts b/src/main/git/source-control/staged-commit-context.ts new file mode 100644 index 00000000000..085c19e71db --- /dev/null +++ b/src/main/git/source-control/staged-commit-context.ts @@ -0,0 +1,52 @@ +import type { CommitMessageDraftContext } from '../../../shared/commit-message-generation' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync } from '../runner' +import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from '../max-buffer-overflow' +import { MAX_STAGED_COMMIT_CONTEXT_BYTES } from './git-show-max-bytes' + +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 + } +} diff --git a/src/main/git/source-control/staging.ts b/src/main/git/source-control/staging.ts new file mode 100644 index 00000000000..5aa8f55fa48 --- /dev/null +++ b/src/main/git/source-control/staging.ts @@ -0,0 +1,99 @@ +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync } from '../runner' +import { invalidateGitReadCaches } from './git-read-cache-invalidation' +import { BULK_CHUNK_SIZE, literalPathspec } from './git-pathspec' + +/** + * 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() + } +} + +/** + * 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() + } +} diff --git a/src/main/git/source-control/status-branch-line-total-input.ts b/src/main/git/source-control/status-branch-line-total-input.ts new file mode 100644 index 00000000000..d2ac795bb55 --- /dev/null +++ b/src/main/git/source-control/status-branch-line-total-input.ts @@ -0,0 +1,56 @@ +import type { GitStatusEntry } from '../../../shared/git-status-types' +import { + computeGitBranchLineTotal, + readGitBranchLineTotalMergeBaseParam, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../../../shared/git-branch-line-total' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitStatusReadOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' +import type { GetStatusOptions } from './get-status-options' + +/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */ +export function createBranchLineTotalInput( + worktreePath: string, + entries: GitStatusEntry[], + options: GetStatusOptions, + statusSucceeded: boolean +): { mergeBase: string; compute: () => Promise } | undefined { + const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase) + // Why: a failed status scan leaves the untracked list untrustworthy, so the + // total would silently under-count rather than be absent. + if (mergeBase === undefined || !statusSucceeded) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Why: the same path can be a different filesystem per WSL distro. + hostKey: options.wslDistro ?? 'native', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path), + runDiffNumstat: (args, signal) => + gitExecFileAsync(args, { + ...gitStatusReadOptionsForWorktree(worktreePath, options), + // Why: after the spread, so the shared lease signal wins over this caller's own. + signal, + env: gitOptionalLocksDisabledEnv(), + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then((result) => result.stdout), + ...(options.signal ? { signal: options.signal } : {}) + }) + } +} + +export 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}` +} diff --git a/src/main/git/source-control/status-conflict-entries.ts b/src/main/git/source-control/status-conflict-entries.ts new file mode 100644 index 00000000000..f4d765d48ce --- /dev/null +++ b/src/main/git/source-control/status-conflict-entries.ts @@ -0,0 +1,87 @@ +import { existsSync } from 'node:fs' +import * as path from 'node:path' +import type { + GitConflictKind, + GitFileStatus, + GitStatusEntry +} from '../../../shared/git-status-types' +import { decodeGitCQuotedPath } from '../../../shared/git-cquoted-path' + +export 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' + } +} diff --git a/src/main/git/source-control/status-line-stats.ts b/src/main/git/source-control/status-line-stats.ts new file mode 100644 index 00000000000..660c9ea018e --- /dev/null +++ b/src/main/git/source-control/status-line-stats.ts @@ -0,0 +1,75 @@ +import type { GitStatusEntry } from '../../../shared/git-status-types' +import { + applyLineStats, + collectUntrackedAdditions, + parseNumstat, + type GitLineStats +} from '../../../shared/git-uncommitted-line-stats' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitStatusReadOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' + +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' + ], + { + ...gitStatusReadOptionsForWorktree(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. */ +export 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 +} diff --git a/src/main/git/source-control/status-read.ts b/src/main/git/source-control/status-read.ts new file mode 100644 index 00000000000..898b46e1ba9 --- /dev/null +++ b/src/main/git/source-control/status-read.ts @@ -0,0 +1,262 @@ +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 { gitOptionalLocksDisabledEnv, gitStreamStdout } from '../runner' +import { findExistingWorktreeSymlinkPaths } from '../worktree-symlink-detection' +import type { GetStatusOptions } from './get-status-options' +import { gitDiffReadDedupe, statusReadLeaseOwner } from './git-read-cache-invalidation' +import { detectConflictOperation } from './git-conflict-operation' +import { parseUnmergedEntry } from './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 { + 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, + // 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[], + 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 + // 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, + 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: 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. + let branchLineTotal: GitBranchLineTotal | undefined + if (!didHitLimit) { + 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 { + 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 }) + } + : {}) + } +} diff --git a/src/main/git/source-control/submodule-diff.ts b/src/main/git/source-control/submodule-diff.ts new file mode 100644 index 00000000000..f3fd95485a5 --- /dev/null +++ b/src/main/git/source-control/submodule-diff.ts @@ -0,0 +1,80 @@ +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { buildDiffResult } from './diff-result' +import { readGitBlobAtOidPath } from './git-blob-read' +import { resolveSubmoduleWorktreePath } from './submodule-paths' +import { + readGitlinkOidFromIndex, + readGitlinkOidFromTree, + readWorkingSubmoduleHead +} from './submodule-gitlink-oid' + +/** + * 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. + */ +export 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). + */ +export 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 + ) +} diff --git a/src/main/git/source-control/submodule-gitlink-oid.ts b/src/main/git/source-control/submodule-gitlink-oid.ts new file mode 100644 index 00000000000..f3d8d474a92 --- /dev/null +++ b/src/main/git/source-control/submodule-gitlink-oid.ts @@ -0,0 +1,51 @@ +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' + +export 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 '' + } +} + +export 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 '' + } +} + +export 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 '' + } +} diff --git a/src/main/git/source-control/submodule-paths.ts b/src/main/git/source-control/submodule-paths.ts new file mode 100644 index 00000000000..b667110b6ae --- /dev/null +++ b/src/main/git/source-control/submodule-paths.ts @@ -0,0 +1,147 @@ +import * as path from 'node:path' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' +import { gitRuntimeOptionsKey } from './git-runtime-options-cache-key' + +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 + +export function clearSubmodulePathsCacheForTests(): void { + clearSubmodulePathsCache() +} + +export 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 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() +} + +/** + * 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 +} + +/** + * 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. + */ +export 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 +} diff --git a/src/main/git/source-control/submodule-status.ts b/src/main/git/source-control/submodule-status.ts new file mode 100644 index 00000000000..f3c808c9f2f --- /dev/null +++ b/src/main/git/source-control/submodule-status.ts @@ -0,0 +1,118 @@ +import type { GitStatusEntry, GitStatusResult } from '../../../shared/git-status-types' +import { capGitStatusEntries, resolveGitStatusLimit } from '../../../shared/git-status-limit' +import { parseNumstat } from '../../../shared/git-uncommitted-line-stats' +import type { GitRuntimeOptions } from '../git-runtime-options' +import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' +import type { GetStatusOptions } from './get-status-options' +import { getStatus } from './status-read' +import { resolveSubmoduleWorktreePath } from './submodule-paths' +import { parseBranchChangeLine } from './branch-change-entries' +import { + readGitlinkOidFromIndex, + readGitlinkOidFromTree, + readWorkingSubmoduleHead +} from './submodule-gitlink-oid' + +/** + * 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 +} diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 7abfd56c888..68bdcd94aab 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1,2362 +1,42 @@ -/* 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, - GitBranchCompareResult, - GitBranchCompareSummary, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { - GitBranchChangeStatus, - GitConflictKind, - GitConflictOperation, - GitFileStatus, - GitStatusEntry, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-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, gitStatusReadOptionsForWorktree } from './git-runtime-options' -import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' -import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' -import { invalidateGitUpstreamStatusReads } from './upstream' -import { - computeGitBranchLineTotal, - invalidateGitBranchLineTotalInFlight, - readGitBranchLineTotalMergeBaseParam, - GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, - type GitBranchLineTotal -} from '../../shared/git-branch-line-total' -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 every in-flight git read cache; clearing only some would let a post-mutation -// getStatus() join a pre-mutation read and publish it as current. -export function invalidateGitReadCaches(): void { - gitDiffReadDedupe.clear() - statusReadLeaseOwner.invalidate() - invalidateGitBranchLineTotalInFlight() - invalidateGitUpstreamStatusReads() - 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 - /** Merge-base OID the caller wants the branch line total measured against; - * omitted means the chip is hidden, so no ranged diff runs at all. */ - branchLineTotalMergeBase?: string - /** - * 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, - // 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[], - 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 - // 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, - 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: 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. - let branchLineTotal: GitBranchLineTotal | undefined - if (!didHitLimit) { - 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 { - 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 }) - } - : {}) - } -} - -/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */ -function createBranchLineTotalInput( - worktreePath: string, - entries: GitStatusEntry[], - options: GetStatusOptions, - statusSucceeded: boolean -): { mergeBase: string; compute: () => Promise } | undefined { - const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase) - // Why: a failed status scan leaves the untracked list untrustworthy, so the - // total would silently under-count rather than be absent. - if (mergeBase === undefined || !statusSucceeded) { - return undefined - } - return { - mergeBase, - compute: () => - computeGitBranchLineTotal({ - worktreePath, - // Why: the same path can be a different filesystem per WSL distro. - hostKey: options.wslDistro ?? 'native', - mergeBase, - untrackedPaths: entries - .filter((entry) => entry.area === 'untracked') - .map((entry) => entry.path), - runDiffNumstat: (args, signal) => - gitExecFileAsync(args, { - ...gitStatusReadOptionsForWorktree(worktreePath, options), - // Why: after the spread, so the shared lease signal wins over this caller's own. - signal, - env: gitOptionalLocksDisabledEnv(), - timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS - }).then((result) => result.stdout), - ...(options.signal ? { signal: options.signal } : {}) - }) - } -} - -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' - ], - { - ...gitStatusReadOptionsForWorktree(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, gitStatusReadOptionsForWorktree(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, gitStatusReadOptionsForWorktree(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() - } -} +// Entry point for the main-process source-control surface. The implementation lives in +// `./source-control/*`; this file stays the single import path because ~40 modules and tests +// (many via `vi.mock('../git/status')`) already resolve their git reads/writes through it. +export { getStatus } from './source-control/status-read' +export type { GetStatusOptions } from './source-control/get-status-options' +export { getSubmoduleStatus } from './source-control/submodule-status' +export { + MAX_SUBMODULE_PATHS_CACHE_ENTRIES, + clearSubmodulePathsCacheForTests, + getSubmodulePathsCacheCountForTests, + listSubmodulePaths, + resolveSubmoduleWorktreePath +} from './source-control/submodule-paths' +export { + invalidateGitReadCaches, + runWithGitReadCacheInvalidation +} from './source-control/git-read-cache-invalidation' +export { + clearEffectiveUpstreamNegativeStatusCache, + clearEffectiveUpstreamStatusCacheForTests, + getEffectiveUpstreamStatusCacheCountForTests, + getEffectiveUpstreamStatusGenerationCountForTests +} from './source-control/effective-upstream-status-cache' +export { + abortMerge, + abortRebase, + detectConflictOperation +} from './source-control/git-conflict-operation' +export { resolveGitDir } from './source-control/resolve-git-dir' +export { getDiff } from './source-control/file-diff' +export { getBranchCompare } from './source-control/branch-compare' +export { getBranchDiff } from './source-control/branch-diff' +export { getCommitCompare } from './source-control/commit-compare' +export { getCommitDiff } from './source-control/commit-diff' +export { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './source-control/staging' +export { getStagedCommitContext } from './source-control/staged-commit-context' +export { commitChanges } from './source-control/commit-changes' +export { + bulkDiscardChanges, + discardChanges, + isWithinWorktree +} from './source-control/discard-changes'