Reduce source-control background load during refreshes (#6189)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
cf39bd7abe
commit
a2fd8c11c2
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue