Reduce source-control background load during refreshes (#6189)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-23 15:06:38 -07:00 committed by GitHub
parent cf39bd7abe
commit a2fd8c11c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1961 additions and 143 deletions

View File

@ -0,0 +1,329 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
readFileMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitStreamStdout: async (
args: string[],
options: { onStdout: (chunk: string) => boolean | void }
) => {
const { stdout } = await gitExecFileAsyncMock(args)
const stoppedEarly = options.onStdout(stdout ?? '') === true
return { stoppedEarly }
},
gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({
...env,
GIT_OPTIONAL_LOCKS: '0'
})
}))
vi.mock('fs/promises', () => ({
readFile: readFileMock
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock
}))
import {
clearEffectiveUpstreamNegativeStatusCache,
clearEffectiveUpstreamStatusCacheForTests,
getEffectiveUpstreamStatusCacheCountForTests,
getEffectiveUpstreamStatusGenerationCountForTests,
getStatus
} from './status'
describe('local upstream negative cache', () => {
beforeEach(() => {
clearEffectiveUpstreamStatusCacheForTests()
existsSyncMock.mockReset()
gitExecFileAsyncMock.mockReset()
readFileMock.mockReset()
existsSyncMock.mockReturnValue(false)
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
})
it('bypasses a cached negative result for strict status reads', async () => {
let originBranchExists = false
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {
return {
stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n'
}
}
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
return { stdout: 'feature\n' }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error('fatal: no upstream configured for branch feature')
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
if (originBranchExists) {
return { stdout: 'abc123\n' }
}
throw new Error('missing remote branch')
}
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
return { stdout: '0\t1\n' }
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
const first = await getStatus('/repo')
originBranchExists = true
const automatic = await getStatus('/repo')
const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
expect(automatic.upstreamStatus).toEqual(first.upstreamStatus)
expect(strict.upstreamStatus).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
})
})
it('keeps an older automatic negative probe from overwriting a strict positive result', async () => {
let originBranchExists = false
let deferredOriginReject: ((error: Error) => void) | null = null
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {
return {
stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n'
}
}
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
return { stdout: 'feature\n' }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error('fatal: no upstream configured for branch feature')
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
if (originBranchExists) {
return { stdout: 'abc123\n' }
}
return await new Promise<{ stdout: string }>((_, reject) => {
deferredOriginReject = reject
})
}
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
return { stdout: '0\t1\n' }
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
const automatic = getStatus('/repo')
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
originBranchExists = true
const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
if (!deferredOriginReject) {
throw new Error('expected deferred origin reject')
}
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
const staleAutomatic = await automatic
const nextAutomatic = await getStatus('/repo')
expect(strict.upstreamStatus).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
})
expect(staleAutomatic.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus)
})
it('does not trim generation for an unresolved automatic probe', async () => {
let originBranchExists = false
let deferredOriginReject: ((error: Error) => void) | null = null
const branchQueue = [
'feature',
'feature',
...Array.from({ length: 512 }, (_, index) => `other-${index}`),
'feature'
]
let currentBranch = 'feature'
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {
currentBranch = branchQueue.shift() ?? currentBranch
return {
stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n`
}
}
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
return { stdout: `${currentBranch}\n` }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error(`fatal: no upstream configured for branch ${currentBranch}`)
}
if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) {
if (originBranchExists) {
return { stdout: 'abc123\n' }
}
return await new Promise<{ stdout: string }>((_, reject) => {
deferredOriginReject = reject
})
}
if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) {
return { stdout: '0\t1\n' }
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
const automatic = getStatus('/repo')
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
originBranchExists = true
const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
for (let index = 0; index < 512; index += 1) {
await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
}
if (!deferredOriginReject) {
throw new Error('expected deferred origin reject')
}
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
await automatic
const nextAutomatic = await getStatus('/repo')
expect(strict.upstreamStatus).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
})
expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus)
expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
})
it('does not trim generation for a cleared automatic probe before it settles', async () => {
let originBranchExists = false
let deferredOriginReject: ((error: Error) => void) | null = null
const branchQueue = [
'feature',
...Array.from({ length: 512 }, (_, index) => `other-${index}`),
'feature'
]
let currentBranch = 'feature'
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {
currentBranch = branchQueue.shift() ?? currentBranch
return {
stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n`
}
}
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
return { stdout: `${currentBranch}\n` }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error(`fatal: no upstream configured for branch ${currentBranch}`)
}
if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) {
if (originBranchExists) {
return { stdout: 'abc123\n' }
}
return await new Promise<{ stdout: string }>((_, reject) => {
deferredOriginReject = reject
})
}
if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) {
return { stdout: '0\t1\n' }
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
const automatic = getStatus('/repo')
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
originBranchExists = true
clearEffectiveUpstreamNegativeStatusCache({ worktreePath: '/repo', branchName: 'feature' })
for (let index = 0; index < 512; index += 1) {
await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
}
if (!deferredOriginReject) {
throw new Error('expected deferred origin reject')
}
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
await automatic
const nextAutomatic = await getStatus('/repo')
expect(nextAutomatic.upstreamStatus).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
})
expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
})
it('bounds effective-upstream negative entries', async () => {
let branchIndex = 0
let currentBranch = 'feature-0'
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {
currentBranch = `feature-${branchIndex}`
branchIndex += 1
return {
stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n`
}
}
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
return { stdout: `${currentBranch}\n` }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error(`fatal: no upstream configured for branch ${currentBranch}`)
}
if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) {
throw new Error('missing remote branch')
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
for (let index = 0; index < 513; index += 1) {
await getStatus('/repo')
}
expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(512)
expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
})
it('bounds write-generation entries from positive strict probes', async () => {
let branchIndex = 0
let currentBranch = 'feature-0'
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {
currentBranch = `feature-${branchIndex}`
branchIndex += 1
return {
stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n`
}
}
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
return { stdout: `${currentBranch}\n` }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error(`fatal: no upstream configured for branch ${currentBranch}`)
}
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${currentBranch}`)) {
return { stdout: 'abc123\n' }
}
if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${currentBranch}`)) {
return { stdout: '0\t1\n' }
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
for (let index = 0; index < 513; index += 1) {
await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
}
expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(0)
expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBe(512)
})
})

View File

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Repro command:
// pnpm exec vitest run --config config/vitest.config.ts src/main/git/status-upstream-probe-churn.test.ts -t "missing-upstream polling churn"
@ -51,6 +51,10 @@ describe('getStatus missing-upstream polling churn', () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/Initi-Project\n')
})
afterEach(() => {
vi.useRealTimers()
})
it('does not repeat failed effective-upstream probes for a branch with no upstream', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {
@ -87,6 +91,39 @@ describe('getStatus missing-upstream polling churn', () => {
expect(sameNameOriginProbeCalls).toHaveLength(1)
})
it('keeps failed effective-upstream probes cached beyond thirty seconds', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {
return {
stdout: '# branch.oid abcdef1234567890\n# branch.head Initi-Project\n'
}
}
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
return { stdout: 'Initi-Project\n' }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error("fatal: no upstream configured for branch 'Initi-Project'")
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project')) {
throw new Error('missing remote branch')
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
await getStatus('/repo')
vi.setSystemTime(31_000)
await getStatus('/repo')
const upstreamProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => {
const args = getGitArgs(call)
return args[0] === 'rev-parse' && args.includes('HEAD@{u}')
})
expect(upstreamProbeCalls).toHaveLength(1)
})
it('coalesces concurrent effective-upstream probes for a branch with no upstream', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {

View File

@ -52,7 +52,8 @@ import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES
const BULK_CHUNK_SIZE = 100
const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 30_000
const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000
const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512
type EffectiveUpstreamStatusCacheEntry = {
expiresAt: number
@ -61,10 +62,22 @@ type EffectiveUpstreamStatusCacheEntry = {
const effectiveUpstreamStatusCache = new Map<string, EffectiveUpstreamStatusCacheEntry>()
const effectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const retiredEffectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
export function clearEffectiveUpstreamStatusCacheForTests(): void {
effectiveUpstreamStatusCache.clear()
effectiveUpstreamStatusInFlight.clear()
retiredEffectiveUpstreamStatusInFlight.clear()
effectiveUpstreamStatusWriteGeneration.clear()
}
export function getEffectiveUpstreamStatusCacheCountForTests(): number {
return effectiveUpstreamStatusCache.size
}
export function getEffectiveUpstreamStatusGenerationCountForTests(): number {
return effectiveUpstreamStatusWriteGeneration.size
}
export type GetStatusOptions = GitRuntimeOptions & {
@ -74,6 +87,7 @@ export type GetStatusOptions = GitRuntimeOptions & {
* `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap.
*/
limit?: number
bypassEffectiveUpstreamNegativeCache?: boolean
}
/**
@ -168,7 +182,8 @@ export async function getStatus(
cacheKey,
worktreePath,
branchName,
options
options,
options.bypassEffectiveUpstreamNegativeCache === true
)
} catch {
// Why: git status polling should not fail just because the richer
@ -282,6 +297,64 @@ function getEffectiveUpstreamStatusCacheKey(
return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0')
}
export function clearEffectiveUpstreamNegativeStatusCache(identity: {
worktreePath: string
branchName: string
upstreamName?: string
options?: GitRuntimeOptions
}): void {
const cacheKey = getEffectiveUpstreamStatusCacheKey(
identity.worktreePath,
identity.branchName,
identity.upstreamName,
identity.options
)
retireEffectiveUpstreamStatusProbe(cacheKey)
effectiveUpstreamStatusCache.delete(cacheKey)
effectiveUpstreamStatusInFlight.delete(cacheKey)
effectiveUpstreamStatusWriteGeneration.set(
cacheKey,
(effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1
)
}
function retireEffectiveUpstreamStatusProbe(cacheKey: string): void {
const retiredProbe = effectiveUpstreamStatusInFlight.get(cacheKey)
if (!retiredProbe) {
return
}
retiredEffectiveUpstreamStatusInFlight.set(cacheKey, retiredProbe)
void retiredProbe
.finally(() => {
if (retiredEffectiveUpstreamStatusInFlight.get(cacheKey) === retiredProbe) {
retiredEffectiveUpstreamStatusInFlight.delete(cacheKey)
trimEffectiveUpstreamStatusGeneration()
}
})
.catch(() => undefined)
}
function hasPendingEffectiveUpstreamStatusProbe(cacheKey: string): boolean {
return (
effectiveUpstreamStatusInFlight.has(cacheKey) ||
retiredEffectiveUpstreamStatusInFlight.has(cacheKey)
)
}
function trimEffectiveUpstreamStatusGeneration(): void {
for (const cacheKey of effectiveUpstreamStatusWriteGeneration.keys()) {
if (
effectiveUpstreamStatusWriteGeneration.size <= MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES
) {
break
}
if (hasPendingEffectiveUpstreamStatusProbe(cacheKey)) {
continue
}
effectiveUpstreamStatusWriteGeneration.delete(cacheKey)
}
}
function readCachedEffectiveUpstreamStatus(
cacheKey: string,
now: number
@ -301,12 +374,18 @@ function rememberEffectiveUpstreamStatus(
cacheKey: string,
status: GitUpstreamStatus,
now: number,
probedSameNameOriginRef: boolean
probedSameNameOriginRef: boolean,
writeGeneration: number
): void {
// Why: hasConfiguredPushTarget gates a write action. Re-probe it each poll
// rather than keeping a stale positive target after branch config changes.
if (status.hasUpstream || status.hasConfiguredPushTarget) {
effectiveUpstreamStatusCache.delete(cacheKey)
effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1)
trimEffectiveUpstreamStatusGeneration()
return
}
if ((effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) {
return
}
if (!probedSameNameOriginRef) {
@ -318,41 +397,58 @@ function rememberEffectiveUpstreamStatus(
status,
expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS
})
while (effectiveUpstreamStatusCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) {
const oldest = effectiveUpstreamStatusCache.keys().next()
if (oldest.done) {
break
}
effectiveUpstreamStatusCache.delete(oldest.value)
effectiveUpstreamStatusWriteGeneration.delete(oldest.value)
}
trimEffectiveUpstreamStatusGeneration()
}
async function readOrProbeEffectiveUpstreamStatus(
cacheKey: string,
worktreePath: string,
branchName: string,
options: GitRuntimeOptions = {}
options: GitRuntimeOptions = {},
bypassCache = false
): Promise<GitUpstreamStatus> {
const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now())
if (cached) {
return cached
}
if (!bypassCache) {
const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now())
if (cached) {
return cached
}
const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey)
if (inFlight) {
return inFlight
const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey)
if (inFlight) {
return inFlight
}
}
// Why: source-control mount and root git refresh can overlap during startup.
// Coalesce the richer upstream probe so a stable missing ref fails once.
const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0
const probe = probeEffectiveUpstreamStatus(worktreePath, branchName, options).then((result) => {
rememberEffectiveUpstreamStatus(
cacheKey,
result.status,
Date.now(),
result.probedSameNameOriginRef
result.probedSameNameOriginRef,
writeGeneration
)
return result.status
})
effectiveUpstreamStatusInFlight.set(cacheKey, probe)
if (!bypassCache) {
effectiveUpstreamStatusInFlight.set(cacheKey, probe)
}
try {
return await probe
} finally {
if (effectiveUpstreamStatusInFlight.get(cacheKey) === probe) {
effectiveUpstreamStatusInFlight.delete(cacheKey)
trimEffectiveUpstreamStatusGeneration()
}
}
}

View File

@ -12,6 +12,7 @@ import type {
} from '../../shared/types'
import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client'
import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit'
import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
type QueueEntry = {
key: string
@ -64,6 +65,7 @@ const BACKGROUND_BUDGET_MAX = 20
const POST_PUSH_DELAY_MS = 2_500
const BACKOFF_BASE_MS = 60_000
const BACKOFF_MAX_MS = 15 * 60_000
const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000
let sequence = 0
let draining = false
@ -74,6 +76,12 @@ const errorBackoff = new Map<string, { failures: number; retryAt: number }>()
let lastBackgroundStartAt = 0
const visibleByWindow = new Map<number, { generation: number; keys: Set<string> }>()
let outcomeObserver: PRRefreshOutcomeObserver | null = null
const diagnosticsCounters = {
enqueued: 0,
coalesced: 0,
skipped: 0,
backgroundPauses: 0
}
export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void {
outcomeObserver = observer
@ -94,6 +102,27 @@ function removeInvisibleVisibleRefreshes(): void {
}
}
function recordPRRefreshQueueDiagnostic(
event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause',
reason: GitHubPRRefreshReason,
skippedReason?: GitHubPRRefreshSkippedReason
): void {
recordCoalescedCrashBreadcrumb({
name: 'pr_refresh_queue',
coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`,
minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS,
data: {
event,
reason,
...(skippedReason ? { skippedReason } : {}),
enqueued: diagnosticsCounters.enqueued,
coalesced: diagnosticsCounters.coalesced,
skipped: diagnosticsCounters.skipped,
backgroundPauses: diagnosticsCounters.backgroundPauses
}
})
}
export function clearVisiblePRRefreshWindow(windowId: number): void {
if (!visibleByWindow.delete(windowId)) {
return
@ -480,6 +509,8 @@ async function drainQueue(): Promise<void> {
const budgetDelay = isBudgetedQueueEntry(next) ? nextBudgetDelay() : 0
if (budgetDelay > 0) {
diagnosticsCounters.backgroundPauses += 1
recordPRRefreshQueueDiagnostic('background-pause', next.reason)
scheduleDrain(budgetDelay)
return
}
@ -488,6 +519,8 @@ async function drainQueue(): Promise<void> {
const aliases = Array.from(next.aliases.values())
const skippedReason = validateCandidate(next.candidate)
if (skippedReason) {
diagnosticsCounters.skipped += 1
recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason)
broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason })
continue
}
@ -578,6 +611,8 @@ export function enqueuePRRefresh(
const skippedReason = validateCandidate(candidate)
if (skippedReason) {
removeQueuedAliasForInvalidCandidate(key, alias)
diagnosticsCounters.skipped += 1
recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason)
broadcast({
aliases: [alias],
reason,
@ -592,6 +627,8 @@ export function enqueuePRRefresh(
const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0)
if (existing) {
existing.aliases.set(alias.cacheKey, alias)
diagnosticsCounters.coalesced += 1
recordPRRefreshQueueDiagnostic('coalesced', reason)
const shouldPromoteExisting =
priority > existing.priority ||
isManual(reason) ||
@ -604,6 +641,8 @@ export function enqueuePRRefresh(
existing.windowId = windowId ?? existing.windowId
}
} else {
diagnosticsCounters.enqueued += 1
recordPRRefreshQueueDiagnostic('enqueued', reason)
queue.set(key, {
key,
candidate,

View File

@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { recordCoalescedCrashBreadcrumbMock } = vi.hoisted(() => ({
recordCoalescedCrashBreadcrumbMock: vi.fn()
}))
vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({
recordCoalescedCrashBreadcrumb: recordCoalescedCrashBreadcrumbMock
}))
import {
clearPRRefreshValidationBackoffForTests,
getPRRefreshValidationBackoffCountForTests,
notePRRefreshValidationDenial
} from './pr-refresh-validation-backoff'
describe('PR refresh validation backoff', () => {
beforeEach(() => {
clearPRRefreshValidationBackoffForTests()
recordCoalescedCrashBreadcrumbMock.mockReset()
})
it('backs off repeated automatic validation denials until the TTL expires', () => {
const identity = {
repoId: 'repo-1',
repoPath: '/workspace/missing',
reason: 'unknown-repo' as const
}
expect(notePRRefreshValidationDenial(identity, 0)).toBe('validation-denied')
expect(notePRRefreshValidationDenial(identity, 60_000)).toBe('validation-backoff')
expect(notePRRefreshValidationDenial(identity, 5 * 60_000 + 1)).toBe('validation-denied')
})
it('bounds validation backoff entries', () => {
for (let index = 0; index < 257; index += 1) {
notePRRefreshValidationDenial(
{
repoId: `repo-${index}`,
repoPath: `/workspace/missing-${index}`,
reason: 'unknown-repo'
},
index
)
}
expect(getPRRefreshValidationBackoffCountForTests()).toBe(256)
})
it('records path-safe diagnostic breadcrumbs', () => {
notePRRefreshValidationDenial(
{
repoId: 'repo-1',
repoPath: '/Users/alice/private/project',
reason: 'repo-path-mismatch'
},
0
)
expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
name: 'pr_refresh_validation_skip',
data: expect.objectContaining({
reason: 'repo-path-mismatch',
result: 'recorded',
token: expect.any(String)
})
})
)
const payload = recordCoalescedCrashBreadcrumbMock.mock.calls[0]?.[0]
expect(JSON.stringify(payload)).not.toContain('/Users/alice/private/project')
})
})

View File

@ -0,0 +1,107 @@
import { createHash } from 'crypto'
import { resolve } from 'path'
import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
const VALIDATION_BACKOFF_TTL_MS = 5 * 60_000
const MAX_VALIDATION_BACKOFF_ENTRIES = 256
const VALIDATION_BREADCRUMB_MIN_INTERVAL_MS = 30_000
export type PRRefreshValidationDenialReason =
| 'unknown-repo'
| 'repo-path-mismatch'
| 'host-mismatch'
type ValidationBackoffIdentity = {
repoId?: string | null
repoPath: string
reason: PRRefreshValidationDenialReason
}
type ValidationBackoffEntry = {
expiresAt: number
}
type ValidationBackoffCounters = {
recorded: number
skipped: number
expired: number
}
const validationBackoff = new Map<string, ValidationBackoffEntry>()
const counters: ValidationBackoffCounters = {
recorded: 0,
skipped: 0,
expired: 0
}
function validationIdentityKey(identity: ValidationBackoffIdentity): string {
return [identity.repoId ?? '', resolve(identity.repoPath), identity.reason].join('\0')
}
function validationIdentityToken(key: string): string {
return createHash('sha256').update(key).digest('hex').slice(0, 12)
}
function evictOldestValidationBackoffEntries(): void {
while (validationBackoff.size > MAX_VALIDATION_BACKOFF_ENTRIES) {
const oldest = validationBackoff.keys().next()
if (oldest.done) {
break
}
validationBackoff.delete(oldest.value)
}
}
function recordValidationBreadcrumb(
reason: PRRefreshValidationDenialReason,
result: 'recorded' | 'backoff',
token: string
): void {
recordCoalescedCrashBreadcrumb({
name: 'pr_refresh_validation_skip',
coalesceKey: `pr-refresh-validation:${reason}:${token}`,
minIntervalMs: VALIDATION_BREADCRUMB_MIN_INTERVAL_MS,
data: {
reason,
result,
token,
recorded: counters.recorded,
skipped: counters.skipped,
expired: counters.expired
}
})
}
export function notePRRefreshValidationDenial(
identity: ValidationBackoffIdentity,
nowMs = Date.now()
): 'validation-denied' | 'validation-backoff' {
const key = validationIdentityKey(identity)
const existing = validationBackoff.get(key)
const token = validationIdentityToken(key)
if (existing && existing.expiresAt > nowMs) {
counters.skipped += 1
recordValidationBreadcrumb(identity.reason, 'backoff', token)
return 'validation-backoff'
}
if (existing) {
counters.expired += 1
validationBackoff.delete(key)
}
counters.recorded += 1
validationBackoff.set(key, { expiresAt: nowMs + VALIDATION_BACKOFF_TTL_MS })
evictOldestValidationBackoffEntries()
recordValidationBreadcrumb(identity.reason, 'recorded', token)
return 'validation-denied'
}
export function clearPRRefreshValidationBackoffForTests(): void {
validationBackoff.clear()
counters.recorded = 0
counters.skipped = 0
counters.expired = 0
}
export function getPRRefreshValidationBackoffCountForTests(): number {
return validationBackoff.size
}

View File

@ -885,6 +885,36 @@ describe('registerFilesystemHandlers', () => {
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true })
})
it('forwards upstream-negative-cache bypass through local and SSH git status IPC', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
const sshProvider = {
getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:status')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
bypassEffectiveUpstreamNegativeCache: true
})
await handlers.get('git:status')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
bypassEffectiveUpstreamNegativeCache: true
})
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
includeIgnored: false,
bypassEffectiveUpstreamNegativeCache: true
})
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', {
includeIgnored: false,
bypassEffectiveUpstreamNegativeCache: true
})
})
it('checks ignored paths through local and SSH git providers', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js'])

View File

@ -829,9 +829,19 @@ export function registerFilesystemHandlers(
'git:status',
async (
_event,
args: { worktreePath: string; connectionId?: string; includeIgnored?: boolean }
args: {
worktreePath: string
connectionId?: string
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
}
): Promise<GitStatusResult> => {
const options = { includeIgnored: args.includeIgnored ?? false }
const options = {
includeIgnored: args.includeIgnored ?? false,
...(args.bypassEffectiveUpstreamNegativeCache === true
? { bypassEffectiveUpstreamNegativeCache: true }
: {})
}
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {

View File

@ -158,6 +158,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({
}))
import { registerGitHubHandlers } from './github'
import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
@ -237,6 +238,7 @@ describe('registerGitHubHandlers', () => {
refreshPRNowMock.mockReset()
reportVisiblePRRefreshCandidatesMock.mockReset()
setPRRefreshOutcomeObserverMock.mockReset()
clearPRRefreshValidationBackoffForTests()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
@ -293,6 +295,156 @@ describe('registerGitHubHandlers', () => {
expect(getIssueMock).not.toHaveBeenCalled()
})
it('returns typed automatic PR refresh validation skips without enqueueing', async () => {
registerGitHubHandlers(store as never, stats as never)
const candidate = {
cacheKey: 'missing::feature/test',
repoPath: '/workspace/missing',
repoId: 'missing-repo',
branch: 'feature/test',
repoKind: 'git' as const
}
const first = await handlers['gh:enqueuePRRefresh'](null, {
candidate,
reason: 'active',
priority: 80
})
const second = await handlers['gh:enqueuePRRefresh'](null, {
candidate,
reason: 'active',
priority: 80
})
expect(first).toEqual({ kind: 'skipped', skippedReason: 'validation-denied' })
expect(second).toEqual({ kind: 'skipped', skippedReason: 'validation-backoff' })
expect(first).not.toBe(false)
expect(second).not.toBe(false)
expect(enqueuePRRefreshMock).not.toHaveBeenCalled()
})
it('uses registered repo routing fields for automatic PR refresh candidates', async () => {
repos = [
{
id: 'repo-ssh',
path: '/workspace/remote-repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-real',
executionHostId: 'ssh:ssh-real'
}
]
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:enqueuePRRefresh'](null, {
candidate: {
cacheKey: 'remote::feature/test',
repoPath: '/workspace/remote-repo',
repoId: 'repo-ssh',
branch: 'feature/test',
repoKind: 'git',
connectionId: 'ssh-stale',
executionHostId: 'runtime:stale',
connectionState: 'disconnected',
localGitOptions: { wslDistro: 'Stale' }
},
reason: 'active',
priority: 80
})
const candidate = enqueuePRRefreshMock.mock.calls[0]?.[0]
expect(candidate).toEqual(
expect.objectContaining({
repoPath: '/workspace/remote-repo',
repoId: 'repo-ssh',
connectionId: 'ssh-real',
executionHostId: 'ssh:ssh-real',
connectionState: 'connected'
})
)
expect(candidate).not.toHaveProperty('localGitOptions')
})
it('keeps manual PR refresh validation strict', async () => {
registerGitHubHandlers(store as never, stats as never)
await expect(
handlers['gh:refreshPRNow'](null, {
candidate: {
cacheKey: 'missing::feature/test',
repoPath: '/workspace/missing',
repoId: 'missing-repo',
branch: 'feature/test',
repoKind: 'git'
}
})
).rejects.toThrow('Access denied: unknown repository path')
expect(refreshPRNowMock).not.toHaveBeenCalled()
})
it('filters invalid visible PR refresh candidates while keeping valid candidates', async () => {
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:reportVisiblePRRefreshCandidates'](
{ sender: { id: 7, once: vi.fn() } },
{
generation: 1,
candidates: [
{
cacheKey: 'valid::feature/test',
repoPath: '/workspace/repo',
repoId: 'repo-1',
branch: 'feature/test',
repoKind: 'git'
},
{
cacheKey: 'missing::feature/old',
repoPath: '/workspace/missing',
repoId: 'missing-repo',
branch: 'feature/old',
repoKind: 'git'
}
]
}
)
expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith(
[
expect.objectContaining({
cacheKey: 'valid::feature/test',
repoPath: '/workspace/repo',
repoId: 'repo-1'
})
],
1,
7
)
})
it('clears a sender visible PR refresh set when all current candidates are invalid', async () => {
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:reportVisiblePRRefreshCandidates'](
{ sender: { id: 8, once: vi.fn() } },
{
generation: 2,
candidates: [
{
cacheKey: 'missing::feature/old',
repoPath: '/workspace/missing',
repoId: 'missing-repo',
branch: 'feature/old',
repoKind: 'git'
}
]
}
)
expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith([], 2, 8)
})
it('rejects GitHub source context from a different host', async () => {
registerGitHubHandlers(store as never, stats as never)

View File

@ -11,6 +11,7 @@ import type {
GitHubOwnerRepo,
GitHubPullRequestStateUpdate,
GitHubPRRefreshCandidate,
GitHubPRRefreshEnqueueResult,
GitHubPRRefreshReason,
PRRefreshOutcome
} from '../../shared/types'
@ -62,6 +63,10 @@ import {
import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details'
import { getRateLimit } from '../github/rate-limit'
import { diagnoseGhAuth } from '../github/auth-diagnose'
import {
notePRRefreshValidationDenial,
type PRRefreshValidationDenialReason
} from '../github/pr-refresh-validation-backoff'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import type { GitHubPRFile } from '../../shared/types'
import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args'
@ -140,27 +145,53 @@ type RepoScopedArgs = {
sourceContext?: TaskSourceContext | null
}
function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo {
type RegisteredRepoValidationResult =
| { kind: 'ok'; repo: Repo }
| { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string }
function validateRegisteredRepo(
args: string | RepoScopedArgs,
store: Store,
repos = store.getRepos()
): RegisteredRepoValidationResult {
const repoPath = typeof args === 'string' ? args : args.repoPath
const repoId = typeof args === 'string' ? undefined : args.repoId
const resolvedRepoPath = resolve(repoPath)
const repo = store
.getRepos()
.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath))
const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath))
if (!repo) {
throw new Error('Access denied: unknown repository path')
return {
kind: 'denied',
reason: 'unknown-repo',
message: 'Access denied: unknown repository path'
}
}
if (repoId && resolve(repo.path) !== resolvedRepoPath) {
throw new Error('Access denied: repository path does not match repo id')
return {
kind: 'denied',
reason: 'repo-path-mismatch',
message: 'Access denied: repository path does not match repo id'
}
}
if (
typeof args !== 'string' &&
args.sourceContext?.provider === 'github' &&
args.sourceContext.hostId !== getRepoExecutionHostId(repo)
) {
throw new Error('Access denied: GitHub source host does not match repository host')
return {
kind: 'denied',
reason: 'host-mismatch',
message: 'Access denied: GitHub source host does not match repository host'
}
}
return repo
return { kind: 'ok', repo }
}
function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo {
const result = validateRegisteredRepo(args, store)
if (result.kind === 'denied') {
throw new Error(result.message)
}
return result.repo
}
function repoConnectionId(repo: Repo): string | null {
@ -172,6 +203,50 @@ function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: strin
return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : []
}
function applyRepoToPRRefreshCandidate(
store: Store,
repo: Repo,
candidate: GitHubPRRefreshCandidate
): GitHubPRRefreshCandidate {
const localGitOptions = localGitOptionArgs(store, repo)[0]
const appliedCandidate = { ...candidate }
delete appliedCandidate.localGitOptions
delete appliedCandidate.connectionId
delete appliedCandidate.executionHostId
delete appliedCandidate.connectionState
return {
...appliedCandidate,
repoPath: repo.path,
repoId: repo.id,
...(localGitOptions ? { localGitOptions } : {}),
connectionId: repoConnectionId(repo),
executionHostId: repo.executionHostId ?? null,
connectionState: repo.connectionId ? 'connected' : 'unknown'
}
}
function validateAutomaticPRRefreshCandidate(
candidate: GitHubPRRefreshCandidate,
store: Store,
repos = store.getRepos()
):
| { kind: 'ok'; candidate: GitHubPRRefreshCandidate }
| {
kind: 'skipped'
result: Extract<GitHubPRRefreshEnqueueResult, { kind: 'skipped' }>
} {
const result = validateRegisteredRepo(candidate, store, repos)
if (result.kind === 'denied') {
const skippedReason = notePRRefreshValidationDenial({
repoId: candidate.repoId,
repoPath: candidate.repoPath,
reason: result.reason
})
return { kind: 'skipped', result: { kind: 'skipped', skippedReason } }
}
return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) }
}
export function registerGitHubHandlers(store: Store, stats: StatsCollector): void {
function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void {
if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) {
@ -246,16 +321,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
ipcMain.handle(
'gh:refreshPRNow',
async (_event, args: { candidate: GitHubPRRefreshCandidate }) => {
const repo = assertRegisteredRepo(args.candidate.repoPath, store)
const localGitOptions = localGitOptionArgs(store, repo)[0]
const outcome = await refreshPRNow({
...args.candidate,
repoPath: repo.path,
repoId: repo.id,
...(localGitOptions ? { localGitOptions } : {}),
connectionId: repo.connectionId ?? args.candidate.connectionId,
connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState
})
const repo = assertRegisteredRepo(args.candidate, store)
const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate))
recordPRIfNeeded(repo, outcome)
return outcome
}
@ -270,22 +337,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
reason: GitHubPRRefreshReason
priority?: number
}
) => {
const repo = assertRegisteredRepo(args.candidate.repoPath, store)
const localGitOptions = localGitOptionArgs(store, repo)[0]
enqueuePRRefresh(
{
...args.candidate,
repoPath: repo.path,
repoId: repo.id,
...(localGitOptions ? { localGitOptions } : {}),
connectionId: repo.connectionId ?? args.candidate.connectionId,
connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState
},
args.reason,
args.priority ?? 0
)
return true
): GitHubPRRefreshEnqueueResult => {
const validation = validateAutomaticPRRefreshCandidate(args.candidate, store)
if (validation.kind === 'skipped') {
return validation.result
}
enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0)
return { kind: 'queued' }
}
)
@ -300,18 +358,14 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
clearVisiblePRRefreshWindow(senderId)
})
}
const candidates = args.candidates.map((candidate) => {
const repo = assertRegisteredRepo(candidate.repoPath, store)
const localGitOptions = localGitOptionArgs(store, repo)[0]
return {
...candidate,
repoPath: repo.path,
repoId: repo.id,
...(localGitOptions ? { localGitOptions } : {}),
connectionId: repo.connectionId ?? candidate.connectionId,
connectionState: repo.connectionId ? 'connected' : candidate.connectionState
const candidates: GitHubPRRefreshCandidate[] = []
const repos = store.getRepos()
for (const candidate of args.candidates) {
const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos)
if (validation.kind === 'ok') {
candidates.push(validation.candidate)
}
})
}
reportVisiblePRRefreshCandidates(candidates, args.generation, senderId)
return true
}

View File

@ -69,6 +69,22 @@ describe('SshGitProvider', () => {
})
})
it('getStatus forwards upstream-negative-cache bypass only when requested', async () => {
const statusResult = { entries: [], conflictOperation: 'unknown' }
mux.request.mockResolvedValue(statusResult)
await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: true })
await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: false })
expect(mux.request).toHaveBeenNthCalledWith(1, 'git.status', {
worktreePath: '/home/user/repo',
bypassEffectiveUpstreamNegativeCache: true
})
expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', {
worktreePath: '/home/user/repo'
})
})
it('checkIgnoredPaths sends git.checkIgnored request', async () => {
mux.request.mockResolvedValue(['dist/bundle.js'])

View File

@ -3,7 +3,7 @@
indirection every method is a 1:1 forwarder to a relay RPC plus a
small amount of param plumbing. */
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { IGitProvider } from './types'
import type { GitProviderStatusOptions, IGitProvider } from './types'
import type {
GitStatusResult,
GitDiffResult,
@ -82,12 +82,16 @@ export class SshGitProvider implements IGitProvider {
async getStatus(
worktreePath: string,
options?: { includeIgnored?: boolean }
options?: GitProviderStatusOptions
): Promise<GitStatusResult> {
const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {}
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
? { bypassEffectiveUpstreamNegativeCache: true }
: {}
return (await this.mux.request('git.status', {
worktreePath,
...includeIgnoredArgs
...includeIgnoredArgs,
...upstreamCacheBypassArgs
})) as GitStatusResult
}

View File

@ -165,8 +165,13 @@ export type IFilesystemProvider = {
// ─── Git Provider ───────────────────────────────────────────────────
export type GitProviderStatusOptions = {
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
}
export type IGitProvider = {
getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise<GitStatusResult>
getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise<GitStatusResult>
checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]>
getHistory(worktreePath: string, options?: GitHistoryOptions): Promise<GitHistoryResult>
commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }>

View File

@ -23,6 +23,7 @@ import {
type ResolvedSourceControlAiGenerationParams
} from '../../shared/source-control-ai'
import type { SourceControlAiOperation } from '../../shared/source-control-ai-types'
import type { GitProviderStatusOptions } from '../providers/types'
import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo'
import {
abortMerge,
@ -164,7 +165,7 @@ export class RuntimeGitCommands {
async getRuntimeGitStatus(
worktreeSelector: string,
options?: { includeIgnored?: boolean }
options?: GitProviderStatusOptions
): Promise<GitStatusResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null

View File

@ -8,7 +8,8 @@ export const WorktreeSelector = z.object({
})
export const GitStatusParams = WorktreeSelector.extend({
includeIgnored: z.boolean().optional()
includeIgnored: z.boolean().optional(),
bypassEffectiveUpstreamNegativeCache: z.boolean().optional()
})
export const GitCheckIgnored = WorktreeSelector.extend({

View File

@ -55,6 +55,32 @@ describe('git RPC methods', () => {
})
})
it('forwards upstream-negative-cache bypass for status requests', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRuntimeGitStatus: vi.fn().mockResolvedValue({
entries: [],
conflictOperation: 'unknown'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.status', {
worktree: 'id:wt-1',
bypassEffectiveUpstreamNegativeCache: true
})
)
expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', {
bypassEffectiveUpstreamNegativeCache: true
})
expect(response).toMatchObject({
ok: true,
result: { entries: [] }
})
})
it('returns ignored paths for selected explorer rows', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',

View File

@ -87,10 +87,23 @@ export const GIT_METHODS: RpcMethod[] = [
defineMethod({
name: 'git.status',
params: GitStatusParams,
handler: async (params, { runtime }) =>
params.includeIgnored === undefined
handler: async (params, { runtime }) => {
const options =
params.includeIgnored === undefined &&
params.bypassEffectiveUpstreamNegativeCache === undefined
? undefined
: {
...(params.includeIgnored === undefined
? {}
: { includeIgnored: params.includeIgnored }),
...(params.bypassEffectiveUpstreamNegativeCache === true
? { bypassEffectiveUpstreamNegativeCache: true }
: {})
}
return options === undefined
? runtime.getRuntimeGitStatus(params.worktree)
: runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored })
: runtime.getRuntimeGitStatus(params.worktree, options)
}
}),
defineMethod({
name: 'git.checkIgnored',

View File

@ -115,6 +115,7 @@ import type {
FloatingTerminalCwdRequest,
GitHubIssueUpdate,
GitHubPRRefreshCandidate,
GitHubPRRefreshEnqueueResult,
GitHubPRRefreshEvent,
GitHubPRRefreshReason,
GetRateLimitResult,
@ -1156,7 +1157,7 @@ export type PreloadApi = {
candidate: GitHubPRRefreshCandidate
reason: GitHubPRRefreshReason
priority?: number
}) => Promise<boolean>
}) => Promise<GitHubPRRefreshEnqueueResult | false>
reportVisiblePRRefreshCandidates: (args: {
candidates: GitHubPRRefreshCandidate[]
generation: number
@ -2107,6 +2108,7 @@ export type PreloadApi = {
worktreePath: string
connectionId?: string
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
}) => Promise<GitStatusResult>
checkIgnored: (args: {
worktreePath: string

View File

@ -2536,6 +2536,7 @@ const api = {
worktreePath: string
connectionId?: string
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
checkIgnored: (args: {
worktreePath: string

View File

@ -30,6 +30,7 @@ describe('getStatusOp', () => {
})
afterEach(async () => {
vi.useRealTimers()
clearNoEffectiveUpstreamStatusCache()
await fs.rm(tmpDir, { recursive: true, force: true })
})
@ -109,6 +110,34 @@ describe('getStatusOp', () => {
).toHaveLength(1)
})
it('keeps no-effective-upstream probes cached beyond thirty seconds', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const git = vi.fn<GitExec>(async (args) => {
if (args.includes('status')) {
return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' }
}
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error('fatal: no upstream configured for branch feature')
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
throw new Error('missing remote branch')
}
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
})
await getStatusOp(git, { worktreePath: tmpDir })
vi.setSystemTime(31_000)
await getStatusOp(git, { worktreePath: tmpDir })
expect(
git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}'))
).toHaveLength(1)
})
it('coalesces concurrent no-effective-upstream probes', async () => {
const git = vi.fn<GitExec>(async (args) => {
if (args.includes('status')) {

View File

@ -136,7 +136,10 @@ export async function getStatusOp(
try {
upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus(
{ worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName },
(args) => git(args, worktreePath)
(args) => git(args, worktreePath),
{
bypassCache: params.bypassEffectiveUpstreamNegativeCache === true
}
)
} catch {
// Why: status polling should keep returning working-tree entries even

View File

@ -0,0 +1,297 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearNoEffectiveUpstreamStatusCache,
clearNoEffectiveUpstreamStatusCacheEntry,
getNoEffectiveUpstreamStatusCacheCountForTests,
getNoEffectiveUpstreamStatusGenerationCountForTests,
readOrProbeNoEffectiveUpstreamStatus
} from './git-status-upstream-negative-cache'
describe('relay upstream negative cache', () => {
beforeEach(() => {
clearNoEffectiveUpstreamStatusCache()
})
afterEach(() => {
clearNoEffectiveUpstreamStatusCache()
})
it('bypasses cached no-effective-upstream status when requested', async () => {
let originBranchExists = false
const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature\n' }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error('fatal: no upstream configured for branch feature')
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
if (originBranchExists) {
return { stdout: 'abc123\n' }
}
throw new Error('missing remote branch')
}
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
return { stdout: '0\t1\n' }
}
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
})
const identity = { worktreePath: '/repo', branchName: 'feature' }
const first = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
originBranchExists = true
const automatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, {
bypassCache: true
})
expect(first).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
expect(automatic).toEqual(first)
expect(strict).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
})
})
it('keeps an older automatic negative probe from overwriting a strict positive result', async () => {
let originBranchExists = false
let deferredOriginReject: ((error: Error) => void) | null = null
const runGit = vi.fn(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature\n' }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error('fatal: no upstream configured for branch feature')
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
if (originBranchExists) {
return { stdout: 'abc123\n' }
}
return await new Promise<{ stdout: string }>((_, reject) => {
deferredOriginReject = reject
})
}
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
return { stdout: '0\t1\n' }
}
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
})
const identity = { worktreePath: '/repo', branchName: 'feature' }
const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
originBranchExists = true
const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, {
bypassCache: true
})
if (!deferredOriginReject) {
throw new Error('expected deferred origin reject')
}
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
const staleAutomatic = await automatic
const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
expect(strict).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
})
expect(staleAutomatic).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
expect(nextAutomatic).toEqual(strict)
})
it('does not trim generation for an unresolved automatic probe', async () => {
let originBranchExists = false
let deferredOriginReject: ((error: Error) => void) | null = null
const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature\n' }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error('fatal: no upstream configured for branch feature')
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
if (originBranchExists) {
return { stdout: 'abc123\n' }
}
return await new Promise<{ stdout: string }>((_, reject) => {
deferredOriginReject = reject
})
}
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
return { stdout: '0\t1\n' }
}
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
})
const identity = { worktreePath: '/repo', branchName: 'feature' }
const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
originBranchExists = true
const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, {
bypassCache: true
})
for (let index = 0; index < 512; index += 1) {
const branchName = `other-${index}`
await readOrProbeNoEffectiveUpstreamStatus(
{ worktreePath: '/repo', branchName },
async (args) => {
if (args[0] === 'symbolic-ref') {
return { stdout: `${branchName}\n` }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error(`fatal: no upstream configured for branch ${branchName}`)
}
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
return { stdout: 'abc123\n' }
}
if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) {
return { stdout: '0\t1\n' }
}
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
},
{ bypassCache: true }
)
}
if (!deferredOriginReject) {
throw new Error('expected deferred origin reject')
}
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
await automatic
const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
expect(strict).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
})
expect(nextAutomatic).toEqual(strict)
expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
})
it('does not trim generation for a cleared automatic probe before it settles', async () => {
let originBranchExists = false
let deferredOriginReject: ((error: Error) => void) | null = null
const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature\n' }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error('fatal: no upstream configured for branch feature')
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
if (originBranchExists) {
return { stdout: 'abc123\n' }
}
return await new Promise<{ stdout: string }>((_, reject) => {
deferredOriginReject = reject
})
}
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
return { stdout: '0\t1\n' }
}
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
})
const identity = { worktreePath: '/repo', branchName: 'feature' }
const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
originBranchExists = true
clearNoEffectiveUpstreamStatusCacheEntry(identity)
for (let index = 0; index < 512; index += 1) {
const branchName = `other-${index}`
await readOrProbeNoEffectiveUpstreamStatus(
{ worktreePath: '/repo', branchName },
async (args) => {
if (args[0] === 'symbolic-ref') {
return { stdout: `${branchName}\n` }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error(`fatal: no upstream configured for branch ${branchName}`)
}
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
return { stdout: 'abc123\n' }
}
if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) {
return { stdout: '0\t1\n' }
}
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
},
{ bypassCache: true }
)
}
if (!deferredOriginReject) {
throw new Error('expected deferred origin reject')
}
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
await automatic
const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
expect(nextAutomatic).toEqual({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
})
expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
})
it('bounds no-effective-upstream entries', async () => {
for (let index = 0; index < 513; index += 1) {
const branchName = `feature-${index}`
await readOrProbeNoEffectiveUpstreamStatus(
{ worktreePath: '/repo', branchName },
async (args) => {
if (args[0] === 'symbolic-ref') {
return { stdout: `${branchName}\n` }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error(`fatal: no upstream configured for branch ${branchName}`)
}
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
throw new Error('missing remote branch')
}
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
}
)
}
expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(512)
expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
})
it('bounds write-generation entries from positive strict probes', async () => {
for (let index = 0; index < 513; index += 1) {
const branchName = `feature-${index}`
await readOrProbeNoEffectiveUpstreamStatus(
{ worktreePath: '/repo', branchName },
async (args) => {
if (args[0] === 'symbolic-ref') {
return { stdout: `${branchName}\n` }
}
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
throw new Error(`fatal: no upstream configured for branch ${branchName}`)
}
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
return { stdout: 'abc123\n' }
}
if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) {
return { stdout: '0\t1\n' }
}
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
},
{ bypassCache: true }
)
}
expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(0)
expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(512)
})
})

View File

@ -2,7 +2,8 @@ import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream'
import type { GitCommandRunner } from '../shared/git-effective-upstream'
import type { GitUpstreamStatus } from '../shared/types'
const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 30_000
const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000
const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512
type NoEffectiveUpstreamCacheIdentity = {
worktreePath: string
@ -17,6 +18,8 @@ type NoEffectiveUpstreamCacheEntry = {
const noEffectiveUpstreamByIdentity = new Map<string, NoEffectiveUpstreamCacheEntry>()
const noEffectiveUpstreamInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const retiredNoEffectiveUpstreamInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const noEffectiveUpstreamWriteGeneration = new Map<string, number>()
function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity): string {
return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0')
@ -37,16 +40,40 @@ function readCachedNoEffectiveUpstreamStatus(
return entry.status
}
function hasPendingNoEffectiveUpstreamProbe(cacheKey: string): boolean {
return (
noEffectiveUpstreamInFlight.has(cacheKey) || retiredNoEffectiveUpstreamInFlight.has(cacheKey)
)
}
function trimNoEffectiveUpstreamWriteGeneration(): void {
for (const cacheKey of noEffectiveUpstreamWriteGeneration.keys()) {
if (noEffectiveUpstreamWriteGeneration.size <= MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) {
break
}
if (hasPendingNoEffectiveUpstreamProbe(cacheKey)) {
continue
}
noEffectiveUpstreamWriteGeneration.delete(cacheKey)
}
}
function cacheNoEffectiveUpstreamStatus(
cacheKey: string,
status: GitUpstreamStatus,
probedSameNameOriginRef: boolean,
writeGeneration: number,
nowMs = Date.now()
): void {
// Why: hasConfiguredPushTarget controls publish behavior; keep that signal
// fresh rather than serving a stale positive from status polling.
if (status.hasUpstream || status.hasConfiguredPushTarget) {
noEffectiveUpstreamByIdentity.delete(cacheKey)
noEffectiveUpstreamWriteGeneration.set(cacheKey, writeGeneration + 1)
trimNoEffectiveUpstreamWriteGeneration()
return
}
if ((noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) {
return
}
// Why: only cache negatives after probing origin/<branch>; other resolution
@ -58,39 +85,55 @@ function cacheNoEffectiveUpstreamStatus(
status,
expiresAt: nowMs + NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS
})
while (noEffectiveUpstreamByIdentity.size > MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) {
const oldest = noEffectiveUpstreamByIdentity.keys().next()
if (oldest.done) {
break
}
noEffectiveUpstreamByIdentity.delete(oldest.value)
noEffectiveUpstreamWriteGeneration.delete(oldest.value)
}
trimNoEffectiveUpstreamWriteGeneration()
}
export async function readOrProbeNoEffectiveUpstreamStatus(
identity: NoEffectiveUpstreamCacheIdentity,
runGit: GitCommandRunner
runGit: GitCommandRunner,
options: { bypassCache?: boolean } = {}
): Promise<GitUpstreamStatus> {
const cacheKey = noEffectiveUpstreamCacheKey(identity)
const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey)
if (cachedStatus) {
return cachedStatus
}
if (options.bypassCache !== true) {
const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey)
if (cachedStatus) {
return cachedStatus
}
const inFlight = noEffectiveUpstreamInFlight.get(cacheKey)
if (inFlight) {
return inFlight
const inFlight = noEffectiveUpstreamInFlight.get(cacheKey)
if (inFlight) {
return inFlight
}
}
let probedSameNameOriginRef = false
const writeGeneration = noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0
const probe = getEffectiveGitUpstreamStatus((args) => {
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${identity.branchName}`)) {
probedSameNameOriginRef = true
}
return runGit(args)
}).then((status) => {
cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef)
cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef, writeGeneration)
return status
})
noEffectiveUpstreamInFlight.set(cacheKey, probe)
if (options.bypassCache !== true) {
noEffectiveUpstreamInFlight.set(cacheKey, probe)
}
try {
return await probe
} finally {
if (noEffectiveUpstreamInFlight.get(cacheKey) === probe) {
noEffectiveUpstreamInFlight.delete(cacheKey)
trimNoEffectiveUpstreamWriteGeneration()
}
}
}
@ -98,4 +141,43 @@ export async function readOrProbeNoEffectiveUpstreamStatus(
export function clearNoEffectiveUpstreamStatusCache(): void {
noEffectiveUpstreamByIdentity.clear()
noEffectiveUpstreamInFlight.clear()
retiredNoEffectiveUpstreamInFlight.clear()
noEffectiveUpstreamWriteGeneration.clear()
}
export function clearNoEffectiveUpstreamStatusCacheEntry(
identity: NoEffectiveUpstreamCacheIdentity
): void {
const cacheKey = noEffectiveUpstreamCacheKey(identity)
retireNoEffectiveUpstreamProbe(cacheKey)
noEffectiveUpstreamByIdentity.delete(cacheKey)
noEffectiveUpstreamInFlight.delete(cacheKey)
noEffectiveUpstreamWriteGeneration.set(
cacheKey,
(noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) + 1
)
}
function retireNoEffectiveUpstreamProbe(cacheKey: string): void {
const retiredProbe = noEffectiveUpstreamInFlight.get(cacheKey)
if (!retiredProbe) {
return
}
retiredNoEffectiveUpstreamInFlight.set(cacheKey, retiredProbe)
void retiredProbe
.finally(() => {
if (retiredNoEffectiveUpstreamInFlight.get(cacheKey) === retiredProbe) {
retiredNoEffectiveUpstreamInFlight.delete(cacheKey)
trimNoEffectiveUpstreamWriteGeneration()
}
})
.catch(() => undefined)
}
export function getNoEffectiveUpstreamStatusCacheCountForTests(): number {
return noEffectiveUpstreamByIdentity.size
}
export function getNoEffectiveUpstreamStatusGenerationCountForTests(): number {
return noEffectiveUpstreamWriteGeneration.size
}

View File

@ -1,19 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { refreshGitStatusForWorktree, type GitStatusRefreshDeps } from './git-status-refresh'
import type { GitStatusResult } from '../../../../shared/types'
import {
clearGitStatusRefreshOrderingForTests,
refreshGitStatusForWorktree,
refreshGitStatusForWorktreeStrict,
type GitStatusRefreshDeps
} from './git-status-refresh'
import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/types'
function makeDeps(): GitStatusRefreshDeps {
return {
setGitStatus: vi.fn(),
updateWorktreeGitIdentity: vi.fn(),
setUpstreamStatus: vi.fn(),
fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined)
fetchUpstreamStatus: vi.fn().mockResolvedValue(null)
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
describe('refreshGitStatusForWorktree', () => {
beforeEach(() => {
vi.unstubAllGlobals()
clearGitStatusRefreshOrderingForTests()
})
it('stores status, branch identity, and upstream data from git status', async () => {
@ -77,7 +97,8 @@ describe('refreshGitStatusForWorktree', () => {
expect(deps.setUpstreamStatus).not.toHaveBeenCalled()
expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, {
runtimeTargetSettings: undefined
runtimeTargetSettings: undefined,
applyUpstreamStatus: false
})
})
@ -106,7 +127,8 @@ describe('refreshGitStatusForWorktree', () => {
})
expect(deps.setUpstreamStatus).not.toHaveBeenCalled()
expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, {
runtimeTargetSettings: undefined
runtimeTargetSettings: undefined,
applyUpstreamStatus: false
})
})
@ -133,6 +155,130 @@ describe('refreshGitStatusForWorktree', () => {
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-3', status)
})
it('bypasses automatic no-upstream backoff only for strict refreshes', async () => {
const status: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
}
const gitStatus = vi.fn().mockResolvedValue(status)
vi.stubGlobal('window', { api: { git: { status: gitStatus } } })
const deps = makeDeps()
await refreshGitStatusForWorktree({
worktreeId: 'wt-normal',
worktreePath: '/repo',
deps
})
await refreshGitStatusForWorktreeStrict({
worktreeId: 'wt-strict',
worktreePath: '/repo',
deps
})
expect(gitStatus).toHaveBeenNthCalledWith(1, {
worktreePath: '/repo',
connectionId: undefined
})
expect(gitStatus).toHaveBeenNthCalledWith(2, {
worktreePath: '/repo',
connectionId: undefined,
bypassEffectiveUpstreamNegativeCache: true
})
})
it('does not let an older automatic upstream result overwrite a strict result', async () => {
const automaticStatus = deferred<GitStatusResult>()
const strictStatus: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
}
}
const staleAutomaticStatus: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
}
const gitStatus = vi
.fn()
.mockReturnValueOnce(automaticStatus.promise)
.mockResolvedValueOnce(strictStatus)
vi.stubGlobal('window', { api: { git: { status: gitStatus } } })
const deps = makeDeps()
const automatic = refreshGitStatusForWorktree({
worktreeId: 'wt-race',
worktreePath: '/repo',
deps
})
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1))
await refreshGitStatusForWorktreeStrict({
worktreeId: 'wt-race',
worktreePath: '/repo',
deps
})
automaticStatus.resolve(staleAutomaticStatus)
await automatic
expect(deps.setGitStatus).toHaveBeenCalledTimes(1)
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-race', strictStatus)
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledTimes(1)
expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1)
expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-race', strictStatus.upstreamStatus)
})
it('does not let an older automatic explicit upstream fetch overwrite a strict result', async () => {
const automaticFetch = deferred<GitUpstreamStatus | null>()
const strictStatus: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
}
}
const staleAutomaticUpstream: GitUpstreamStatus = { hasUpstream: false, ahead: 0, behind: 0 }
const gitStatus = vi
.fn()
.mockResolvedValueOnce({
entries: [],
conflictOperation: 'unknown'
} satisfies GitStatusResult)
.mockResolvedValueOnce(strictStatus)
vi.stubGlobal('window', { api: { git: { status: gitStatus } } })
const deps = makeDeps()
vi.mocked(deps.fetchUpstreamStatus).mockReturnValueOnce(automaticFetch.promise)
const automatic = refreshGitStatusForWorktree({
worktreeId: 'wt-fetch-race',
worktreePath: '/repo',
deps
})
await vi.waitFor(() => expect(deps.fetchUpstreamStatus).toHaveBeenCalledTimes(1))
await refreshGitStatusForWorktreeStrict({
worktreeId: 'wt-fetch-race',
worktreePath: '/repo',
deps
})
automaticFetch.resolve(staleAutomaticUpstream)
await automatic
expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1)
expect(deps.setUpstreamStatus).toHaveBeenCalledWith(
'wt-fetch-race',
strictStatus.upstreamStatus
)
})
it('clears stale branch identity when git status reports detached HEAD', async () => {
const status: GitStatusResult = {
entries: [],

View File

@ -18,8 +18,94 @@ export type GitStatusRefreshDeps = {
worktreePath: string,
connectionId?: string,
pushTarget?: GitPushTarget,
options?: { runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null }
) => Promise<void>
options?: {
runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
applyUpstreamStatus?: boolean
}
) => Promise<GitUpstreamStatus | null>
}
const MAX_REFRESH_ORDERING_WORKTREES = 1024
const strictUpstreamRefreshGenerationByWorktree = new Map<string, number>()
const automaticUpstreamRefreshInFlightByWorktree = new Map<string, number>()
function trimRefreshOrderingState(): void {
for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) {
if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) {
break
}
if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) {
continue
}
strictUpstreamRefreshGenerationByWorktree.delete(worktreeId)
}
}
function beginAutomaticUpstreamRefresh(worktreeId: string): number {
automaticUpstreamRefreshInFlightByWorktree.set(
worktreeId,
(automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1
)
return strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0
}
function finishAutomaticUpstreamRefresh(worktreeId: string): void {
const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0
if (count <= 1) {
automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId)
} else {
automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1)
}
trimRefreshOrderingState()
}
function shouldApplyAutomaticUpstreamRefresh(worktreeId: string, startGeneration: number): boolean {
return (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === startGeneration
}
async function fetchAndApplyAutomaticUpstreamStatus({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget,
deps,
startGeneration
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
worktreePath: string
connectionId?: string
pushTarget?: GitPushTarget
deps: GitStatusRefreshDeps
startGeneration: number
}): Promise<void> {
const upstreamStatus = await deps.fetchUpstreamStatus(
worktreeId,
worktreePath,
connectionId,
pushTarget,
{
runtimeTargetSettings: settings,
applyUpstreamStatus: false
}
)
if (upstreamStatus && shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) {
deps.setUpstreamStatus(worktreeId, upstreamStatus)
}
}
function beginStrictUpstreamRefresh(worktreeId: string): void {
strictUpstreamRefreshGenerationByWorktree.set(
worktreeId,
(strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1
)
trimRefreshOrderingState()
}
export function clearGitStatusRefreshOrderingForTests(): void {
strictUpstreamRefreshGenerationByWorktree.clear()
automaticUpstreamRefreshInFlightByWorktree.clear()
}
export async function refreshGitStatusForWorktree({
@ -37,51 +123,77 @@ export async function refreshGitStatusForWorktree({
pushTarget?: GitPushTarget
deps: GitStatusRefreshDeps
}): Promise<void> {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
const upstreamStartGeneration = beginAutomaticUpstreamRefresh(worktreeId)
try {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`
// gives us the new identity without a separate worktree-list poll.
deps.updateWorktreeGitIdentity(worktreeId, {
head: status.head,
// Why: detached HEAD reports a head oid and no branch. Pass null as an
// explicit clear signal so stale branch names don't linger in the UI.
branch: status.branch ?? (status.head ? null : undefined)
})
if (pushTarget) {
// Why: porcelain status reports Git's configured upstream. Source Control
// actions for PR-created worktrees must instead reconcile with Orca's
// explicit publish target.
await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, {
runtimeTargetSettings: settings
if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, upstreamStartGeneration)) {
return
}
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`
// gives us the new identity without a separate worktree-list poll.
deps.updateWorktreeGitIdentity(worktreeId, {
head: status.head,
// Why: detached HEAD reports a head oid and no branch. Pass null as an
// explicit clear signal so stale branch names don't linger in the UI.
branch: status.branch ?? (status.head ? null : undefined)
})
return
}
if (status.upstreamStatus) {
if (
status.upstreamStatus.ahead > 0 &&
status.upstreamStatus.behind > 0 &&
status.upstreamStatus.behindCommitsArePatchEquivalent === undefined
) {
// Why: porcelain status has counts but cannot tell stale post-rebase
// upstream commits from real remote work. Writing it first makes the
// primary action flicker between Sync and Force Push on every poll.
await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, {
runtimeTargetSettings: settings
if (pushTarget) {
// Why: porcelain status reports Git's configured upstream. Source Control
// actions for PR-created worktrees must instead reconcile with Orca's
// explicit publish target.
await fetchAndApplyAutomaticUpstreamStatus({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget,
deps,
startGeneration: upstreamStartGeneration
})
return
}
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
return
if (status.upstreamStatus) {
if (
status.upstreamStatus.ahead > 0 &&
status.upstreamStatus.behind > 0 &&
status.upstreamStatus.behindCommitsArePatchEquivalent === undefined
) {
// Why: porcelain status has counts but cannot tell stale post-rebase
// upstream commits from real remote work. Writing it first makes the
// primary action flicker between Sync and Force Push on every poll.
await fetchAndApplyAutomaticUpstreamStatus({
settings,
worktreeId,
worktreePath,
connectionId,
deps,
startGeneration: upstreamStartGeneration
})
return
}
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
return
}
await fetchAndApplyAutomaticUpstreamStatus({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget,
deps,
startGeneration: upstreamStartGeneration
})
} finally {
finishAutomaticUpstreamRefresh(worktreeId)
}
await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, {
runtimeTargetSettings: settings
})
}
export async function refreshGitStatusForWorktreeStrict({
@ -101,12 +213,20 @@ export async function refreshGitStatusForWorktreeStrict({
fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus']
}
}): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
beginStrictUpstreamRefresh(worktreeId)
const status = (await getRuntimeGitStatus(
{
settings,
worktreeId,
worktreePath,
connectionId
},
{
// Why: strict refreshes are user-triggered reconciliation and must not reuse
// automatic polling's no-upstream backoff window.
bypassEffectiveUpstreamNegativeCache: true
}
)) as GitStatusResult
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`

View File

@ -169,7 +169,8 @@ describe('useGitStatusPolling', () => {
undefined,
undefined,
{
runtimeTargetSettings: { activeRuntimeEnvironmentId: null }
runtimeTargetSettings: { activeRuntimeEnvironmentId: null },
applyUpstreamStatus: false
}
)
})
@ -192,7 +193,8 @@ describe('useGitStatusPolling', () => {
undefined,
pushTarget,
{
runtimeTargetSettings: { activeRuntimeEnvironmentId: null }
runtimeTargetSettings: { activeRuntimeEnvironmentId: null },
applyUpstreamStatus: false
}
)
})

View File

@ -132,6 +132,37 @@ describe('runtime git client', () => {
})
})
it('forwards upstream-negative-cache bypass to local git status only when enabled', async () => {
gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
await getRuntimeGitStatus(
{
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-1',
worktreePath: '/repo'
},
{ bypassEffectiveUpstreamNegativeCache: true }
)
await getRuntimeGitStatus(
{
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-1',
worktreePath: '/repo'
},
{ bypassEffectiveUpstreamNegativeCache: false }
)
expect(gitStatus).toHaveBeenNthCalledWith(1, {
worktreePath: '/repo',
connectionId: undefined,
bypassEffectiveUpstreamNegativeCache: true
})
expect(gitStatus).toHaveBeenNthCalledWith(2, {
worktreePath: '/repo',
connectionId: undefined
})
})
it('checks ignored paths through local git IPC', async () => {
gitCheckIgnored.mockResolvedValue(['dist/bundle.js'])
@ -262,6 +293,31 @@ describe('runtime git client', () => {
})
})
it('forwards upstream-negative-cache bypass through the active runtime environment', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { entries: [], conflictOperation: 'unknown' },
_meta: { runtimeId: 'remote-runtime' }
})
await getRuntimeGitStatus(
{
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/repo'
},
{ bypassEffectiveUpstreamNegativeCache: true }
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'git.status',
params: { worktree: 'id:wt-1', bypassEffectiveUpstreamNegativeCache: true },
timeoutMs: 15_000
})
})
it('checks ignored paths through the active runtime environment', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',

View File

@ -121,21 +121,29 @@ export function getRuntimeGitScope(
export async function getRuntimeGitStatus(
context: RuntimeGitContext,
options?: { includeIgnored?: boolean }
options?: { includeIgnored?: boolean; bypassEffectiveUpstreamNegativeCache?: boolean }
): Promise<GitStatusResult> {
const target = getActiveRuntimeTarget(context.settings)
const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {}
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
? { bypassEffectiveUpstreamNegativeCache: true }
: {}
if (target.kind === 'local' || !context.worktreeId) {
return window.api.git.status({
worktreePath: context.worktreePath,
connectionId: context.connectionId,
...includeIgnoredArgs
...includeIgnoredArgs,
...upstreamCacheBypassArgs
})
}
return callRuntimeRpc<GitStatusResult>(
target,
'git.status',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs },
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
...includeIgnoredArgs,
...upstreamCacheBypassArgs
},
{ timeoutMs: 15_000 }
)
}

View File

@ -295,6 +295,7 @@ type EditorOpenTargetOptions = {
type GitRuntimeOperationOptions = {
runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
applyUpstreamStatus?: boolean
}
export type PendingEditorReveal = {
@ -596,7 +597,7 @@ export type EditorSlice = {
connectionId?: string,
pushTarget?: GitPushTarget,
options?: GitRuntimeOperationOptions
) => Promise<void>
) => Promise<GitUpstreamStatus | null>
pushBranch: (
worktreeId: string,
worktreePath: string,
@ -3540,7 +3541,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
},
pushTarget
)
get().setUpstreamStatus(worktreeId, status)
if (options?.applyUpstreamStatus !== false) {
get().setUpstreamStatus(worktreeId, status)
}
return status
} catch (error) {
// Why: on error we leave the prior status in place rather than writing a
// synthetic {hasUpstream:false} — that would flash 'Publish Branch' on a
@ -3549,6 +3553,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// genuinely newly unpublished, the polling effect will eventually correct
// the status on success.
console.error('fetchUpstreamStatus failed', error)
return null
}
},
pushBranch: async (

View File

@ -2967,6 +2967,75 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => {
})
})
it('does not direct-fetch when enqueue returns an automatic validation skip', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const worktreeId = 'wt-1'
mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({
kind: 'skipped',
skippedReason: 'validation-denied'
})
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
worktreesByRepo: {
'repo-1': [
{
id: worktreeId,
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false
}
]
}
} as unknown as Partial<AppState>)
store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80)
await Promise.resolve()
expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1)
expect(mockApi.gh.prForBranch).not.toHaveBeenCalled()
})
it('direct-fetches when enqueue returns an explicit fallback result', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const worktreeId = 'wt-1'
mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ kind: 'fallback' })
mockApi.gh.refreshPRNow.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: 1 })
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
worktreesByRepo: {
'repo-1': [
{
id: worktreeId,
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false
}
]
}
} as unknown as Partial<AppState>)
store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80)
await Promise.resolve()
await Promise.resolve()
expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1)
expect(mockApi.gh.refreshPRNow).toHaveBeenCalledTimes(1)
})
it('enqueues active PR refresh with a GitHub hosted-review fallback number', () => {
const store = createTestStore()
const repoPath = '/repo'

View File

@ -3441,7 +3441,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
if (enqueue) {
void enqueue({ candidate, reason, priority })
.then((queued) => {
if (queued === false) {
if (queued === false || queued.kind === 'fallback') {
return get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: bypassesGitHubPRRefreshFreshness(reason),
repoId: candidate.repoId,

View File

@ -1125,6 +1125,11 @@ export type PRRefreshOutcome =
export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr'
export type GitHubPRRefreshEnqueueResult =
| { kind: 'queued' }
| { kind: 'skipped'; skippedReason: 'validation-denied' | 'validation-backoff' }
| { kind: 'fallback' }
export type GitHubPRRefreshAlias = {
cacheKey: string
repoId?: string