refactor(git): split oversized repository services

This commit is contained in:
Neil
2026-08-23 16:02:24 -07:00
parent d3e8d711af
commit cf5f3f5070
346 changed files with 11315 additions and 10296 deletions
-7
View File
@@ -22,13 +22,6 @@ inline src/main/codex-accounts/service.ts
inline src/main/codex/config-toml-trust.ts
inline src/main/codex/hook-service.ts
inline src/main/daemon/daemon-init.ts
inline src/main/git/repo.ts
inline src/main/git/runner.ts
inline src/main/git/status.ts
inline src/main/git/worktree.ts
inline src/main/github/pr-refresh-coordinator.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
inline src/main/github/work-item-details.ts
inline src/main/gitlab/client.ts
inline src/main/index.ts
@@ -19,7 +19,7 @@ vi.mock('electron', () => ({
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}))
vi.mock('../git/worktree', () => ({
vi.mock('../git/worktree-listing', () => ({
listWorktrees: vi.fn().mockResolvedValue([]),
listWorktreesStrict: vi.fn().mockResolvedValue([])
}))
@@ -1,7 +1,7 @@
import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import { gitExecFileAsync } from './git/runner'
import { resolveDefaultBaseRefWithLocalGit } from './git/repo'
import { resolveDefaultBaseRefWithLocalGit } from './git/default-base-ref'
import { getLocalProjectGitExecOptions } from './project-runtime-git-options'
import { resolveWorktreeCreateBase } from './worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref'
+3 -2
View File
@@ -34,9 +34,10 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
vi.mock('./status', () => ({
resolveGitDir: resolveGitDirMock,
vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock }))
vi.mock('./git-read-cache', () => ({
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
}))
+239
View File
@@ -0,0 +1,239 @@
import type { BaseRefSearchResult } from '../../shared/repo-types'
import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities'
import { getLocalGitCapabilityCache } from './git-capability-state'
import { gitExecFileAsync } from './runner'
const REF_SEARCH_CANDIDATE_MULTIPLIER = 4
const REF_SEARCH_LEGACY_HEADROOM = 100
type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot'
function getRefSearchTokens(normalizedQuery: string): string[] {
return normalizedQuery.split('/').filter((token) => token.length > 0)
}
function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER
return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM
}
/** Build the Git 2.25-compatible argv for ref search after query normalization. */
export function buildSearchBaseRefsArgv(
normalizedQuery: string,
limit: number,
options: {
excludeRemoteHead?: boolean
remoteNames?: readonly string[]
patternGroup?: RefSearchPatternGroup
} = {}
): string[] {
const excludeRemoteHead = options.excludeRemoteHead ?? true
const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead)
const base = [
'for-each-ref',
'--format=%(refname)%00%(refname:short)',
'--sort=-committerdate',
...(excludeRemoteHead ? ['--exclude=refs/remotes/**/HEAD'] : []),
`--count=${candidateCount}`
]
const tokens = getRefSearchTokens(normalizedQuery)
if (tokens.length <= 1) {
const query = tokens[0] ?? ''
return [
...base,
`refs/heads/**/*${query}*`,
`refs/heads/**/*${query}*/**`,
`refs/remotes/**/*${query}*`,
`refs/remotes/**/*${query}*/**`
]
}
// One `*token*` per segment lets a retyped `<remote>/<branch>` display result match.
const segmented = tokens.map((token) => `*${token}*`).join('/')
const substringQuery = tokens.join('/')
const remoteBranchRootPatterns =
options.remoteNames && options.remoteNames.length > 0
? options.remoteNames.flatMap((remote) => [
`refs/remotes/${remote}/${substringQuery}*`,
`refs/remotes/${remote}/${substringQuery}*/**`
])
: [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`]
const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`]
const branchRootPatterns = [
`refs/heads/${substringQuery}*`,
`refs/heads/${substringQuery}*/**`,
...remoteBranchRootPatterns
]
const patterns =
options.patternGroup === 'segmented'
? segmentedPatterns
: options.patternGroup === 'branchRoot'
? branchRootPatterns
: [...segmentedPatterns, ...branchRootPatterns]
return [...base, ...patterns]
}
async function runSearchBaseRefsGit(
path: string,
normalizedQuery: string,
limit: number,
options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup }
): Promise<{ stdout: string }> {
return getLocalGitCapabilityCache({ cwd: path }).runWithFallback(
'for-each-ref-exclude',
() =>
gitExecFileAsync(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: options.remoteNames,
patternGroup: options.patternGroup
}),
{ cwd: path }
),
() =>
gitExecFileAsync(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: options.remoteNames,
patternGroup: options.patternGroup
}),
{ cwd: path }
),
isForEachRefExcludeUnsupportedError
)
}
export function mergeBaseRefSearchResultGroups(
groups: readonly BaseRefSearchResult[][],
limit: number
): BaseRefSearchResult[] {
const seen = new Set<string>()
const merged: BaseRefSearchResult[] = []
const maxLength = Math.max(0, ...groups.map((group) => group.length))
for (let index = 0; index < maxLength && merged.length < limit; index += 1) {
for (const group of groups) {
const entry = group[index]
if (!entry || seen.has(entry.refName)) {
continue
}
seen.add(entry.refName)
merged.push(entry)
if (merged.length >= limit) {
break
}
}
}
return merged
}
export async function searchBaseRefs(path: string, query: string, limit = 25): Promise<string[]> {
return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName)
}
export async function searchBaseRefDetails(
path: string,
query: string,
limit = 25
): Promise<BaseRefSearchResult[]> {
if (!Number.isInteger(limit) || limit <= 0) {
return []
}
const normalizedQuery = normalizeRefSearchQuery(query)
try {
const remotes = await listRemoteNames(path)
const tokens = getRefSearchTokens(normalizedQuery)
if (tokens.length > 1) {
const results = await Promise.all([
runSearchBaseRefsGit(path, normalizedQuery, limit, {
remoteNames: remotes,
patternGroup: 'segmented'
}),
runSearchBaseRefsGit(path, normalizedQuery, limit, {
remoteNames: remotes,
patternGroup: 'branchRoot'
})
])
return mergeBaseRefSearchResultGroups(
results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)),
limit
)
}
const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, {
remoteNames: remotes
})
return parseAndFilterSearchRefDetails(result.stdout, limit, remotes)
} catch (err) {
console.warn('[searchBaseRefs] for-each-ref failed', { path, err })
return []
}
}
async function listRemoteNames(path: string): Promise<string[]> {
try {
const { stdout } = await gitExecFileAsync(['remote'], { cwd: path })
return stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
export function parseAndFilterSearchRefDetails(
stdout: string,
limit: number,
remotes: string[] = []
): BaseRefSearchResult[] {
const seen = new Set<string>()
const sortedRemotes = [...remotes].sort((left, right) => right.length - left.length)
return stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
const nul = line.indexOf('\0')
if (nul === -1) {
return null
}
return { full: line.slice(0, nul), short: line.slice(nul + 1) }
})
.filter((entry): entry is { full: string; short: string } => entry !== null)
.filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full))
.filter(({ short }) => {
if (seen.has(short)) {
return false
}
seen.add(short)
return true
})
.map(({ full, short }) => ({
refName: short,
localBranchName: resolveLocalBranchName(full, short, sortedRemotes)
}))
.slice(0, Math.max(0, limit))
}
export function resolveLocalBranchName(
fullRef: string,
shortRef: string,
remotes: readonly string[]
): string {
const remoteRefPrefix = 'refs/remotes/'
if (!fullRef.startsWith(remoteRefPrefix)) {
return shortRef
}
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`))
if (remote) {
return remoteAndBranch.slice(remote.length + 1)
}
return remoteAndBranch.split('/').slice(1).join('/') || shortRef
}
export function normalizeRefSearchQuery(query: string): string {
return query.trim().replace(/[*?[\]\\]/g, '')
}
+211
View File
@@ -0,0 +1,211 @@
import type {
GitBranchChangeEntry,
GitBranchCompareResult,
GitBranchCompareSummary,
GitDiffResult
} from '../../shared/git-diff-compare-types'
import { parseNumstat } from '../../shared/git-uncommitted-line-stats'
import { readBranchCompareHead } from '../../shared/git-branch-compare-head'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe'
import { gitExecFileAsync } from './runner'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { buildDiffResult, readGitBlobAtOidPath } from './git-blob-content'
import { runGitDiffRead } from './git-diff-read-cache'
import { parseBranchChangeLine } from './git-change-entry-parser'
import {
countAheadCommits,
resolveCompareRef,
resolveMergeBase,
resolveRefOid
} from './git-comparison-refs'
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
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'
}
// 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<string, string>()
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<GitDiffResult> {
return runGitDiffRead(
['branchDiff', worktreePath, args.headOid, args.mergeBase, args.filePath, args.oldPath ?? null],
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
// 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
}
}
}
async function loadBranchChanges(
worktreePath: string,
mergeBase: string,
headOid: string,
options: GitRuntimeOptions = {}
): Promise<GitBranchChangeEntry[]> {
// 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
}
+69
View File
@@ -0,0 +1,69 @@
import { gitExecFileAsync } from './runner'
import { resolveLocalBranchName } from './base-ref-search'
import { repositoryGitExecOptions, type LocalGitExecOptions } from './repository-git-execution'
export type BranchConflictKind = 'local' | 'remote'
async function hasGitRef(
path: string,
ref: string,
options: LocalGitExecOptions
): Promise<boolean> {
try {
await gitExecFileAsync(['rev-parse', '--verify', ref], repositoryGitExecOptions(path, options))
return true
} catch {
return false
}
}
async function listRemoteNames(path: string, options: LocalGitExecOptions): Promise<string[]> {
try {
const { stdout } = await gitExecFileAsync(['remote'], repositoryGitExecOptions(path, options))
return stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
export async function getBranchConflictKind(
path: string,
branchName: string,
allowedBaseRef?: string,
options: LocalGitExecOptions = {}
): Promise<BranchConflictKind | null> {
if (await hasGitRef(path, `refs/heads/${branchName}`, options)) {
return 'local'
}
try {
const remoteNames = (await listRemoteNames(path, options)).sort(
(left, right) => right.length - left.length
)
const { stdout } = await gitExecFileAsync(
['for-each-ref', '--format=%(refname)', 'refs/remotes'],
repositoryGitExecOptions(path, options)
)
const normalizedAllowedRef = allowedBaseRef
? allowedBaseRef.startsWith('refs/remotes/')
? allowedBaseRef
: `refs/remotes/${allowedBaseRef}`
: null
const hasRemoteConflict = stdout.split('\n').some((ref) => {
const trimmed = ref.trim()
if (normalizedAllowedRef && trimmed === normalizedAllowedRef) {
return false
}
const shortRef = trimmed.replace(/^refs\/remotes\//, '')
// Git allows slashes in remote names; use the configured list to find the branch boundary.
return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName
})
return hasRemoteConflict ? 'remote' : null
} catch {
return null
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { runWithGitReadCacheInvalidation } from './git-read-cache'
/**
* Reject branch names git would parse as an option (`-`/`--…`) or that aren't a
+348
View File
@@ -0,0 +1,348 @@
import { getDefaultWslDistro, parseWslPath, type WslPathInfo } from '../wsl'
import { WSL_EXECUTABLE } from '../wsl/wsl-executable'
import {
buildWslCapturedLoginShellCommand,
buildWslExecArgs,
buildWslLoginShellCommand,
quotePosixShell,
type WslCapturedLoginShellCommand
} from '../../shared/wsl-login-shell-command'
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale'
import type { WslGitReadEnvironment } from './wsl-git-read-environment'
import { createAbortError } from './subprocess-lifecycle'
// ─── Core resolution ────────────────────────────────────────────────
// Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted.
const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV)
.map(([key, value]) => `${key}=${value}`)
.join(' ')
const GIT_OUTPUT_LOCALE_ENV_ARGS = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV).map(
([key, value]) => `${key}=${value}`
)
export type ResolvedCommand = {
binary: string
args: string[]
cwd: string | undefined
/** Non-null when the command was routed through WSL. */
wsl: WslPathInfo | null
wslMode: 'direct-git' | 'login-shell' | 'non-login-shell' | null
/** Present only when the caller opted into a fenced login-shell read. */
captured?: WslCapturedLoginShellCommand
}
/**
* Translate Windows-style path arguments to Linux paths for commands run in WSL.
*
* Why: callers pass Windows paths as git arguments, which WSL git can't read.
* UNC paths (\\wsl.localhost\…) become native Linux; drive paths (C:\…) → /mnt/c/…
*/
function translateArgsForWsl(args: string[]): string[] {
return args.map(translateArgForWsl)
}
function translateArgForWsl(arg: string): string {
// WSL UNC path → native linux path
const wslInfo = parseWslPath(arg)
if (wslInfo) {
return wslInfo.linuxPath
}
// Windows drive path (e.g. C:\Users\...) → /mnt/c/Users/...
const driveMatch = arg.match(/^([A-Za-z]):[/\\](.*)$/)
if (driveMatch) {
const driveLetter = driveMatch[1].toLowerCase()
const rest = driveMatch[2].replace(/\\/g, '/')
return `/mnt/${driveLetter}/${rest}`
}
return arg
}
function hasExplicitRepoArg(args: string[]): boolean {
for (let i = 0; i < args.length; i++) {
if (
(args[i] === '--repo' || args[i] === '-R') &&
typeof args[i + 1] === 'string' &&
args[i + 1].trim()
) {
return true
}
if (args[i].startsWith('--repo=') || args[i].startsWith('-R=')) {
return args[i].slice(args[i].indexOf('=') + 1).trim().length > 0
}
if (args[i].startsWith('-R') && args[i].length > 2) {
return args[i].slice(2).trim().length > 0
}
}
return false
}
function argsUseGhApiPlaceholders(args: string[]): boolean {
return args.some(
(arg) => arg.includes('{owner}') || arg.includes('{repo}') || arg.includes('{branch}')
)
}
function hasExplicitRepoViewTarget(args: string[]): boolean {
const target = args[2]
return (
args[0] === 'repo' &&
args[1] === 'view' &&
typeof target === 'string' &&
!target.startsWith('-') &&
target.includes('/')
)
}
function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean {
if (hasExplicitRepoArg(args)) {
return true
}
if (args[0] === 'api') {
return !argsUseGhApiPlaceholders(args)
}
return args[0] === 'auth' || hasExplicitRepoViewTarget(args)
}
function isMissingCommandInWsl(stderr: string, command: string): boolean {
const s = stderr.toLowerCase()
const c = command.toLowerCase()
return s.includes(`${c}: command not found`) || s.includes(`${c}: not found`)
}
export function canFallBackToHostGitHubCli(
command: 'gh',
args: string[],
resolved: ResolvedCommand,
stderr: string
): boolean {
return (
process.platform === 'win32' &&
resolved.wsl !== null &&
isMissingCommandInWsl(stderr, command) &&
canRunGitHubCliWithoutRepoCwd(args)
)
}
export function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
return {
binary: command,
args,
// Why: host gh can't use a WSL UNC cwd; we only fall back for commands with explicit repo/API context, so none is needed.
cwd: undefined,
wsl: null,
wslMode: null
}
}
let defaultWslDistroOverride: string | null = null
let waitForWindowsHostGitEnvironment: (() => Promise<void>) | null = null
// Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails.
export function setDefaultWslDistroOverride(distro: string | null): void {
defaultWslDistroOverride = distro
}
export function configureWindowsHostGitEnvironmentReadiness(
waitUntilReady: (() => Promise<void>) | null
): void {
waitForWindowsHostGitEnvironment = waitUntilReady
}
function refreshWindowsHostPath(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined {
if (!env) {
return undefined
}
const currentPath = process.env.Path ?? process.env.PATH
if (currentPath === undefined) {
return env
}
const next = { ...env }
const pathKeys = Object.keys(next).filter((key) => key.toLowerCase() === 'path')
if (pathKeys.length === 0) {
next[process.env.Path === undefined ? 'PATH' : 'Path'] = currentPath
} else {
for (const key of pathKeys) {
next[key] = currentPath
}
}
return next
}
export function prepareWindowsHostGitEnvironment(
resolved: ResolvedCommand,
env: NodeJS.ProcessEnv | undefined,
signal?: AbortSignal
): Promise<NodeJS.ProcessEnv | undefined> | null {
if (
process.platform !== 'win32' ||
resolved.wsl !== null ||
waitForWindowsHostGitEnvironment === null
) {
return null
}
const ready = waitForWindowsHostGitEnvironment().then(() => refreshWindowsHostPath(env))
if (!signal) {
return ready
}
if (signal.aborted) {
return Promise.reject(createAbortError())
}
return new Promise((resolve, reject) => {
let settled = false
const cleanup = (): void => signal.removeEventListener('abort', onAbort)
const onAbort = (): void => {
if (settled) {
return
}
settled = true
cleanup()
reject(createAbortError())
}
signal.addEventListener('abort', onAbort, { once: true })
ready.then(
(value) => {
if (settled) {
return
}
settled = true
cleanup()
resolve(value)
},
(error) => {
if (settled) {
return
}
settled = true
cleanup()
reject(error)
}
)
})
}
export function resolveDefaultWslCli(
command: 'gh' | 'glab',
args: string[]
): ResolvedCommand | null {
const distro = defaultWslDistroOverride ?? getDefaultWslDistro()
return distro ? resolveCommand(command, args, undefined, distro) : null
}
export function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean {
if (!err || typeof err !== 'object') {
return false
}
const e = err as { code?: unknown; message?: unknown; syscall?: unknown; path?: unknown }
if (e.code === 'ENOENT') {
return true
}
const message = typeof e.message === 'string' ? e.message.toLowerCase() : ''
return (
message.includes('enoent') &&
(message.includes(command) || e.path === command || e.syscall === 'spawn')
)
}
/**
* Resolve whether a command invocation should be routed through wsl.exe.
*
* Why `bash -c "cd … && …"` instead of `--cd`: wsl.exe's --cd fails with
* ERROR_PATH_NOT_FOUND under Node's execFile/spawn in some configs.
*/
export function resolveCommand(
command: string,
args: string[],
cwd: string | undefined,
wslDistroOverride?: string,
options: {
useWslLoginShell?: boolean
captureLoginShellOutput?: boolean
wslGitReadEnvironment?: WslGitReadEnvironment
env?: NodeJS.ProcessEnv
} = {}
): ResolvedCommand {
if (process.platform !== 'win32') {
return { binary: command, args, cwd, wsl: null, wslMode: null }
}
// Why: global gh callers (rate_limit, listAccessibleProjects) have no cwd to derive a distro from; a distro hint still routes through wsl.exe.
// TODO(wsl-default-distro): no default-distro setting yet, so override-less global gh callers fall back to host gh.exe (ENOENT on WSL-only installs).
const cwdWsl = cwd ? parseWslPath(cwd) : null
const wsl: WslPathInfo | null =
cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null)
if (!wsl) {
return { binary: command, args, cwd, wsl: null, wslMode: null }
}
const translatedArgs = translateArgsForWsl(args)
// Why: env on wsl.exe stays Windows-side (WSLENV forwards only named vars), so the locale must ride the command string (issue #7808).
const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : ''
const escapedCommand = quotePosixShell(command)
// Why: shell-escape each arg to prevent word splitting / glob expansion inside the bash -c string.
const escapedArgs = translatedArgs.map(quotePosixShell)
// Why: prepend `cd <linuxPath> &&` for a UNC cwd; skip it when only a distro override was given (global gh needs no cwd).
const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null)
const shellCmd = linuxCwd
? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
: `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
if (command === 'git' && options.wslGitReadEnvironment) {
const optionalLocks = options.env?.GIT_OPTIONAL_LOCKS
return {
binary: WSL_EXECUTABLE,
args: [
'-d',
wsl.distro,
'--exec',
'/usr/bin/env',
`PATH=${options.wslGitReadEnvironment.path}`,
`HOME=${options.wslGitReadEnvironment.home}`,
...GIT_OUTPUT_LOCALE_ENV_ARGS,
...(optionalLocks !== undefined ? [`GIT_OPTIONAL_LOCKS=${optionalLocks}`] : []),
options.wslGitReadEnvironment.gitPath,
...(linuxCwd ? ['-C', linuxCwd] : []),
...translatedArgs
],
cwd: undefined,
wsl,
wslMode: 'direct-git'
}
}
if (options.useWslLoginShell) {
// Why opt-in: the login shell is interactive for bash/zsh, so its rc output
// lands on stdout ahead of the payload. Callers that buffer the whole stream
// fence it; streaming consumers (git grep, ls-files -z) must not, because a
// marker would be glued onto their first record.
if (options.captureLoginShellOutput) {
const captured = buildWslCapturedLoginShellCommand(shellCmd)
return {
binary: WSL_EXECUTABLE,
args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]),
cwd: undefined,
wsl,
wslMode: 'login-shell',
captured
}
}
return {
binary: WSL_EXECUTABLE,
args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]),
cwd: undefined,
wsl,
wslMode: 'login-shell'
}
}
return {
binary: WSL_EXECUTABLE,
args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]),
// Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals.
cwd: undefined,
wsl,
wslMode: 'non-login-shell'
}
}
+195
View File
@@ -0,0 +1,195 @@
import type {
GitBranchChangeEntry,
GitCommitCompareResult,
GitDiffResult
} from '../../shared/git-diff-compare-types'
import { parseNumstat } from '../../shared/git-uncommitted-line-stats'
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
import { gitExecFileAsync } from './runner'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { buildDiffResult, readGitBlobAtOidPath } from './git-blob-content'
import { runGitDiffRead } from './git-diff-read-cache'
import { parseBranchChangeLine } from './git-change-entry-parser'
import { resolveRefOid } from './git-comparison-refs'
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
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 runGitDiffRead(
[
'commitDiff',
worktreePath,
args.commitOid,
args.parentOid ?? null,
args.filePath,
args.oldPath ?? null
],
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
// 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 loadCommitChanges(
worktreePath: string,
parentOid: string | null,
commitOid: string,
options: GitRuntimeOptions = {}
): Promise<GitBranchChangeEntry[]> {
// 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
}
+1 -1
View File
@@ -9,7 +9,7 @@ vi.mock('./runner', () => ({
gitExecFileAsyncBuffer: vi.fn()
}))
import { commitChanges } from './status'
import { commitChanges } from './git-commit-operations'
describe('commitChanges', () => {
beforeEach(() => {
+160
View File
@@ -0,0 +1,160 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import * as path from 'node:path'
import type {
GitConflictKind,
GitConflictOperation,
GitFileStatus,
GitStatusEntry
} from '../../shared/git-status-types'
import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path'
import { gitExecFileAsync } from './runner'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { runWithGitReadCacheInvalidation } from './git-read-cache'
export async function parseUnmergedEntry(
worktreePath: string,
line: string
): Promise<GitStatusEntry | null> {
// 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<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: 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<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
}
+120
View File
@@ -0,0 +1,120 @@
import { gitExecFileAsync } from './runner'
import { gitExecFileSync } from './git-process-launch'
import { repositoryGitExecOptions, type LocalGitExecOptions } from './repository-git-execution'
export type GitExec = (argv: string[]) => Promise<{ stdout: string }>
type LocalDefaultBaseRefGitOptions = {
cwd: string
wslDistro?: string
}
const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000
export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [
{ ref: 'refs/remotes/origin/main', returnAs: 'origin/main' },
{ ref: 'refs/remotes/origin/master', returnAs: 'origin/master' },
{ ref: 'refs/heads/main', returnAs: 'main' },
{ ref: 'refs/heads/master', returnAs: 'master' }
]
async function resolveDefaultBaseRefFromProbes(
hasRef: (ref: string) => Promise<boolean>
): Promise<string | null> {
for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) {
if (await hasRef(ref)) {
return returnAs
}
}
return null
}
function hasGitRef(path: string, ref: string): boolean {
try {
gitExecFileSync(['rev-parse', '--verify', ref], { cwd: path })
return true
} catch {
return false
}
}
function gitRefToDefaultBaseRef(ref: string): string {
return ref.replace(/^refs\/remotes\//, '')
}
function getVerifiedOriginHeadBaseRef(path: string): string | null {
try {
const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
cwd: path
}).trim()
return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null
} catch {
return null
}
}
export function getDefaultBaseRef(path: string): string | null {
const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path)
if (originHeadBaseRef) {
return originHeadBaseRef
}
for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) {
if (hasGitRef(path, ref)) {
return returnAs
}
}
return null
}
export function getBaseRefDefault(
path: string,
options: LocalGitExecOptions = {}
): Promise<string | null> {
return resolveDefaultBaseRefWithLocalGit(repositoryGitExecOptions(path, options))
}
async function hasGitRefViaExec(exec: GitExec, ref: string): Promise<boolean> {
try {
await exec(['rev-parse', '--verify', '--quiet', ref])
return true
} catch {
return false
}
}
async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise<string | null> {
try {
const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
const ref = stdout.trim()
if (!ref || !(await hasGitRefViaExec(exec, ref))) {
return null
}
return gitRefToDefaultBaseRef(ref)
} catch {
return null
}
}
/**
* Resolve the default base ref via a git exec callback: prefer origin/HEAD's symbolic-ref target,
* else fall back to DEFAULT_BASE_REF_PROBES. Shared local/SSH so both transports agree.
*/
export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise<string | null> {
const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec)
if (originHeadBaseRef) {
return originHeadBaseRef
}
return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref))
}
export function resolveDefaultBaseRefWithLocalGit(
options: LocalDefaultBaseRefGitOptions
): Promise<string | null> {
return resolveDefaultBaseRefViaExec((argv) =>
gitExecFileAsync(argv, {
...options,
// Async avoids main-thread stalls, but dead local/WSL filesystems still need a bound.
timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS
})
)
}
@@ -0,0 +1,300 @@
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 { gitExecFileAsync } from './runner'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitStatusReadOptionsForWorktree } from './git-runtime-options'
const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000
const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512
const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000
type EffectiveUpstreamStatusCacheEntry = {
expiresAt: number
status: GitUpstreamStatus
}
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 effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
export function clearEffectiveUpstreamStatusCaches(): void {
effectiveUpstreamStatusCache.clear()
effectiveUpstreamStatusInFlight.clear()
retiredEffectiveUpstreamStatusInFlight.clear()
effectiveUpstreamStatusWriteGeneration.clear()
resolvedUpstreamNameCache.clear()
}
export function getEffectiveUpstreamStatusCacheCountForTests(): number {
return effectiveUpstreamStatusCache.size
}
export function getEffectiveUpstreamStatusGenerationCountForTests(): number {
return effectiveUpstreamStatusWriteGeneration.size
}
export function clearResolvedUpstreamNameCache(): void {
resolvedUpstreamNameCache.clear()
}
export function getShortBranchName(branch: string | undefined): string | null {
const prefix = 'refs/heads/'
return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null
}
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)
)
}
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()
}
export 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: 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
}
+155
View File
@@ -0,0 +1,155 @@
import {
execNodeFile as execFile,
type ExecutedFileOptions as ExecFileOptions,
type SpawnedProcess as ChildProcess
} from '../../shared/child-process/node-process-execution'
import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe'
import { endSubprocessStdin } from '../../shared/subprocess-stdin-write'
import { createAbortError, killSpawnedCommandTree } from './subprocess-lifecycle'
// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER.
export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024
export type ExecFileCaptureOptions = Omit<ExecFileOptions, 'timeout'> & {
timeout?: number
stdin?: string
}
function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer {
return options.encoding === 'buffer' ? Buffer.alloc(0) : ''
}
function isExecFileResultObject(
value: unknown
): value is { stdout: string | Buffer; stderr: string | Buffer } {
return (
value !== null &&
typeof value === 'object' &&
!Buffer.isBuffer(value) &&
'stdout' in value &&
'stderr' in value
)
}
export function execFileCapture(
command: string,
args: string[],
options: ExecFileCaptureOptions
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(createAbortError())
return
}
let settled = false
let terminating = false
let child: ChildProcess | null = null
let timer: NodeJS.Timeout | null = null
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
options.signal?.removeEventListener('abort', onAbort)
}
const finish = (
error: Error | null,
stdout: string | Buffer = emptyExecFileOutput(options),
stderr: string | Buffer = emptyExecFileOutput(options)
): void => {
if (settled) {
return
}
settled = true
cleanup()
if (error) {
const enriched = error as Error & { stdout?: string | Buffer; stderr?: string | Buffer }
enriched.stdout ??= stdout
enriched.stderr ??= stderr
reject(enriched)
return
}
resolve({ stdout, stderr })
}
const onAbort = (): void => {
if (settled || terminating) {
return
}
terminating = true
const abortError = createAbortError()
if (!child) {
terminating = false
finish(abortError)
return
}
void killSpawnedCommandTree(child).then(() => {
terminating = false
finish(abortError)
})
}
try {
const spawnStartedAt = performance.now()
// Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children.
child = execFile(
command,
args,
{
cwd: options.cwd,
// Why: git.exe is console-subsystem and Orca's main process owns no
// console, so every spawn without this flashes a conhost that takes
// foreground. Git runs on nearly every interaction (#14543).
windowsHide: true,
encoding: options.encoding,
maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
env: options.env
},
(error, stdout, stderr) => {
if (terminating) {
return
}
if (!error && stderr === undefined && isExecFileResultObject(stdout)) {
finish(null, stdout.stdout, stdout.stderr)
return
}
finish(error, stdout, stderr)
}
)
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
return
}
child.once('error', (error) => {
if (!terminating) {
finish(error)
}
})
if (options.stdin !== undefined) {
endSubprocessStdin(child.stdin, options.stdin)
}
// Why: Node's timeout waits forever on signal-ignoring CLIs; enforce our own deadline with bounded tree cleanup.
if (options.timeout && options.timeout > 0) {
timer = setTimeout(() => {
if (settled || terminating) {
return
}
terminating = true
const timeoutError = new Error(`${command} timed out.`)
if (!child) {
terminating = false
finish(timeoutError)
return
}
void killSpawnedCommandTree(child).then(() => {
terminating = false
finish(timeoutError)
})
}, options.timeout)
}
options.signal?.addEventListener('abort', onAbort, { once: true })
})
}
+178
View File
@@ -0,0 +1,178 @@
import { readFile, stat } from 'node:fs/promises'
import * as path from 'node:path'
import type { GitDiffResult } from '../../shared/git-diff-compare-types'
import { isBinaryBuffer } from '../../shared/binary-buffer'
import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
import { isMaxBufferOverflowError } from './max-buffer-overflow'
import { gitExecFileAsyncBuffer } from './runner'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
export 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)
}
export 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 }
}
}
export 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 }
}
}
export async function readWorkingTreeFile(filePath: string): Promise<GitBlobReadResult> {
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
}
}
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
}
}
export 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'
}
+41
View File
@@ -0,0 +1,41 @@
import type { GitBranchChangeEntry } from '../../shared/git-diff-compare-types'
import type { GitBranchChangeStatus } from '../../shared/git-status-types'
import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path'
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 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 }
}
+116
View File
@@ -0,0 +1,116 @@
import { parseWslPath } from '../wsl'
import { isWslDirectGitReadCommand } from './wsl-direct-git-read-commands'
import {
disableWslGitReadEnvironment,
getWslGitReadEnvironment,
invalidateWslGitReadEnvironment,
peekWslGitReadEnvironment
} from './wsl-git-read-environment'
import { usesHostGitForWslLinkedWorktree } from './wsl-linked-worktree-git-routing'
import { resolveCommand, type ResolvedCommand } from './command-resolution'
export type GitExecOptions = {
cwd: string
encoding?: BufferEncoding | 'buffer'
maxBuffer?: number
timeout?: number
stdin?: string
env?: NodeJS.ProcessEnv
signal?: AbortSignal
wslDistro?: string
preferWslDirectGit?: boolean
useConfiguredSshCommandForNetwork?: boolean
}
function wslDistroForCommand(cwd: string | undefined, override?: string): string | null {
if (process.platform !== 'win32') {
return null
}
return (cwd ? parseWslPath(cwd)?.distro : undefined) ?? override ?? null
}
export function resolveGitCommand(
args: string[],
options: GitExecOptions,
forceLoginShell = false,
captureLoginShellOutput = false
): ResolvedCommand {
if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) {
// Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd.
return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null }
}
if (!forceLoginShell && shouldAttemptWslDirectGit(args, options)) {
const distro = wslDistroForCommand(options.cwd, options.wslDistro)
const environment = distro ? peekWslGitReadEnvironment(distro) : undefined
if (environment) {
return resolveCommand('git', args, options.cwd, options.wslDistro, {
wslGitReadEnvironment: environment,
env: options.env
})
}
if (distro) {
void getWslGitReadEnvironment(distro)
}
}
return resolveGitCommandWithoutProbe(args, options, captureLoginShellOutput)
}
function shouldAttemptWslDirectGit(args: string[], options: GitExecOptions): boolean {
return Boolean(
process.platform === 'win32' &&
// Why either: callers can still opt in explicitly, but a plain read no
// longer has to -- it needs nothing the login shell provides, and routing
// it through one is what exposes callers to the shell's rc output.
(options.preferWslDirectGit || isWslDirectGitReadCommand(args)) &&
!options.useConfiguredSshCommandForNetwork &&
!Object.entries(options.env ?? {}).some(
([key, value]) =>
key.startsWith('GIT_') && key !== 'GIT_OPTIONAL_LOCKS' && value !== process.env[key]
) &&
options.wslDistro
)
}
export function resolveGitCommandWithoutProbe(
args: string[],
options: GitExecOptions,
captureLoginShellOutput = false
): ResolvedCommand {
return resolveCommand('git', args, options.cwd, options.wslDistro, {
useWslLoginShell: Boolean(options.wslDistro),
captureLoginShellOutput
})
}
function isDirectWslGitNotFound(error: unknown, resolved: ResolvedCommand): boolean {
if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') {
return false
}
const { code, stderr } = error as { code?: unknown; stderr?: unknown }
const message = typeof stderr === 'string' ? stderr : String(stderr ?? '')
return code === 127 && (message.includes('not found') || message.includes('No such file'))
}
export function directWslGitExitCode(error: unknown, resolved: ResolvedCommand): number | null {
if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') {
return null
}
const code = (error as { code?: unknown }).code
return typeof code === 'number' ? code : null
}
export function invalidateMissingDirectWslGit(error: unknown, resolved: ResolvedCommand): boolean {
const isMissing = isDirectWslGitNotFound(error, resolved)
if (isMissing && resolved.wsl) {
invalidateWslGitReadEnvironment(resolved.wsl.distro)
}
return isMissing
}
export function disableDirectWslGitAfterSuccessfulFallback(
wasMissing: boolean,
resolved: ResolvedCommand
): void {
if (!wasMissing && resolved.wsl) {
disableWslGitReadEnvironment(resolved.wsl.distro)
}
}
+84
View File
@@ -0,0 +1,84 @@
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from './max-buffer-overflow'
import { gitExecFileAsync } from './runner'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { invalidateGitReadCaches } from './git-read-cache'
const MAX_STAGED_COMMIT_CONTEXT_BYTES = 10 * 1024 * 1024
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: 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<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()
}
}
+53
View File
@@ -0,0 +1,53 @@
import { gitExecFileAsync } from './runner'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
export 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'
}
}
export 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()
}
export 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()
}
export 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
}
+17
View File
@@ -0,0 +1,17 @@
import type { GitDiffResult } from '../../shared/git-diff-compare-types'
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
import type { GitRuntimeOptions } from './git-runtime-options'
const gitDiffReadDedupe = new InFlightPromiseDedupe<GitDiffResult>()
export function clearGitDiffReadCache(): void {
gitDiffReadDedupe.clear()
}
export function runGitDiffRead(
keyParts: readonly unknown[],
options: GitRuntimeOptions,
load: () => Promise<GitDiffResult>
): Promise<GitDiffResult> {
return gitDiffReadDedupe.run(stableInFlightKey([...keyParts, options.wslDistro ?? null]), load)
}
+184
View File
@@ -0,0 +1,184 @@
import * as path from 'node:path'
import {
removeSafeUntrackedDiscardTarget,
removeSafeUntrackedDiscardTargets
} from '../../shared/git-discard-path-safety'
import { gitExecFileAsync } from './runner'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { invalidateGitReadCaches } from './git-read-cache'
import { literalGitPathspec } from './git-literal-pathspec'
const BULK_CHUNK_SIZE = 100
/**
* 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', '--', literalGitPathspec(filePath, options)],
{
...gitOptionsForWorktree(worktreePath, options)
}
)
tracked = true
} catch {
// File is not tracked by git
}
if (tracked) {
await gitExecFileAsync(
['restore', '--worktree', '--source=HEAD', '--', literalGitPathspec(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 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) => literalGitPathspec(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<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) => literalGitPathspec(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) => literalGitPathspec(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)
)
}
+232
View File
@@ -0,0 +1,232 @@
import { addWslEnvKeys } from '../wsl-env'
import { gitCredentialPromptGuardEnv } from '../../shared/git-credential-prompt-env'
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale'
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
import { DEFAULT_GIT_MAX_BUFFER, execFileCapture } from './exec-file-capture'
import { resolveGitCommand, type GitExecOptions } from './git-command-resolution'
export function gitOptionalLocksDisabledEnv(
env: NodeJS.ProcessEnv = process.env
): NodeJS.ProcessEnv {
return {
...env,
GIT_OPTIONAL_LOCKS: '0'
}
}
/**
* Pin Orca-spawned git to untranslated English output so stderr/progress parsers
* work under any user locale (issue #7808). Terminal git is untouched.
*/
export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV }
}
export function promptGuardGitEnv(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
return gitCredentialPromptGuardEnv(untranslatedGitOutputEnv(env), platform)
}
/**
* Credential-prompt guard for a general-purpose shell (PTYs, hook scripts):
* like promptGuardGitEnv but without the issue-7808 locale pins, which would
* change the locale of every child process, not just git's.
*/
export function promptGuardShellEnv(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
return gitCredentialPromptGuardEnv(env, platform)
}
/**
* Force git non-interactive so it fails fast instead of hanging on a prompt with
* no terminal to answer it; on headless `serve` those stuck calls wedge every
* client (issue #5308).
*
* - GIT_TERMINAL_PROMPT=0: git errors instead of prompting for credentials.
* - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI helper blocks; a
* caller-provided askpass is preserved (custom setups serve creds non-interactively).
* - GIT_SSH_COMMAND BatchMode=yes: SSH errors instead of prompting (doesn't change
* host trust); only added when the caller hasn't set its own.
*/
export function nonInteractiveGitEnv(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
const next = promptGuardGitEnv(env, platform)
if (!next.GIT_SSH_COMMAND) {
next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
if (platform === 'win32') {
// Why: forward GIT_SSH_COMMAND to WSL only when we set it — a caller's Windows-specific value must not leak into Linux git.
addWslEnvKeys(next, ['GIT_SSH_COMMAND'])
}
}
return next
}
export type GitSshPolicyMode =
| 'default'
| 'explicit-env'
| 'fallback'
| 'configured-openssh'
| 'configured-wrapper-passthrough'
const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500
function commandBasename(command: string): string {
const pieces = command.split(/[\\/]+/)
return pieces.at(-1)?.toLowerCase() ?? command.toLowerCase()
}
function isMergeableOpenSshCommand(command: string): boolean {
const basename = commandBasename(command)
return basename === 'ssh' || basename === 'ssh.exe'
}
function shellTokenize(command: string): string[] | null {
const tokens: string[] = []
let current = ''
let quote: "'" | '"' | null = null
let escaped = false
for (let i = 0; i < command.length; i++) {
const char = command[i]
if (escaped) {
current += char
escaped = false
continue
}
if (char === '\\') {
const next = command[i + 1]
if (next && /[\s'"\\]/.test(next)) {
escaped = true
} else {
current += char
}
continue
}
if (quote) {
if (char === quote) {
quote = null
} else {
current += char
}
continue
}
if (char === "'" || char === '"') {
quote = char
continue
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current)
current = ''
}
continue
}
if (';&|<>()`'.includes(char)) {
return null
}
current += char
}
if (escaped || quote) {
return null
}
if (current) {
tokens.push(current)
}
return tokens
}
function shellQuoteToken(token: string): string {
return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(token) ? token : quotePosixShell(token)
}
function containsShellExpansionSyntax(command: string): boolean {
return command.includes('$')
}
function withoutBatchModeOptions(tokens: string[]): string[] {
const next: string[] = []
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const lower = token.toLowerCase()
if (lower === '-o') {
const option = tokens[i + 1]?.toLowerCase()
if (option?.startsWith('batchmode')) {
i += 1
continue
}
}
if (lower.startsWith('-obatchmode')) {
continue
}
next.push(token)
}
return next
}
function buildOpenSshBatchModeCommand(configuredCommand: string): string | null {
if (containsShellExpansionSyntax(configuredCommand)) {
return null
}
const tokens = shellTokenize(configuredCommand)
if (!tokens || tokens.length === 0 || !isMergeableOpenSshCommand(tokens[0])) {
return null
}
return [...withoutBatchModeOptions(tokens), '-o', 'BatchMode=yes'].map(shellQuoteToken).join(' ')
}
export async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{
env: NodeJS.ProcessEnv
mode: GitSshPolicyMode
}> {
const promptEnv = promptGuardGitEnv(options.env)
if (promptEnv.GIT_SSH_COMMAND) {
return { env: promptEnv, mode: 'explicit-env' }
}
// Why fenced: a login-shell banner here reads as a user-configured sshCommand,
// which skips the BatchMode fallback below and disarms the no-prompt guard.
const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true, true)
let configuredCommand = ''
try {
const { stdout } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'utf-8',
maxBuffer: DEFAULT_GIT_MAX_BUFFER,
timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS,
env: promptEnv,
signal: options.signal
})
const payload = resolved.captured?.readStdout(String(stdout)) ?? String(stdout)
configuredCommand = payload.trim()
} catch {
configuredCommand = ''
}
if (!configuredCommand) {
const env = { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
// Why: WSL routing can come from either an explicit distro or a UNC cwd.
if (resolved.wsl) {
addWslEnvKeys(env, ['GIT_SSH_COMMAND'])
}
return { env, mode: 'fallback' }
}
const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand)
if (!batchModeCommand) {
// Why: custom SSH wrappers are user policy; rewriting their argv is riskier than relying on prompt guards + timeout.
return { env: promptEnv, mode: 'configured-wrapper-passthrough' }
}
const env = { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand }
if (resolved.wsl) {
addWslEnvKeys(env, ['GIT_SSH_COMMAND'])
}
return { env, mode: 'configured-openssh' }
}
+101
View File
@@ -0,0 +1,101 @@
import { gitExecFileAsync } from './runner'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { invalidateGitReadCaches } from './git-read-cache'
import { literalGitPathspec } from './git-literal-pathspec'
const BULK_CHUNK_SIZE = 100
/**
* Stage a file.
*/
export async function stageFile(
worktreePath: string,
filePath: string,
options: GitRuntimeOptions = {}
): Promise<void> {
invalidateGitReadCaches()
try {
await gitExecFileAsync(
['add', '--', literalGitPathspec(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', '--', literalGitPathspec(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<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) => literalGitPathspec(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) => literalGitPathspec(filePath, options))
],
{
...gitOptionsForWorktree(worktreePath, options)
}
)
}
} finally {
invalidateGitReadCaches()
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { GitRuntimeOptions } from './git-runtime-options'
export function literalGitPathspec(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}`
}
+90
View File
@@ -0,0 +1,90 @@
import {
execNodeFileSync as execFileSync,
spawnNodeProcess as spawn,
type SpawnedProcess as ChildProcess,
type SpawnedProcessOptions as SpawnOptions
} from '../../shared/child-process/node-process-execution'
import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe'
import { resolveCommand, prepareWindowsHostGitEnvironment } from './command-resolution'
import { resolveGitCommand } from './git-command-resolution'
import { createAbortError } from './subprocess-lifecycle'
import { untranslatedGitOutputEnv } from './git-environment-policy'
// Why: sync git blocks the main thread; a dead network drive can hang git for minutes without a timeout (issue #7225's 127s freeze).
const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000
/**
* Sync git command execution. Drop-in replacement for
* `execFileSync('git', args, { cwd, encoding, ... })`.
*
* Returns trimmed stdout as a string.
*/
export function gitExecFileSync(
args: string[],
options: {
cwd: string
encoding?: BufferEncoding
stdio?: SpawnOptions['stdio']
timeout?: number
}
): string {
const resolved = resolveCommand('git', args, options.cwd)
const spawnStartedAt = performance.now()
try {
return execFileSync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
env: untranslatedGitOutputEnv(),
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS,
windowsHide: true
}) as string
} finally {
// Sync exec blocks the main thread for its whole duration — the cost issue #7576 flags.
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
}
}
/**
* Spawn a git child process. Drop-in replacement for
* `spawn('git', args, { cwd, stdio, ... })`.
*/
type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string }
export async function gitSpawnAfterWindowsEnvironmentReady(
args: string[],
options: GitSpawnOptions
): Promise<ChildProcess> {
if (options.signal?.aborted) {
throw createAbortError()
}
const resolved = resolveGitCommand(args, {
cwd: options.cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.env ? { env: options.env } : {})
})
const env = await (prepareWindowsHostGitEnvironment(resolved, options.env, options.signal) ??
options.env)
if (options.signal?.aborted) {
throw createAbortError()
}
return gitSpawn(args, env === options.env ? options : { ...options, env })
}
export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess {
const { wslDistro, ...spawnOptions } = options
const resolved = resolveGitCommand(args, {
cwd: options.cwd,
...(wslDistro ? { wslDistro } : {}),
...(spawnOptions.env ? { env: spawnOptions.env } : {})
})
const spawnStartedAt = performance.now()
const child = spawn(resolved.binary, resolved.args, {
...spawnOptions,
env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env),
windowsHide: true,
cwd: resolved.cwd
})
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
return child
}
+47
View File
@@ -0,0 +1,47 @@
import type { GitStatusResult } from '../../shared/git-status-types'
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 { clearGitDiffReadCache } from './git-diff-read-cache'
import { clearSubmodulePathsCacheForTests } from './submodule-paths'
import {
clearEffectiveUpstreamStatusCaches,
clearResolvedUpstreamNameCache
} from './effective-upstream-status-cache'
const statusReadLeaseOwner = new GitStatusReadLeaseOwner<GitStatusResult>()
export function leaseGitStatusRead(
cacheKey: string,
signal: AbortSignal | undefined,
read: (signal: AbortSignal) => Promise<GitStatusResult>
): Promise<GitStatusResult> {
return statusReadLeaseOwner.lease(cacheKey, signal, read)
}
// Why: clearing only some in-flight reads would let a post-mutation status join
// a pre-mutation read and publish it as current.
export function invalidateGitReadCaches(): void {
clearGitDiffReadCache()
statusReadLeaseOwner.invalidate()
invalidateGitBranchLineTotalInFlight()
invalidateGitUpstreamStatusReads()
clearGitStatusLineStatsCache()
clearSubmodulePathsCacheForTests()
clearResolvedUpstreamNameCache()
}
export function clearEffectiveUpstreamStatusCacheForTests(): void {
clearEffectiveUpstreamStatusCaches()
invalidateGitReadCaches()
}
export async function runWithGitReadCacheInvalidation<T>(run: () => Promise<T>): Promise<T> {
invalidateGitReadCaches()
try {
return await run()
} finally {
// Why: a read that started mid-mutation can be stale too, so invalidate again after.
invalidateGitReadCaches()
}
}
+223
View File
@@ -0,0 +1,223 @@
import {
spawnNodeProcess as spawn,
type SpawnedProcess as ChildProcess,
type SpawnedProcessOptions as SpawnOptions
} from '../../shared/child-process/node-process-execution'
import { StringDecoder } from 'node:string_decoder'
import { withGitSpan } from '../observability/instrumentation'
import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe'
import {
isWslLinkedWorktreeGitRoutingCandidate,
prepareWslLinkedWorktreeGitRouting
} from './wsl-linked-worktree-git-routing'
import {
directWslGitExitCode,
disableDirectWslGitAfterSuccessfulFallback,
invalidateMissingDirectWslGit,
resolveGitCommand,
resolveGitCommandWithoutProbe,
type GitExecOptions
} from './git-command-resolution'
import { DEFAULT_GIT_MAX_BUFFER } from './exec-file-capture'
import { createAbortError, killSpawnedCommandTree } from './subprocess-lifecycle'
import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-environment-policy'
import { gitSpawn } from './git-process-launch'
import { prepareWindowsHostGitEnvironment, type ResolvedCommand } from './command-resolution'
/** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */
export type GitStreamResult = { stoppedEarly: boolean }
type GitStreamOptions = {
cwd: string
env?: NodeJS.ProcessEnv
wslDistro?: string
preferWslDirectGit?: boolean
signal?: AbortSignal
/** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */
maxBuffer?: number
/**
* Called for each decoded stdout chunk. Return true to stop: the child is
* killed and the promise resolves with stoppedEarly=true.
*/
onStdout: (chunk: string) => boolean | void
}
/**
* Stream a git command's stdout incrementally instead of buffering it whole.
*
* Why: output larger than V8's max string (e.g. status on a repo with a huge
* un-ignored folder) crashes the process when buffered; streaming keeps memory
* bounded and lets the parser stop git early. Built on gitSpawn for WSL routing.
*/
export async function gitStreamStdout(
args: string[],
options: GitStreamOptions
): Promise<GitStreamResult> {
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
return withGitSpan({ args, cwd: options.cwd }, async () => {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, {
signal: options.signal
})
}
const gitOptions: GitExecOptions = {
cwd: options.cwd,
...(options.env ? { env: options.env } : {}),
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.preferWslDirectGit ? { preferWslDirectGit: true } : {}),
...(options.signal ? { signal: options.signal } : {})
}
let resolved = resolveGitCommand(args, gitOptions)
const environmentReady = prepareWindowsHostGitEnvironment(
resolved,
gitOptions.env,
options.signal
)
if (environmentReady) {
gitOptions.env = await environmentReady
}
resolved = resolveGitCommand(args, gitOptions)
const stream = (command: ResolvedCommand): Promise<GitStreamResult> =>
new Promise<GitStreamResult>((resolve, reject) => {
if (options.signal?.aborted) {
reject(createAbortError())
return
}
const stdio: SpawnOptions['stdio'] = ['ignore', 'pipe', 'pipe']
const spawnOptions = {
cwd: options.cwd,
env: nonInteractiveGitEnv(gitOptions.env),
stdio,
wslDistro: options.wslDistro,
windowsHide: true
}
let child: ChildProcess
if (command.wslMode === 'direct-git') {
const spawnStartedAt = performance.now()
child = spawn(command.binary, command.args, {
cwd: command.cwd,
env: untranslatedGitOutputEnv(spawnOptions.env),
stdio: spawnOptions.stdio,
windowsHide: true
})
recordSubprocessSpawn(command.binary, command.args, performance.now() - spawnStartedAt)
} else {
child = gitSpawn(args, spawnOptions)
}
let settled = false
let stoppedEarly = false
let stdoutBytes = 0
let stderr = ''
let stderrBytes = 0
// Why: decode statefully so a multibyte UTF-8 char split across chunks isn't corrupted into replacement chars.
const stdoutDecoder = new StringDecoder('utf8')
const stderrDecoder = new StringDecoder('utf8')
const cleanup = (): void => {
child.stdout?.off('data', onStdoutData)
child.stderr?.off('data', onStderrData)
child.off('error', onError)
child.off('close', onClose)
options.signal?.removeEventListener('abort', onAbort)
// Flush any bytes the decoders were holding for an incomplete sequence.
stdoutDecoder.end()
stderrDecoder.end()
}
const finish = (error: Error | null): void => {
if (settled) {
return
}
settled = true
cleanup()
if (error) {
reject(Object.assign(error, { stderr, stdoutBytes }))
return
}
resolve({ stoppedEarly })
}
function onStdoutData(chunk: Buffer): void {
stdoutBytes += chunk.byteLength
if (stdoutBytes > maxBuffer) {
void killSpawnedCommandTree(child)
finish(new Error('git stdout exceeded maxBuffer.'))
return
}
const decoded = stdoutDecoder.write(chunk)
if (decoded.length === 0) {
return
}
// Why: a throw from the caller's parser would escape this event handler and crash main; convert to a rejection.
let shouldStop: boolean | void
try {
shouldStop = options.onStdout(decoded)
} catch (error) {
void killSpawnedCommandTree(child)
finish(error instanceof Error ? error : new Error(String(error)))
return
}
if (shouldStop === true) {
// Parser hit its limit: kill git and resolve cleanly with the partial output.
stoppedEarly = true
void killSpawnedCommandTree(child)
finish(null)
}
}
function onStderrData(chunk: Buffer): void {
stderrBytes += chunk.byteLength
if (stderrBytes > maxBuffer) {
void killSpawnedCommandTree(child)
finish(new Error('git stderr exceeded maxBuffer.'))
return
}
stderr += stderrDecoder.write(chunk)
}
function onError(error: Error): void {
finish(error)
}
function onClose(code: number | null): void {
if (stoppedEarly || code === 0) {
finish(null)
return
}
finish(Object.assign(new Error(`git exited with ${code}: ${stderr}`), { code }))
}
function onAbort(): void {
if (!child.pid) {
// Why: failed spawn reports ENOENT after abort cleanup; retain a listener so it cannot crash main.
child.once('error', () => {})
}
void killSpawnedCommandTree(child)
finish(createAbortError())
}
child.stdout?.on('data', onStdoutData)
child.stderr?.on('data', onStderrData)
child.on('error', onError)
child.on('close', onClose)
options.signal?.addEventListener('abort', onAbort, { once: true })
if (options.signal?.aborted) {
onAbort()
}
})
try {
return await stream(resolved)
} catch (error) {
const stdoutBytes =
error && typeof error === 'object' ? (error as { stdoutBytes?: unknown }).stdoutBytes : null
if (
stdoutBytes === 0 &&
directWslGitExitCode(error, resolved) !== null &&
!options.signal?.aborted
) {
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
resolved = resolveGitCommandWithoutProbe(args, gitOptions)
const result = await stream(resolved)
disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved)
return result
}
throw error
}
})
}
+4 -2
View File
@@ -1,7 +1,9 @@
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner'
import { gitExecFileAsync } from './runner'
import { ghExecFileAsync } from './github-cli-runner'
import { extractExecError } from './exec-error'
import { parseHostedRemote } from './hosted-remote-url'
import { resolveDefaultBaseRefViaExec } from './repo'
import { resolveDefaultBaseRefViaExec } from './default-base-ref'
const EXPLICIT_USERNAME_CONFIG_KEYS = ['github.user', 'user.username'] as const
+297
View File
@@ -0,0 +1,297 @@
import {
createGhRateLimitBlockedError,
getGhRateLimitBlockedUntilMs,
ghRateLimitScopeKey,
type GhRateLimitBucket
} from './gh-rate-limit-breaker'
import { parseRetryAfterMs } from './exec-error'
import type { ResolvedCommand } from './command-resolution'
import type { GitExecOptions } from './git-command-resolution'
import { createAbortError } from './subprocess-lifecycle'
// `cwd?` omitted for non-repo-scoped gh calls (rate_limit, listAccessibleProjects) so one WSL-aware wrapper serves both.
// `wslDistro?` routes global cwd-less gh through `wsl.exe -d <distro>` on WSL-only Windows where gh.exe isn't on host PATH.
// `idempotent?` gates transient-error retry (auto-detected from argv); retrying a write that already reached GitHub would duplicate it.
export type GhExecOptions = Omit<GitExecOptions, 'cwd'> & {
cwd?: string
wslDistro?: string
idempotent?: boolean
// Why: `gh api` and `--repo OWNER/REPO` shorthand resolve against gh's
// default host, not the repo's remote. Carrying the host here lets the
// runner qualify every spawn once, so call sites can't silently fall back
// to github.com for GHES repos; it also scopes the rate-limit breaker.
host?: string
}
const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE'])
// `gh <noun> <verb>` write subcommands; reads are absent on purpose so they keep retrying.
const NON_IDEMPOTENT_GH_VERBS = new Set([
'create',
'edit',
'update',
'delete',
'close',
'reopen',
'merge',
'comment',
'review',
'ready',
'lock',
'unlock',
'pin',
'unpin',
'transfer',
'develop'
])
export function argsLookIdempotent(args: string[]): boolean {
let explicitMethodSeen = false
let hasApiBodyField = false
let hasGraphQlQuery = false
const isGraphQlApi = args[0] === 'api' && args[1] === 'graphql'
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '-X' || a === '--method') {
explicitMethodSeen = true
const next = args[i + 1]
if (typeof next === 'string' && NON_IDEMPOTENT_METHODS.has(next.toUpperCase())) {
return false
}
}
// Single-token form `--method=POST` (gh accepts this).
if (a.startsWith('--method=')) {
explicitMethodSeen = true
const value = a.slice('--method='.length)
if (NON_IDEMPOTENT_METHODS.has(value.toUpperCase())) {
return false
}
}
// `gh api` auto-POSTs when -f/-F/--field body fields are given without -X; track them.
if (a === '-f' || a === '-F' || a === '--field' || a === '--raw-field') {
hasApiBodyField = true
} else if (
a.startsWith('-f=') ||
a.startsWith('-F=') ||
a.startsWith('--field=') ||
a.startsWith('--raw-field=')
) {
hasApiBodyField = true
}
// Detect GraphQL `query=mutation(…)` so endpoint writes also fail fast on transient errors.
if (a.startsWith('query=')) {
hasGraphQlQuery = true
const trimmed = a.slice('query='.length).trimStart().toLowerCase()
if (trimmed.startsWith('mutation')) {
return false
}
}
}
// `gh api -f foo=bar` with no -X auto-POSTs → non-idempotent; GraphQL query bodies are the exception (still reads).
if (
args[0] === 'api' &&
hasApiBodyField &&
!explicitMethodSeen &&
!(isGraphQlApi && hasGraphQlQuery)
) {
return false
}
// `gh <noun> <verb>` writes (args[1]); `gh api` without -X defaults to idempotent GET, so it's excluded here.
if (args.length >= 2 && args[0] !== 'api') {
if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) {
return false
}
}
return true
}
/**
* Classify whether a gh execFile rejection is worth retrying.
*
* Why: gh surfaces HTTP status as stderr substrings ("HTTP 504", econnreset, …).
* Retry 5xx/network resets and 429 only without Retry-After (propagate those so
* the UI can show the wait); primary-rate-limit 403 is never transient.
*/
export function isTransientGhError(stderr: string): boolean {
const s = stderr.toLowerCase()
if (
s.includes('http 500') ||
s.includes('http 502') ||
s.includes('http 503') ||
s.includes('http 504') ||
s.includes('econnreset') ||
s.includes('etimedout') ||
s.includes('socket hang up')
) {
return true
}
// 429 without Retry-After: retry. With Retry-After: propagate.
if (s.includes('http 429')) {
return parseRetryAfterMs(stderr) === null
}
return false
}
// Why: 3 attempts total (250ms → 1s backoff); array length defines retry count (total attempts = length + 1).
export const GH_RETRY_DELAYS_MS = [250, 1000] as const
// Why: Retry-After is unbounded and untrusted; cap at 30s so a gh call can't block the IPC thread indefinitely.
export const GH_RETRY_AFTER_MAX_MS = 30_000
const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000
export async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
throw createAbortError()
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(finish, ms)
const onAbort = (): void => finish(createAbortError())
function finish(error?: Error): void {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
if (error) {
reject(error)
} else {
resolve()
}
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
export function defaultGhExecTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.ORCA_GH_EXEC_TIMEOUT_MS
if (!raw) {
return DEFAULT_GH_EXEC_TIMEOUT_MS
}
const parsed = Number(raw)
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GH_EXEC_TIMEOUT_MS
}
export function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return {
...env,
GH_PROMPT_DISABLED: env.GH_PROMPT_DISABLED ?? '1'
}
}
function hasGhHostnameFlag(args: readonly string[]): boolean {
return args.some((arg) => arg === '--hostname' || arg.startsWith('--hostname='))
}
function hostQualifiedGhRepoValue(value: string, host: string): string {
// URLs and already-qualified HOST/OWNER/REPO values pass through untouched.
if (value.includes('://') || value.split('/').length !== 2) {
return value
}
return `${host}/${value}`
}
/**
* Host-qualify a gh invocation from `options.host`: `--hostname` for `api`
* calls, `HOST/OWNER/REPO` for `--repo`/`-R` shorthand. SSH-backed repos run
* gh with no cwd, so this is their only host signal (#8312).
*
* @internal exported for tests.
*/
export function applyGhHostToArgs(args: string[], host?: string): string[] {
if (!host) {
return args
}
let result = args
if (result[0] === 'api' && !hasGhHostnameFlag(result)) {
result = ['api', '--hostname', host, ...result.slice(1)]
}
// Why: bare OWNER/REPO shorthand resolves against gh's default host — GH_HOST
// when set — so github.com must be qualified too, not just GHES, or a
// process-level GH_HOST redirects pinned github.com commands.
// Combined short forms (`-Ra/b`, `-R=a/b`) are deliberately not rewritten:
// no call site uses them, and prefix-matching `-R` corrupts free-text values
// of other flags (e.g. a --title that happens to start with `-R`).
const qualified: string[] = []
for (let i = 0; i < result.length; i += 1) {
const arg = result[i]
if (arg === '--repo' || arg === '-R') {
qualified.push(arg)
const value = result[i + 1]
if (value !== undefined) {
qualified.push(hostQualifiedGhRepoValue(value, host))
i += 1
}
continue
}
if (arg.startsWith('--repo=')) {
qualified.push(`--repo=${hostQualifiedGhRepoValue(arg.slice('--repo='.length), host)}`)
continue
}
qualified.push(arg)
}
return qualified
}
function explicitGhHostname(args: readonly string[]): string | undefined {
for (let i = 0; i < args.length; i += 1) {
if (args[i] === '--hostname') {
const value = args[i + 1]?.trim()
return value || undefined
}
if (args[i].startsWith('--hostname=')) {
const value = args[i].slice('--hostname='.length).trim()
return value || undefined
}
}
return undefined
}
function explicitGhRepoHostname(args: readonly string[]): string | undefined {
for (let i = 0; i < args.length; i += 1) {
let value: string | undefined
if (args[i] === '--repo' || args[i] === '-R') {
value = args[i + 1]
} else if (args[i].startsWith('--repo=')) {
value = args[i].slice('--repo='.length)
}
const parts = value?.trim().split('/')
if (parts?.length === 3 && parts.every(Boolean)) {
return parts[0]
}
}
return undefined
}
export function ghRateLimitScope(
args: readonly string[],
options: GhExecOptions,
resolved: ResolvedCommand
): string {
const runtime = resolved.wsl ? `wsl:${resolved.wsl.distro.toLowerCase()}` : 'native'
// Why: an explicit argv hostname controls the actual gh request even when
// GH_HOST or options.host disagree, so breaker state must follow that host.
const host =
explicitGhHostname(args) ??
options.host ??
explicitGhRepoHostname(args) ??
options.env?.GH_HOST ??
process.env.GH_HOST ??
'github.com'
return ghRateLimitScopeKey(runtime, host)
}
export function assertGhRateLimitScopeAvailable(
args: readonly string[],
options: GhExecOptions,
resolved: ResolvedCommand,
bucket: GhRateLimitBucket,
exemptProbe: boolean
): void {
if (exemptProbe) {
return
}
const blockedUntilMs = getGhRateLimitBlockedUntilMs(
bucket,
Date.now(),
ghRateLimitScope(args, options, resolved)
)
if (blockedUntilMs !== null) {
throw createGhRateLimitBlockedError(bucket, blockedUntilMs)
}
}
+112
View File
@@ -0,0 +1,112 @@
import {
classifyGhRateLimitBucket,
isGhPrimaryRateLimitStderr,
isGhRateLimitProbe,
notifyGhPrimaryRateLimit
} from './gh-rate-limit-breaker'
import { extractExecError, parseRetryAfterMs } from './exec-error'
import {
canFallBackToHostGitHubCli,
isHostCommandMissing,
resolveCommand,
resolveDefaultWslCli,
resolveHostGitHubCli
} from './command-resolution'
import { execFileCapture } from './exec-file-capture'
import {
applyGhHostToArgs,
argsLookIdempotent,
assertGhRateLimitScopeAvailable,
defaultGhExecTimeoutMs,
ghRateLimitScope,
GH_RETRY_AFTER_MAX_MS,
GH_RETRY_DELAYS_MS,
isTransientGhError,
nonInteractiveGhEnv,
sleep,
type GhExecOptions
} from './github-cli-policy'
/**
* Async gh CLI execution. Drop-in replacement for
* `execFileAsync('gh', args, { cwd, encoding, ... })`.
*
* Retries transient 5xx / 429-without-Retry-After / network-reset failures with
* exponential backoff; other errors fail fast.
*/
export async function ghExecFileAsync(
args: string[],
options: GhExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
// Why: retry safety must reflect the original call even when fallbacks replace the resolved command.
const idempotent = options.idempotent ?? argsLookIdempotent(args)
args = applyGhHostToArgs(args, options.host)
let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro)
// Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset.
// Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other.
const rateLimitBucket = classifyGhRateLimitBucket(args)
const rateLimitProbe = isGhRateLimitProbe(args)
assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe)
let lastError: unknown
let attemptedHostFallback = false
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
// Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane.
timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env),
env: nonInteractiveGhEnv(options.env),
signal: options.signal
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
lastError = err
const { stderr } = extractExecError(err)
if (isGhPrimaryRateLimitStderr(stderr)) {
notifyGhPrimaryRateLimit(rateLimitBucket, ghRateLimitScope(args, options, resolved))
}
if (
process.platform === 'win32' &&
!attemptedDefaultWslFallback &&
resolved.wsl === null &&
!options.cwd &&
!options.wslDistro &&
isHostCommandMissing(err, 'gh')
) {
const wslResolved = resolveDefaultWslCli('gh', args)
if (wslResolved) {
// Why: WSL-only Windows installs have no host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by.
resolved = wslResolved
attemptedDefaultWslFallback = true
assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe)
attempt = -1
continue
}
}
if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) {
resolved = resolveHostGitHubCli('gh', args)
attemptedHostFallback = true
assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe)
attempt = -1
continue
}
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
if (idempotent && !isLastAttempt && isTransientGhError(stderr)) {
// Why: honor the server's Retry-After over our backoff (a shorter sleep just re-fails); cap so a huge hint can't stall IPC.
const retryAfterMs = parseRetryAfterMs(stderr)
const delayMs =
retryAfterMs !== null
? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS)
: GH_RETRY_DELAYS_MS[attempt]
await sleep(delayMs, options.signal)
continue
}
throw err
}
}
// Unreachable: the loop either returns or throws. Here for TS exhaustiveness.
throw lastError
}
+111
View File
@@ -0,0 +1,111 @@
import { addWslEnvKeys } from '../wsl-env'
import { extractExecError, parseRetryAfterMs } from './exec-error'
import { isHostCommandMissing, resolveCommand, resolveDefaultWslCli } from './command-resolution'
import { execFileCapture } from './exec-file-capture'
import {
argsLookIdempotent,
GH_RETRY_AFTER_MAX_MS,
GH_RETRY_DELAYS_MS,
isTransientGhError,
sleep
} from './github-cli-policy'
import type { GitExecOptions } from './git-command-resolution'
// ─── glab CLI runner ────────────────────────────────────────────────
// Why: cloned from the gh runner rather than abstracted behind a generic runner, to avoid touching the working gh path.
type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & {
cwd?: string
wslDistro?: string
idempotent?: boolean
allowDefaultWslFallback?: boolean
}
/** Async glab CLI execution; drop-in for execFileAsync('glab', …). Retry policy mirrors ghExecFileAsync. */
/**
* glab's `--hostname` rejects host:port, so a ported self-hosted GitLab must use the GITLAB_HOST env var instead — translate it.
* @internal exported for tests.
*/
export function redirectPortedHostnameToEnv(
args: string[],
options: GlabExecOptions
): { args: string[]; options: GlabExecOptions } {
const i = args.indexOf('--hostname')
if (i === -1 || i + 1 >= args.length) {
return { args, options }
}
const host = args[i + 1]
if (!/^[^/\s]+:\d+$/.test(host)) {
return { args, options }
}
// Why WSLENV: a glab routed into a distro only sees Windows-side variables
// named in WSLENV, so without this the ported host silently never crosses and
// glab talks to gitlab.com instead (#12557). Credit: #12558.
const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), GITLAB_HOST: host }
// Unguarded by platform on purpose: WSLENV is meaningless outside Windows, so
// the extra key is inert there, and gating it would need the test to know the
// platform for no behavioural gain.
addWslEnvKeys(env, ['GITLAB_HOST'])
return {
args: [...args.slice(0, i), ...args.slice(i + 2)],
options: { ...options, env }
}
}
export async function glabExecFileAsync(
args: string[],
options: GlabExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
;({ args, options } = redirectPortedHostnameToEnv(args, options))
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
let lastError: unknown
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
env: options.env,
signal: options.signal
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
lastError = err
const { stderr } = extractExecError(err)
if (
process.platform === 'win32' &&
!attemptedDefaultWslFallback &&
resolved.wsl === null &&
!options.cwd &&
!options.wslDistro &&
options.allowDefaultWslFallback !== false &&
isHostCommandMissing(err, 'glab')
) {
const wslResolved = resolveDefaultWslCli('glab', args)
if (wslResolved) {
// Why: mirror gh's WSL-only fallback for global GitLab project/auth calls.
resolved = wslResolved
attemptedDefaultWslFallback = true
attempt = -1
continue
}
}
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
// Why: mirror gh's write-safety gate — don't auto-retry a non-idempotent write that GitLab may already have applied.
const idempotent = options.idempotent ?? argsLookIdempotent(args)
if (idempotent && !isLastAttempt && isTransientGhError(stderr)) {
const retryAfterMs = parseRetryAfterMs(stderr)
const delayMs =
retryAfterMs !== null
? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS)
: GH_RETRY_DELAYS_MS[attempt]
await sleep(delayMs)
continue
}
throw err
}
}
throw lastError
}
+52
View File
@@ -0,0 +1,52 @@
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { gitExecFileAsync } from './runner'
import { repositoryGitExecOptions, type LocalGitExecOptions } from './repository-git-execution'
const REMOTE_DRIFT_PROBE_TIMEOUT_MS = 15_000
/** Return the merge-base-symmetric delta for localRef vs remoteRef, or null on failure. */
export async function getRemoteDrift(
repoPath: string,
localRef: string,
remoteRef: string,
options: LocalGitExecOptions = {}
): Promise<{ ahead: number; behind: number } | null> {
try {
const { stdout } = await gitExecFileAsync(
['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`],
{
...repositoryGitExecOptions(repoPath, options),
timeout: REMOTE_DRIFT_PROBE_TIMEOUT_MS
}
)
const counts = parseGitRevListAheadBehindCounts(stdout)
if (counts.status !== 'ok') {
return null
}
return { ahead: counts.ahead, behind: counts.behind }
} catch {
return null
}
}
/** Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. */
export async function getRecentDriftSubjects(
repoPath: string,
localRef: string,
remoteRef: string,
limit: number,
options: LocalGitExecOptions = {}
): Promise<string[]> {
try {
const { stdout } = await gitExecFileAsync(
['log', '--format=%s', '-n', String(limit), `${localRef}..${remoteRef}`],
{
...repositoryGitExecOptions(repoPath, options),
timeout: REMOTE_DRIFT_PROBE_TIMEOUT_MS
}
)
return stdout.split('\n').filter((subject) => subject.trim().length > 0)
} catch {
return []
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { validateGitPushTarget } from './push-target-validation'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { runWithGitReadCacheInvalidation } from './git-read-cache'
async function getConfiguredPushTarget(
worktreePath: string,
@@ -34,9 +34,10 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
vi.mock('./status', () => ({
resolveGitDir: resolveGitDirMock,
vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock }))
vi.mock('./git-read-cache', () => ({
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
}))
@@ -53,7 +54,8 @@ import {
resetWorktreeRemovalState
} from './remove-worktree-test-harness'
import { forceDeleteLocalBranch, removeWorktree } from './worktree'
import { forceDeleteLocalBranch } from './worktree-branch-cleanup'
import { removeWorktree } from './worktree'
const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock)
const getGitCalls = createGitCallReader(gitExecFileAsyncMock)
@@ -34,9 +34,10 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
vi.mock('./status', () => ({
resolveGitDir: resolveGitDirMock,
vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock }))
vi.mock('./git-read-cache', () => ({
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
}))
@@ -47,7 +48,10 @@ vi.mock('fs/promises', async () => {
import { resetWorktreeRemovalState } from './remove-worktree-test-harness'
import { assertWorktreeCleanForRemoval, WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } from './worktree'
import {
assertWorktreeCleanForRemoval,
WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS
} from './worktree-removal-preflight'
beforeEach(() => {
resetWorktreeRemovalState({
+1 -1
View File
@@ -1,7 +1,7 @@
import { expect, type Mock } from 'vitest'
import { clearGitCapabilityStateForTests } from './git-capability-state'
import { _resetWorktreeScanCacheForTests } from './worktree'
import { resetWorktreeScanCache as _resetWorktreeScanCacheForTests } from './worktree-listing'
export type MockResult = {
error?: Error
+5 -3
View File
@@ -34,9 +34,10 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
vi.mock('./status', () => ({
resolveGitDir: resolveGitDirMock,
vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock }))
vi.mock('./git-read-cache', () => ({
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
}))
@@ -53,7 +54,8 @@ import {
resetWorktreeRemovalState
} from './remove-worktree-test-harness'
import { removeWorktree, WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree'
import { removeWorktree } from './worktree'
import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree-registration-repair'
const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock)
const getGitCalls = createGitCallReader(gitExecFileAsyncMock)
@@ -8,8 +8,9 @@ vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: vi.fn()
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: vi.fn() }))
import { getBaseRefDefault } from './repo'
import { getBaseRefDefault } from './default-base-ref'
describe('getBaseRefDefault async subprocess bounds', () => {
beforeEach(() => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getRecentDriftSubjects, getRemoteDrift } from './repo'
import { getRecentDriftSubjects, getRemoteDrift } from './remote-drift'
describe('remote drift real Git contract', () => {
const tempPaths: string[] = []
+1 -1
View File
@@ -15,7 +15,7 @@ vi.mock('./runner', async (importOriginal) => {
}
})
import { getRecentDriftSubjects, getRemoteDrift } from './repo'
import { getRecentDriftSubjects, getRemoteDrift } from './remote-drift'
describe('remote drift Git probes', () => {
beforeEach(() => {
+2 -1
View File
@@ -8,9 +8,10 @@ vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: vi.fn()
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: vi.fn() }))
import { clearGitCapabilityStateForTests } from './git-capability-state'
import { searchBaseRefs } from './repo'
import { searchBaseRefs } from './base-ref-search'
describe('searchBaseRefs git compatibility', () => {
afterEach(() => {
+8 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RunnerModule from './runner'
import type * as GitHubCliRunnerModule from './github-cli-runner'
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
const ghExecFileAsyncMock = vi.hoisted(() => vi.fn())
@@ -8,7 +9,13 @@ vi.mock('./runner', async () => {
const actual = await vi.importActual<typeof RunnerModule>('./runner')
return {
...actual,
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileAsync: gitExecFileAsyncMock
}
})
vi.mock('./github-cli-runner', async () => {
const actual = await vi.importActual<typeof GitHubCliRunnerModule>('./github-cli-runner')
return {
...actual,
ghExecFileAsync: ghExecFileAsyncMock
}
})
+4 -5
View File
@@ -4,16 +4,15 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { getDefaultBaseRef, resolveDefaultBaseRefViaExec } from './default-base-ref'
import { getBranchConflictKind } from './branch-conflict-detection'
import { getRemoteCount } from './repository-remotes'
import {
buildSearchBaseRefsArgv,
getDefaultBaseRef,
getBranchConflictKind,
getRemoteCount,
parseAndFilterSearchRefDetails,
resolveDefaultBaseRefViaExec,
searchBaseRefDetails,
searchBaseRefs
} from './repo'
} from './base-ref-search'
// Why: use real git state (not mocked) because the bug is in the for-each-ref glob shape a mock would miss.
+18 -945
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
export type LocalGitExecOptions = {
wslDistro?: string
}
export function repositoryGitExecOptions(
cwd: string,
options: LocalGitExecOptions = {}
): { cwd: string; wslDistro?: string } {
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
}
+255
View File
@@ -0,0 +1,255 @@
import { readFileSync, realpathSync, statSync, type Stats } from 'node:fs'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { toWindowsWslPath } from '../wsl'
export type GitMarkerScanResult =
| { status: 'valid'; rootPath: string }
| { status: 'absent' | 'invalid' }
/**
* Filesystem-only fallback check for genuine Git metadata when git can't answer cleanly. Strict enough to
* reject a garbage `.git` file: accepts a `.git` dir/file with real gitdir shape or a bare-repo root
* (HEAD + objects/ + refs/, not a worktree admin dir).
*/
export function scanGitMarkerSync(path: string): GitMarkerScanResult {
const realPath = resolveRealPathSync(path)
if (realPath && realPath !== path) {
const lexicalScan = scanGitMarkerAncestorsSync(path)
const realPathScan = scanGitMarkerAncestorsSync(realPath)
if (
lexicalScan.status === 'valid' &&
realPathScan.status === 'valid' &&
pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath)
) {
// Why: preserve lexical spellings (/var vs /private/var), but let a cross-repo symlink bind to the real target like git.
return lexicalScan
}
return realPathScan
}
return scanGitMarkerAncestorsSync(path)
}
export function resolveRealPathSync(path: string): string | null {
try {
return realpathSync.native(path)
} catch {
try {
return realpathSync(path)
} catch {
return null
}
}
}
function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult {
for (const candidate of ancestorDirectories(path)) {
if (!isInsideDotGitMarker(candidate, path)) {
const worktreeMarker = scanWorktreeMarkerSync(candidate)
if (worktreeMarker.status !== 'absent') {
return worktreeMarker
}
}
if (hasValidBareRepoMarkerSync(candidate)) {
return { status: 'valid', rootPath: candidate }
}
}
return { status: 'absent' }
}
function ancestorDirectories(path: string): string[] {
const directories: string[] = []
let current = path
while (true) {
directories.push(current)
const parent = dirname(current)
if (parent === current) {
return directories
}
current = parent
}
}
function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean {
const relativePath = relative(rootPath, targetPath)
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
return false
}
const firstSegment = relativePath.split(/[\\/]+/)[0]
if (firstSegment === '.git') {
return true
}
if (firstSegment.toLowerCase() !== '.git') {
return false
}
return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git'))
}
function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean {
try {
const leftStat = statSync(leftPath)
const rightStat = statSync(rightPath)
if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) {
return true
}
const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath))
const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath))
return process.platform === 'win32'
? leftRealPath.toLowerCase() === rightRealPath.toLowerCase()
: leftRealPath === rightRealPath
} catch {
return false
}
}
function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult {
const dotGit = join(worktreePath, '.git')
let marker: Stats
try {
marker = statSync(dotGit)
} catch {
return { status: 'absent' }
}
if (marker.isDirectory()) {
return hasValidGitDirectorySync(dotGit)
? { status: 'valid', rootPath: worktreePath }
: { status: 'invalid' }
}
if (marker.isFile()) {
let gitDir: string | null
try {
gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8'))
} catch {
return { status: 'invalid' }
}
return gitDir !== null && hasValidGitDirectorySync(gitDir)
? { status: 'valid', rootPath: worktreePath }
: { status: 'invalid' }
}
return { status: 'invalid' }
}
function parseGitdirFile(basePath: string, content: string): string | null {
const firstLine = content.split(/\r?\n/, 1)[0] ?? ''
const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i)
if (!match) {
return null
}
return resolveGitMetadataPath(basePath, match[1])
}
function resolveGitMetadataPath(basePath: string, rawPath: string): string | null {
const value = rawPath.trim()
if (!value) {
return null
}
const baseWsl = parseWslUncPath(basePath)
if (baseWsl && value.startsWith('/')) {
return toWindowsWslPath(value, baseWsl.distro)
}
return isAbsolute(value) ? value : resolve(basePath, value)
}
function hasValidGitDirectorySync(gitDir: string): boolean {
return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir)
}
function hasValidCommonGitDirectorySync(gitDir: string): boolean {
try {
return (
statSync(join(gitDir, 'HEAD')).isFile() &&
statSync(join(gitDir, 'objects')).isDirectory() &&
statSync(join(gitDir, 'refs')).isDirectory()
)
} catch {
return false
}
}
function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean {
try {
if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) {
return false
}
const commonDir = resolveGitMetadataPath(
gitDir,
readFileSync(join(gitDir, 'commondir'), 'utf8')
)
return commonDir !== null && hasValidCommonGitDirectorySync(commonDir)
} catch {
return false
}
}
function hasValidBareRepoMarkerSync(path: string): boolean {
return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path)
}
function gitConfigDeclaresNonBare(gitDir: string): boolean {
try {
const config = readFileSync(join(gitDir, 'config'), 'utf8')
let inCoreSection = false
for (const line of config.split(/\r?\n/)) {
const section = line.match(/^\s*\[([^\]]+)\]/)
if (section) {
inCoreSection = section[1].trim().toLowerCase() === 'core'
continue
}
const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i)
if (inCoreSection && bare) {
return isGitBooleanFalse(normalizeGitConfigValue(bare[1]))
}
}
return false
} catch {
return false
}
}
function normalizeGitConfigValue(value: string): string {
const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"')
if (
unescaped.length >= 2 &&
((unescaped.startsWith('"') && unescaped.endsWith('"')) ||
(unescaped.startsWith("'") && unescaped.endsWith("'")))
) {
return unescaped.slice(1, -1)
}
return unescaped
}
function stripGitConfigInlineComment(value: string): string {
let quote: '"' | "'" | null = null
let escaped = false
for (let i = 0; i < value.length; i++) {
const char = value[i]
if (escaped) {
escaped = false
continue
}
if (char === '\\') {
escaped = true
continue
}
if (quote) {
if (char === quote) {
quote = null
}
continue
}
if (char === '"' || char === "'") {
quote = char
continue
}
if (char === '#' || char === ';') {
return value.slice(0, i)
}
}
return value
}
function isGitBooleanFalse(value: string): boolean {
return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase())
}
+116
View File
@@ -0,0 +1,116 @@
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url'
import { getDefaultBaseRef, getBaseRefDefault } from './default-base-ref'
import { gitExecFileAsync } from './runner'
import { gitExecFileSync } from './git-process-launch'
import { repositoryGitExecOptions, type LocalGitExecOptions } from './repository-git-execution'
/** Get the remote origin URL, or null if not set. */
export function getRemoteUrl(path: string): string | null {
try {
return gitExecFileSync(['remote', 'get-url', 'origin'], { cwd: path }).trim()
} catch {
return null
}
}
/** Parse `git remote` stdout into a remote count. Shared local/SSH so count semantics can't drift. */
export function parseRemoteCount(stdout: string): number {
return stdout.split('\n').filter((line) => line.trim().length > 0).length
}
/** Count configured remotes; returns 0 on error so callers read it as unknown/no hint. */
export async function getRemoteCount(path: string): Promise<number> {
try {
const { stdout } = await gitExecFileAsync(['remote'], { cwd: path })
return parseRemoteCount(stdout)
} catch (err) {
console.warn('[getRemoteCount] git remote failed', { path, err })
return 0
}
}
/**
* Resolve the default push remote: configured default branch remote, origin, or the sole remote.
*/
export async function getDefaultRemote(
path: string,
options: LocalGitExecOptions = {}
): Promise<string> {
const defaultRef = await getBaseRefDefault(path, options)
const defaultBranch = defaultRef
? defaultRef.includes('/')
? defaultRef.split('/').slice(1).join('/')
: defaultRef
: null
if (defaultBranch) {
try {
const { stdout } = await gitExecFileAsync(
['config', '--get', `branch.${defaultBranch}.remote`],
repositoryGitExecOptions(path, options)
)
const value = stdout.trim()
if (value) {
return value
}
} catch {
// Fall through: branch has no explicit remote configured.
}
}
try {
const { stdout } = await gitExecFileAsync(['remote'], repositoryGitExecOptions(path, options))
const remotes = stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
if (remotes.includes('origin')) {
return 'origin'
}
if (remotes.length === 1) {
return remotes[0]
}
if (remotes.length === 0) {
throw new Error('Repo has no configured git remotes.')
}
throw new Error(
`Repo has multiple remotes (${remotes.join(', ')}) and no default is configured. Set branch.<default>.remote.`
)
} catch (error) {
if (error instanceof Error) {
throw error
}
throw new Error('Failed to resolve default remote for repo.')
}
}
/** Build a hosted URL for a file and line; null when origin is unsupported or no base ref exists. */
export function getRemoteFileUrl(
repoPath: string,
relativePath: string,
line: number
): string | null {
const remoteUrl = getRemoteUrl(repoPath)
if (!remoteUrl) {
return null
}
const defaultBaseRef = getDefaultBaseRef(repoPath)
if (!defaultBaseRef) {
return null
}
return buildHostedRemoteFileUrl(
remoteUrl,
relativePath,
defaultBaseRef.replace(/^origin\//, ''),
line
)
}
/** Build a hosted URL for a commit; null when origin isn't a recognized host. */
export function getRemoteCommitUrl(repoPath: string, sha: string): string | null {
const remoteUrl = getRemoteUrl(repoPath)
if (!remoteUrl) {
return null
}
return buildHostedRemoteCommitUrl(remoteUrl, sha)
}
+2 -2
View File
@@ -15,12 +15,12 @@ vi.mock('node:child_process', () => ({
import {
commandExecFileAsync,
ghExecFileAsync,
gitExecFileAsync,
gitStreamStdout,
translateWslOutputPaths,
wslAwareSpawn
} from './runner'
import { ghExecFileAsync } from './github-cli-runner'
import { gitStreamStdout } from './git-stream'
type MockChildProcess = EventEmitter & {
stdout: EventEmitter
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { applyGhHostToArgs } from './runner'
import { applyGhHostToArgs } from './github-cli-policy'
describe('applyGhHostToArgs', () => {
it('returns args unchanged when no host is given', () => {
@@ -12,7 +12,7 @@ vi.mock('child_process', () => ({
spawn: spawnMock
}))
import { ghExecFileAsync } from './runner'
import { ghExecFileAsync } from './github-cli-runner'
import {
_resetGhRateLimitBreaker,
getGhRateLimitBlockedUntilMs,
@@ -14,12 +14,12 @@ vi.mock('node:child_process', () => ({
import {
awaitWindowsHostGitEnvironmentReady,
configureWindowsHostGitEnvironmentReadiness,
gitExecFileAsync,
gitExecFileAsyncBuffer,
gitSpawnAfterWindowsEnvironmentReady,
gitStreamStdout
gitExecFileAsyncBuffer
} from './runner'
import { configureWindowsHostGitEnvironmentReadiness } from './command-resolution'
import { gitSpawnAfterWindowsEnvironmentReady } from './git-process-launch'
import { gitStreamStdout } from './git-stream'
type MockChildProcess = EventEmitter & {
stdout: EventEmitter
+3 -1
View File
@@ -17,7 +17,9 @@ vi.mock('../observability/instrumentation', () => ({
}))
vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() }))
import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner'
import { gitExecFileAsync } from './runner'
import { gitSpawn } from './git-process-launch'
import { gitStreamStdout } from './git-stream'
import {
getWslGitReadEnvironment,
resetWslGitReadEnvironmentForTests,
+3 -1
View File
@@ -20,7 +20,9 @@ vi.mock('../wsl', async (importOriginal) => ({
getDefaultWslDistro: getDefaultWslDistroMock
}))
import { ghExecFileAsync, glabExecFileAsync, setDefaultWslDistroOverride } from './runner'
import { ghExecFileAsync } from './github-cli-runner'
import { glabExecFileAsync } from './gitlab-cli-runner'
import { setDefaultWslDistroOverride } from './command-resolution'
import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker'
const PRIMARY_RATE_LIMIT_STDERR =
@@ -4,14 +4,10 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { listFilesWithGit } from '../ipc/filesystem-list-files-git-fallback'
import { searchWithGitGrep } from '../ipc/filesystem-search-git'
import {
gitExecFileAsync,
gitExecFileAsyncBuffer,
gitStreamStdout,
toLinuxPath,
toWindowsWslPath
} from './runner'
import { listWorktrees } from './worktree'
import { gitExecFileAsync, gitExecFileAsyncBuffer } from './runner'
import { gitStreamStdout } from './git-stream'
import { toLinuxPath, toWindowsWslPath } from '../wsl'
import { listWorktrees } from './worktree-listing'
import { resetWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing'
const distro = process.env.ORCA_TEST_WSL_DISTRO?.trim()
+8 -8
View File
@@ -3,17 +3,18 @@
// and stderr extraction from execFile rejections (err.message is unreliable).
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
appendGitConfigEnv,
extractExecError,
isTransientGhError,
nonInteractiveGitEnv,
parseRetryAfterMs,
promptGuardGitEnv,
promptGuardShellEnv,
redirectPortedHostnameToEnv,
untranslatedGitOutputEnv
} from './runner'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
} from './git-environment-policy'
import { extractExecError, parseRetryAfterMs } from './exec-error'
import { isTransientGhError } from './github-cli-policy'
import { redirectPortedHostnameToEnv } from './gitlab-cli-runner'
import {
appendGitConfigEnv,
mergeGitConfigEnvProtocol
} from '../../shared/git-credential-prompt-env'
// Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol
// back into a plain key→value map so tests can assert on it directly.
@@ -344,7 +345,6 @@ describe('redirectPortedHostnameToEnv WSLENV forwarding', () => {
// A WSL-routed glab only sees Windows variables listed in WSLENV; without
// the entry the ported host is silently dropped and glab talks to
// gitlab.com (#12557).
const { redirectPortedHostnameToEnv } = await import('./runner')
const { options } = redirectPortedHostnameToEnv(
['api', '--hostname', 'gitlab.example.com:8443'],
{ env: { PATH: '/usr/bin' } }
+27 -1804
View File
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
import { spawnNodeProcess as spawn } from '../../shared/child-process/node-process-execution'
import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe'
import { getSpawnArgsForWindows } from '../win32-utils'
import type { ResolvedCommand } from './command-resolution'
import { createAbortError, killSpawnedCommandTree } from './subprocess-lifecycle'
export type CommandExecOptions = {
cwd?: string
encoding?: BufferEncoding
maxBuffer?: number
timeout?: number
env?: NodeJS.ProcessEnv
signal?: AbortSignal
wslDistro?: string
}
function isMissingCommandError(error: unknown): boolean {
return Boolean(
error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT'
)
}
function hasPathSeparator(command: string): boolean {
return command.includes('/') || command.includes('\\')
}
export function shouldRetryWindowsCommandShim(error: unknown, resolved: ResolvedCommand): boolean {
return (
process.platform === 'win32' &&
resolved.wsl === null &&
isMissingCommandError(error) &&
!hasPathSeparator(resolved.binary) &&
!/\.[A-Za-z0-9]+$/.test(resolved.binary)
)
}
export async function spawnCommandCapture(
command: string,
args: string[],
options: CommandExecOptions
): Promise<{ stdout: string; stderr: string }> {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args)
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(createAbortError())
return
}
let settled = false
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let stderrBytes = 0
const spawnStartedAt = performance.now()
const child = spawn(spawnCmd, spawnArgs, {
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
recordSubprocessSpawn(spawnCmd, spawnArgs, performance.now() - spawnStartedAt)
let timer: NodeJS.Timeout | null = null
const onAbort = (): void => {
void killSpawnedCommandTree(child)
finish(createAbortError())
}
const cleanupListeners = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
options.signal?.removeEventListener('abort', onAbort)
child.stdout?.off('data', onStdoutData)
child.stderr?.off('data', onStderrData)
child.off('error', onError)
child.off('close', onClose)
}
const finish = (error: Error | null): void => {
if (settled) {
return
}
settled = true
cleanupListeners()
if (error) {
reject(Object.assign(error, { stdout, stderr }))
return
}
resolve({ stdout, stderr })
}
timer = options.timeout
? setTimeout(() => {
void killSpawnedCommandTree(child)
finish(new Error(`${command} timed out.`))
}, options.timeout)
: null
options.signal?.addEventListener('abort', onAbort, { once: true })
function onStdoutData(chunk: Buffer): void {
stdoutBytes += chunk.byteLength
if (options.maxBuffer && stdoutBytes > options.maxBuffer) {
void killSpawnedCommandTree(child)
finish(new Error(`${command} stdout exceeded maxBuffer.`))
return
}
stdout += chunk.toString(options.encoding ?? 'utf-8')
}
function onStderrData(chunk: Buffer): void {
stderrBytes += chunk.byteLength
if (options.maxBuffer && stderrBytes > options.maxBuffer) {
void killSpawnedCommandTree(child)
finish(new Error(`${command} stderr exceeded maxBuffer.`))
return
}
stderr += chunk.toString(options.encoding ?? 'utf-8')
}
function onError(error: Error): void {
finish(error)
}
function onClose(code: number | null): void {
if (code === 0) {
finish(null)
return
}
finish(new Error(`${command} exited with ${code}.`))
}
child.stdout?.on('data', onStdoutData)
child.stderr?.on('data', onStderrData)
child.on('error', onError)
child.on('close', onClose)
})
}
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getBranchCompare } from './status'
import { getBranchCompare } from './branch-comparison'
const tempRoots: string[] = []
+2 -1
View File
@@ -51,7 +51,8 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
})
)
import { getBranchCompare, getCommitCompare } from './status'
import { getBranchCompare } from './branch-comparison'
import { getCommitCompare } from './commit-comparison'
describe('getBranchCompare', () => {
beforeEach(() => {
@@ -10,6 +10,7 @@ import {
import type * as BranchLineTotal from '../../shared/git-branch-line-total'
import type * as GitRunner from './runner'
import type * as GitStream from './git-stream'
// Why: real git, spied argv. Every exec is recorded so the performance contract
// ("no ranged diff unless asked", "one exec for concurrent callers") can be
@@ -51,8 +52,15 @@ vi.mock('./runner', async (importOriginal) => {
) => {
gitExecCalls.push(args[0])
return actual.gitExecFileAsyncBuffer(...args)
},
gitStreamStdout: async (...args: Parameters<typeof GitRunner.gitStreamStdout>) => {
}
}
})
vi.mock('./git-stream', async (importOriginal) => {
const actual = await importOriginal<typeof GitStream>()
return {
...actual,
gitStreamStdout: async (...args: Parameters<typeof GitStream.gitStreamStdout>) => {
gitExecCalls.push(args[0])
return actual.gitStreamStdout(...args)
}
@@ -61,10 +69,10 @@ vi.mock('./runner', async (importOriginal) => {
import {
clearEffectiveUpstreamStatusCacheForTests,
clearSubmodulePathsCacheForTests,
getStatus,
invalidateGitReadCaches
} from './status'
} from './git-read-cache'
import { clearSubmodulePathsCacheForTests } from './submodule-paths'
import { getStatus } from './status'
const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5)
// No fixture path here looks like test or generated code, so it is all source.
@@ -4,7 +4,8 @@ import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total'
import { getStatus, invalidateGitReadCaches } from './status'
import { getStatus } from './status'
import { invalidateGitReadCaches } from './git-read-cache'
const tempRoots: string[] = []
@@ -51,7 +51,7 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
})
)
import { abortMerge, abortRebase, detectConflictOperation } from './status'
import { abortMerge, abortRebase, detectConflictOperation } from './conflict-status'
describe('abortMerge', () => {
beforeEach(() => {
@@ -2,7 +2,7 @@ import { performance } from 'node:perf_hooks'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromises from 'node:fs/promises'
import type * as GitRunner from './runner'
import type * as GitStream from './git-stream'
import type { GitExec } from '../../relay/git-handler-ops'
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
@@ -16,8 +16,8 @@ vi.mock('node:fs/promises', async (importOriginal) => {
return { ...actual, readFile: readFileMock }
})
vi.mock('./runner', async (importOriginal) => {
const actual = await importOriginal<typeof GitRunner>()
vi.mock('./git-stream', async (importOriginal) => {
const actual = await importOriginal<typeof GitStream>()
return { ...actual, gitStreamStdout: gitStreamStdoutMock }
})
+2 -1
View File
@@ -3,7 +3,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getStatus, stageFile } from './status'
import { getStatus } from './status'
import { stageFile } from './git-index-mutations'
const tempRoots: string[] = []
+5 -1
View File
@@ -56,7 +56,11 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
})
)
import { getBranchDiff, getCommitDiff, getDiff, getStagedCommitContext, stageFile } from './status'
import { getBranchDiff } from './branch-comparison'
import { getCommitDiff } from './commit-comparison'
import { getDiff } from './status-file-diff'
import { getStagedCommitContext } from './git-commit-operations'
import { stageFile } from './git-index-mutations'
function deferredBuffer(content: string): {
promise: Promise<{ stdout: Buffer }>
@@ -52,13 +52,8 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
})
)
import {
bulkDiscardChanges,
bulkStageFiles,
bulkUnstageFiles,
discardChanges,
isWithinWorktree
} from './status'
import { bulkDiscardChanges, discardChanges, isWithinWorktree } from './git-discard-changes'
import { bulkStageFiles, bulkUnstageFiles } from './git-index-mutations'
describe('discardChanges', () => {
beforeEach(() => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'
import { mkdtemp, mkdir, rm, symlink, writeFile, access, readFile } from 'node:fs/promises'
import * as path from 'node:path'
import { tmpdir } from 'node:os'
import { bulkDiscardChanges, discardChanges } from './status'
import { bulkDiscardChanges, discardChanges } from './git-discard-changes'
const tempRoots: string[] = []
+204
View File
@@ -0,0 +1,204 @@
import * as path from 'node:path'
import type { GitDiffResult } from '../../shared/git-diff-compare-types'
import type { GitRuntimeOptions } from './git-runtime-options'
import { runGitDiffRead } from './git-diff-read-cache'
import {
buildDiffResult,
readGitBlobAtIndexPath,
readGitBlobAtOidPath,
readUnstagedLeftBlob,
readWorkingTreeFile
} from './git-blob-content'
import {
findContainingSubmodule,
listSubmodulePaths,
readGitlinkOidFromIndex,
readGitlinkOidFromTree,
readWorkingSubmoduleHead,
resolveSubmoduleWorktreePath
} from './submodule-paths'
/**
* Synthesize a gitlink pointer diff: Git represents submodule commit changes as a
* one-line `Subproject commit <oid>` 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<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 (change is committed).
*/
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 dedupe synchronously (before any await) so concurrent identical reads coalesce.
return runGitDiffRead(['diff', worktreePath, filePath, staged, compareAgainstHead], 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, 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
}
+128
View File
@@ -0,0 +1,128 @@
import type { GitStatusEntry } from '../../shared/git-status-types'
import {
applyLineStats,
collectUntrackedAdditions,
parseNumstat,
type GitLineStats
} from '../../shared/git-uncommitted-line-stats'
import {
computeGitBranchLineTotal,
readGitBranchLineTotalMergeBaseParam,
GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS,
type GitBranchLineTotal
} from '../../shared/git-branch-line-total'
import { gitExecFileAsync } from './runner'
import { gitOptionalLocksDisabledEnv } from './git-environment-policy'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitStatusReadOptionsForWorktree } from './git-runtime-options'
import type { GetStatusOptions } from './status-query-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<GitBranchLineTotal | undefined> } | 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}`
}
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'
],
{
...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<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
}
@@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './status'
import { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './git-index-mutations'
const tempRoots: string[] = []
const globNamedFile = '[k]eep.log'
+13
View File
@@ -0,0 +1,13 @@
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. */
branchLineTotalMergeBase?: string
/** Max changed-file entries; 0 disables the cap. */
limit?: number
bypassEffectiveUpstreamNegativeCache?: boolean
/** Orca-managed shared links that may need filtering from untracked rows. */
sharedLinkPaths?: readonly string[]
}
+12 -2
View File
@@ -5,7 +5,8 @@ import path from 'node:path'
import {
createBoundedFileReaderModuleMock,
createFsPromisesModuleMock,
createGitRunnerModuleMock
createGitRunnerModuleMock,
createGitStreamModuleMock
} from './status-test-harness'
const {
@@ -37,6 +38,13 @@ vi.mock('./runner', () =>
gitStreamOptionsMock
})
)
vi.mock('./git-stream', () =>
createGitStreamModuleMock({
gitExecFileAsyncMock,
gitExecFileAsyncBufferMock,
gitStreamOptionsMock
})
)
vi.mock('fs/promises', () =>
createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock })
@@ -53,7 +61,9 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
})
)
import { clearEffectiveUpstreamStatusCacheForTests, getStatus, stageFile } from './status'
import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache'
import { getStatus } from './status'
import { stageFile } from './git-index-mutations'
describe('getStatus', () => {
beforeEach(() => {
@@ -20,12 +20,11 @@ vi.mock('./runner', () => ({
import {
MAX_SUBMODULE_PATHS_CACHE_ENTRIES,
abortMerge,
abortRebase,
clearSubmodulePathsCacheForTests,
getSubmodulePathsCacheCountForTests,
listSubmodulePaths
} from './status'
} from './submodule-paths'
import { abortMerge, abortRebase } from './conflict-status'
import { checkoutBranch } from './checkout'
import { gitPull, gitPullRebaseFromBase } from './remote'
import { addWorktree, removeWorktree } from './worktree'
+13 -5
View File
@@ -4,7 +4,8 @@ import path from 'node:path'
import {
createBoundedFileReaderModuleMock,
createFsPromisesModuleMock,
createGitRunnerModuleMock
createGitRunnerModuleMock,
createGitStreamModuleMock
} from './status-test-harness'
const {
@@ -36,6 +37,13 @@ vi.mock('./runner', () =>
gitStreamOptionsMock
})
)
vi.mock('./git-stream', () =>
createGitStreamModuleMock({
gitExecFileAsyncMock,
gitExecFileAsyncBufferMock,
gitStreamOptionsMock
})
)
vi.mock('fs/promises', () =>
createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock })
@@ -52,14 +60,14 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
})
)
import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache'
import {
clearEffectiveUpstreamStatusCacheForTests,
clearSubmodulePathsCacheForTests,
getDiff,
getSubmoduleStatus,
listSubmodulePaths,
resolveSubmoduleWorktreePath
} from './status'
} from './submodule-paths'
import { getDiff } from './status-file-diff'
import { getSubmoduleStatus } from './submodule-status'
describe('submodule diff routing', () => {
const OLD_OID = 'a'.repeat(40)
+15
View File
@@ -42,6 +42,21 @@ export function createGitRunnerModuleMock(mocks: GitRunnerMocks): Record<string,
})
}
}
export function createGitStreamModuleMock(mocks: GitRunnerMocks): Record<string, unknown> {
return {
// Why: status tests seed the status call through gitExecFileAsyncMock; preserve
// that queue while mocking the concrete stream module used by production.
gitStreamStdout: async (
args: string[],
options: { signal?: AbortSignal; onStdout: (chunk: string) => boolean | void }
) => {
mocks.gitStreamOptionsMock(options)
const { stdout } = await mocks.gitExecFileAsyncMock(args)
const stoppedEarly = options.onStdout(stdout ?? '') === true
return { stoppedEarly }
}
}
}
export function createFsPromisesModuleMock(mocks: FsPromisesMocks): Record<string, unknown> {
return {
@@ -21,6 +21,16 @@ vi.mock('./runner', () => ({
GIT_OPTIONAL_LOCKS: '0'
})
}))
vi.mock('./git-stream', () => ({
gitStreamStdout: async (
args: string[],
options: { onStdout: (chunk: string) => boolean | void }
) => {
const { stdout } = await gitExecFileAsyncMock(args)
const stoppedEarly = options.onStdout(stdout ?? '') === true
return { stoppedEarly }
}
}))
vi.mock('fs/promises', () => ({
readFile: readFileMock
@@ -40,11 +50,11 @@ function emptyGitConfigSnapshot(): { stdout: string } {
import {
clearEffectiveUpstreamNegativeStatusCache,
clearEffectiveUpstreamStatusCacheForTests,
getEffectiveUpstreamStatusCacheCountForTests,
getEffectiveUpstreamStatusGenerationCountForTests,
getStatus
} from './status'
getEffectiveUpstreamStatusGenerationCountForTests
} from './effective-upstream-status-cache'
import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache'
import { getStatus } from './status'
describe('local upstream negative cache', () => {
beforeEach(() => {
@@ -26,6 +26,16 @@ vi.mock('./runner', () => ({
GIT_OPTIONAL_LOCKS: '0'
})
}))
vi.mock('./git-stream', () => ({
gitStreamStdout: async (
args: string[],
options: { onStdout: (chunk: string) => boolean | void }
) => {
const { stdout } = await gitExecFileAsyncMock(args)
const stoppedEarly = options.onStdout(stdout ?? '') === true
return { stoppedEarly }
}
}))
vi.mock('fs/promises', () => ({
readFile: readFileMock
@@ -35,7 +45,8 @@ vi.mock('fs', () => ({
existsSync: existsSyncMock
}))
import { clearEffectiveUpstreamStatusCacheForTests, getStatus } from './status'
import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache'
import { getStatus } from './status'
function getGitArgs(call: unknown[]): string[] {
return call[0] as string[]
+2 -8
View File
@@ -14,14 +14,8 @@ vi.mock('./runner', () => ({
gitStreamStdout: vi.fn()
}))
import {
bulkDiscardChanges,
bulkStageFiles,
bulkUnstageFiles,
discardChanges,
stageFile,
unstageFile
} from './status'
import { bulkDiscardChanges, discardChanges } from './git-discard-changes'
import { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './git-index-mutations'
const worktreePath = path.resolve('repo')
const windowsRelativePath = 'tests\\breakgit'
+12 -2
View File
@@ -3,7 +3,8 @@ import type * as BoundedFileReader from '../../shared/node-bounded-file-reader'
import {
createBoundedFileReaderModuleMock,
createFsPromisesModuleMock,
createGitRunnerModuleMock
createGitRunnerModuleMock,
createGitStreamModuleMock
} from './status-test-harness'
const {
@@ -35,6 +36,13 @@ vi.mock('./runner', () =>
gitStreamOptionsMock
})
)
vi.mock('./git-stream', () =>
createGitStreamModuleMock({
gitExecFileAsyncMock,
gitExecFileAsyncBufferMock,
gitStreamOptionsMock
})
)
vi.mock('fs/promises', () =>
createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock })
@@ -51,7 +59,9 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
})
)
import { clearEffectiveUpstreamStatusCacheForTests, getStatus, stageFile } from './status'
import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache'
import { getStatus } from './status'
import { stageFile } from './git-index-mutations'
describe('getStatus', () => {
beforeEach(() => {
+31 -2155
View File
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
import * as path from 'node:path'
import { gitExecFileAsync } from './runner'
import { gitOptionalLocksDisabledEnv } from './git-environment-policy'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
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
export function clearSubmodulePathsCacheForTests(): 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, options.wslDistro ?? null].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<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 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
}
export 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 ''
}
}
export 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 ''
}
}
export 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 ''
}
}
+119
View File
@@ -0,0 +1,119 @@
import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types'
import { parseNumstat } from '../../shared/git-uncommitted-line-stats'
import { capGitStatusEntries, resolveGitStatusLimit } from '../../shared/git-status-limit'
import { gitExecFileAsync } from './runner'
import { gitOptionalLocksDisabledEnv } from './git-environment-policy'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import type { GetStatusOptions } from './status-query-options'
import { parseBranchChangeLine } from './git-change-entry-parser'
import {
readGitlinkOidFromIndex,
readGitlinkOidFromTree,
readWorkingSubmoduleHead,
resolveSubmoduleWorktreePath
} from './submodule-paths'
import { getStatus } from './status'
/**
* 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<GitStatusResult> {
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<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
}
+62
View File
@@ -0,0 +1,62 @@
import {
spawnNodeProcess as spawn,
type SpawnedProcess as ChildProcess
} from '../../shared/child-process/node-process-execution'
export function createAbortError(): Error {
const error = new Error('The operation was aborted.')
error.name = 'AbortError'
return error
}
const WINDOWS_TREE_KILL_WAIT_MS = 2_000
export function killSpawnedCommandTree(child: ChildProcess): Promise<void> {
const pid = child.pid
if (!pid || process.platform !== 'win32') {
child.kill()
return Promise.resolve()
}
return new Promise((resolve) => {
let killer: ChildProcess
try {
// Why: Windows shims/wsl.exe own descendants; wait for /t tree cleanup so a timed-out command can't outlive its probe.
killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
if (!killer || typeof killer.unref !== 'function') {
child.kill()
resolve()
return
}
} catch {
child.kill()
resolve()
return
}
let settled = false
let timer: NodeJS.Timeout | null = null
const finish = (fallbackToChildKill: boolean): void => {
if (settled) {
return
}
settled = true
if (timer) {
clearTimeout(timer)
}
killer.removeAllListeners()
if (fallbackToChildKill) {
child.kill()
}
resolve()
}
killer.once('error', () => finish(true))
killer.once('close', (code) => finish(code !== 0))
timer = setTimeout(() => {
killer.kill()
finish(true)
}, WINDOWS_TREE_KILL_WAIT_MS)
killer.unref()
})
}
+1 -1
View File
@@ -11,7 +11,7 @@ vi.mock('./runner', () => ({
}))
import { getUpstreamStatus, invalidateGitUpstreamStatusReads } from './upstream'
import { runWithGitReadCacheInvalidation } from './status'
import { runWithGitReadCacheInvalidation } from './git-read-cache'
const missingTrackingRefError = new Error(
"fatal: ambiguous argument 'HEAD@{u}': unknown revision or path not in the working tree.\n" +
@@ -18,6 +18,7 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
vi.mock('../worktree-trash', () => ({
@@ -18,6 +18,7 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
vi.mock('../worktree-trash', () => ({
@@ -18,6 +18,7 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
vi.mock('../worktree-trash', () => ({
@@ -18,6 +18,7 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
vi.mock('../worktree-trash', () => ({
+245
View File
@@ -0,0 +1,245 @@
import type {
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion
} from '../../shared/worktree/base-ref-drift-types'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { probeWorktreeBaseRefPresence } from './worktree-base-ref-probe'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import type { GitWorktreeExecOptions } from './worktree-execution-options'
import { gitWorktreeExecOptions } from './worktree-execution-options'
import type { AddWorktreeOptions } from './worktree-operation-contracts'
import { parseWorktreeList } from './worktree-list-output'
type Refreshability =
| {
refreshable: true
baseRef: string
localBranch: string
fullRef: string
remoteTrackingRef: string
localOid: string
remoteOid: string
behind: number
ownerWorktreePath?: string
}
| { refreshable: false; result: LocalBaseRefRefreshResult }
export async function getLocalBaseRefUpdateSuggestion(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'],
options: GitWorktreeExecOptions = {}
): Promise<LocalBaseRefUpdateSuggestion | undefined> {
const evaluation = await evaluateRefreshability(
repoPath,
baseBranch,
remoteTrackingRef,
remoteTrackingBase,
options,
(behind) => behind > 0
)
if (!evaluation?.refreshable || evaluation.behind <= 0) {
return undefined
}
return {
baseRef: evaluation.baseRef,
localBranch: evaluation.localBranch,
behind: evaluation.behind
}
}
export async function refreshLocalBaseRef(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'],
options: GitWorktreeExecOptions = {}
): Promise<LocalBaseRefRefreshResult | undefined> {
const evaluation = await evaluateRefreshability(
repoPath,
baseBranch,
remoteTrackingRef,
remoteTrackingBase,
options
)
if (!evaluation) {
return undefined
}
if (!evaluation.refreshable) {
return evaluation.result
}
const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch }
try {
if (evaluation.ownerWorktreePath) {
const { stdout } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
gitWorktreeExecOptions(repoPath, options)
)
const owner = parseWorktreeList(translateWslOutputPaths(stdout, repoPath, options)).find(
(worktree) => worktree.branch === evaluation.fullRef
)
if (!owner || owner.path !== evaluation.ownerWorktreePath) {
return { ...resultBase, status: 'skipped_error' }
}
const { stdout: status } = await gitExecFileAsync(
['status', '--porcelain', '--untracked-files=no'],
gitWorktreeExecOptions(owner.path, options)
)
if (status.trim()) {
return {
...resultBase,
status: 'skipped_dirty_worktree',
ownerWorktreePath: owner.path
}
}
await gitExecFileAsync(
['reset', '--hard', evaluation.remoteOid],
gitWorktreeExecOptions(owner.path, options)
)
return { ...resultBase, status: 'updated', ownerWorktreePath: owner.path }
}
await gitExecFileAsync(
['update-ref', evaluation.fullRef, evaluation.remoteOid, evaluation.localOid],
gitWorktreeExecOptions(repoPath, options)
)
return { ...resultBase, status: 'updated' }
} catch {
return { ...resultBase, status: 'skipped_error' }
}
}
async function evaluateRefreshability(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase: AddWorktreeOptions['remoteTrackingBase'],
options: GitWorktreeExecOptions,
shouldInspectOwner: (behind: number) => boolean = () => true
): Promise<Refreshability | undefined> {
const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase)
if (!parsed) {
return undefined
}
const resultBase = { baseRef: parsed.baseRef, localBranch: parsed.localBranch }
let drift: { ahead: number; behind: number }
let localOid = ''
let remoteOid = ''
try {
const { stdout } = await gitExecFileAsync(
['rev-list', '--left-right', '--count', `${parsed.fullRef}...${remoteTrackingRef}`],
gitWorktreeExecOptions(repoPath, options)
)
const counts = parseGitRevListAheadBehindCounts(stdout)
if (counts.status !== 'ok' || counts.ahead !== 0) {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
if (!shouldInspectOwner(counts.behind)) {
return undefined
}
const local = await gitExecFileAsync(
['rev-parse', '--verify', `${parsed.fullRef}^{commit}`],
gitWorktreeExecOptions(repoPath, options)
)
localOid = local.stdout.trim()
if (!localOid) {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
const remote = await gitExecFileAsync(
['rev-parse', '--verify', `${remoteTrackingRef}^{commit}`],
gitWorktreeExecOptions(repoPath, options)
)
remoteOid = remote.stdout.trim()
if (!remoteOid) {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
await gitExecFileAsync(
['merge-base', '--is-ancestor', localOid, remoteOid],
gitWorktreeExecOptions(repoPath, options)
)
drift = { ahead: counts.ahead, behind: counts.behind }
} catch {
const presence = await probeWorktreeBaseRefPresence(
(args) => gitExecFileAsync(args, gitWorktreeExecOptions(repoPath, options)),
parsed.fullRef
)
if (presence === 'absent') {
return undefined
}
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
try {
const { stdout } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
gitWorktreeExecOptions(repoPath, options)
)
const owner = parseWorktreeList(translateWslOutputPaths(stdout, repoPath, options)).find(
(worktree) => worktree.branch === parsed.fullRef
)
if (owner) {
const { stdout: status } = await gitExecFileAsync(
['status', '--porcelain', '--untracked-files=no'],
gitWorktreeExecOptions(owner.path, options)
)
if (status.trim()) {
return {
refreshable: false,
result: {
...resultBase,
status: 'skipped_dirty_worktree',
ownerWorktreePath: owner.path
}
}
}
return {
refreshable: true,
...resultBase,
fullRef: parsed.fullRef,
remoteTrackingRef,
localOid,
remoteOid,
behind: drift.behind,
ownerWorktreePath: owner.path
}
}
return {
refreshable: true,
...resultBase,
fullRef: parsed.fullRef,
remoteTrackingRef,
localOid,
remoteOid,
behind: drift.behind
}
} catch {
return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } }
}
}
function parseRemoteTrackingLocalBaseRef(
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase']
): { baseRef: string; localBranch: string; fullRef: string } | undefined {
if (remoteTrackingBase?.ref === remoteTrackingRef) {
return {
baseRef: remoteTrackingBase.base,
localBranch: remoteTrackingBase.branch,
fullRef: `refs/heads/${remoteTrackingBase.branch}`
}
}
const prefix = 'refs/remotes/'
if (!remoteTrackingRef.startsWith(prefix)) {
return undefined
}
const shortRef = remoteTrackingRef.slice(prefix.length)
const slashIndex = shortRef.indexOf('/')
if (slashIndex <= 0) {
return undefined
}
const localBranch = shortRef.slice(slashIndex + 1)
return { baseRef: baseBranch, localBranch, fullRef: `refs/heads/${localBranch}` }
}
+188
View File
@@ -0,0 +1,188 @@
import {
branchHasNoUnmergedChangesWithLazyTargetRefresh,
getBranchCleanupTargetRefs
} from '../../shared/git-branch-cleanup'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
import { gitExecFileAsync } from './runner'
import type { GitWorktreeExecOptions } from './worktree-execution-options'
import { gitWorktreeExecOptions } from './worktree-execution-options'
import { parseWorktreeList } from './worktree-list-output'
import type { RemoveWorktreeOptions } from './worktree-operation-contracts'
export async function deleteBranchAfterWorktreeRemoval(
repoPath: string,
branchName: string,
branchHead: string,
options: RemoveWorktreeOptions
): Promise<RemoveWorktreeResult> {
try {
const result = await deleteLocalBranch(
repoPath,
branchName,
options.forceBranchDelete === true,
options
)
if (result === 'checked-out') {
return {}
}
return {}
} catch (error) {
if (!options.forceBranchDelete && branchHead) {
try {
if (await deleteAlreadyMergedBranch(repoPath, branchName, branchHead, options)) {
return {}
}
} catch (alreadyMergedDeleteError) {
console.warn(
`[git] Failed to delete already-merged local branch "${branchName}" after removing worktree`,
alreadyMergedDeleteError
)
}
}
console.warn(
`[git] Preserved local branch "${branchName}" after removing worktree (not fully merged)`,
error
)
return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } }
}
}
async function deleteLocalBranch(
repoPath: string,
branchName: string,
forceBranchDelete: boolean,
options: GitWorktreeExecOptions
): Promise<'deleted' | 'checked-out'> {
const deleteFlag = forceBranchDelete ? '-D' : '-d'
try {
await gitExecFileAsync(
['branch', deleteFlag, '--', branchName],
gitWorktreeExecOptions(repoPath, options)
)
return 'deleted'
} catch (error) {
if (!isBranchCheckedOutInWorktreeError(error)) {
throw error
}
}
try {
await gitExecFileAsync(['worktree', 'prune'], gitWorktreeExecOptions(repoPath, options))
} catch (error) {
console.warn(`[git] Failed to prune worktrees before deleting branch "${branchName}"`, error)
return 'checked-out'
}
try {
await gitExecFileAsync(
['branch', deleteFlag, '--', branchName],
gitWorktreeExecOptions(repoPath, options)
)
return 'deleted'
} catch (error) {
if (isBranchCheckedOutInWorktreeError(error)) {
return 'checked-out'
}
throw error
}
}
async function deleteAlreadyMergedBranch(
repoPath: string,
branchName: string,
branchHead: string,
options: GitWorktreeExecOptions
): Promise<boolean> {
const runGit = (args: string[], execOptions?: { stdin?: string }) =>
gitExecFileAsync(args, {
...gitWorktreeExecOptions(repoPath, options),
...(execOptions?.stdin !== undefined ? { stdin: execOptions.stdin } : {})
})
const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName)
const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
(capabilities) =>
branchHasNoUnmergedChangesWithLazyTargetRefresh(runGit, branchName, targetRefs, capabilities)
)
if (!hasNoUnmergedChanges) {
return false
}
await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) =>
gitExecFileAsync(args, gitWorktreeExecOptions(cwd, options))
)
return true
}
export async function forceDeleteLocalBranch(
repoPath: string,
branchName: string,
expectedHead: string,
runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> = (
args,
cwd
) => gitExecFileAsync(args, { cwd })
): Promise<void> {
if (!branchName || branchName.includes('\0')) {
throw new Error('Invalid branch name')
}
if (!expectedHead) {
throw new Error(
`Cannot force-delete local branch "${branchName}" without the commit Git preserved.`
)
}
if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) {
throw new Error(`Local branch "${branchName}" is checked out in another worktree.`)
}
try {
await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath)
} catch {
throw new Error(
`Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.`
)
}
if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) {
try {
await runGit(['update-ref', `refs/heads/${branchName}`, expectedHead, ''], repoPath)
} catch (restoreError) {
console.warn(
`[git] Failed to restore local branch "${branchName}" after concurrent checkout`,
restoreError
)
}
throw new Error(`Local branch "${branchName}" is checked out in another worktree.`)
}
try {
await runGit(['config', '--remove-section', `branch.${branchName}`], repoPath)
} catch {
// Best-effort parity with `git branch -D`; stale config is harmless.
}
}
async function isLocalBranchCheckedOut(
repoPath: string,
branchName: string,
runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>
): Promise<boolean> {
const { stdout } = await runGit(['worktree', 'list', '--porcelain'], repoPath)
return parseWorktreeList(stdout).some(
(worktree) => worktree.branch.replace(/^refs\/heads\//, '') === branchName
)
}
function isBranchCheckedOutInWorktreeError(error: unknown): boolean {
let text = String(error)
if (typeof error === 'object' && error !== null) {
const parts: string[] = []
if ('message' in error && typeof error.message === 'string') {
parts.push(error.message)
}
if ('stderr' in error && typeof error.stderr === 'string') {
parts.push(error.stderr)
}
text = parts.join('\n')
}
return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test(
text
)
}
+145
View File
@@ -0,0 +1,145 @@
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
import type {
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion
} from '../../shared/worktree/base-ref-drift-types'
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { getLocalBaseRefUpdateSuggestion, refreshLocalBaseRef } from './worktree-base-ref-refresh'
import { gitExecFileAsync } from './runner'
import { gitWorktreeExecOptions } from './worktree-execution-options'
import type { AddWorktreeOptions, AddWorktreeResult } from './worktree-operation-contracts'
export async function createWorktreeCheckout(
repoPath: string,
worktreePath: string,
branch: string,
baseBranch: string | undefined,
refreshBaseRef: boolean,
noCheckout: boolean,
options: AddWorktreeOptions,
resolveTimeout: () => number
): Promise<AddWorktreeResult> {
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add']
let effectiveBase: string | undefined
if (noCheckout) {
args.push('--no-checkout')
}
if (options.checkoutExistingBranch) {
args.push(worktreePath, branch)
} else {
args.push('--no-track', '-b', branch, worktreePath)
if (baseBranch) {
effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
)
if (refreshBaseRef) {
localBaseRefRefresh = await refreshLocalBaseRef(
repoPath,
baseBranch,
effectiveBase,
options.remoteTrackingBase,
options
)
} else if (options.suggestLocalBaseRefUpdate) {
localBaseRefUpdateSuggestion = await getLocalBaseRefUpdateSuggestion(
repoPath,
baseBranch,
effectiveBase,
options.remoteTrackingBase,
options
)
}
args.push(effectiveBase)
}
}
await gitExecFileAsync(args, {
...gitWorktreeExecOptions(repoPath, options),
timeout: resolveTimeout()
})
if (options.checkoutExistingBranch) {
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
}
if (effectiveBase) {
await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options)
}
await configureAutomaticUpstreamSetup(worktreePath, options)
return {
...(localBaseRefRefresh ? { localBaseRefRefresh } : {}),
...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {})
}
}
export async function clearWorktreeCreationBase(
worktreePath: string,
branch: string,
options: AddWorktreeOptions
): Promise<void> {
try {
await gitExecFileAsync(
['config', '--local', '--unset-all', `branch.${branch}.base`],
gitWorktreeExecOptions(worktreePath, options)
)
} catch {
// Best-effort cleanup leaves the original sparse setup error actionable.
}
}
async function persistWorktreeCreationBase(
worktreePath: string,
branch: string,
effectiveBase: string,
options: AddWorktreeOptions
): Promise<void> {
const configKey = `branch.${branch}.base`
try {
await gitExecFileAsync(
['config', '--local', '--replace-all', configKey, effectiveBase],
gitWorktreeExecOptions(worktreePath, options)
)
} catch (error) {
console.warn(`addWorktree: failed to set ${configKey} for ${worktreePath}`, error)
try {
await gitExecFileAsync(
['config', '--local', '--unset-all', configKey],
gitWorktreeExecOptions(worktreePath, options)
)
} catch (unsetError) {
console.warn(
`addWorktree: failed to unset stale ${configKey} for ${worktreePath}`,
unsetError
)
}
}
}
async function configureAutomaticUpstreamSetup(
worktreePath: string,
options: AddWorktreeOptions
): Promise<void> {
try {
let alreadySet = false
try {
await gitExecFileAsync(
['config', '--get', 'push.autoSetupRemote'],
gitWorktreeExecOptions(worktreePath, options)
)
alreadySet = true
} catch (error) {
if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 1)) {
throw error
}
}
if (!alreadySet) {
await gitExecFileAsync(
['config', '--local', 'push.autoSetupRemote', 'true'],
gitWorktreeExecOptions(worktreePath, options)
)
}
} catch (error) {
console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error)
}
}
@@ -0,0 +1,17 @@
export type GitWorktreeExecOptions = {
wslDistro?: string
signal?: AbortSignal
timeout?: number
}
export function gitWorktreeExecOptions(
cwd: string,
options: GitWorktreeExecOptions = {}
): { cwd: string; wslDistro?: string; signal?: AbortSignal; timeout?: number } {
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.signal ? { signal: options.signal } : {}),
...(options.timeout ? { timeout: options.timeout } : {})
}
}
@@ -9,9 +9,10 @@ vi.mock('./runner', () => ({
gitExecFileSync: vi.fn(),
translateWslOutputPaths: (output: string) => output
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: vi.fn() }))
import { clearGitCapabilityStateForTests } from './git-capability-state'
import { listWorktrees } from './worktree'
import { listWorktrees } from './worktree-listing'
const WORKTREE_LIST_OUTPUT = `worktree /repo
HEAD abc123
+2 -1
View File
@@ -18,6 +18,7 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
vi.mock('../worktree-trash', () => ({
@@ -26,7 +27,7 @@ vi.mock('../worktree-trash', () => ({
scheduleWorktreeTrashDeletion: vi.fn()
}))
import { listWorktreeGraph, WORKTREE_LIST_TIMEOUT_MS } from './worktree'
import { listWorktreeGraph, WORKTREE_LIST_TIMEOUT_MS } from './worktree-listing'
import { registerWorktreeSuiteHooks } from './worktree-test-harness'
registerWorktreeSuiteHooks()
+93
View File
@@ -0,0 +1,93 @@
import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
/** Parse the porcelain output of `git worktree list --porcelain`. */
export function parseWorktreeList(
output: string,
options: { nulDelimited?: boolean } = {}
): GitWorktreeInfo[] {
const worktrees: GitWorktreeInfo[] = []
const blocks = options.nulDelimited ? splitNulWorktreeList(output) : splitLineWorktreeList(output)
for (const lines of blocks) {
if (lines.length === 0) {
continue
}
let path = ''
let head = ''
let branch = ''
let isBare = false
let isSparse = false
let locked = false
let lockReason = ''
let prunable = false
let prunableReason = ''
for (const line of lines) {
if (line.startsWith('worktree ')) {
path = line.slice('worktree '.length)
} else if (line.startsWith('HEAD ')) {
head = line.slice('HEAD '.length)
} else if (line.startsWith('branch ')) {
branch = line.slice('branch '.length)
} else if (line === 'bare') {
isBare = true
} else if (line === 'sparse') {
isSparse = true
} else if (line === 'locked' || line.startsWith('locked ')) {
locked = true
const reason = line.slice('locked'.length).trim()
lockReason = options.nulDelimited ? reason : decodeGitCQuotedPath(reason)
} else if (line === 'prunable' || line.startsWith('prunable ')) {
prunable = true
const reason = line.slice('prunable'.length).trim()
prunableReason = options.nulDelimited ? reason : decodeGitCQuotedPath(reason)
}
}
if (path) {
worktrees.push({
path,
head,
branch,
isBare,
...(isSparse ? { isSparse } : {}),
...(locked ? { locked: true } : {}),
...(lockReason ? { lockReason } : {}),
...(prunable ? { prunable: true } : {}),
...(prunableReason ? { prunableReason } : {}),
isMainWorktree: worktrees.length === 0
})
}
}
return worktrees
}
function splitLineWorktreeList(output: string): string[][] {
return output
.trim()
.split(/\r?\n\r?\n/)
.map((block) => block.trim().split(/\r?\n/))
}
function splitNulWorktreeList(output: string): string[][] {
if (!output.includes('\0')) {
return splitLineWorktreeList(output)
}
const blocks: string[][] = []
let currentBlock: string[] = []
for (const field of output.split('\0')) {
if (field) {
currentBlock.push(field)
} else if (currentBlock.length > 0) {
blocks.push(currentBlock)
currentBlock = []
}
}
if (currentBlock.length > 0) {
blocks.push(currentBlock)
}
return blocks
}
+2 -1
View File
@@ -3,7 +3,8 @@ import { mkdtemp, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { listWorktrees, removeWorktree } from './worktree'
import { removeWorktree } from './worktree'
import { listWorktrees } from './worktree-listing'
const tempRoots: string[] = []
+4 -3
View File
@@ -34,9 +34,10 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
vi.mock('./status', () => ({
resolveGitDir: resolveGitDirMock,
vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock }))
vi.mock('./git-read-cache', () => ({
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
}))
@@ -52,7 +53,7 @@ import {
resetWorktreeRemovalState
} from './remove-worktree-test-harness'
import { listWorktrees, WORKTREE_LIST_TIMEOUT_MS } from './worktree'
import { listWorktrees, WORKTREE_LIST_TIMEOUT_MS } from './worktree-listing'
const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock)
const getGitCalls = createGitCallReader(gitExecFileAsyncMock)
+233
View File
@@ -0,0 +1,233 @@
import { stat } from 'node:fs/promises'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { isUnsupportedWorktreeListZError } from '../../shared/git-worktree-command-capabilities'
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
import { gitExecFileAsync } from './runner'
import type { GitWorktreeExecOptions } from './worktree-execution-options'
import { normalizeMainWorktreePath, translateWorktreePath } from './worktree-paths'
import { parseWorktreeList } from './worktree-list-output'
import { annotateSparseCheckoutStatus } from './worktree-sparse-checkout'
export const WORKTREE_LIST_TIMEOUT_MS = 30_000
const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
async function readWorktreeList(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
const execOptions = {
cwd: repoPath,
...options,
timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
}
return withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
(capabilities) =>
capabilities.runWithFallback(
'worktree-list-z',
async () => {
const { stdout } = await gitExecFileAsync(
['worktree', 'list', '--porcelain', '-z'],
execOptions
)
return normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true }),
options
)
},
async () => {
const { stdout } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
execOptions
)
const normalized = await normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout),
options
)
return annotatePrunableByExistence(normalized, repoPath, options)
},
isUnsupportedWorktreeListZError
)
)
}
async function annotatePrunableByExistence(
worktrees: GitWorktreeInfo[],
repoPath: string,
options: GitWorktreeExecOptions
): Promise<GitWorktreeInfo[]> {
const annotated = [...worktrees]
let nextIndex = 0
async function probeNext(): Promise<void> {
while (nextIndex < worktrees.length) {
const index = nextIndex
nextIndex += 1
const worktree = worktrees[index]
if (
!worktree ||
worktree.isMainWorktree ||
worktree.isBare ||
worktree.locked ||
worktree.prunable
) {
continue
}
try {
await stat(translateWorktreePath(worktree.path, repoPath, options))
} catch (error) {
if (getErrorCode(error) === 'ENOENT') {
annotated[index] = { ...worktree, prunable: true }
}
}
}
}
const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => probeNext()))
return annotated
}
async function readTranslatedWorktreeGraph(
repoPath: string,
options: GitWorktreeExecOptions
): Promise<GitWorktreeInfo[]> {
return (await readWorktreeList(repoPath, options)).map((worktree) => {
const translatedPath = translateWorktreePath(worktree.path, repoPath, options)
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
})
}
export async function listWorktreeGraph(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
try {
return await readTranslatedWorktreeGraph(repoPath, options)
} catch (error) {
if (await shouldTreatListingErrorAsEmpty(repoPath, error)) {
return []
}
console.warn(`[git/worktree] listWorktreeGraph failed for ${repoPath}:`, error)
return []
}
}
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
const worktreeScanGenerations = new Map<string, number>()
function hasInFlightWorktreeScanForRepo(repoPath: string): boolean {
const keyPrefix = `${repoPath}\0`
for (const key of inFlightWorktreeScans.keys()) {
if (key.startsWith(keyPrefix)) {
return true
}
}
return false
}
export function bumpWorktreeScanGeneration(repoPath: string): void {
if (!hasInFlightWorktreeScanForRepo(repoPath)) {
return
}
worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1)
}
function pruneWorktreeScanGeneration(repoPath: string): void {
if (worktreeScanGenerations.has(repoPath) && !hasInFlightWorktreeScanForRepo(repoPath)) {
worktreeScanGenerations.delete(repoPath)
}
}
export function getWorktreeScanCacheSizes(): { inFlight: number; generations: number } {
return { inFlight: inFlightWorktreeScans.size, generations: worktreeScanGenerations.size }
}
export function resetWorktreeScanCache(): void {
inFlightWorktreeScans.clear()
worktreeScanGenerations.clear()
}
export function listWorktrees(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
if (options.signal) {
return listWorktreesUnshared(repoPath, options)
}
const generation = worktreeScanGenerations.get(repoPath) ?? 0
const timeout = options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\0${generation}`
const inFlight = inFlightWorktreeScans.get(key)
if (inFlight) {
return inFlight
}
const scan = listWorktreesUnshared(repoPath, options).finally(() => {
if (inFlightWorktreeScans.get(key) === scan) {
inFlightWorktreeScans.delete(key)
}
pruneWorktreeScanGeneration(repoPath)
})
inFlightWorktreeScans.set(key, scan)
return scan
}
async function listWorktreesUnshared(
repoPath: string,
options: GitWorktreeExecOptions
): Promise<GitWorktreeInfo[]> {
try {
return annotateSparseCheckoutStatus(await readTranslatedWorktreeGraph(repoPath, options))
} catch (error) {
if (await shouldTreatListingErrorAsEmpty(repoPath, error)) {
return []
}
console.warn(`[git/worktree] listWorktrees failed for ${repoPath}:`, error)
return []
}
}
export async function listWorktreesStrict(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
const worktrees = (await readWorktreeList(repoPath, options)).map((worktree) => {
const translatedPath = translateWorktreePath(worktree.path, repoPath, options)
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
})
return annotateSparseCheckoutStatus(worktrees)
}
async function shouldTreatListingErrorAsEmpty(repoPath: string, error: unknown): Promise<boolean> {
if (getErrorCode(error) === 'ENOENT') {
try {
await stat(repoPath)
} catch (statError) {
if (getErrorCode(statError) === 'ENOENT') {
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
return true
}
}
}
return /not a git repository/i.test(getErrorText(error))
}
function getErrorCode(error: unknown): string | undefined {
return typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code)
: undefined
}
function getErrorText(error: unknown): string {
if (typeof error !== 'object' || error === null) {
return String(error)
}
const parts: string[] = []
if ('message' in error && typeof error.message === 'string') {
parts.push(error.message)
}
if ('stderr' in error && typeof error.stderr === 'string') {
parts.push(error.stderr)
}
return parts.join('\n')
}
+1
View File
@@ -18,6 +18,7 @@ vi.mock('./runner', () => ({
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock }))
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
vi.mock('../worktree-trash', () => ({

Some files were not shown because too many files have changed in this diff Show More