diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e0a2bbf5d..43ef357ee 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -477,7 +477,11 @@ async function refreshRemoteTrackingBaseForWorktreeCreate( return getOrStartSshWorktreeCreateFetch( getSshWorktreeCreateBaseFetchKey(repo, base), getSshWorktreeCreateRemoteQueueKey(repo, base.remote), - () => provider.fetchRemoteTrackingRef(repo.path, base.remote, base.branch, base.ref) + () => + // Why: the exact-base refresh gates create; unrelated repo housekeeping must not extend it. + provider.fetchRemoteTrackingRef(repo.path, base.remote, base.branch, base.ref, { + skipAutoMaintenance: true + }) ) } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 43c943257..7b4f699cd 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -3733,7 +3733,8 @@ describe('registerWorktreeHandlers', () => { '/remote/repo', 'origin', 'master', - 'refs/remotes/origin/master' + 'refs/remotes/origin/master', + { skipAutoMaintenance: true } ) }) @@ -3800,7 +3801,8 @@ describe('registerWorktreeHandlers', () => { '/remote/repo', 'origin', 'main', - 'refs/remotes/origin/main' + 'refs/remotes/origin/main', + { skipAutoMaintenance: true } ) expect(provider.addWorktree).toHaveBeenCalledWith( '/remote/repo', @@ -4032,7 +4034,8 @@ describe('registerWorktreeHandlers', () => { '/remote/repo', 'origin', 'main', - 'refs/remotes/origin/main' + 'refs/remotes/origin/main', + { skipAutoMaintenance: true } ) expect(provider.addWorktree).toHaveBeenCalledTimes(2) }) diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index acfaefaed..0974367ef 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -850,14 +850,16 @@ describe('SshGitProvider', () => { '/home/user/repo', 'origin', 'main', - 'refs/remotes/origin/main' + 'refs/remotes/origin/main', + { skipAutoMaintenance: true } ) expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', { worktreePath: '/home/user/repo', remote: 'origin', branch: 'main', - ref: 'refs/remotes/origin/main' + ref: 'refs/remotes/origin/main', + skipAutoMaintenance: true }) }) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 5ba6a29e3..0cde62d53 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -553,14 +553,16 @@ export class SshGitProvider implements IGitProvider { worktreePath: string, remote: string, branch: string, - ref: string + ref: string, + options?: { skipAutoMaintenance?: boolean } ): Promise { await this.runWithDiffDedupeClear(async () => { await this.mux.request('git.fetchRemoteTrackingRef', { worktreePath, remote, branch, - ref + ref, + ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) }) }) } diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index fce2a5e6d..14c181156 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -22,10 +22,19 @@ vi.mock('../git/runner', async (importOriginal) => { // normally — none of them trigger IO until a runtime method is called. import { OrcaRuntimeService } from './orca-runtime' +function isFetchArgs(argv: unknown): argv is string[] { + if (!Array.isArray(argv)) { + return false + } + let commandIndex = 0 + while (argv[commandIndex] === '-c' && typeof argv[commandIndex + 1] === 'string') { + commandIndex += 2 + } + return argv[commandIndex] === 'fetch' +} + function fetchCallCount(): number { - return gitExecFileAsyncMock.mock.calls.filter( - ([argv]) => Array.isArray(argv) && argv[0] === 'fetch' - ).length + return gitExecFileAsyncMock.mock.calls.filter(([argv]) => isFetchArgs(argv)).length } function exactBaseRefreshOptions(cwd: string): { @@ -36,6 +45,21 @@ function exactBaseRefreshOptions(cwd: string): { return { cwd, timeout: 60_000, useConfiguredSshCommandForNetwork: true } } +function exactBaseRefreshArgs(branch = 'main'): string[] { + return [ + '-c', + 'maintenance.auto=false', + '-c', + 'maintenance.commit-graph.auto=0', + '-c', + 'gc.auto=0', + 'fetch', + '--no-tags', + 'origin', + `+refs/heads/${branch}:refs/remotes/origin/${branch}` + ] +} + // Why (STA-1292): the broad create-path fetch must carry a timeout so a Windows // credential-manager GUI hang can't wedge worktree creation forever. function fullRemoteFetchOptions(cwd: string): { cwd: string; timeout: number } { @@ -188,11 +212,23 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], + exactBaseRefreshArgs(), exactBaseRefreshOptions('/repo/f') ) }) + it('keeps automatic maintenance enabled for ordinary full remote fetches', async () => { + mockFetchResults([{ stdout: '', stderr: '' }]) + const runtime = new OrcaRuntimeService(null) + + await runtime.getOrStartRemoteFetch('/repo/full-maintenance', 'origin') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['fetch', 'origin'], + fullRemoteFetchOptions('/repo/full-maintenance') + ) + }) + it('shares an in-flight remote-tracking base refresh and reuses exact-base freshness', async () => { let resolveFetch!: () => void const pending = new Promise<{ stdout: string; stderr: string }>((resolve) => { @@ -297,14 +333,9 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { { ok: true }, { ok: true } ]) - const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( - ([argv]) => Array.isArray(argv) && argv[0] === 'fetch' - ) + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter(([argv]) => isFetchArgs(argv)) expect(fetchCalls).toEqual([ - [ - ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], - exactBaseRefreshOptions('/repo/h') - ], + [exactBaseRefreshArgs(), exactBaseRefreshOptions('/repo/h')], [['fetch', 'origin'], fullRemoteFetchOptions('/repo/h')] ]) }) @@ -343,15 +374,10 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { { ok: true }, { ok: true } ]) - const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( - ([argv]) => Array.isArray(argv) && argv[0] === 'fetch' - ) + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter(([argv]) => isFetchArgs(argv)) expect(fetchCalls).toEqual([ [['fetch', 'origin'], fullRemoteFetchOptions('/repo/i')], - [ - ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], - exactBaseRefreshOptions('/repo/i') - ] + [exactBaseRefreshArgs(), exactBaseRefreshOptions('/repo/i')] ]) }) @@ -389,15 +415,10 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { { ok: false, errorKind: 'git_error' }, { ok: true } ]) - const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( - ([argv]) => Array.isArray(argv) && argv[0] === 'fetch' - ) + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter(([argv]) => isFetchArgs(argv)) expect(fetchCalls).toEqual([ [['fetch', 'origin'], fullRemoteFetchOptions('/repo/i-fail')], - [ - ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], - exactBaseRefreshOptions('/repo/i-fail') - ] + [exactBaseRefreshArgs(), exactBaseRefreshOptions('/repo/i-fail')] ]) }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ef1401327..b5fb46e50 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2312,7 +2312,7 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'rev-parse' && args[1] === '--verify') { return { stdout: 'base-sha\n', stderr: '' } } - if (args[0] === 'fetch') { + if (args.includes('fetch')) { return refresh.promise } return { stdout: '', stderr: '' } @@ -2325,7 +2325,18 @@ describe('OrcaRuntimeService', () => { await vi.waitFor(() => { expect(gitSpy).toHaveBeenCalledWith( - ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], + [ + '-c', + 'maintenance.auto=false', + '-c', + 'maintenance.commit-graph.auto=0', + '-c', + 'gc.auto=0', + 'fetch', + '--no-tags', + 'origin', + '+refs/heads/main:refs/remotes/origin/main' + ], { cwd: TEST_REPO_PATH, useConfiguredSshCommandForNetwork: true, @@ -2443,7 +2454,7 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { return { stdout: 'base-sha\n', stderr: '' } } - if (args[0] === 'fetch') { + if (args.includes('fetch')) { throw new Error('network unavailable') } return { stdout: '', stderr: '' } @@ -2505,7 +2516,7 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) { return { stdout: 'develop-sha\n', stderr: '' } } - if (args[0] === 'fetch') { + if (args.includes('fetch')) { throw new Error('network unavailable') } return { stdout: '', stderr: '' } @@ -2559,7 +2570,7 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) { return { stdout: 'team-feature-sha\n', stderr: '' } } - if (args[0] === 'fetch') { + if (args.includes('fetch')) { throw new Error('network unavailable') } return { stdout: '', stderr: '' } @@ -2580,7 +2591,18 @@ describe('OrcaRuntimeService', () => { false ) expect(gitSpy).not.toHaveBeenCalledWith( - ['fetch', '--no-tags', 'team', '+refs/heads/feature:refs/remotes/team/feature'], + [ + '-c', + 'maintenance.auto=false', + '-c', + 'maintenance.commit-graph.auto=0', + '-c', + 'gc.auto=0', + 'fetch', + '--no-tags', + 'team', + '+refs/heads/feature:refs/remotes/team/feature' + ], expect.any(Object) ) } finally { @@ -2604,7 +2626,7 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'rev-parse' && args[1] === '--verify') { throw new Error('missing ref') } - if (args[0] === 'fetch') { + if (args.includes('fetch')) { throw new Error('network unavailable') } return { stdout: '', stderr: '' } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a16fcca9f..f420ebd00 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -49,6 +49,7 @@ import { getClonePathComparisonKey } from '../git/repo-clone-path' import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' +import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' import { createHash, randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' @@ -14620,8 +14621,15 @@ export class OrcaRuntimeService { if (this.getFreshFetchCompletedAt(key) !== null) { return { ok: true } } + // Why: this exact refresh gates worktree create; ordinary fetches still own maintenance. return gitExecFileAsync( - ['fetch', '--no-tags', base.remote, `+refs/heads/${base.branch}:${base.ref}`], + [ + ...GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS, + 'fetch', + '--no-tags', + base.remote, + `+refs/heads/${base.branch}:${base.ref}` + ], { cwd: repoPath, ...gitOptions, diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 07b84db43..ba8f631d8 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -1222,7 +1222,8 @@ describe('GitHandler', () => { worktreePath: tmpDir, remote: 'origin', branch: 'main', - ref: 'refs/remotes/origin/main' + ref: 'refs/remotes/origin/main', + skipAutoMaintenance: true }) const second = dispatcher.callRequest('git.diff', { @@ -1238,7 +1239,18 @@ describe('GitHandler', () => { expect(gitBufferSpy).toHaveBeenCalledTimes(2) expect(gitSpy).toHaveBeenCalledWith( - ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], + [ + '-c', + 'maintenance.auto=false', + '-c', + 'maintenance.commit-graph.auto=0', + '-c', + 'gc.auto=0', + 'fetch', + '--no-tags', + 'origin', + '+refs/heads/main:refs/remotes/origin/main' + ], tmpDir ) }) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index a5ae6d3cb..c878dfd56 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -60,6 +60,7 @@ import { import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../shared/git-fork-sync' import { InFlightPromiseDedupe, stableInFlightKey } from '../shared/in-flight-promise-dedupe' +import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -844,10 +845,14 @@ export class GitHandler { const remote = params.remote const branch = params.branch const ref = params.ref + const skipAutoMaintenance = params.skipAutoMaintenance try { if (typeof remote !== 'string' || typeof branch !== 'string' || typeof ref !== 'string') { throw new Error('Invalid remote-tracking fetch request.') } + if (skipAutoMaintenance !== undefined && typeof skipAutoMaintenance !== 'boolean') { + throw new Error('Invalid remote-tracking fetch maintenance option.') + } if (remote.startsWith('-') || branch.startsWith('-')) { throw new Error('Remote-tracking fetch inputs must not start with "-".') } @@ -866,7 +871,16 @@ export class GitHandler { } await this.git(['check-ref-format', `refs/heads/${branch}`], worktreePath) await this.git(['check-ref-format', ref], worktreePath) - await this.git(['fetch', '--no-tags', remote, `+refs/heads/${branch}:${ref}`], worktreePath) + await this.git( + [ + ...(skipAutoMaintenance ? GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS : []), + 'fetch', + '--no-tags', + remote, + `+refs/heads/${branch}:${ref}` + ], + worktreePath + ) } catch (error) { // Why: create-worktree needs a write-capable fetch, but generic git.exec // intentionally rejects fetch. This narrow RPC keeps the relay allowlist diff --git a/src/shared/git-fetch-auto-maintenance.ts b/src/shared/git-fetch-auto-maintenance.ts new file mode 100644 index 000000000..3723298ab --- /dev/null +++ b/src/shared/git-fetch-auto-maintenance.ts @@ -0,0 +1,10 @@ +// Why: Git 2.29 can auto-run commit-graph work before maintenance.auto became a gate. +// The other keys cover modern maintenance and legacy auto-gc without changing user config. +export const GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS = [ + '-c', + 'maintenance.auto=false', + '-c', + 'maintenance.commit-graph.auto=0', + '-c', + 'gc.auto=0' +] as const