diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 4c6c625e113..d8dd54e5764 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -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) diff --git a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts index abbc3d21aa7..6ec8514351c 100644 --- a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts +++ b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts @@ -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' }, diff --git a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts index 99f9872aac4..bb79bd2a2c2 100644 --- a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts +++ b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts @@ -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 | 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 | 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() } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index eabed3d30c5..92d0371f68b 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -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 = (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 = (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 }