Fix push target upstream cache recovery

This commit is contained in:
Neil
2026-06-26 10:49:56 -07:00
parent f327c2327a
commit 41e16e87f7
4 changed files with 106 additions and 7 deletions
@@ -2,6 +2,7 @@ import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runt
import {
clearAutomaticPushTargetUpstreamStatusCache,
getCachedAutomaticPushTargetUpstreamStatus,
invalidateAutomaticPushTargetUpstreamStatusCache,
storeCachedAutomaticPushTargetUpstreamStatus
} from './push-target-upstream-refresh-cache'
import type {
@@ -95,7 +96,21 @@ async function fetchAndApplyAutomaticUpstreamStatus({
applyUpstreamStatus: false
}
)
if (!upstreamStatus || !shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) {
if (!upstreamStatus) {
if (pushTarget) {
// Why: failed publish-target refreshes must not let an older automatic
// cache entry suppress the next recovery poll for the same target.
invalidateAutomaticPushTargetUpstreamStatusCache({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget
})
}
return null
}
if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) {
return null
}
deps.setUpstreamStatus(worktreeId, upstreamStatus)
@@ -7,6 +7,7 @@ import {
} from './git-status-refresh'
import {
getCachedAutomaticPushTargetUpstreamStatus,
invalidateAutomaticPushTargetUpstreamStatusCache,
storeCachedAutomaticPushTargetUpstreamStatus
} from './push-target-upstream-refresh-cache'
import type { GitPushTarget, GitStatusResult, GitUpstreamStatus } from '../../../../shared/types'
@@ -107,6 +108,42 @@ describe('push-target upstream refresh cache', () => {
})
})
it('retries after a failed push-target refresh invalidates an unchanged automatic poll cache hit', async () => {
stubGitStatus([unchangedStatus])
const deps = makeDeps()
vi.mocked(deps.fetchUpstreamStatus)
.mockResolvedValueOnce({
hasUpstream: true,
upstreamName: 'fork/feature/pr-head',
ahead: 1,
behind: 0
})
.mockResolvedValueOnce({
hasUpstream: true,
upstreamName: 'fork/feature/pr-head',
ahead: 0,
behind: 0
})
await refreshAutomatically({ deps })
invalidateAutomaticPushTargetUpstreamStatusCache({
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-1',
worktreePath: '/repo',
pushTarget
})
await refreshAutomatically({ deps })
await refreshAutomatically({ deps })
expect(deps.fetchUpstreamStatus).toHaveBeenCalledTimes(2)
expect(deps.setUpstreamStatus).toHaveBeenLastCalledWith('wt-1', {
hasUpstream: true,
upstreamName: 'fork/feature/pr-head',
ahead: 0,
behind: 0
})
})
it('refreshes again when HEAD changes', async () => {
stubGitStatus([
{ ...unchangedStatus, head: 'abc123' },
@@ -9,6 +9,7 @@ const AUTOMATIC_PUSH_TARGET_UPSTREAM_REFRESH_TTL_MS = 60_000
const MAX_AUTOMATIC_PUSH_TARGET_UPSTREAM_CACHE_ENTRIES = 1024
type PushTargetUpstreamRefreshCacheEntry = {
scopeKey: string
status: GitUpstreamStatus
refreshedAt: number
}
@@ -37,6 +38,28 @@ function getStatusIdentityKey(status: GitStatusResult): readonly unknown[] {
return [status.head ?? null, status.branch ?? null]
}
function getCacheScopeKey({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
worktreePath: string
connectionId?: string
pushTarget: GitPushTarget
}): string {
return JSON.stringify([
worktreeId,
worktreePath,
connectionId ?? null,
getRuntimeEnvironmentKey(settings),
getPushTargetKey(pushTarget)
])
}
function getCacheKey({
settings,
worktreeId,
@@ -53,11 +76,7 @@ function getCacheKey({
status: GitStatusResult
}): string {
return JSON.stringify([
worktreeId,
worktreePath,
connectionId ?? null,
getRuntimeEnvironmentKey(settings),
getPushTargetKey(pushTarget),
getCacheScopeKey({ settings, worktreeId, worktreePath, connectionId, pushTarget }),
getStatusIdentityKey(status)
])
}
@@ -106,12 +125,28 @@ export function storeCachedAutomaticPushTargetUpstreamStatus(
upstreamStatus: GitUpstreamStatus
): void {
automaticPushTargetUpstreamRefreshCache.set(getCacheKey(input), {
scopeKey: getCacheScopeKey(input),
status: upstreamStatus,
refreshedAt: Date.now()
})
trimAutomaticPushTargetUpstreamRefreshCache()
}
export function invalidateAutomaticPushTargetUpstreamStatusCache(input: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
worktreePath: string
connectionId?: string
pushTarget: GitPushTarget
}): void {
const scopeKey = getCacheScopeKey(input)
for (const [key, entry] of automaticPushTargetUpstreamRefreshCache) {
if (entry.scopeKey === scopeKey) {
automaticPushTargetUpstreamRefreshCache.delete(key)
}
}
}
export function clearAutomaticPushTargetUpstreamStatusCache(): void {
automaticPushTargetUpstreamRefreshCache.clear()
}
+13 -1
View File
@@ -39,6 +39,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action'
import { invalidateAutomaticPushTargetUpstreamStatusCache } from '@/components/right-sidebar/push-target-upstream-refresh-cache'
import {
isNonFastForwardRemoteError,
resolveRemoteOperationErrorMessage
@@ -3545,8 +3546,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
}),
fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId, pushTarget, options) => {
const runtimeSettings = options?.runtimeTargetSettings ?? get().settings
try {
const runtimeSettings = options?.runtimeTargetSettings ?? get().settings
const status = await getRuntimeGitUpstreamStatus(
{
settings: runtimeSettings,
@@ -3567,6 +3568,17 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// re-publish, clobbering the upstream relationship. If the branch is
// genuinely newly unpublished, the polling effect will eventually correct
// the status on success.
if (pushTarget) {
// Why: an old automatic poll cache entry must not suppress the next
// retry after a post-push/fetch refresh fails transiently.
invalidateAutomaticPushTargetUpstreamStatusCache({
settings: runtimeSettings,
worktreeId,
worktreePath,
connectionId,
pushTarget
})
}
console.error('fetchUpstreamStatus failed', error)
return null
}