mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
refactor(git): split oversized repository services
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,7 +9,7 @@ vi.mock('./runner', () => ({
|
||||
gitExecFileAsyncBuffer: vi.fn()
|
||||
}))
|
||||
|
||||
import { commitChanges } from './status'
|
||||
import { commitChanges } from './git-commit-operations'
|
||||
|
||||
describe('commitChanges', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,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
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 ''
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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}` }
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user