diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index b7d82ea141d..766a734f26b 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -94,6 +94,28 @@ describe('pr-refresh-coordinator', () => { vi.useRealTimers() }) + it('forwards the candidate worktree head into the branch lookup options', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValueOnce({ + kind: 'no-pr', + fetchedAt: Date.now() + }) + + reportVisiblePRRefreshCandidates([makeCandidate({ currentHeadOid: 'worktree-head-oid' })], 1, 1) + await vi.runOnlyPendingTimersAsync() + + // Why: without the head, a panel-supplied fallback number preserves a + // merged PR head-blind after the branch moves on to new work. + expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith( + '/repo', + 'feature/test', + null, + null, + null, + expect.objectContaining({ currentHeadOid: 'worktree-head-oid' }) + ) + }) + it('does not show visible background refreshes as queued', async () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index fccc422674c..83438ce31e7 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -34,7 +34,7 @@ type PRRefreshOutcomeObserver = ( type PRBranchLookupCandidate = Pick< GitHubPRRefreshCandidate, - 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' + 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' > function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { @@ -55,6 +55,9 @@ function hostedReviewOptionArgs( if (shouldAcceptMergedFallbackPR(candidate)) { options.acceptMergedFallbackPR = true } + if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { + options.currentHeadOid = candidate.currentHeadOid + } return Object.keys(options).length > 0 ? [options] : [] } diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index e3ae35b7fdc..0d64c2512bf 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -278,20 +278,28 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi linkedPRNumber?: number | null fallbackPRNumber?: number | null acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null } ) => { const repo = assertRegisteredRepo(args, store) const localGitOptions = localGitOptionArgs(store, repo)[0] const hostedReviewOptionArgs: [] | [{ localGitExecOptions: { wslDistro?: string } }] = localGitOptions ? [{ localGitExecOptions: localGitOptions }] : [] + const currentHeadOid = + typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0 + ? args.currentHeadOid.trim() + : null const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptionArgs[0] ? { ...hostedReviewOptionArgs[0] } - : args.acceptMergedFallbackPR === true + : args.acceptMergedFallbackPR === true || currentHeadOid !== null ? {} : undefined if (lookupOptions && args.acceptMergedFallbackPR === true) { lookupOptions.acceptMergedFallbackPR = true } + if (lookupOptions && currentHeadOid !== null) { + lookupOptions.currentHeadOid = currentHeadOid + } const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions ? [lookupOptions] : [] diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ab2ffc0a8d1..5ae0c67d065 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10799,7 +10799,8 @@ export class OrcaRuntimeService { branch: string, linkedPRNumber?: number | null, fallbackPRNumber?: number | null, - acceptMergedFallbackPR?: boolean + acceptMergedFallbackPR?: boolean, + currentHeadOid?: string | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) const options: GitHubPRBranchLookupOptions = this.getHostedReviewExecutionOptions(repo) ?? {} @@ -10807,6 +10808,9 @@ export class OrcaRuntimeService { if (acceptMergedFallbackPR === true) { lookupOptions.acceptMergedFallbackPR = true } + if (typeof currentHeadOid === 'string' && currentHeadOid.trim().length > 0) { + lookupOptions.currentHeadOid = currentHeadOid.trim() + } const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = Object.keys(lookupOptions).length > 0 ? [lookupOptions] : [] return getPRForBranch( diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 6c8b8bd9995..c1419af1651 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -53,7 +53,8 @@ const PrForBranch = RepoSelector.extend({ branch: requiredString('Missing branch'), linkedPRNumber: z.number().int().positive().nullable().optional(), fallbackPRNumber: z.number().int().positive().nullable().optional(), - acceptMergedFallbackPR: z.boolean().optional() + acceptMergedFallbackPR: z.boolean().optional(), + currentHeadOid: z.string().nullable().optional() }) const Issue = RepoSelector.extend({ @@ -361,7 +362,8 @@ export const GITHUB_METHODS: RpcMethod[] = [ params.branch, params.linkedPRNumber, params.fallbackPRNumber, - params.acceptMergedFallbackPR + params.acceptMergedFallbackPR, + params.currentHeadOid ) }), defineMethod({ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e5cb1ffb907..0af29e96d02 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1257,6 +1257,7 @@ export type PreloadApi = { linkedPRNumber?: number | null fallbackPRNumber?: number | null acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null }) => Promise refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise enqueuePRRefresh: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7e619af49a3..01a42bd43e7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1026,6 +1026,7 @@ const api = { linkedPRNumber?: number | null fallbackPRNumber?: number | null acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null }): Promise => ipcRenderer.invoke('gh:prForBranch', args), refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 318eebaa5f1..e17be50508a 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -3573,7 +3573,12 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { // GitHub stops reporting the deleted head by branch name. expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledWith( expect.objectContaining({ - candidate: expect.objectContaining({ fallbackPRNumber: 13 }) + candidate: expect.objectContaining({ + fallbackPRNumber: 13, + // Why: main can only head-gate fallback preservation when the + // candidate carries the worktree head it was built for. + currentHeadOid: 'behind-head' + }) }) ) }) @@ -3960,7 +3965,7 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: 12 }, + params: { repo: 'repo-1', branch, linkedPRNumber: 12, currentHeadOid: null }, timeoutMs: 30_000 }) expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toMatchObject({ @@ -4010,7 +4015,7 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: null }, timeoutMs: 30_000 }) expect(store.getState().prCache[`runtime:env-1::repo-runtime::${branch}`]?.data).toMatchObject({ @@ -4247,7 +4252,7 @@ describe('createGitHubSlice.refreshAllGitHub', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: null }, + params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null }, timeoutMs: 30_000 }) }) @@ -4377,7 +4382,7 @@ describe('createGitHubSlice.refreshGitHubForWorktree', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: null }, + params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null }, timeoutMs: 30_000 }) }) diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 62bc688fe53..da5f0a5defa 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -1066,6 +1066,7 @@ function buildPRRefreshCandidate( branch, cacheKey, worktreeId: worktree.id, + currentHeadOid: worktree.head ?? null, // Why: persisted linked PR metadata is exact, while PR cache numbers are // only fallback hints after branch lookup misses. linkedPRNumber: worktree.linkedPR ?? null, @@ -2949,6 +2950,9 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { try { const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) + const candidateWorktree = options?.worktreeId + ? findWorktreeById(get(), options.worktreeId) + : null const outcome = runtimeRepo ? await callRuntimeRpc( runtimeRepo.target, @@ -2957,6 +2961,7 @@ export const createGitHubSlice: StateCreator = (s repo: runtimeRepo.repo.id, branch, linkedPRNumber, + currentHeadOid: candidateWorktree?.head ?? null, ...(fallbackPRNumber !== null ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } : {}) @@ -2975,6 +2980,7 @@ export const createGitHubSlice: StateCreator = (s branch, cacheKey, worktreeId: options?.worktreeId, + currentHeadOid: candidateWorktree?.head ?? null, linkedPRNumber, fallbackPRNumber, fallbackPRSource, @@ -2996,7 +3002,9 @@ export const createGitHubSlice: StateCreator = (s branch, linkedPRNumber, fallbackPRNumber, - acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null + acceptMergedFallbackPR: + fallbackPRNumber !== null && fallbackPRSource !== null, + currentHeadOid: candidateWorktree?.head ?? null }) .then((pr) => pr diff --git a/src/shared/types.ts b/src/shared/types.ts index ffd0f8e4c9c..57c2d61c188 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1196,6 +1196,10 @@ export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { cachedMergeable?: PRMergeableState | null cachedMergeStateStatus?: string | null localGitOptions?: { wslDistro?: string } + // Why: merged branch-matched PRs are only visible for heads that belong to + // the PR; without the worktree head, a panel-supplied fallback number would + // keep a merged PR alive head-blind after the branch moves on. + currentHeadOid?: string | null } export type GitHubPRRefreshSkippedReason =