mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
* Add mobile image-diff previews via shared data-URI builder - Extracts a `buildImageDataUri` helper (src/shared/image-data-uri.ts) shared by the desktop ImageViewer and mobile, so both trim whitespace-wrapped base64 and skip non-previewable mimes (e.g. application/pdf) the same way. - Adds mobile-diff-image-preview.ts to render binary git.diff results (add/modify/ delete) as images instead of falling back to "Binary preview unavailable". - Extracts resolveMobileFileTabDoc to consolidate the session file-tab loading logic (diff/image/html/text) out of the route file for testability. * Fix stale binary image fallback for empty modified diffs and relay reads - mobileDiffImageDataUri now distinguishes a true deletion (modified side absent) from a modify whose binary bytes arrived empty (relay/size-cap cases), returning null instead of the stale pre-change image - readWorkingDiffFile passes the file path to bufferToBlob so relay working-tree reads can detect previewable image extensions instead of always reporting empty binary content - add mobile-file-tab-doc.test.ts covering diff/image/binary/text resolution paths * Regenerate skill bundle manifest for 1.4.144-rc.2 Co-authored-by: Orca <help@stably.ai> * fix(review): trim comments to AGENTS.md's one/two-line why-only rule Comments in mobile-diff-image-preview.ts and mobile-file-tab-doc.ts ran 3-6 lines and narrated mechanism instead of stating only the non-obvious reason, per AGENTS.md's "Code Comments: Document the Why, Briefly" rule. Co-authored-by: Orca <help@stably.ai> * Distinguish read failures from true deletions in binary diff results - Working-tree stat/readFile errors and relay reads previously collapsed onto the same empty-content signal as a genuine deletion, letting previewers fall back to stale original bytes on a failed read. - Add modifiedDeleted/missing flags through status.ts, git-handler-ops, and git-working-file-read so only proven deletions trigger the original-bytes fallback; failed reads now return null. - Tighten buildImageDataUri to accept only image/* mimes instead of special-casing application/pdf. * fix(relay): expect missing:false on index blob maxBuffer overflow readBlobAtIndex now returns a missing flag so staged deletions are distinct from size-capped binary reads; update the overflow test. * Allow opening deleted files to show pre-delete text or image diffs Deleted files can now be opened to view their pre-delete content via git.diff (including images via modifiedDeleted). Only unresolved conflicts remain unopenable. Centralizes the canOpen rule in canOpenMobileGitStatusEntry() to keep opener guards consistent across the mobile source control UI. --------- Co-authored-by: Orca <help@stably.ai>
2336 lines
74 KiB
TypeScript
2336 lines
74 KiB
TypeScript
/* eslint-disable max-lines */
|
||
import { existsSync } from 'node:fs'
|
||
import { readFile, stat } from 'node:fs/promises'
|
||
import * as path from 'node:path'
|
||
import type {
|
||
GitBranchChangeEntry,
|
||
GitBranchChangeStatus,
|
||
GitBranchCompareResult,
|
||
GitBranchCompareSummary,
|
||
GitCommitCompareResult,
|
||
GitConflictKind,
|
||
GitConflictOperation,
|
||
GitDiffResult,
|
||
GitFileStatus,
|
||
GitStatusEntry,
|
||
GitStatusResult,
|
||
GitUpstreamStatus
|
||
} from '../../shared/types'
|
||
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
|
||
import {
|
||
getEffectiveGitUpstreamStatus,
|
||
getGitUpstreamStatusForUpstreamName,
|
||
splitRemoteBranchName
|
||
} from '../../shared/git-effective-upstream'
|
||
import { createGitConfigSnapshotRunner } from '../../shared/git-config-snapshot-runner'
|
||
import { isBinaryBuffer } from '../../shared/binary-buffer'
|
||
import {
|
||
applyLineStats,
|
||
collectUntrackedAdditions,
|
||
parseNumstat,
|
||
type GitLineStats
|
||
} from '../../shared/git-uncommitted-line-stats'
|
||
import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path'
|
||
import {
|
||
gitExecFileAsync,
|
||
gitExecFileAsyncBuffer,
|
||
gitOptionalLocksDisabledEnv,
|
||
gitStreamStdout
|
||
} from './runner'
|
||
import { StatusPorcelainParser } from './status-porcelain-parser'
|
||
import { DEFAULT_GIT_STATUS_LIMIT } from '../../shared/git-status-limit'
|
||
import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from './max-buffer-overflow'
|
||
import {
|
||
removeSafeUntrackedDiscardTarget,
|
||
removeSafeUntrackedDiscardTargets
|
||
} from '../../shared/git-discard-path-safety'
|
||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
|
||
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
|
||
import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
|
||
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
|
||
import {
|
||
beginGitStatusLineStatsCacheWrite,
|
||
clearGitStatusLineStatsCache,
|
||
clearGitStatusLineStatsCacheKey,
|
||
reuseOrRecomputeGitStatusLineStats
|
||
} from '../../shared/git-status-line-stats-cache'
|
||
|
||
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
|
||
const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES
|
||
const BULK_CHUNK_SIZE = 100
|
||
const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000
|
||
const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512
|
||
|
||
type EffectiveUpstreamStatusCacheEntry = {
|
||
expiresAt: number
|
||
status: GitUpstreamStatus
|
||
}
|
||
|
||
const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000
|
||
export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512
|
||
type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number }
|
||
const submodulePathsCache = new Map<string, SubmodulePathsCacheEntry>()
|
||
let submodulePathsCacheGeneration = 0
|
||
|
||
// Why: the effective-upstream resolution chain (symbolic-ref + rev-parse ×2-3
|
||
// + config snapshot) costs 4-5 subprocess spawns and only changes when branch
|
||
// or git config changes. Ahead/behind is a pure function of the two rev-list
|
||
// endpoints, so a recently-resolved name can be revalidated with one rev-list
|
||
// spawn per poll tick; a failed rev-list (deleted ref) falls back to a full
|
||
// re-resolve. Issue #7576: this path dominated idle main-process spawn churn.
|
||
const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000
|
||
|
||
type ResolvedUpstreamNameCacheEntry = {
|
||
upstreamName: string
|
||
expiresAt: number
|
||
}
|
||
|
||
const resolvedUpstreamNameCache = new Map<string, ResolvedUpstreamNameCacheEntry>()
|
||
|
||
const effectiveUpstreamStatusCache = new Map<string, EffectiveUpstreamStatusCacheEntry>()
|
||
const effectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
|
||
const retiredEffectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
|
||
const gitDiffReadDedupe = new InFlightPromiseDedupe<GitDiffResult>()
|
||
const effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
|
||
const statusReadsInFlight = new Map<string, Promise<GitStatusResult>>()
|
||
|
||
// Why: a mutation invalidates both in-flight diff reads and in-flight status
|
||
// coalescing; clearing only the diff dedupe would let a post-mutation
|
||
// getStatus() join a pre-mutation read and return stale entries.
|
||
export function invalidateGitReadCaches(): void {
|
||
gitDiffReadDedupe.clear()
|
||
statusReadsInFlight.clear()
|
||
clearGitStatusLineStatsCache()
|
||
clearSubmodulePathsCache()
|
||
resolvedUpstreamNameCache.clear()
|
||
}
|
||
|
||
export async function runWithGitReadCacheInvalidation<T>(run: () => Promise<T>): Promise<T> {
|
||
invalidateGitReadCaches()
|
||
try {
|
||
return await run()
|
||
} finally {
|
||
// Why: a read that started during the mutation can be stale too, so the
|
||
// post-mutation boundary retires both pre-existing and overlapping reads.
|
||
invalidateGitReadCaches()
|
||
}
|
||
}
|
||
|
||
export function clearSubmodulePathsCacheForTests(): void {
|
||
clearSubmodulePathsCache()
|
||
}
|
||
|
||
function clearSubmodulePathsCache(): void {
|
||
submodulePathsCache.clear()
|
||
// Why: a pre-mutation .gitmodules read must not repopulate the cache after
|
||
// the mutation invalidated it.
|
||
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 string can refer to different filesystem views across
|
||
// WSL distros, so the `.gitmodules` cache must follow 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: status tests reuse this reset hook, so every cross-call memoization layer
|
||
// must reset together even though the historical name mentions upstream only.
|
||
export function clearEffectiveUpstreamStatusCacheForTests(): void {
|
||
effectiveUpstreamStatusCache.clear()
|
||
effectiveUpstreamStatusInFlight.clear()
|
||
retiredEffectiveUpstreamStatusInFlight.clear()
|
||
effectiveUpstreamStatusWriteGeneration.clear()
|
||
invalidateGitReadCaches()
|
||
}
|
||
|
||
export function getEffectiveUpstreamStatusCacheCountForTests(): number {
|
||
return effectiveUpstreamStatusCache.size
|
||
}
|
||
|
||
export function getEffectiveUpstreamStatusGenerationCountForTests(): number {
|
||
return effectiveUpstreamStatusWriteGeneration.size
|
||
}
|
||
|
||
export type GetStatusOptions = GitRuntimeOptions & {
|
||
includeIgnored?: boolean
|
||
reuseLineStats?: boolean
|
||
/**
|
||
* Max changed-file entries before git is stopped and the result is marked
|
||
* `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap.
|
||
*/
|
||
limit?: number
|
||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||
}
|
||
|
||
/**
|
||
* Parse `git status --porcelain=v2` output into structured entries.
|
||
*/
|
||
export async function getStatus(
|
||
worktreePath: string,
|
||
options: GetStatusOptions = {}
|
||
): Promise<GitStatusResult> {
|
||
gitDiffReadDedupe.clear()
|
||
if (options.signal) {
|
||
return runGetStatus(worktreePath, options)
|
||
}
|
||
// Why: dedupe only concurrent identical reads; after settle, callers must
|
||
// execute a fresh status read rather than observing a cached result.
|
||
const cacheKey = getStatusReadKey(worktreePath, options)
|
||
const inFlightStatus = statusReadsInFlight.get(cacheKey)
|
||
if (inFlightStatus) {
|
||
return inFlightStatus
|
||
}
|
||
|
||
const statusPromise = runGetStatus(worktreePath, options)
|
||
statusReadsInFlight.set(cacheKey, statusPromise)
|
||
try {
|
||
return await statusPromise
|
||
} finally {
|
||
if (statusReadsInFlight.get(cacheKey) === statusPromise) {
|
||
statusReadsInFlight.delete(cacheKey)
|
||
}
|
||
}
|
||
}
|
||
|
||
function getStatusReadKey(worktreePath: string, options: GetStatusOptions): string {
|
||
// Why: each key part can change the output shape or runtime routing.
|
||
const limit =
|
||
typeof options.limit === 'number' && Number.isInteger(options.limit) && options.limit >= 0
|
||
? options.limit
|
||
: DEFAULT_GIT_STATUS_LIMIT
|
||
return [
|
||
worktreePath,
|
||
options.wslDistro ?? '',
|
||
options.includeIgnored === true,
|
||
options.reuseLineStats === true,
|
||
options.bypassEffectiveUpstreamNegativeCache === true,
|
||
limit
|
||
].join('\0')
|
||
}
|
||
|
||
async function runGetStatus(
|
||
worktreePath: string,
|
||
options: GetStatusOptions = {}
|
||
): Promise<GitStatusResult> {
|
||
const lineStatsCacheKey = getStatusLineStatsCacheKey(worktreePath, options)
|
||
const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey)
|
||
let effectiveUpstreamStatus: GitUpstreamStatus | undefined
|
||
let statusSucceeded = false
|
||
// Why: a negative/fractional/NaN limit would trigger spurious early-stop or
|
||
// inconsistent truncation; fall back to the default unless it's a valid
|
||
// non-negative integer (0 explicitly disables the cap).
|
||
const limit =
|
||
typeof options.limit === 'number' && Number.isInteger(options.limit) && options.limit >= 0
|
||
? options.limit
|
||
: DEFAULT_GIT_STATUS_LIMIT
|
||
|
||
// Why: detectConflictOperation (4 existsSync + readFile) and git status are
|
||
// independent. Running them concurrently saves one round-trip of I/O latency.
|
||
const conflictPromise = detectConflictOperation(worktreePath)
|
||
// Why: -c core.quotePath=false keeps non-ASCII filenames (Japanese, emoji,
|
||
// etc.) as raw UTF-8 instead of git's default C-style octal escapes wrapped
|
||
// in double quotes. Without it, the parsed entry.path is unreadable in the
|
||
// sidebar and downstream `git show :"docs/\346..."` lookups silently miss.
|
||
const statusArgs = [
|
||
'-c',
|
||
'core.quotePath=false',
|
||
'status',
|
||
'--porcelain=v2',
|
||
'--branch',
|
||
'--untracked-files=all'
|
||
]
|
||
if (options.includeIgnored) {
|
||
statusArgs.push('--ignored=matching')
|
||
}
|
||
|
||
// Why: stream + parse incrementally and stop git the moment the entry count
|
||
// crosses `limit`, so a repo with an enormous un-ignored folder never buffers
|
||
// a status listing big enough to crash the process. See StatusPorcelainParser.
|
||
const parser = new StatusPorcelainParser()
|
||
let didHitLimit = false
|
||
const conflictOperation = await conflictPromise
|
||
|
||
try {
|
||
const { stoppedEarly } = await gitStreamStdout(statusArgs, {
|
||
cwd: worktreePath,
|
||
wslDistro: options.wslDistro,
|
||
// Why: status polling is read-like; avoid refreshing the index and racing
|
||
// terminal Git commands on `.git/worktrees/*/index.lock`.
|
||
env: gitOptionalLocksDisabledEnv(),
|
||
signal: options.signal,
|
||
onStdout: (chunk) => parser.update(chunk, limit)
|
||
})
|
||
if (!stoppedEarly) {
|
||
parser.finish()
|
||
}
|
||
didHitLimit = stoppedEarly
|
||
statusSucceeded = true
|
||
} catch (error) {
|
||
// Why: an aborted scan must reject, not resolve — swallowing here would let
|
||
// a cancelled request be mistaken for a completed (empty) status result.
|
||
if (options.signal?.aborted) {
|
||
throw error
|
||
}
|
||
// Not a git repo or git not available
|
||
}
|
||
|
||
// Why: the parser stops one entry past the limit (it checks after pushing), so
|
||
// trim back to exactly `limit` for a stable "first N shown" contract.
|
||
const entries = didHitLimit ? parser.entries.slice(0, limit) : parser.entries
|
||
const { head, branch, upstreamName, upstreamAheadBehind } = parser.branch
|
||
|
||
// Why: unmerged (`u`) records need async per-file git lookups, so the parser
|
||
// collected their raw lines; resolve them now. Conflicts are rare and never
|
||
// the source of huge output, so this stays off the streamed hot path.
|
||
if (!didHitLimit) {
|
||
for (const line of parser.unmergedLines) {
|
||
const unmergedEntry = await parseUnmergedEntry(worktreePath, line)
|
||
if (unmergedEntry) {
|
||
entries.push(unmergedEntry)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (statusSucceeded && !didHitLimit && shouldProbeEffectiveUpstreamStatus(branch, upstreamName)) {
|
||
const branchName = getShortBranchName(branch)
|
||
if (branchName) {
|
||
const cacheKey = getEffectiveUpstreamStatusCacheKey(
|
||
worktreePath,
|
||
branchName,
|
||
upstreamName,
|
||
options
|
||
)
|
||
try {
|
||
// Why: the probe promise and its name/negative caches are shared by
|
||
// concurrent status reads, so one caller's abort must not reject the
|
||
// shared probe or evict warm cache state for the others. The probe is
|
||
// small and its cached result stays useful, so run it unbound from
|
||
// this request's signal.
|
||
const { signal: _requestSignal, ...sharedProbeOptions } = options
|
||
effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus(
|
||
cacheKey,
|
||
worktreePath,
|
||
branchName,
|
||
sharedProbeOptions,
|
||
options.bypassEffectiveUpstreamNegativeCache === true
|
||
)
|
||
} catch {
|
||
// Why: git status polling should not fail just because the richer
|
||
// upstream probe hit a transient ref/read error; the explicit
|
||
// upstream-status path will surface those failures when invoked.
|
||
}
|
||
}
|
||
}
|
||
|
||
// Why: attach per-area line counts for the sidebar. Diffs run after status
|
||
// (we need the entry list first) and only for areas that have entries, so a
|
||
// clean tree costs zero extra git calls. Skipped when the limit was hit —
|
||
// running numstat over a huge change set would reintroduce the cost the limit
|
||
// exists to avoid, matching how a "huge" repo disables extra git features.
|
||
if (!didHitLimit) {
|
||
await reuseOrRecomputeGitStatusLineStats({
|
||
cacheKey: lineStatsCacheKey,
|
||
head,
|
||
entries,
|
||
writeToken: lineStatsWriteToken,
|
||
reuse: options.reuseLineStats === true,
|
||
isAborted: () => options.signal?.aborted === true,
|
||
recompute: () => attachLineStats(worktreePath, entries, options)
|
||
})
|
||
} else {
|
||
clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken)
|
||
}
|
||
|
||
// Why: abort after the stream (e.g. during unmerged/upstream/line-stats work)
|
||
// must still reject — never resolve a cancelled scan as a completed result.
|
||
if (options.signal?.aborted) {
|
||
const error = new Error('The operation was aborted.')
|
||
error.name = 'AbortError'
|
||
throw error
|
||
}
|
||
|
||
return {
|
||
entries,
|
||
conflictOperation,
|
||
head,
|
||
branch,
|
||
...(options.includeIgnored ? { ignoredPaths: parser.ignoredPaths } : {}),
|
||
...(didHitLimit ? { didHitLimit: true, statusLength: parser.statusLength } : {}),
|
||
...(statusSucceeded
|
||
? {
|
||
upstreamStatus:
|
||
effectiveUpstreamStatus ??
|
||
(upstreamName
|
||
? {
|
||
hasUpstream: true,
|
||
upstreamName,
|
||
ahead: upstreamAheadBehind?.ahead ?? 0,
|
||
behind: upstreamAheadBehind?.behind ?? 0
|
||
}
|
||
: { hasUpstream: false, ahead: 0, behind: 0 })
|
||
}
|
||
: {})
|
||
}
|
||
}
|
||
|
||
function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string {
|
||
// Why: identical path strings can address different Linux filesystems in
|
||
// different WSL distros, so derived stats must follow 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. Shared by the
|
||
* on-demand submodule status query and the submodule-aware diff router.
|
||
*/
|
||
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. Used by the lazy
|
||
* "expand submodule" flow — the parent status only reports a single gitlink
|
||
* row, so the inner per-file changes are fetched on demand here. Entry paths
|
||
* are relative to the submodule root; the renderer prefixes them with the
|
||
* submodule path.
|
||
*/
|
||
export async function getSubmoduleStatus(
|
||
worktreePath: string,
|
||
submodulePath: string,
|
||
options: GitRuntimeOptions & { staged?: boolean } = {}
|
||
): Promise<GitStatusResult> {
|
||
const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath)
|
||
const workingResult = await getStatus(submoduleWorktreePath, options)
|
||
// Why: a moved gitlink (clean worktree) has no uncommitted status rows; its
|
||
// real changes live between the parent-recorded commit and the checked-out
|
||
// commit. Surface those as inner rows so the expansion isn't empty.
|
||
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, entries: rangeEntries }
|
||
}
|
||
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, entries }
|
||
}
|
||
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 the committed file
|
||
* changes (each row diffs the file across the two commits).
|
||
*/
|
||
async function computeSubmoduleRangeEntries(
|
||
submoduleWorktreePath: string,
|
||
fromOid: string,
|
||
toOid: string,
|
||
options: GitRuntimeOptions = {}
|
||
): Promise<GitStatusEntry[]> {
|
||
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<Map<string, GitLineStats> | null> {
|
||
try {
|
||
const { stdout } = await gitExecFileAsync(
|
||
[
|
||
'-c',
|
||
'core.quotePath=false',
|
||
'diff',
|
||
'-z',
|
||
...(cached ? ['--cached'] : []),
|
||
'--numstat',
|
||
'-M'
|
||
],
|
||
{ ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() }
|
||
)
|
||
return parseNumstat(stdout)
|
||
} catch (error) {
|
||
// Why: an aborted pass must reject so a cancelled scan is never treated as
|
||
// a completed one; only a genuine (non-abort) numstat failure degrades to
|
||
// uncounted rows below.
|
||
if (options.signal?.aborted) {
|
||
throw error
|
||
}
|
||
// Why: a numstat failure (e.g. transient lock) should leave rows without
|
||
// counts rather than break the whole status refresh. Null (vs an empty
|
||
// map) tells the caller the pass is incomplete and must not be cached.
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** Returns false when a numstat pass failed, so callers skip caching it. */
|
||
async function attachLineStats(
|
||
worktreePath: string,
|
||
entries: GitStatusEntry[],
|
||
options: GitRuntimeOptions = {}
|
||
): Promise<boolean> {
|
||
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<string, GitLineStats>()
|
||
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 it each poll
|
||
// rather than keeping a stale positive target after branch config changes.
|
||
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: a stable no-upstream branch should not spawn failed git probes every
|
||
// source-control poll, but remote refs can appear after push/fetch.
|
||
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<GitUpstreamStatus> {
|
||
if (!bypassCache) {
|
||
const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now())
|
||
if (cached) {
|
||
return cached
|
||
}
|
||
|
||
const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey)
|
||
if (inFlight) {
|
||
return inFlight
|
||
}
|
||
}
|
||
|
||
// Why: source-control mount and root git refresh can overlap during startup.
|
||
// Coalesce the richer upstream probe so a stable missing ref fails once.
|
||
const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0
|
||
const probe = probeOrRevalidateEffectiveUpstreamStatus(
|
||
cacheKey,
|
||
worktreePath,
|
||
branchName,
|
||
options,
|
||
bypassCache
|
||
).then((result) => {
|
||
rememberEffectiveUpstreamStatus(
|
||
cacheKey,
|
||
result.status,
|
||
Date.now(),
|
||
result.probedSameNameOriginRef,
|
||
writeGeneration
|
||
)
|
||
return result.status
|
||
})
|
||
if (!bypassCache) {
|
||
effectiveUpstreamStatusInFlight.set(cacheKey, probe)
|
||
}
|
||
try {
|
||
return await probe
|
||
} finally {
|
||
if (effectiveUpstreamStatusInFlight.get(cacheKey) === probe) {
|
||
effectiveUpstreamStatusInFlight.delete(cacheKey)
|
||
trimEffectiveUpstreamStatusGeneration()
|
||
}
|
||
}
|
||
}
|
||
|
||
async function probeOrRevalidateEffectiveUpstreamStatus(
|
||
cacheKey: string,
|
||
worktreePath: string,
|
||
branchName: string,
|
||
options: GitRuntimeOptions = {},
|
||
bypassCache = false
|
||
): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> {
|
||
const now = Date.now()
|
||
const cached = resolvedUpstreamNameCache.get(cacheKey)
|
||
if (cached && (bypassCache || cached.expiresAt <= now)) {
|
||
resolvedUpstreamNameCache.delete(cacheKey)
|
||
} else if (cached) {
|
||
try {
|
||
const status = await getGitUpstreamStatusForUpstreamName(
|
||
(args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)),
|
||
cached.upstreamName
|
||
)
|
||
return { status, probedSameNameOriginRef: false }
|
||
} catch (error) {
|
||
// Why: an aborted probe says nothing about the ref; evicting the warm
|
||
// name cache here would force a pointless full re-resolve next scan.
|
||
if (options.signal?.aborted) {
|
||
throw error
|
||
}
|
||
// Ref deleted or repo state changed — fall through to a full re-resolve.
|
||
resolvedUpstreamNameCache.delete(cacheKey)
|
||
}
|
||
}
|
||
const result = await probeEffectiveUpstreamStatus(worktreePath, branchName, options)
|
||
if (result.status.hasUpstream && result.status.upstreamName) {
|
||
resolvedUpstreamNameCache.set(cacheKey, {
|
||
upstreamName: result.status.upstreamName,
|
||
expiresAt: Date.now() + RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS
|
||
})
|
||
while (resolvedUpstreamNameCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) {
|
||
const oldest = resolvedUpstreamNameCache.keys().next()
|
||
if (oldest.done) {
|
||
break
|
||
}
|
||
resolvedUpstreamNameCache.delete(oldest.value)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
async function probeEffectiveUpstreamStatus(
|
||
worktreePath: string,
|
||
branchName: string,
|
||
options: GitRuntimeOptions = {}
|
||
): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> {
|
||
let probedSameNameOriginRef = false
|
||
const snapshotRunner = createGitConfigSnapshotRunner((args) =>
|
||
gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options))
|
||
)
|
||
const status = await getEffectiveGitUpstreamStatus((args) => {
|
||
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
|
||
probedSameNameOriginRef = true
|
||
}
|
||
return snapshotRunner(args)
|
||
})
|
||
return { status, probedSameNameOriginRef }
|
||
}
|
||
|
||
function shouldProbeEffectiveUpstreamStatus(
|
||
branch: string | undefined,
|
||
upstreamName: string | undefined
|
||
): boolean {
|
||
const branchName = getShortBranchName(branch)
|
||
if (!branchName) {
|
||
return false
|
||
}
|
||
if (!upstreamName) {
|
||
return true
|
||
}
|
||
const parsed = splitRemoteBranchName(upstreamName)
|
||
return parsed?.remoteName === 'origin' && parsed.branchName !== branchName
|
||
}
|
||
|
||
function parseBranchStatusChar(char: string): GitBranchChangeStatus {
|
||
switch (char) {
|
||
case 'M':
|
||
return 'modified'
|
||
case 'A':
|
||
return 'added'
|
||
case 'D':
|
||
return 'deleted'
|
||
case 'R':
|
||
return 'renamed'
|
||
case 'C':
|
||
return 'copied'
|
||
default:
|
||
return 'modified'
|
||
}
|
||
}
|
||
|
||
async function parseUnmergedEntry(
|
||
worktreePath: string,
|
||
line: string
|
||
): Promise<GitStatusEntry | null> {
|
||
// Why: porcelain v2 unmerged entries are fully space-separated (like type-1
|
||
// ordinary entries), NOT tab-separated. The format is:
|
||
// u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
|
||
// The path starts at field index 10 and may contain spaces, so we join the
|
||
// remaining fields. The earlier tab-based parsing silently dropped all
|
||
// unmerged entries because the tab was never present.
|
||
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.
|
||
// Presenting them with normal file-conflict UX would be misleading because
|
||
// submodule resolution requires different Git commands and user mental model.
|
||
if ([modeStage1, modeStage2, modeStage3].some((mode) => mode === '160000')) {
|
||
return null
|
||
}
|
||
|
||
const conflictKind = parseConflictKind(xy)
|
||
if (!conflictKind) {
|
||
return null
|
||
}
|
||
|
||
// Why: porcelain v2 `u` records do not provide rename-origin metadata (unlike
|
||
// `2` records), so oldPath is intentionally omitted. v1 should not promise
|
||
// rename ancestry in conflict rows without a separate Git query.
|
||
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: the `status` field on conflict entries is a *rendering compatibility*
|
||
// choice for existing icon/color plumbing, not a semantic claim about the file.
|
||
// The conflict badge and subtype carry the real meaning. We use 'modified' when
|
||
// a working-tree file exists and 'deleted' when it does not, so that downstream
|
||
// consumers (file explorer decorations, tab badges) get a reasonable fallback
|
||
// without needing conflict-aware upgrades in v1.
|
||
//
|
||
// For `deleted_by_us` / `deleted_by_them` and the `added_by_*` variants, Git's
|
||
// behavior depends on the merge strategy, so we check the filesystem rather
|
||
// than hardcoding an assumption.
|
||
async function getConflictCompatibilityStatus(
|
||
worktreePath: string,
|
||
filePath: string,
|
||
conflictKind: GitConflictKind
|
||
): Promise<GitFileStatus> {
|
||
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: if the filesystem check throws (permissions error, unmounted path,
|
||
// etc.), 'modified' is the safer fallback. It avoids suppressing the row
|
||
// from the sidebar and avoids a misleading 'deleted' when we simply could
|
||
// not check. The conflict badge still carries the real semantics.
|
||
return 'modified'
|
||
}
|
||
}
|
||
|
||
// Why: there is an inherent race between the `git status` call and these
|
||
// fs.existsSync checks — the HEAD file may not yet exist or may already be
|
||
// cleaned up by the time we check. In that case we fall back to 'unknown' for
|
||
// one poll cycle, which is acceptable. The renderer uses this to label the
|
||
// merge summary ("Merge conflicts" vs "Rebase conflicts" vs generic "Conflicts").
|
||
//
|
||
// Why rebase detection relies on rebase-merge/ or rebase-apply/ directories
|
||
// instead of REBASE_HEAD: those directories persist for the entire rebase, so
|
||
// they cover both conflicting and non-conflicting steps. REBASE_HEAD, by
|
||
// contrast, only exists on some steps and can also be left behind after a
|
||
// completed rebase, which would make the UI show a stale "Rebasing" badge.
|
||
export async function detectConflictOperation(worktreePath: string): Promise<GitConflictOperation> {
|
||
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<void> {
|
||
await runWithGitReadCacheInvalidation(() =>
|
||
gitExecFileAsync(['merge', '--abort'], gitOptionsForWorktree(worktreePath, options))
|
||
)
|
||
}
|
||
|
||
export async function abortRebase(
|
||
worktreePath: string,
|
||
options: GitRuntimeOptions = {}
|
||
): Promise<void> {
|
||
await runWithGitReadCacheInvalidation(() =>
|
||
gitExecFileAsync(['rebase', '--abort'], gitOptionsForWorktree(worktreePath, options))
|
||
)
|
||
}
|
||
|
||
export async function resolveGitDir(worktreePath: string): Promise<string> {
|
||
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` so a single diff click doesn't pay
|
||
* for an index-wide `ls-files` scan. Used to route gitlink/inner diffs.
|
||
*/
|
||
export async function listSubmodulePaths(
|
||
worktreePath: string,
|
||
options: GitRuntimeOptions = {}
|
||
): Promise<string[]> {
|
||
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 do not accumulate while hot
|
||
// cache 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<string> {
|
||
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<string> {
|
||
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<string> {
|
||
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 <oid>` swap, so feeding the old/new oids through
|
||
* the normal text differ matches git's own rendering.
|
||
*/
|
||
async function buildSubmodulePointerDiff(
|
||
worktreePath: string,
|
||
submodulePath: string,
|
||
staged: boolean,
|
||
compareAgainstHead: boolean,
|
||
options: GitRuntimeOptions,
|
||
// Why: default to the validated resolver so every caller (not just loadDiff)
|
||
// is protected from a .gitmodules path escaping the parent worktree.
|
||
submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath)
|
||
): Promise<GitDiffResult> {
|
||
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 — the change is committed,
|
||
* so compare the recorded commit's blob against the checked-out commit's blob.
|
||
*/
|
||
async function buildSubmoduleInnerCommitRangeDiff(
|
||
submoduleWorktreePath: string,
|
||
innerPath: string,
|
||
fromOid: string,
|
||
toOid: string,
|
||
options: GitRuntimeOptions
|
||
): Promise<GitDiffResult> {
|
||
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<GitDiffResult> {
|
||
// Why: register the in-flight dedupe synchronously (before any await) so
|
||
// concurrent identical reads coalesce; submodule routing happens inside.
|
||
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<GitDiffResult> {
|
||
// Why: gitlink paths can't be read as blobs (`git show HEAD:<sub>` is a "bad
|
||
// object") and a submodule working dir reads as empty, so route submodule
|
||
// diffs explicitly: the gitlink root → pointer diff, inner files → recurse
|
||
// into the submodule's own worktree.
|
||
const submodulePaths = await listSubmodulePaths(worktreePath, options)
|
||
if (submodulePaths.length > 0) {
|
||
const matchedSubmodule = findContainingSubmodule(submodulePaths, filePath)
|
||
if (matchedSubmodule) {
|
||
// Why: matchedSubmodule originates from .gitmodules, so validate it against
|
||
// the worktree boundary before any inner read — a crafted submodule path
|
||
// must not let the diff escape the selected 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: when the gitlink moved but the submodule worktree is clean, the
|
||
// file's change lives in committed history — diff the two commits. Only
|
||
// fall back to the working-tree blob read when the commit didn't move.
|
||
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 {
|
||
const leftBlob = staged
|
||
? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options)
|
||
: compareAgainstHead
|
||
? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options)
|
||
: await readUnstagedLeftBlob(worktreePath, filePath, options)
|
||
originalContent = leftBlob.content
|
||
originalIsBinary = leftBlob.isBinary
|
||
|
||
if (staged) {
|
||
const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options)
|
||
modifiedContent = rightBlob.content
|
||
modifiedIsBinary = rightBlob.isBinary
|
||
modifiedDeleted = !rightBlob.exists
|
||
} else {
|
||
const workingTreeBlob = await readWorkingTreeFile(path.join(worktreePath, filePath))
|
||
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 can fall back to the original bytes
|
||
// without mistaking a read failure's empty modified side for a deletion.
|
||
if (result.kind === 'binary' && modifiedDeleted) {
|
||
return { ...result, modifiedDeleted: true }
|
||
}
|
||
return result
|
||
}
|
||
|
||
export async function getBranchCompare(
|
||
worktreePath: string,
|
||
baseRef: string,
|
||
options: GitRuntimeOptions = {}
|
||
): Promise<GitBranchCompareResult> {
|
||
const summary: GitBranchCompareSummary = {
|
||
baseRef,
|
||
baseOid: null,
|
||
compareRef: 'HEAD',
|
||
headOid: null,
|
||
mergeBase: null,
|
||
changedFiles: 0,
|
||
status: 'loading'
|
||
}
|
||
|
||
const compareRef = await resolveCompareRef(worktreePath, options)
|
||
summary.compareRef = compareRef
|
||
// Why: short remote display refs like "origin/main" can collide with a local
|
||
// branch of the same name. Compare against the proven remote-tracking ref.
|
||
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) =>
|
||
hasWorktreeBaseCommitRef(worktreePath, qualifiedRef, options)
|
||
)
|
||
|
||
let headOid = ''
|
||
let baseOid = ''
|
||
try {
|
||
headOid = await resolveRefOid(worktreePath, 'HEAD', options)
|
||
summary.headOid = headOid
|
||
} catch {
|
||
try {
|
||
baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options)
|
||
summary.baseOid = baseOid
|
||
// Why: new remote worktrees can be on an unborn branch until the first
|
||
// commit. There are no committed branch changes yet; surfacing this as a
|
||
// compare error makes the source-control panel look broken.
|
||
summary.changedFiles = 0
|
||
summary.commitsAhead = 0
|
||
summary.status = 'ready'
|
||
return { summary, entries: [] }
|
||
} catch {
|
||
// Preserve the existing unborn-head message when even the base is not
|
||
// resolvable; callers cannot compare or present a useful empty state.
|
||
}
|
||
summary.status = 'unborn-head'
|
||
summary.errorMessage =
|
||
'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.'
|
||
return { summary, entries: [] }
|
||
}
|
||
|
||
try {
|
||
baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options)
|
||
summary.baseOid = baseOid
|
||
} catch {
|
||
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<GitDiffResult> {
|
||
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<GitDiffResult> {
|
||
try {
|
||
const leftPath = args.oldPath ?? args.filePath
|
||
const leftBlob = await readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options)
|
||
const rightBlob = await 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<GitCommitCompareResult> {
|
||
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<GitDiffResult> {
|
||
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<GitDiffResult> {
|
||
try {
|
||
const leftPath = args.oldPath ?? args.filePath
|
||
const leftBlob = args.parentOid
|
||
? await readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options)
|
||
: { content: '', isBinary: false }
|
||
const rightBlob = await 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<GitBranchChangeEntry[]> {
|
||
// Why: see core.quotePath=false rationale in getStatus — same reason here so
|
||
// branch-diff entries render with their real UTF-8 paths.
|
||
const gitOptions = {
|
||
...gitOptionsForWorktree(worktreePath, options),
|
||
maxBuffer: MAX_GIT_SHOW_BYTES
|
||
}
|
||
// Why: both diffs walk the same range and are independent, so start them
|
||
// together instead of serializing two potentially large git operations.
|
||
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[] = []
|
||
// [Fix]: Split by /\r?\n/ instead of '\n' to handle Git CRLF output on Windows,
|
||
// preventing trailing \r characters in extracted file 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<GitBranchChangeEntry[]> {
|
||
// Why: root commits have no parent tree; diff-tree --root asks git to
|
||
// compare against the repository's empty tree without hardcoding hash format.
|
||
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: commit diff rows need metadata and line counts, but those git queries
|
||
// do not depend on each other.
|
||
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<string> {
|
||
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<string> {
|
||
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<string> {
|
||
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<number> {
|
||
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<GitBlobReadResult> {
|
||
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<GitBlobReadResult> {
|
||
// Why: Git's `:<path>` 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<GitBlobReadResult> {
|
||
// Why: Git's `<oid>:<path>` 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<GitBlobReadResult> {
|
||
let fileStat
|
||
try {
|
||
fileStat = await stat(filePath)
|
||
} catch (error) {
|
||
// Why: ENOENT means the working-tree file is genuinely gone (a deletion);
|
||
// any other stat error is a read failure, which must not be reported as an
|
||
// absence since callers fall back to the original bytes only for deletions.
|
||
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: git blob reads are capped through maxBuffer; mirror that bound for
|
||
// unstaged working-tree content before readFile can 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: binary diff previews were originally image-only, so the renderer
|
||
// still checks `isImage` before showing a preview component. Preserve
|
||
// that legacy flag for PDFs until the wider contract is renamed.
|
||
...(mimeType ? { isImage: true, mimeType } : {})
|
||
} as GitDiffResult
|
||
}
|
||
|
||
// Why: if the diff exceeds safe render limits, avoid sending large text
|
||
// payloads and return metadata 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<string, string> = {
|
||
'.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<void> {
|
||
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<void> {
|
||
invalidateGitReadCaches()
|
||
try {
|
||
await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], {
|
||
...gitOptionsForWorktree(worktreePath, options)
|
||
})
|
||
} finally {
|
||
invalidateGitReadCaches()
|
||
}
|
||
}
|
||
|
||
export async function getStagedCommitContext(
|
||
worktreePath: string,
|
||
options: GitRuntimeOptions = {}
|
||
): Promise<CommitMessageDraftContext | null> {
|
||
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: a very large staged diff overflows maxBuffer (ENOBUFS). The patch is
|
||
// optional context that gets truncated to STAGED_DIFF_BYTE_BUDGET anyway, so
|
||
// degrade to the file-name summary instead of failing commit-message generation.
|
||
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: surface whichever channel carries the useful message. Pre-commit/GPG
|
||
// hook failures write to stderr; "nothing to commit, working tree clean"
|
||
// writes to stdout. Try stderr first, fall back to stdout, then error.message.
|
||
const readStringField = (field: string): string | null => {
|
||
if (typeof error === 'object' && error && field in error) {
|
||
const v = (error as Record<string, unknown>)[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<void> {
|
||
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: Windows validation produces backslashes, but Git running inside WSL
|
||
// needs POSIX paths. Host paths stay untouched so POSIX filenames remain literal.
|
||
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<string[]> {
|
||
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 contain enough paths for push(...split)
|
||
// to exceed the JavaScript argument limit before discard decisions run.
|
||
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<void> {
|
||
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<void> {
|
||
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<typeof path, 'isAbsolute' | 'relative' | 'sep'>,
|
||
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<void> {
|
||
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<void> {
|
||
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()
|
||
}
|
||
}
|