diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index c6736738efe..eb794325774 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -821,6 +821,15 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake') expect(pr).toBeNull() + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + + // A definitive "not part of this PR" answer is immutable for a merged PR: + // repeated polls must not re-probe GitHub. + mockMergedBranchPRLookupBehindHead() + const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake') + + expect(second).toBeNull() + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(5) }) it('keeps hiding a merged branch PR when the commit membership probe fails', async () => { diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 4cbdb8f3257..4475000a863 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2950,6 +2950,10 @@ export async function getPRForBranchOutcome( explicitCurrentHeadOid !== null && shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) && !(await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) + // Why no lazy-HEAD re-check on preservation: fallback numbers come from + // callers that already gated them on head equality or confirmed + // containment; re-hiding against the main-repo HEAD would blank + // deleted-head merged PRs that are deliberately kept visible. const shouldPreserveMergedFallback = !explicitHeadHidesMergedImplicitPR && (fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true) diff --git a/src/main/github/merged-pr-commit-membership.ts b/src/main/github/merged-pr-commit-membership.ts index 6ab3f7e39e5..a09f43ef01e 100644 --- a/src/main/github/merged-pr-commit-membership.ts +++ b/src/main/github/merged-pr-commit-membership.ts @@ -4,12 +4,13 @@ import type { OwnerRepo } from './github-repository-identity' type GhExecOptions = Parameters[1] -// Why: a merged PR's commit set is immutable, so confirmations never expire in -// practice; the TTLs only bound memory and let "commit not on GitHub yet" -// answers retry after a future push. +// Why: a merged PR's commit set is immutable, so definitive answers — member +// or not — never change; that TTL only bounds memory. Errors (a commit not on +// GitHub yet, network) get a short TTL so a future push can flip the answer +// without the checks-panel poll re-probing every cycle. const MEMBERSHIP_CACHE_MAX_ENTRIES = 200 -const MEMBERSHIP_CONFIRMED_TTL_MS = 6 * 60 * 60 * 1000 -const MEMBERSHIP_NEGATIVE_TTL_MS = 5 * 60 * 1000 +const MEMBERSHIP_DEFINITIVE_TTL_MS = 6 * 60 * 60 * 1000 +const MEMBERSHIP_ERROR_TTL_MS = 5 * 60 * 1000 const membershipCache = new Map() @@ -80,7 +81,7 @@ export async function isCommitPartOfMergedPR(args: { ) membershipCache.set(cacheKey, { value, - expiresAt: now + (value ? MEMBERSHIP_CONFIRMED_TTL_MS : MEMBERSHIP_NEGATIVE_TTL_MS) + expiresAt: now + MEMBERSHIP_DEFINITIVE_TTL_MS }) return value } catch { @@ -88,7 +89,7 @@ export async function isCommitPartOfMergedPR(args: { // definitive "new local work" today, but a push can change it; retry later. membershipCache.set(cacheKey, { value: false, - expiresAt: now + MEMBERSHIP_NEGATIVE_TTL_MS + expiresAt: now + MEMBERSHIP_ERROR_TTL_MS }) return false } diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 718f4d9b29d..62bc688fe53 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -1038,6 +1038,9 @@ function buildPRRefreshCandidate( true ) const cachedFallbackPRNumber = cachedPR?.number ?? null + // Why: a merged PR stays a valid fallback when the worktree sits on its head + // or on a commit main confirmed as part of the PR; anything else means the + // branch moved on and the number must not resurrect the old merged PR. const cachedMergedPRMovedPastHead = worktree.linkedPR == null && cachedPR?.state === 'merged' &&