diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index eb7865fac23..d73297acfed 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -75,6 +75,7 @@ async function gitExecFileAsyncUnlocked( cwd: options.cwd, wslDistro: options.wslDistro, tier: options.admissionTier, + admissionClass: options.admissionClass, signal: options.admissionSignal ?? options.signal }) span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index e98f02ca1f1..2b478c4bf5e 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -26,6 +26,8 @@ export type GitExecOptions = { observeChildTermination?: (terminated: Promise, writes: boolean) => void /** Scheduler priority for this child; status is the safe default. */ admissionTier?: GitAdmissionTier + /** File materialization during worktree creation has a separate concurrency budget. */ + admissionClass?: 'checkout' /** Skips general admission; only for a caller that bounds its own concurrency (worktree deletes). */ admissionExempt?: true /** Native macOS deletion spawns use the shared process worker. */ diff --git a/src/main/git/worktrees/creation/checkout-workers-git-args.ts b/src/main/git/worktrees/creation/checkout-workers-git-args.ts index f6d75b709e7..eba6eda4ea3 100644 --- a/src/main/git/worktrees/creation/checkout-workers-git-args.ts +++ b/src/main/git/worktrees/creation/checkout-workers-git-args.ts @@ -5,8 +5,8 @@ import { type GitWorktreeExecOptions } from '../worktree-operation-options' -// Git checks out with one worker by default; 0 means one per core (Git 2.32+; older Git ignores the key). -const PARALLEL_CHECKOUT_GIT_ARGS = ['-c', 'checkout.workers=0'] as const +// Bound default fan-out across simultaneous creates; older Git ignores the key (2.32+). +const PARALLEL_CHECKOUT_GIT_ARGS = ['-c', 'checkout.workers=4'] as const /** Parallel checkout args unless checkout.workers is configured at any scope for this repo and host. */ export async function checkoutWorkersGitArgs( diff --git a/src/main/git/worktrees/creation/worktree-add.ts b/src/main/git/worktrees/creation/worktree-add.ts index 02ffb4d41f7..be4105b0b45 100644 --- a/src/main/git/worktrees/creation/worktree-add.ts +++ b/src/main/git/worktrees/creation/worktree-add.ts @@ -271,6 +271,7 @@ async function performAddWorktree( try { await gitExecFileAsync([...(await checkoutWorkersArgs), ...args], { ...gitExecOptions(repoPath, options), + ...(!noCheckout ? { admissionClass: 'checkout' as const } : {}), // Resolve per call so later environment overrides take effect. timeout: resolveWorktreeAddTimeoutMs(), ...(checkoutProgress ? { onStderr: checkoutProgress.read } : {}) diff --git a/src/main/git/worktrees/creation/worktree-base-refresh.ts b/src/main/git/worktrees/creation/worktree-base-refresh.ts index a1e84c2b9a5..abb4d7a7390 100644 --- a/src/main/git/worktrees/creation/worktree-base-refresh.ts +++ b/src/main/git/worktrees/creation/worktree-base-refresh.ts @@ -13,6 +13,7 @@ import { type LocalBaseBranchGit } from '../../../../shared/worktree/local-base-branch-fast-forward' import { gitExecFileAsync } from '../../command-runner/git-exec-file' +import { findGitSubcommandIndex } from '../../../../shared/git/commands/git-command-classification' import { translateWslOutputPaths } from '../../command-runner/host-routing/wsl-path-translation' import { runWithGitReadCacheInvalidation } from '../../source-control/git-read-cache-invalidation' import type { AddWorktreeOptions, GitWorktreeExecOptions } from '../worktree-operation-options' @@ -115,7 +116,13 @@ function localBaseBranchGit(options: GitWorktreeExecOptions): LocalBaseBranchGit ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) }) return { - exec: (args, cwd) => gitExecFileAsync(args, execOptions(cwd)), + exec: (args, cwd) => + gitExecFileAsync(args, { + ...execOptions(cwd), + ...(args[findGitSubcommandIndex(args)] === 'merge' + ? { admissionClass: 'checkout' as const } + : {}) + }), listWorktrees: async (repoPath) => { const { stdout } = await withRepositoryGitCause( repoPath, diff --git a/src/main/git/worktrees/creation/worktree-create-admission-real-git.test.ts b/src/main/git/worktrees/creation/worktree-create-admission-real-git.test.ts new file mode 100644 index 00000000000..fa25af37ecc --- /dev/null +++ b/src/main/git/worktrees/creation/worktree-create-admission-real-git.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { GitAdmissionScheduler } from '../../../../shared/git/admission/git-admission-scheduler' +import { findGitSubcommandIndex } from '../../../../shared/git/commands/git-command-classification' +import { createWorktreePreparationLockReason } from '../../../../shared/worktree/create-preparation' +import { _resetGitAdmissionForTests } from '../../command-runner/admission/git-subprocess-admission' +import { gitExecFileAsync } from '../../command-runner/git-exec-file' +import { addWorktree } from './worktree-add' +import { addSparseWorktree } from './worktree-sparse-add' +import { + finalizePreparedWorktree, + prepareWorktreeCreateCheckout +} from './worktree-create-preparation' +import { refreshPreparedWorktreeTip } from './worktree-preparation-tip-refresh' +import { refreshLocalBaseRefForWorktreeCreate } from './worktree-base-refresh' + +let root: string +let repo: string +let admission: GitAdmissionScheduler + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-create-admission-')) + repo = join(root, 'repo') + admission = new GitAdmissionScheduler() + _resetGitAdmissionForTests(admission) + await gitExecFileAsync(['init', '-q', repo], { cwd: root }) + await gitExecFileAsync(['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: repo }) + await mkdir(join(repo, 'included')) + await writeFile(join(repo, 'included', 'file.txt'), 'one\n') + await gitExecFileAsync(['add', '.'], { cwd: repo }) + await gitExecFileAsync( + ['-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture'], + { cwd: repo } + ) +}) + +afterEach(async () => { + vi.restoreAllMocks() + _resetGitAdmissionForTests() + await rm(root, { recursive: true, force: true }) +}) + +async function behindCheckoutBudget(command: string, run: () => Promise): Promise { + const held = await Promise.all( + Array.from({ length: 3 }, () => + admission.acquire({ + args: ['worktree', 'add'], + cwd: repo, + admissionClass: 'checkout', + tier: 'interactive' + }) + ) + ) + const requested = Promise.withResolvers() + let admitted = false + const acquire = admission.acquire.bind(admission) + const spy = vi.spyOn(admission, 'acquire').mockImplementation((request) => { + const grant = acquire(request) + if ( + request.args[findGitSubcommandIndex(request.args)] === command && + (command !== 'sparse-checkout' || request.args.includes('set')) + ) { + requested.resolve() + void grant.then(() => { + admitted = true + }) + } + return grant + }) + const operation = run() + try { + await requested.promise + const read = await gitExecFileAsync(['rev-parse', '--verify', 'HEAD'], { cwd: repo }) + expect(read.stdout.trim()).toMatch(/^[0-9a-f]{40}$/) + expect(admitted).toBe(false) + } finally { + held.forEach((grant) => grant.release()) + await operation + spy.mockRestore() + } +} + +it('isolates ordinary and sparse creates while honoring the execution host checkout config', async () => { + await gitExecFileAsync(['config', 'checkout.workers', 'not-an-integer'], { cwd: repo }) + await expect(addWorktree(repo, join(root, 'invalid'), 'invalid', 'main')).rejects.toThrow() + await gitExecFileAsync(['config', 'checkout.workers', '0'], { cwd: repo }) + const ordinary = join(root, 'ordinary') + await behindCheckoutBudget('worktree', () => addWorktree(repo, ordinary, 'ordinary', 'main')) + expect(await readFile(join(ordinary, 'included', 'file.txt'), 'utf8')).toBe('one\n') + await gitExecFileAsync(['config', '--unset', 'checkout.workers'], { cwd: repo }) + const sparse = join(root, 'sparse') + await behindCheckoutBudget('sparse-checkout', () => + addSparseWorktree(repo, sparse, 'sparse', ['included'], 'main') + ) + expect(await readFile(join(sparse, 'included', 'file.txt'), 'utf8')).toBe('one\n') +}) + +it('isolates preparation, fetched-tip refresh and changed-head finalization materializations', async () => { + const prepared = join(root, 'prepared') + const reason = createWorktreePreparationLockReason('admission') + await behindCheckoutBudget('reset', () => + prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + ) + await writeFile(join(repo, 'included', 'file.txt'), 'two\n') + await gitExecFileAsync(['commit', '-qam', 'next', '--author=Test '], { + cwd: repo, + env: { ...process.env, GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.invalid' } + }) + await behindCheckoutBudget('reset', () => + refreshPreparedWorktreeTip(repo, prepared, 'main', reason) + ) + expect(await readFile(join(prepared, 'included', 'file.txt'), 'utf8')).toBe('two\n') + await writeFile(join(repo, 'included', 'file.txt'), 'three\n') + await gitExecFileAsync( + ['-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qam', 'third'], + { cwd: repo } + ) + const target = join(root, 'finalized') + await behindCheckoutBudget('reset', () => + finalizePreparedWorktree(repo, prepared, target, 'finalized', 'main', false, {}, reason) + ) + expect(await readFile(join(target, 'included', 'file.txt'), 'utf8')).toBe('three\n') +}) + +it('isolates the production base fast-forward with its leading Git config arguments', async () => { + const newer = join(root, 'newer') + await addWorktree(repo, newer, 'newer', 'main') + await writeFile(join(newer, 'included', 'file.txt'), 'remote\n') + await gitExecFileAsync( + ['-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qam', 'remote'], + { cwd: newer } + ) + const { stdout } = await gitExecFileAsync(['rev-parse', 'HEAD'], { cwd: newer }) + await gitExecFileAsync(['update-ref', 'refs/remotes/origin/main', stdout.trim()], { cwd: repo }) + await behindCheckoutBudget('merge', () => + refreshLocalBaseRefForWorktreeCreate(repo, 'origin/main', 'refs/remotes/origin/main') + ) + expect(await readFile(join(repo, 'included', 'file.txt'), 'utf8')).toBe('remote\n') +}) diff --git a/src/main/git/worktrees/creation/worktree-create-preparation.ts b/src/main/git/worktrees/creation/worktree-create-preparation.ts index e2318c2293b..be2bb01e94f 100644 --- a/src/main/git/worktrees/creation/worktree-create-preparation.ts +++ b/src/main/git/worktrees/creation/worktree-create-preparation.ts @@ -98,7 +98,11 @@ export async function prepareWorktreeCreateCheckout( '--hard', materializationBase ], - { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } + { + ...gitExecOptions(worktreePath, options), + admissionClass: 'checkout', + timeout: resolveWorktreeAddTimeoutMs() + } ) await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) }) diff --git a/src/main/git/worktrees/creation/worktree-preparation-finalization-work.ts b/src/main/git/worktrees/creation/worktree-preparation-finalization-work.ts index eeb968453b0..cfccc9fa9fa 100644 --- a/src/main/git/worktrees/creation/worktree-preparation-finalization-work.ts +++ b/src/main/git/worktrees/creation/worktree-preparation-finalization-work.ts @@ -93,7 +93,7 @@ export async function performPreparedWorktreeFinalization( '--hard', targetHead ], - gitExecOptions(preparedPath, finalizeGitOptions) + { ...gitExecOptions(preparedPath, finalizeGitOptions), admissionClass: 'checkout' } ) } diff --git a/src/main/git/worktrees/creation/worktree-preparation-tip-refresh.ts b/src/main/git/worktrees/creation/worktree-preparation-tip-refresh.ts index 9394f40c304..ba7acfc04df 100644 --- a/src/main/git/worktrees/creation/worktree-preparation-tip-refresh.ts +++ b/src/main/git/worktrees/creation/worktree-preparation-tip-refresh.ts @@ -66,7 +66,7 @@ export async function refreshPreparedWorktreeTip( '--hard', targetHead ], - gitExecOptions(preparedPath, refreshOptions) + { ...gitExecOptions(preparedPath, refreshOptions), admissionClass: 'checkout' } ) ) await verifyWorktreePreparationLockAtPath(lockPath, lockReason, refreshOptions.signal) diff --git a/src/main/git/worktrees/creation/worktree-sparse-add.ts b/src/main/git/worktrees/creation/worktree-sparse-add.ts index f8f25a9e8a8..c700eee8381 100644 --- a/src/main/git/worktrees/creation/worktree-sparse-add.ts +++ b/src/main/git/worktrees/creation/worktree-sparse-add.ts @@ -38,14 +38,14 @@ export async function addSparseWorktree( checkoutWorkersGitArgs(worktreePath, options), gitExecFileAsync(['sparse-checkout', 'init', '--cone'], gitExecOptions(worktreePath, options)) ]) - await gitExecFileAsync( - [...longPathArgs, 'sparse-checkout', 'set', '--', ...directories], - gitExecOptions(worktreePath, options) - ) - await gitExecFileAsync( - [...checkoutWorkersArgs, ...longPathArgs, 'checkout', branch], - gitExecOptions(worktreePath, options) - ) + await gitExecFileAsync([...longPathArgs, 'sparse-checkout', 'set', '--', ...directories], { + ...gitExecOptions(worktreePath, options), + admissionClass: 'checkout' + }) + await gitExecFileAsync([...checkoutWorkersArgs, ...longPathArgs, 'checkout', branch], { + ...gitExecOptions(worktreePath, options), + admissionClass: 'checkout' + }) return addResult } catch (error) { const wrapped: SparseWorktreeCreateError = diff --git a/src/shared/git/admission/git-admission-checkout.test.ts b/src/shared/git/admission/git-admission-checkout.test.ts new file mode 100644 index 00000000000..f81fda8088e --- /dev/null +++ b/src/shared/git/admission/git-admission-checkout.test.ts @@ -0,0 +1,68 @@ +import { expect, it } from 'vitest' +import { GitAdmissionScheduler } from './git-admission-scheduler' + +const checkout = { + args: ['worktree', 'add', '/checkout'], + cwd: '/repo', + admissionClass: 'checkout' as const +} + +it('reserves interactive create capacity and leaves reads and other mutations independent', async () => { + const scheduler = new GitAdmissionScheduler() + const preparations = await Promise.all([ + scheduler.acquire({ ...checkout, tier: 'status' }), + scheduler.acquire({ ...checkout, tier: 'status' }) + ]) + const cancelPreparation = new AbortController() + const queuedPreparation = scheduler.acquire({ + ...checkout, + tier: 'status', + signal: cancelPreparation.signal + }) + const canceled = expect(queuedPreparation).rejects.toMatchObject({ name: 'AbortError' }) + const interactive = await scheduler.acquire({ ...checkout, tier: 'interactive' }) + const cancelCreate = new AbortController() + const queuedCreate = scheduler.acquire({ + ...checkout, + tier: 'interactive', + signal: cancelCreate.signal + }) + const canceledCreate = expect(queuedCreate).rejects.toMatchObject({ name: 'AbortError' }) + const unrelated = await Promise.all( + [ + ['rev-parse', 'HEAD'], + ['fetch', 'origin'], + ['restore', 'file'], + ['checkout', 'feature'] + ].map((args) => scheduler.acquire({ args, cwd: '/other', tier: 'interactive' })) + ) + unrelated.forEach((grant) => grant.release()) + cancelPreparation.abort() + cancelCreate.abort() + await Promise.all([canceled, canceledCreate]) + preparations.forEach((grant) => grant.release()) + interactive.release() +}) + +it('reuses checkout capacity after cancellation and idempotent release across WSL routes', async () => { + const scheduler = new GitAdmissionScheduler({ checkoutCap: 4, checkoutHeadroom: 0 }) + const held = await Promise.all([ + scheduler.acquire({ ...checkout, wslDistro: 'Ubuntu' }), + scheduler.acquire({ ...checkout, cwd: '\\\\wsl.localhost\\Ubuntu\\repo' }) + ]) + const cancel = new AbortController() + const waiting = scheduler.acquire({ ...checkout, wslDistro: 'ubuntu', signal: cancel.signal }) + const rejected = expect(waiting).rejects.toMatchObject({ name: 'AbortError' }) + const otherRoute = await scheduler.acquire({ ...checkout, wslDistro: 'Debian' }) + cancel.abort() + await rejected + const replacement = scheduler.acquire({ ...checkout, wslDistro: 'UBUNTU' }) + held[0].release() + held[0].release() + const granted = await replacement + granted.release() + held[1].release() + otherRoute.release() + const next = await scheduler.acquire({ ...checkout, wslDistro: 'Ubuntu' }) + next.release() +}) diff --git a/src/shared/git/admission/git-admission-scheduler.ts b/src/shared/git/admission/git-admission-scheduler.ts index 0ee82993c69..45379dc8b5d 100644 --- a/src/shared/git/admission/git-admission-scheduler.ts +++ b/src/shared/git/admission/git-admission-scheduler.ts @@ -91,7 +91,9 @@ export class GitAdmissionScheduler { route: string | null budgetKeys: readonly string[] } { - const admissionClass = classifyGitCommand(request.args) === 'network' ? 'network' : 'general' + const admissionClass = + request.admissionClass ?? + (classifyGitCommand(request.args) === 'network' ? 'network' : 'general') const route = routeKey(request) const keys: string[] = [admissionClass] if (route) { @@ -110,17 +112,22 @@ export class GitAdmissionScheduler { } const isRoute = key.startsWith('route:') const isNetwork = key === 'network' + const isCheckout = key === 'checkout' budget = { baseCapacity: isRoute ? this.config.routeCap : isNetwork ? this.config.networkCap - : this.config.generalCap, + : isCheckout + ? this.config.checkoutCap + : this.config.generalCap, headroomCapacity: isRoute ? this.config.routeHeadroom : isNetwork ? this.config.networkHeadroom - : this.config.generalHeadroom, + : isCheckout + ? this.config.checkoutHeadroom + : this.config.generalHeadroom, baseUsed: 0, headroomUsed: 0 } diff --git a/src/shared/git/admission/git-admission-state.ts b/src/shared/git/admission/git-admission-state.ts index 119e7c0e930..c4dc864e26e 100644 --- a/src/shared/git/admission/git-admission-state.ts +++ b/src/shared/git/admission/git-admission-state.ts @@ -5,16 +5,18 @@ import type { GitAdmissionTier } from '../../rpc-contract/git-admission-tier-par const parallelism = typeof os.availableParallelism === 'function' ? os.availableParallelism() : 1 export const GENERAL_CAP = Math.max(2, Math.min(4, parallelism - 4)) export const NETWORK_CAP = 3 +export const CHECKOUT_CAP = 2 export const GENERAL_HEADROOM = 2 export const NETWORK_HEADROOM = 1 +export const CHECKOUT_HEADROOM = 1 export const ROUTE_CAP = 2 export const ROUTE_HEADROOM = 1 export const GIT_ADMISSION_AGING_MS = 15_000 -// General 4+2 and network 3+1 are disjoint, so at most ten admitted git children run globally; +// General 4+2, network 3+1 and checkout 2+1 admit at most thirteen Git roots per runtime; // worktree deletes bypass admission under their own limit (WORKTREE_DELETE_CONCURRENCY). -export const MAX_GIT_CHILDREN = 10 +export const MAX_GIT_CHILDREN = 13 -export type AdmissionClass = 'general' | 'network' +export type AdmissionClass = 'general' | 'network' | 'checkout' export type AdmissionSlotKind = 'base' | 'headroom' export type GitAdmissionRequest = { @@ -22,6 +24,7 @@ export type GitAdmissionRequest = { cwd: string wslDistro?: string tier?: GitAdmissionTier + admissionClass?: 'checkout' signal?: AbortSignal } @@ -55,8 +58,10 @@ export type GitAdmissionEvent = { export type AdmissionSchedulerConfig = { generalCap: number networkCap: number + checkoutCap: number generalHeadroom: number networkHeadroom: number + checkoutHeadroom: number routeCap: number routeHeadroom: number agingMs: number @@ -67,8 +72,10 @@ export type AdmissionSchedulerConfig = { export const DEFAULT_ADMISSION_SCHEDULER_CONFIG: AdmissionSchedulerConfig = { generalCap: GENERAL_CAP, networkCap: NETWORK_CAP, + checkoutCap: CHECKOUT_CAP, generalHeadroom: GENERAL_HEADROOM, networkHeadroom: NETWORK_HEADROOM, + checkoutHeadroom: CHECKOUT_HEADROOM, routeCap: ROUTE_CAP, routeHeadroom: ROUTE_HEADROOM, agingMs: GIT_ADMISSION_AGING_MS, diff --git a/src/shared/git/admission/git-admission-waiter-queue.ts b/src/shared/git/admission/git-admission-waiter-queue.ts index afb301fe7ac..09702b1c9b9 100644 --- a/src/shared/git/admission/git-admission-waiter-queue.ts +++ b/src/shared/git/admission/git-admission-waiter-queue.ts @@ -25,7 +25,8 @@ const createTierLanes = (): TierLanes => ({ export class GitAdmissionWaiterQueue { private readonly lanes: Record = { general: createTierLanes(), - network: createTierLanes() + network: createTierLanes(), + checkout: createTierLanes() } private readonly countsByBudget = new Map() private readonly baseCandidates: Record> = @@ -39,11 +40,17 @@ export class GitAdmissionWaiterQueue { interactive: new CandidateHeap(), status: new CandidateHeap(), background: new CandidateHeap() + }, + checkout: { + interactive: new CandidateHeap(), + status: new CandidateHeap(), + background: new CandidateHeap() } } private readonly headroomCandidates: Record = { general: new CandidateHeap(), - network: new CandidateHeap() + network: new CandidateHeap(), + checkout: new CandidateHeap() } private totalCount = 0 @@ -53,8 +60,9 @@ export class GitAdmissionWaiterQueue { /** @internal - exposed for bounded-storage regression tests only. */ get candidateCountForTests(): number { - let count = this.headroomCandidates.general.size + this.headroomCandidates.network.size - for (const admissionClass of ['general', 'network'] as const) { + let count = 0 + for (const admissionClass of ['general', 'network', 'checkout'] as const) { + count += this.headroomCandidates[admissionClass].size for (const tier of TIERS) { count += this.baseCandidates[admissionClass][tier].size } @@ -136,7 +144,7 @@ export class GitAdmissionWaiterQueue { } snapshot(): AdmissionWaiter[] { - return (['general', 'network'] as const) + return (['general', 'network', 'checkout'] as const) .flatMap((admissionClass) => TIERS.flatMap((tier) => { return [...this.lanes[admissionClass][tier].values()].flatMap((lane) =>