diff --git a/src/main/git/command-runner/git-admission-candidate-heap.ts b/src/main/git/command-runner/git-admission-candidate-heap.ts new file mode 100644 index 00000000000..ca22cac6097 --- /dev/null +++ b/src/main/git/command-runner/git-admission-candidate-heap.ts @@ -0,0 +1,85 @@ +import type { AdmissionWaiter } from './git-admission-state' + +export type WaiterLane = { + items: AdmissionWaiter[] + head: number + count: number + baseEligible: boolean + headroomEligible: boolean + version: number +} + +export type Candidate = { lane: WaiterLane; waiter: AdmissionWaiter; version: number } + +const CANDIDATE_HEAP_COMPACTION_SLACK = 64 + +export class CandidateHeap { + private items: Candidate[] = [] + + get size(): number { + return this.items.length + } + + push(candidate: Candidate): void { + this.items.push(candidate) + let index = this.items.length - 1 + while (index > 0) { + const parent = Math.floor((index - 1) / 2) + if (this.items[parent].waiter.id <= candidate.waiter.id) { + break + } + this.items[index] = this.items[parent] + index = parent + } + this.items[index] = candidate + } + + peek(valid: (candidate: Candidate) => boolean): Candidate | null { + // Eligibility changes invalidate by version so route updates stay O(log N). + while (this.items.length > 0 && !valid(this.items[0])) { + this.pop() + } + return this.items[0] ?? null + } + + compactIfOversized(maxLiveCandidates: number, valid: (candidate: Candidate) => boolean): void { + if (this.items.length <= maxLiveCandidates * 2 + CANDIDATE_HEAP_COMPACTION_SLACK) { + return + } + this.items = this.items.filter(valid) + for (let index = Math.floor(this.items.length / 2) - 1; index >= 0; index -= 1) { + this.siftDown(index) + } + } + + private pop(): void { + const last = this.items.pop() + if (!last || this.items.length === 0) { + return + } + this.items[0] = last + this.siftDown(0) + } + + private siftDown(index: number): void { + const candidate = this.items[index] + let cursor = index + while (true) { + const left = cursor * 2 + 1 + if (left >= this.items.length) { + break + } + const right = left + 1 + const child = + right < this.items.length && this.items[right].waiter.id < this.items[left].waiter.id + ? right + : left + if (this.items[child].waiter.id >= candidate.waiter.id) { + break + } + this.items[cursor] = this.items[child] + cursor = child + } + this.items[cursor] = candidate + } +} diff --git a/src/main/git/command-runner/git-admission-waiter-queue.ts b/src/main/git/command-runner/git-admission-waiter-queue.ts index daf3fbbb253..1f0e9a55cde 100644 --- a/src/main/git/command-runner/git-admission-waiter-queue.ts +++ b/src/main/git/command-runner/git-admission-waiter-queue.ts @@ -1,4 +1,5 @@ import type { AdmissionClass, AdmissionSlotKind, AdmissionWaiter } from './git-admission-state' +import { CandidateHeap, type Candidate, type WaiterLane } from './git-admission-candidate-heap' import type { GitAdmissionTier } from './git-exec-options' type SelectedWaiter = { @@ -6,15 +7,6 @@ type SelectedWaiter = { slotKind: AdmissionSlotKind } -type WaiterLane = { - items: AdmissionWaiter[] - head: number - count: number - baseEligible: boolean - headroomEligible: boolean - version: number -} - const TIERS = ['interactive', 'status', 'background'] as const const createLane = (): WaiterLane => ({ items: [], @@ -30,59 +22,6 @@ const createTierLanes = (): TierLanes => ({ status: new Map(), background: new Map() }) -type Candidate = { lane: WaiterLane; waiter: AdmissionWaiter; version: number } - -class CandidateHeap { - private readonly items: Candidate[] = [] - - push(candidate: Candidate): void { - this.items.push(candidate) - let index = this.items.length - 1 - while (index > 0) { - const parent = Math.floor((index - 1) / 2) - if (this.items[parent].waiter.id <= candidate.waiter.id) { - break - } - this.items[index] = this.items[parent] - index = parent - } - this.items[index] = candidate - } - - peek(valid: (candidate: Candidate) => boolean): Candidate | null { - // Eligibility changes invalidate by version so route updates stay O(log N). - while (this.items.length > 0 && !valid(this.items[0])) { - this.pop() - } - return this.items[0] ?? null - } - - private pop(): void { - const last = this.items.pop() - if (!last || this.items.length === 0) { - return - } - let index = 0 - while (true) { - const left = index * 2 + 1 - if (left >= this.items.length) { - break - } - const right = left + 1 - const child = - right < this.items.length && this.items[right].waiter.id < this.items[left].waiter.id - ? right - : left - if (this.items[child].waiter.id >= last.waiter.id) { - break - } - this.items[index] = this.items[child] - index = child - } - this.items[index] = last - } -} - export class GitAdmissionWaiterQueue { private readonly lanes: Record = { general: createTierLanes(), @@ -112,6 +51,17 @@ export class GitAdmissionWaiterQueue { return this.totalCount } + /** @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) { + for (const tier of TIERS) { + count += this.baseCandidates[admissionClass][tier].size + } + } + return count + } + enqueue(waiter: AdmissionWaiter): void { const lanes = this.lanes[waiter.admissionClass][waiter.tier] let lane = lanes.get(waiter.route) @@ -151,9 +101,11 @@ export class GitAdmissionWaiterQueue { if (lane.count === 0) { lane.version += 1 lanes.delete(waiter.route) + this.compactCandidateHeaps(waiter.admissionClass, waiter.tier) } else if (lane.items[lane.head] !== previousHead) { lane.version += 1 this.publishLaneHead(waiter.admissionClass, waiter.tier, lane) + this.compactCandidateHeaps(waiter.admissionClass, waiter.tier) } } @@ -175,6 +127,7 @@ export class GitAdmissionWaiterQueue { lane.headroomEligible = headroomEligible lane.version += 1 this.publishLaneHead(admissionClass, tier, lane) + this.compactCandidateHeaps(admissionClass, tier) } } @@ -250,15 +203,31 @@ export class GitAdmissionWaiterQueue { } private peekValid(heap: CandidateHeap, slotKind: AdmissionSlotKind): Candidate | null { - return heap.peek( - ({ lane, waiter, version }) => - version === lane.version && - waiter === lane.items[lane.head] && - waiter.state === 'queued' && - (slotKind === 'base' ? lane.baseEligible : lane.headroomEligible) + return heap.peek((candidate) => this.candidateIsValid(candidate, slotKind)) + } + + private candidateIsValid(candidate: Candidate, slotKind: AdmissionSlotKind): boolean { + const { lane, waiter, version } = candidate + return ( + version === lane.version && + waiter === lane.items[lane.head] && + waiter.state === 'queued' && + (slotKind === 'base' ? lane.baseEligible : lane.headroomEligible) ) } + private compactCandidateHeaps(admissionClass: AdmissionClass, tier: GitAdmissionTier): void { + const liveLaneCount = this.lanes[admissionClass][tier].size + this.baseCandidates[admissionClass][tier].compactIfOversized(liveLaneCount, (candidate) => + this.candidateIsValid(candidate, 'base') + ) + if (tier === 'interactive') { + this.headroomCandidates[admissionClass].compactIfOversized(liveLaneCount, (candidate) => + this.candidateIsValid(candidate, 'headroom') + ) + } + } + private compact(lane: WaiterLane): void { let head = lane.head while (head < lane.items.length && lane.items[head].state !== 'queued') { diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index b28e3abc14e..05ce2fd0778 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -110,6 +110,7 @@ async function gitExecFileAsyncUnlocked( result = await capture(resolved) } catch (error) { if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) { + await terminationState.current const wasMissing = invalidateMissingDirectWslGit(error, resolved) const fallback = resolveGitCommand( args, diff --git a/src/main/git/command-runner/git-subprocess-admission.test.ts b/src/main/git/command-runner/git-subprocess-admission.test.ts index a66f434fad0..0e5bfa808ee 100644 --- a/src/main/git/command-runner/git-subprocess-admission.test.ts +++ b/src/main/git/command-runner/git-subprocess-admission.test.ts @@ -285,6 +285,28 @@ describe('GitAdmissionScheduler', () => { running.release() }) + it('bounds canceled candidate storage while the only permit stays saturated', async () => { + const scheduler = schedulerWithOneSlot() + const running = await scheduler.acquire(local()) + const canceled: Promise[] = [] + + for (let index = 0; index < 4_000; index += 1) { + const controller = new AbortController() + const request = scheduler.acquire({ ...local(), signal: controller.signal }).then( + () => undefined, + (error) => expect(error).toMatchObject({ name: 'AbortError' }) + ) + controller.abort() + canceled.push(request) + } + await Promise.all(canceled) + + expect(scheduler.snapshot().queued).toBe(0) + expect(scheduler.snapshot().candidateCount).toBeLessThanOrEqual(64) + running.release() + expect(scheduler.snapshot().candidateCount).toBe(0) + }) + it('does not decrement for an already-aborted signal with a free slot', async () => { const scheduler = schedulerWithOneSlot() const controller = new AbortController() @@ -293,7 +315,12 @@ describe('GitAdmissionScheduler', () => { await expect( scheduler.acquire({ ...local(), signal: controller.signal }) ).rejects.toMatchObject({ name: 'AbortError' }) - expect(scheduler.snapshot()).toEqual({ queued: 0, queuedWaiters: [], budgets: {} }) + expect(scheduler.snapshot()).toEqual({ + queued: 0, + queuedWaiters: [], + candidateCount: 0, + budgets: {} + }) }) it('returns a grant selected in the same tick when abort wins delivery', async () => { diff --git a/src/main/git/command-runner/git-subprocess-admission.ts b/src/main/git/command-runner/git-subprocess-admission.ts index 3688b599a9b..b23cbda170b 100644 --- a/src/main/git/command-runner/git-subprocess-admission.ts +++ b/src/main/git/command-runner/git-subprocess-admission.ts @@ -92,11 +92,13 @@ export class GitAdmissionScheduler { queued: number queuedWaiters: { id: number; args: readonly string[]; tier: AdmissionWaiter['tier'] }[] budgets: Record + candidateCount: number } { const queuedWaiters = this.waiters.snapshot() return { queued: this.waiters.count, queuedWaiters: queuedWaiters.map(({ id, args, tier }) => ({ id, args, tier })), + candidateCount: this.waiters.candidateCountForTests, budgets: Object.fromEntries( [...this.budgets].map(([key, budget]) => [ key, diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index 567f6c81523..284cda55718 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -500,17 +500,18 @@ describe('WSL direct Git reads', () => { }) }) - it('invalidates a missing direct executable and retries through the login shell', async () => { + it('waits for direct Git to close before retrying through the login shell', async () => { await withPlatform('win32', async () => { const admissionEvents: GitAdmissionEvent[] = [] _resetGitAdmissionForTests( new GitAdmissionScheduler({ onAdmissionEvent: (event) => admissionEvents.push(event) }) ) seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) + let directChild!: ReturnType execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() + directChild = child queueMicrotask(() => { - child.emit('close', 127, null) callback?.( Object.assign(new Error('exit 127'), { code: 127 }), '', @@ -528,13 +529,17 @@ describe('WSL direct Git reads', () => { return child }) - await expect( - gitExecFileAsync(['status', '--short'], { - cwd: String.raw`C:\repo`, - preferWslDirectGit: true, - wslDistro: DISTRO - }) - ).resolves.toEqual({ stdout: 'ok', stderr: '' }) + const result = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + preferWslDirectGit: true, + wslDistro: DISTRO + }) + + await vi.waitFor(() => expect(directChild).toBeDefined()) + await Promise.resolve() + expect(execFileMock).toHaveBeenCalledTimes(1) + directChild.emit('close', 127, null) + await expect(result).resolves.toEqual({ stdout: 'ok', stderr: '' }) expect(execFileMock.mock.calls[0]?.[1]).toContain('--exec') expect(execFileMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc']) @@ -550,14 +555,18 @@ describe('WSL direct Git reads', () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => + queueMicrotask(() => { callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'helper failed') - ) + child.emit('close', 128, null) + }) return child }) execFileMock.mockImplementation((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => callback?.(null, 'ok', '')) + queueMicrotask(() => { + callback?.(null, 'ok', '') + child.emit('close', 0, null) + }) return child }) const options = { @@ -581,21 +590,26 @@ describe('WSL direct Git reads', () => { execFileMock .mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => + queueMicrotask(() => { callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref') - ) + child.emit('close', 128, null) + }) return child }) .mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => + queueMicrotask(() => { callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref') - ) + child.emit('close', 128, null) + }) return child }) .mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => callback?.(null, 'ok', '')) + queueMicrotask(() => { + callback?.(null, 'ok', '') + child.emit('close', 0, null) + }) return child }) const options = { diff --git a/src/main/git/source-control/status-read.ts b/src/main/git/source-control/status-read.ts index 7893cabde15..cc4d7ebc5fc 100644 --- a/src/main/git/source-control/status-read.ts +++ b/src/main/git/source-control/status-read.ts @@ -132,6 +132,7 @@ async function runGetStatus( const result = await gitStreamStdout(statusArgs, { cwd: worktreePath, wslDistro: options.wslDistro, + admissionTier: options.admissionTier, preferWslDirectGit: true, // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. env: gitOptionalLocksDisabledEnv(), diff --git a/src/main/git/status-read-coalescing.test.ts b/src/main/git/status-read-coalescing.test.ts index 6eacba1fb69..73737e42c12 100644 --- a/src/main/git/status-read-coalescing.test.ts +++ b/src/main/git/status-read-coalescing.test.ts @@ -71,15 +71,19 @@ describe('getStatus', () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) }) - it('opts status reads into direct WSL Git without changing mutation options', async () => { + it('preserves status admission through the direct WSL stream without changing mutation options', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) - await getStatus('/repo', { wslDistro: 'Ubuntu' }) + await getStatus('/repo', { wslDistro: 'Ubuntu', admissionTier: 'interactive' }) await stageFile('/repo', 'src/file.ts', { wslDistro: 'Ubuntu' }) expect(gitStreamOptionsMock).toHaveBeenCalledWith( - expect.objectContaining({ preferWslDirectGit: true, wslDistro: 'Ubuntu' }) + expect.objectContaining({ + admissionTier: 'interactive', + preferWslDirectGit: true, + wslDistro: 'Ubuntu' + }) ) const addOptions = gitExecFileAsyncMock.mock.calls.find(([args]) => (args as string[]).includes('add') diff --git a/src/main/git/wsl-direct-git-read-commands.test.ts b/src/main/git/wsl-direct-git-read-commands.test.ts index ec420bffb22..aa0fc9711fe 100644 --- a/src/main/git/wsl-direct-git-read-commands.test.ts +++ b/src/main/git/wsl-direct-git-read-commands.test.ts @@ -49,6 +49,8 @@ describe('isWslDirectGitReadCommand', () => { [['symbolic-ref', 'HEAD', 'refs/heads/main']], [['worktree', 'add', '/tmp/wt']], [['branch', '-D', 'feature']], + [['branch', '-r', '-d', 'origin/feature']], + [['branch', '-rd', 'origin/feature']], [['branch', 'newbranch']], [[]] ])('keeps %j on the login shell', (args) => { @@ -103,6 +105,8 @@ describe('classifyGitCommand', () => { [['-C', '/repo', 'status', '--porcelain=v2'], 'read'], [['status', '--porcelain=v2'], 'read'], [['branch', '--show-current'], 'read'], + [['branch', '-r', '-d', 'origin/feature'], 'other'], + [['branch', '-rd', 'origin/feature'], 'other'], [['remote', 'get-url', 'origin'], 'read'], [['config', '--get-regexp', String.raw`^remote\.`], 'read'], [['show', '--end-of-options', 'HEAD:file'], 'read'], diff --git a/src/main/git/wsl-direct-git-read-commands.ts b/src/main/git/wsl-direct-git-read-commands.ts index a19571b62aa..1072ceb2107 100644 --- a/src/main/git/wsl-direct-git-read-commands.ts +++ b/src/main/git/wsl-direct-git-read-commands.ts @@ -50,6 +50,38 @@ const READ_FLAG_SUBCOMMANDS: Record> = { config: new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l']) } +const BRANCH_MUTATION_FLAGS = new Set([ + '--copy', + '--create-reflog', + '--delete', + '--edit-description', + '--force', + '--move', + '--no-create-reflog', + '--no-track', + '--recurse-submodules', + '--set-upstream-to', + '--track', + '--unset-upstream' +]) +const BRANCH_MUTATION_SHORT_FLAGS = new Set(['c', 'C', 'd', 'D', 'f', 'm', 'M', 't', 'u']) + +function hasBranchMutationFlag(args: readonly string[]): boolean { + return args.some((arg) => { + const flag = arg.split('=')[0] + if (BRANCH_MUTATION_FLAGS.has(flag)) { + return true + } + return ( + /^-[^-]/.test(flag) && + flag + .slice(1) + .split('') + .some((part) => BRANCH_MUTATION_SHORT_FLAGS.has(part)) + ) + }) +} + // Read markers that must be the *first non-flag* argument, i.e. the action. // Position matters here: matching them anywhere would read `worktree remove list` // as a listing, because a worktree may legitimately be named "list". @@ -97,6 +129,10 @@ export function isWslDirectGitReadCommand(args: readonly string[]): boolean { return rest.filter((arg) => arg !== '--' && !arg.startsWith('-')).length <= 1 } + if (subcommand === 'branch' && hasBranchMutationFlag(rest)) { + return false + } + const readActions = READ_ACTION_SUBCOMMANDS[subcommand] if (readActions) { const action = rest.find((arg) => !arg.startsWith('-')) diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts index 506b1c9f9ea..36d96407301 100644 --- a/src/main/github/conflict-summary.test.ts +++ b/src/main/github/conflict-summary.test.ts @@ -298,6 +298,26 @@ describe('getPRConflictSummary caching', () => { ).toHaveLength(1) }) + it('preserves background admission across the complete WSL derivation chain', async () => { + mockGitDispatch() + + await getPRConflictSummary('/repo-root', 'main', 'github-base-oid', 'head-oid-1', { + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(5) + for (const [, options] of gitExecFileAsyncMock.mock.calls) { + expect(options).toEqual( + expect.objectContaining({ + cwd: '/repo-root', + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + ) + } + }) + it('keeps identities distinct when paths or ref names contain a joiner character', async () => { mockGitDispatch() diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index b6d19ce1d2a..470f8573367 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -4,6 +4,7 @@ import { isUnsupportedMergeTreeWriteTreeError } from '../../shared/git-merge-tree-capability' import { gitExecFileAsync } from '../git/runner' +import { gitOptionsForWorktree, type GitRuntimeOptions } from '../git/git-runtime-options' import { clearGitCapabilityStateForTests, withLocalGitCapabilityCacheForExecution @@ -21,9 +22,7 @@ import { storeCachedSummary } from './conflict-summary-cache' -type LocalGitExecOptions = { - wslDistro?: string -} +type LocalGitExecOptions = Pick export function __resetPRConflictSummaryCachesForTests(): void { clearGitCapabilityStateForTests() @@ -159,9 +158,8 @@ async function resolveLatestBaseOid( // Why: cap the fetch at 10 s so slow or unreachable remotes don't block // the conflict-summary derivation indefinitely. await gitExecFileAsync(['fetch', '--quiet', remoteName, baseRefName], { - cwd: repoPath, - timeout: 10_000, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions), + timeout: 10_000 }) } catch { // Why: fetching the base ref keeps the conflict list aligned with GitHub's @@ -172,8 +170,7 @@ async function resolveLatestBaseOid( for (const ref of [`refs/remotes/${remoteName}/${baseRefName}`, `${remoteName}/${baseRefName}`]) { try { const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) const oid = stdout.trim() if (oid) { @@ -194,8 +191,7 @@ async function resolveMergeBase( localGitOptions: LocalGitExecOptions ): Promise { const { stdout } = await gitExecFileAsync(['merge-base', headOid, baseOid], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) return stdout.trim() } @@ -206,8 +202,7 @@ async function countCommits( localGitOptions: LocalGitExecOptions ): Promise { const { stdout } = await gitExecFileAsync(['rev-list', '--count', range], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) return Number.parseInt(stdout.trim(), 10) || 0 } @@ -251,8 +246,7 @@ async function loadConflictingFiles( async () => { try { const result = await gitExecFileAsync(modernArgs, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) return parseMergeTreeNameOnlyOutput(result.stdout) } catch (error) { @@ -288,8 +282,7 @@ async function loadConflictingFilesWithLegacyMergeTree( ): Promise { try { const result = await gitExecFileAsync(legacyArgs, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) return parseMergeTreeNameOnlyOutput(result.stdout) } catch (fallbackError) { diff --git a/src/main/github/pr-refresh-candidate-policy.ts b/src/main/github/pr-refresh-candidate-policy.ts index 9623008ab5a..af7ac3b4e47 100644 --- a/src/main/github/pr-refresh-candidate-policy.ts +++ b/src/main/github/pr-refresh-candidate-policy.ts @@ -34,7 +34,7 @@ export function hostedReviewOptionArgs( ...(candidate.localGitOptions?.wslDistro ? { wslDistro: candidate.localGitOptions.wslDistro } : {}), - admissionTier: reason === 'manual' ? 'interactive' : 'background' + admissionTier: admissionTierForRefreshReason(reason) } } if (shouldAcceptMergedFallbackPR(candidate)) { @@ -46,6 +46,12 @@ export function hostedReviewOptionArgs( return Object.keys(options).length > 0 ? [options] : [] } +export function admissionTierForRefreshReason( + reason: GitHubPRRefreshReason +): 'interactive' | 'background' { + return reason === 'manual' ? 'interactive' : 'background' +} + export function refreshKey(candidate: GitHubPRRefreshCandidate): string { const connectionScope = candidate.connectionId ?? 'local' const runtimeScope = candidate.connectionId diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 0d64d7bd02b..237c2ffeb90 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -152,7 +152,8 @@ async function probeGlabKnownHosts( timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS, ...(!connectionId && localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } - : {}) + : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) }) const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) const remembered = knownHostsCacheByExecutionContext.get(key) ?? [] diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index f0c294ce93e..356c25075fe 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -662,6 +662,21 @@ describe('getGlabKnownHosts', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 }) }) + it('preserves WSL and background admission on the cold auth-status probe', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await getGlabKnownHosts(undefined, { + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + timeout: 10_000, + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + }) + it('falls back to default when glab auth status fails', async () => { glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated')) diff --git a/src/main/ipc/filesystem-branch-compare-diff.test.ts b/src/main/ipc/filesystem-branch-compare-diff.test.ts index 53c4bcec4fc..bb0669ec697 100644 --- a/src/main/ipc/filesystem-branch-compare-diff.test.ts +++ b/src/main/ipc/filesystem-branch-compare-diff.test.ts @@ -90,10 +90,13 @@ describe('registerFilesystemHandlers', () => { await handlers.get('git:branchCompare')!(null, { worktreePath: WORKTREE_FEATURE_PATH, - baseRef: 'origin/main' + baseRef: 'origin/main', + admissionTier: 'background' }) - expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', {}) + expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', { + admissionTier: 'background' + }) }) it('allows git operations on worktrees outside repo/workspace roots', async () => { diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index c1937c4b290..7782d4e9373 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -28,6 +28,7 @@ import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' import type { SshMutationExpectation } from '../../shared/ssh-types' import { sortDirEntries } from '../../shared/file-name-sort' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' @@ -1831,14 +1832,23 @@ export function registerFilesystemHandlers( 'git:branchCompare', async ( _event, - args: { worktreePath: string; baseRef: string; connectionId?: string } + args: { + worktreePath: string + baseRef: string + connectionId?: string + admissionTier?: GitAdmissionTier + } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.getBranchCompare(args.worktreePath, args.baseRef) + return args.admissionTier + ? provider.getBranchCompare(args.worktreePath, args.baseRef, { + admissionTier: args.admissionTier + }) + : provider.getBranchCompare(args.worktreePath, args.baseRef) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -1846,7 +1856,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - return getBranchCompare(worktreePath, args.baseRef, gitOptions) + return getBranchCompare(worktreePath, args.baseRef, { + ...gitOptions, + ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) + }) } ) diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index f70f3ddcc72..e6edb9f1c75 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -15,6 +15,7 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { GitProviderStatusOptions } from './git-provider-status-options' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' export type { GitProviderStatusOptions } from './git-provider-status-options' @@ -46,7 +47,11 @@ export type IGitProvider = { abortRebase(worktreePath: string): Promise checkoutBranch(worktreePath: string, branch: string): Promise listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }> - getBranchCompare(worktreePath: string, baseRef: string): Promise + getBranchCompare( + worktreePath: string, + baseRef: string, + options?: { admissionTier?: GitAdmissionTier } + ): Promise getCommitCompare(worktreePath: string, commitId: string): Promise getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise pushBranch( diff --git a/src/main/providers/ssh-git-provider-status.test.ts b/src/main/providers/ssh-git-provider-status.test.ts index b2814624a6c..295d07b81d8 100644 --- a/src/main/providers/ssh-git-provider-status.test.ts +++ b/src/main/providers/ssh-git-provider-status.test.ts @@ -193,10 +193,13 @@ describe('SshGitProvider', () => { const compareResult = { summary: { ahead: 2, behind: 0 }, entries: [] } mux.request.mockResolvedValue(compareResult) - const result = await provider.getBranchCompare('/home/user/repo', 'main') + const result = await provider.getBranchCompare('/home/user/repo', 'main', { + admissionTier: 'background' + }) expect(mux.request).toHaveBeenCalledWith('git.branchCompare', { worktreePath: '/home/user/repo', - baseRef: 'main' + baseRef: 'main', + admissionTier: 'background' }) expect(result).toEqual(compareResult) }) diff --git a/src/main/providers/ssh-git-working-tree-provider.ts b/src/main/providers/ssh-git-working-tree-provider.ts index 6942b91ca1b..85e2bbb2938 100644 --- a/src/main/providers/ssh-git-working-tree-provider.ts +++ b/src/main/providers/ssh-git-working-tree-provider.ts @@ -4,6 +4,7 @@ import type { } from '../../shared/git-diff-compare-types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { GitConflictOperation } from '../../shared/git-status-types' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' import { SshGitNoninteractiveProvider } from './ssh-git-noninteractive-provider' export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { @@ -106,10 +107,15 @@ export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { } } - async getBranchCompare(worktreePath: string, baseRef: string): Promise { + async getBranchCompare( + worktreePath: string, + baseRef: string, + options: { admissionTier?: GitAdmissionTier } = {} + ): Promise { return (await this.mux.request('git.branchCompare', { worktreePath, - baseRef + baseRef, + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) })) as GitBranchCompareResult } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d8c677d6d67..0d7d22a9168 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7789,6 +7789,24 @@ describe('OrcaRuntimeService', () => { const checkDetailsSignal = new AbortController().signal await runtime.getRepoPRForBranch('id:repo-1', 'feature/wsl', 42, 43) + await runtime.getRepoPRForBranch( + 'id:repo-1', + 'feature/wsl-manual', + 42, + 43, + undefined, + undefined, + 'manual' + ) + await runtime.getRepoPRForBranch( + 'id:repo-1', + 'feature/wsl-active', + 42, + 43, + undefined, + undefined, + 'active' + ) await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr') await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr') @@ -7860,6 +7878,22 @@ describe('OrcaRuntimeService', () => { localGitExecOptions: localGitOptions } ) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/wsl-manual', + 42, + null, + null, + { localGitExecOptions: { ...localGitOptions, admissionTier: 'interactive' } } + ) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/wsl-active', + 42, + null, + null, + { localGitExecOptions: { ...localGitOptions, admissionTier: 'background' } } + ) expect(getGitHubWorkItemMock).toHaveBeenCalledWith( TEST_REPO_PATH, 42, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 65eb0a37879..e38b5a93f52 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -288,7 +288,11 @@ import type { GitHubPRReviewCommentInput, GitHubReactionContent } from '../../shared/github/comment-types' -import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' +import type { + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { admissionTierForRefreshReason } from '../github/pr-refresh-candidate-policy' import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' import type { ListWorkItemsResult } from '../../shared/github/work-item-types' import type { @@ -22880,10 +22884,15 @@ export class OrcaRuntimeService { linkedPRNumber?: number | null, fallbackPRNumber?: number | null, acceptMergedFallbackPR?: boolean, - currentHeadOid?: string | null + currentHeadOid?: string | null, + reason?: GitHubPRRefreshReason ): Promise { const repo = await this.resolveRepoSelector(repoSelector) - const options: GitHubPRBranchLookupOptions = this.getHostedReviewExecutionOptions(repo) ?? {} + const options: GitHubPRBranchLookupOptions = + this.getHostedReviewExecutionOptions( + repo, + reason ? admissionTierForRefreshReason(reason) : undefined + ) ?? {} const lookupOptions = { ...options } if (acceptMergedFallbackPR === true) { lookupOptions.acceptMergedFallbackPR = true diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 00ec4bb6913..31d6411d822 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -48,6 +48,7 @@ export const GitDiff = GitFilePath.extend({ }) export const GitBranchCompare = WorktreeSelector.extend({ + admissionTier: z.enum(['interactive', 'status', 'background']).optional(), baseRef: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 92221d4bb33..1f7fb2fb8cc 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -679,6 +679,38 @@ describe('git RPC methods', () => { expect(runtime.getRuntimeGitBranchCompare).not.toHaveBeenCalled() }) + it('forwards valid branch-compare admission and rejects invalid tiers', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitBranchCompare: vi.fn().mockResolvedValue({ summary: {}, entries: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const accepted = await dispatcher.dispatch( + makeRequest('git.branchCompare', { + worktree: 'id:wt-1', + baseRef: 'origin/main', + admissionTier: 'background' + }) + ) + const rejected = await dispatcher.dispatch( + makeRequest('git.branchCompare', { + worktree: 'id:wt-1', + baseRef: 'origin/main', + admissionTier: 'urgent' + }) + ) + + expect(accepted.ok).toBe(true) + expect(runtime.getRuntimeGitBranchCompare).toHaveBeenCalledOnce() + expect(runtime.getRuntimeGitBranchCompare).toHaveBeenCalledWith( + 'id:wt-1', + 'origin/main', + 'background' + ) + expect(rejected.ok).toBe(false) + }) + it('rejects git history limits above the runtime cap', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index e89113f9b60..a0ba1ff2b4b 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -103,7 +103,7 @@ export const GIT_METHODS: RpcMethod[] = [ name: 'git.branchCompare', params: GitBranchCompare, handler: async (params, { runtime }) => - runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef) + runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef, params.admissionTier) }), defineMethod({ name: 'git.commitCompare', diff --git a/src/main/runtime/rpc/methods/github-pull-request-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-methods.ts index 30aea3f2c6c..495f1bd709b 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-methods.ts @@ -5,6 +5,7 @@ import { RepoSelector, SlugRepo } from './github-repo-target-schemas' const PrForBranch = RepoSelector.extend({ branch: requiredString('Missing branch'), + reason: z.enum(['visible', 'active', 'post-push', 'manual', 'swr']).optional(), linkedPRNumber: z.number().int().positive().nullable().optional(), fallbackPRNumber: z.number().int().positive().nullable().optional(), acceptMergedFallbackPR: z.boolean().optional(), @@ -75,7 +76,8 @@ export const GITHUB_PULL_REQUEST_METHODS: RpcMethod[] = [ params.linkedPRNumber, params.fallbackPRNumber, params.acceptMergedFallbackPR, - params.currentHeadOid + params.currentHeadOid, + params.reason ) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 72a4e150077..b2b13595359 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -9,6 +9,46 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('github RPC methods', () => { + it('forwards an optional PR refresh reason without requiring it from older clients', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoPRForBranch: vi.fn().mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + await dispatcher.dispatch( + makeRequest('github.prForBranch', { + repo: 'repo-1', + branch: 'feature/admission', + reason: 'manual' + }) + ) + await dispatcher.dispatch( + makeRequest('github.prForBranch', { repo: 'repo-1', branch: 'feature/legacy' }) + ) + + expect(runtime.getRepoPRForBranch).toHaveBeenNthCalledWith( + 1, + 'repo-1', + 'feature/admission', + undefined, + undefined, + undefined, + undefined, + 'manual' + ) + expect(runtime.getRepoPRForBranch).toHaveBeenNthCalledWith( + 2, + 'repo-1', + 'feature/legacy', + undefined, + undefined, + undefined, + undefined, + undefined + ) + }) + it('resolves the repo slug on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/runtime-git-branch-compare-admission.test.ts b/src/main/runtime/runtime-git-branch-compare-admission.test.ts new file mode 100644 index 00000000000..f2b77c209de --- /dev/null +++ b/src/main/runtime/runtime-git-branch-compare-admission.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeGitCommandHost, RuntimeGitTarget } from './runtime-git-command-target' + +const mocks = vi.hoisted(() => ({ + getBranchCompare: vi.fn(), + getSshGitProvider: vi.fn() +})) + +vi.mock('../git/status', () => ({ + getBranchCompare: mocks.getBranchCompare, + getBranchDiff: vi.fn(), + getCommitCompare: vi.fn(), + getCommitDiff: vi.fn(), + getDiff: vi.fn() +})) +vi.mock('../git/repo', () => ({ getRemoteCommitUrl: vi.fn(), getRemoteFileUrl: vi.fn() })) +vi.mock('../git/runner', () => ({ awaitWindowsHostGitEnvironmentReady: vi.fn() })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: mocks.getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'provider unavailable' +})) + +import { RuntimeGitDiffCommands } from './runtime-git-diff-commands' + +function makeCommands(overrides: Partial = {}): RuntimeGitDiffCommands { + const target = { + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\repo', + git: { + path: 'C:\\repo', + branch: 'feature', + isBare: false, + isMainWorktree: false, + head: 'a'.repeat(40) + } + }, + localGitOptions: { wslDistro: 'Ubuntu' }, + ...overrides + } as RuntimeGitTarget + const host = { + resolveRuntimeGitTarget: async () => target, + getRuntimeSettings: () => ({}) + } as unknown as RuntimeGitCommandHost + return new RuntimeGitDiffCommands(host) +} + +describe('RuntimeGitDiffCommands branch-compare admission', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getBranchCompare.mockResolvedValue({ summary: {}, entries: [] }) + }) + + it('preserves a background tier with WSL routing and defaults legacy callers to interactive', async () => { + const commands = makeCommands() + + await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background') + await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main') + + expect(mocks.getBranchCompare).toHaveBeenNthCalledWith(1, 'C:\\repo', 'origin/main', { + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + expect(mocks.getBranchCompare).toHaveBeenNthCalledWith(2, 'C:\\repo', 'origin/main', { + wslDistro: 'Ubuntu', + admissionTier: 'interactive' + }) + }) + + it('forwards background admission to the SSH execution host', async () => { + const getBranchCompare = vi.fn().mockResolvedValue({ summary: {}, entries: [] }) + mocks.getSshGitProvider.mockReturnValue({ getBranchCompare }) + const commands = makeCommands({ connectionId: 'conn-1' }) + + await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background') + + expect(getBranchCompare).toHaveBeenCalledWith('C:\\repo', 'origin/main', { + admissionTier: 'background' + }) + expect(mocks.getBranchCompare).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-git-diff-commands.ts b/src/main/runtime/runtime-git-diff-commands.ts index 925e699079a..a6d94775ae5 100644 --- a/src/main/runtime/runtime-git-diff-commands.ts +++ b/src/main/runtime/runtime-git-diff-commands.ts @@ -13,6 +13,7 @@ import { getDiff } from '../git/status' import { awaitWindowsHostGitEnvironmentReady } from '../git/runner' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE @@ -58,7 +59,8 @@ export class RuntimeGitDiffCommands { async getRuntimeGitBranchCompare( worktreeSelector: string, - baseRef: string + baseRef: string, + admissionTier: GitAdmissionTier = 'interactive' ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -66,11 +68,11 @@ export class RuntimeGitDiffCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.getBranchCompare(target.worktree.path, baseRef) + return provider.getBranchCompare(target.worktree.path, baseRef, { admissionTier }) } return getBranchCompare(target.worktree.path, baseRef, { ...localGitOptionsForTarget(target), - admissionTier: 'interactive' + admissionTier }) } diff --git a/src/preload/api/git-inspection-api.ts b/src/preload/api/git-inspection-api.ts index b71234d2d0c..4f691c4c0f1 100644 --- a/src/preload/api/git-inspection-api.ts +++ b/src/preload/api/git-inspection-api.ts @@ -66,6 +66,7 @@ export type GitInspectionApi = { worktreePath: string baseRef: string connectionId?: string + admissionTier?: 'interactive' | 'status' | 'background' }) => Promise commitCompare: (args: { worktreePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d36b385d10a..1e179350854 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3596,6 +3596,7 @@ const api = { worktreePath: string baseRef: string connectionId?: string + admissionTier?: 'interactive' | 'status' | 'background' }): Promise => ipcRenderer.invoke('git:branchCompare', args), commitCompare: (args: { worktreePath: string diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx index 959ea4468ec..94c07d7171d 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx @@ -112,5 +112,13 @@ describe('useChecksPanelManualRefresh ordering', () => { 'comments-loading:false', 'refreshing:false' ]) + expect(input.fetchPRForBranch).toHaveBeenCalledWith('/workspace/repo', 'main', { + force: true, + repoId: 'repo-1', + worktreeId: undefined, + linkedPRNumber: null, + fallbackPRNumber: null, + reason: 'manual' + }) }) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx index 25ab151a19a..3da3251b1e2 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx @@ -212,7 +212,8 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput repoId: repo.id, worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: linkedPR, - fallbackPRNumber: fallbackGitHubPRNumber + fallbackPRNumber: fallbackGitHubPRNumber, + reason: 'manual' }) } finally { if (startedPRRefreshToken) { diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts index 00df9951ec3..50d2f4dad4b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts @@ -61,61 +61,65 @@ export function useSourceControlBranchCompare({ const branchCompareStatusHeadRef = useRef(null) const branchCompareRemoteStatusRef = useRef(null) - const runBranchCompare = useCallback(async () => { - if (!activeWorktreeId || !worktreePath || !compareBaseRef || isFolder) { - return - } - const requestKey = `${activeWorktreeId}:${compareBaseRef}:${Date.now()}` - const existingSummary = - useAppStore.getState().gitBranchCompareSummaryByWorktree[activeWorktreeId] - // Why: only reset to 'loading' on the first request or a base-ref change; resetting on every poll caused a visible loading→error→loading flicker. - const baseRefChanged = existingSummary && existingSummary.baseRef !== compareBaseRef - const shouldResetToLoading = !existingSummary || baseRefChanged - if (shouldResetToLoading) { - beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef) - } else { - beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef, { - preserveExistingSummary: true - }) - } - try { - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - const result = await getRuntimeGitBranchCompare( - { - // Why: route the branch compare by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, - compareBaseRef - ) - setGitBranchCompareResult(activeWorktreeId, requestKey, result) - } catch (error) { - setGitBranchCompareResult(activeWorktreeId, requestKey, { - summary: { - baseRef: compareBaseRef, - baseOid: null, - compareRef: branchName, - headOid: null, - mergeBase: null, - changedFiles: 0, - status: 'error', - errorMessage: error instanceof Error ? error.message : 'Branch compare failed' - }, - entries: [] - }) - } - }, [ - activeRepoSettings, - activeWorktreeId, - beginGitBranchCompareRequest, - branchName, - compareBaseRef, - isFolder, - setGitBranchCompareResult, - worktreePath - ]) + const runBranchCompare = useCallback( + async (kind: BranchCompareRefreshKind) => { + if (!activeWorktreeId || !worktreePath || !compareBaseRef || isFolder) { + return + } + const requestKey = `${activeWorktreeId}:${compareBaseRef}:${Date.now()}` + const existingSummary = + useAppStore.getState().gitBranchCompareSummaryByWorktree[activeWorktreeId] + // Why: only reset to 'loading' on the first request or a base-ref change; resetting on every poll caused a visible loading→error→loading flicker. + const baseRefChanged = existingSummary && existingSummary.baseRef !== compareBaseRef + const shouldResetToLoading = !existingSummary || baseRefChanged + if (shouldResetToLoading) { + beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef) + } else { + beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef, { + preserveExistingSummary: true + }) + } + try { + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const result = await getRuntimeGitBranchCompare( + { + // Why: route the branch compare by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + compareBaseRef, + kind === 'interval' ? 'background' : 'interactive' + ) + setGitBranchCompareResult(activeWorktreeId, requestKey, result) + } catch (error) { + setGitBranchCompareResult(activeWorktreeId, requestKey, { + summary: { + baseRef: compareBaseRef, + baseOid: null, + compareRef: branchName, + headOid: null, + mergeBase: null, + changedFiles: 0, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Branch compare failed' + }, + entries: [] + }) + } + }, + [ + activeRepoSettings, + activeWorktreeId, + beginGitBranchCompareRequest, + branchName, + compareBaseRef, + isFolder, + setGitBranchCompareResult, + worktreePath + ] + ) const clearBranchComparePollTimer = useCallback((): void => { if (branchComparePollTimerRef.current !== null) { @@ -177,7 +181,7 @@ export function useSourceControlBranchCompare({ const runPromise = (async (): Promise => { // Why: keep one branch-compare chain in flight and collapse skipped ticks into one trailing refresh instead of stacking git subprocesses. try { - await runBranchCompare() + await runBranchCompare(kind) } finally { const endedAt = Date.now() branchCompareLastRunEndedAtRef.current = endedAt diff --git a/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx b/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx index 294ac7d2020..88d3c3add8f 100644 --- a/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx @@ -238,6 +238,11 @@ describe('useSourceControlBranchCompare scheduler', () => { // Visible mounts run once immediately through the visibility interval. await mount({ isBranchVisible: true }) expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledTimes(1) + expect(mocks.getRuntimeGitBranchCompare).toHaveBeenLastCalledWith( + expect.objectContaining({ worktreeId: 'A' }), + 'origin/main', + 'background' + ) await act(async () => { vi.advanceTimersByTime(BRANCH_REFRESH_INTERVAL_MS * 3) @@ -261,6 +266,11 @@ describe('useSourceControlBranchCompare scheduler', () => { await vi.advanceTimersByTimeAsync(1) }) expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledTimes(2) + expect(mocks.getRuntimeGitBranchCompare).toHaveBeenLastCalledWith( + expect.objectContaining({ worktreeId: 'A' }), + 'origin/main', + 'background' + ) }) it('preserves the existing summary on a poll refresh but resets to loading on a base-ref change', async () => { @@ -315,7 +325,8 @@ describe('useSourceControlBranchCompare scheduler', () => { }) expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'A' }), - 'origin/dev' + 'origin/dev', + 'interactive' ) }) diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 8754aee2b23..37c539285b5 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -9,6 +9,7 @@ import { fetchRuntimeGit, generateRuntimeCommitMessage, generateRuntimePullRequestFields, + getRuntimeGitBranchCompare, getRuntimeGitDiff, getRuntimeGitHistory, getRuntimeGitIgnoredPaths, @@ -29,6 +30,7 @@ const gitCancelStatus = vi.fn() const gitCheckIgnored = vi.fn() const gitSubmoduleStatus = vi.fn() const gitDiff = vi.fn() +const gitBranchCompare = vi.fn() const gitHistory = vi.fn() const gitBulkStage = vi.fn() const gitBulkDiscard = vi.fn() @@ -53,6 +55,7 @@ beforeEach(() => { gitCheckIgnored.mockReset() gitSubmoduleStatus.mockReset() gitDiff.mockReset() + gitBranchCompare.mockReset() gitHistory.mockReset() gitBulkStage.mockReset() gitBulkDiscard.mockReset() @@ -79,6 +82,7 @@ beforeEach(() => { checkIgnored: gitCheckIgnored, submoduleStatus: gitSubmoduleStatus, diff: gitDiff, + branchCompare: gitBranchCompare, history: gitHistory, bulkStage: gitBulkStage, bulkDiscard: gitBulkDiscard, @@ -99,6 +103,28 @@ beforeEach(() => { }) describe('runtime git client', () => { + it('preserves branch-compare admission through local IPC', async () => { + gitBranchCompare.mockResolvedValue({ summary: {}, entries: [] }) + + await getRuntimeGitBranchCompare( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + 'origin/main', + 'background' + ) + + expect(gitBranchCompare).toHaveBeenCalledWith({ + worktreePath: '/repo', + baseRef: 'origin/main', + connectionId: 'ssh-1', + admissionTier: 'background' + }) + }) + it('uses local git IPC when no remote runtime is active', async () => { gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) diff --git a/src/renderer/src/runtime/runtime-git-diff-client.ts b/src/renderer/src/runtime/runtime-git-diff-client.ts index ec112e78065..ea44571ecde 100644 --- a/src/renderer/src/runtime/runtime-git-diff-client.ts +++ b/src/renderer/src/runtime/runtime-git-diff-client.ts @@ -31,20 +31,22 @@ export async function getRuntimeGitDiff( export async function getRuntimeGitBranchCompare( context: RuntimeGitContext, - baseRef: string + baseRef: string, + admissionTier: 'interactive' | 'background' = 'interactive' ): Promise { const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.branchCompare({ worktreePath: resolveLocalWorktreePath(context), baseRef, - connectionId: context.connectionId + connectionId: context.connectionId, + admissionTier }) } return callRuntimeRpc( target, 'git.branchCompare', - { worktree: toRuntimeWorktreeSelector(context.worktreeId), baseRef }, + { worktree: toRuntimeWorktreeSelector(context.worktreeId), baseRef, admissionTier }, { timeoutMs: 15_000 } ) } diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 3233fc2f40b..90488d7a11b 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -80,6 +80,7 @@ export function startPullRequestLookup(args: { branch, linkedPRNumber, currentHeadOid: requestHeadOid, + ...(options?.reason ? { reason: options.reason } : {}), ...(fallbackPRNumber !== null ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } : {}) @@ -248,7 +249,8 @@ export function startPullRequestLookup(args: { void get().fetchPRForBranch(repoPath, branch, { force: true, repoId, - worktreeId: options.worktreeId + worktreeId: options.worktreeId, + reason: options.reason }) } } diff --git a/src/renderer/src/store/github/refresh-routing-actions.ts b/src/renderer/src/store/github/refresh-routing-actions.ts index bca0e2020d2..955fecabaaa 100644 --- a/src/renderer/src/store/github/refresh-routing-actions.ts +++ b/src/renderer/src/store/github/refresh-routing-actions.ts @@ -33,7 +33,8 @@ export const createRefreshRoutingActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason }) return } @@ -68,7 +69,8 @@ export const createRefreshRoutingActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason: 'visible' }) continue } diff --git a/src/renderer/src/store/github/refresh-sweep-actions.ts b/src/renderer/src/store/github/refresh-sweep-actions.ts index ab28629539d..c2c973c0e92 100644 --- a/src/renderer/src/store/github/refresh-sweep-actions.ts +++ b/src/renderer/src/store/github/refresh-sweep-actions.ts @@ -133,7 +133,8 @@ export const createRefreshSweepActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason: 'swr' }) } else if (shouldEnqueueLocalPRRefresh(candidate)) { enqueueLocalGitHubPRRefresh({ candidate, reason: 'swr', priority: 10 }) @@ -207,7 +208,8 @@ export const createRefreshSweepActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason: 'post-push' }) } else if (shouldEnqueueLocalPRRefresh(candidate)) { enqueueLocalGitHubPRRefresh({ candidate, reason: 'post-push', priority: 100 }) diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts index 0d1f41c5da6..e1dbd322e44 100644 --- a/src/renderer/src/store/github/slice-types.ts +++ b/src/renderer/src/store/github/slice-types.ts @@ -58,6 +58,7 @@ export type GitHubSlice = { linkedPRNumber?: number | null fallbackPRNumber?: number | null fallbackPRSource?: GitHubPRFallbackSource | null + reason?: GitHubPRRefreshReason } ) => Promise fetchIssue: ( diff --git a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts index 1ead3a5d360..61361907f6c 100644 --- a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts +++ b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts @@ -56,7 +56,8 @@ export const createStaleWorktreeRefreshActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason: 'active' }) } else if (shouldEnqueueLocalPRRefresh(candidate)) { enqueueLocalGitHubPRRefresh({ candidate, reason: 'active', priority: 80 }) diff --git a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts index f78393f62c7..33b3c87437c 100644 --- a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts +++ b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts @@ -126,41 +126,53 @@ describe('GitHub PR refresh owner-host routing', () => { resetRuntimeMocks() }) - it('routes explicit PR refresh for a runtime-owned repo to its owner while Local desktop is active', async () => { - runtimeEnvironmentCall.mockResolvedValueOnce({ - id: 'rpc-1', - ok: true, - result: makePR({ number: 23 }), - _meta: { runtimeId: 'remote-runtime' } - }) - const store = createTestStore() - const repoPath = '/runtime/repo' - const branch = 'feature/runtime-owner' - seed(store, { - settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], - repos: [ - makeRepo({ - id: 'repo-runtime', - path: repoPath, - executionHostId: 'runtime:env-1' - }) - ], - worktreesByRepo: { - 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] - } - }) + it.each([ + ['active', 80], + ['manual', 100] + ] as const)( + 'routes %s PR refresh to the runtime repo owner with its reason', + async (reason, priority) => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 23 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/runtime-owner' + seed(store, { + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + repos: [ + makeRepo({ + id: 'repo-runtime', + path: repoPath, + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] + } + }) - store.getState().enqueueGitHubPRRefresh('wt-runtime', 'active', 80) + store.getState().enqueueGitHubPRRefresh('wt-runtime', reason, priority) - await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) - expect(enqueuePRRefresh).not.toHaveBeenCalled() - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' }, - timeoutMs: 30_000 - }) - }) + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { + repo: 'repo-runtime', + branch, + linkedPRNumber: null, + currentHeadOid: 'head-oid', + reason + }, + timeoutMs: 30_000 + }) + } + ) it('keeps connected SSH PR refresh on the local coordinator even when a runtime is focused', () => { const store = createTestStore() @@ -230,7 +242,13 @@ describe('GitHub PR refresh owner-host routing', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' }, + params: { + repo: 'repo-runtime', + branch, + linkedPRNumber: null, + currentHeadOid: 'head-oid', + reason: 'post-push' + }, timeoutMs: 30_000 }) }) @@ -278,7 +296,8 @@ describe('GitHub PR refresh owner-host routing', () => { repo: 'repo-runtime', branch: 'feature/runtime', linkedPRNumber: null, - currentHeadOid: 'head-oid' + currentHeadOid: 'head-oid', + reason: 'visible' }, timeoutMs: 30_000 }) diff --git a/src/renderer/src/store/slices/github-refresh-sweep.test.ts b/src/renderer/src/store/slices/github-refresh-sweep.test.ts index d275f9850a2..35c3e37f3cf 100644 --- a/src/renderer/src/store/slices/github-refresh-sweep.test.ts +++ b/src/renderer/src/store/slices/github-refresh-sweep.test.ts @@ -587,7 +587,13 @@ describe('createGitHubSlice.refreshAllGitHub', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null }, + params: { + repo: 'repo-1', + branch, + linkedPRNumber: null, + currentHeadOid: null, + reason: 'swr' + }, timeoutMs: 30_000 }) }) @@ -717,7 +723,13 @@ describe('createGitHubSlice.refreshGitHubForWorktree', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null }, + params: { + repo: 'repo-1', + branch, + linkedPRNumber: null, + currentHeadOid: null, + reason: 'post-push' + }, timeoutMs: 30_000 }) }) diff --git a/src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts b/src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts index 430b83db0df..f14eb67ab23 100644 --- a/src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts +++ b/src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts @@ -530,7 +530,13 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: 12, currentHeadOid: null }, + params: { + repo: 'repo-1', + branch, + linkedPRNumber: 12, + currentHeadOid: null, + reason: 'active' + }, timeoutMs: 30_000 }) expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toMatchObject({ diff --git a/src/renderer/src/web/preload-api/web-git-api.ts b/src/renderer/src/web/preload-api/web-git-api.ts index f0b0423270a..a16cf72e937 100644 --- a/src/renderer/src/web/preload-api/web-git-api.ts +++ b/src/renderer/src/web/preload-api/web-git-api.ts @@ -120,11 +120,12 @@ export function createGitApi(): NonNullable['git']> { compareAgainstHead }) }, - branchCompare: async ({ worktreePath, baseRef }) => { + branchCompare: async ({ worktreePath, baseRef, admissionTier }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) return callRuntimeResult('git.branchCompare', { worktree: toRuntimeWorktreeSelector(worktree.id), - baseRef + baseRef, + ...(admissionTier ? { admissionTier } : {}) }) }, commitCompare: async ({ worktreePath, commitId }) => { diff --git a/src/renderer/src/web/web-preload-api-git.test.ts b/src/renderer/src/web/web-preload-api-git.test.ts index e96eceb573b..639532784fe 100644 --- a/src/renderer/src/web/web-preload-api-git.test.ts +++ b/src/renderer/src/web/web-preload-api-git.test.ts @@ -164,6 +164,11 @@ describe('web git preload API', () => { branchLineTotalMergeBase: TEST_COMMIT_OID }) await globals.window.api.git.status({ worktreePath: '/workspace/repo' }) + await globals.window.api.git.branchCompare({ + worktreePath: '/workspace/repo', + baseRef: 'origin/main', + admissionTier: 'background' + }) const statusCalls = runtimeCalls.filter((call) => call.method === 'git.status') // Why: strict — `toEqual` would pass on a forwarded `branchLineTotalMergeBase: undefined`, @@ -189,5 +194,13 @@ describe('web git preload API', () => { } } ]) + expect(runtimeCalls.find((call) => call.method === 'git.branchCompare')).toStrictEqual({ + method: 'git.branchCompare', + params: { + worktree: 'id:wt-1', + baseRef: 'origin/main', + admissionTier: 'background' + } + }) }) })