From 955c8d2d6fe3cb8b95ae8340c8089f276dba6087 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Fri, 26 Jun 2026 21:35:17 -0700 Subject: [PATCH] Fix detached PR status refresh Co-authored-by: Orca --- src/main/github/client.test.ts | 71 +++++--- src/main/github/client.ts | 26 ++- .../github/pr-refresh-coordinator.test.ts | 26 +++ src/main/github/pr-refresh-coordinator.ts | 6 + src/main/ipc/github.ts | 17 +- src/main/runtime/orca-runtime.test.ts | 10 ++ src/main/runtime/orca-runtime.ts | 6 +- src/main/runtime/rpc/methods/github.ts | 15 +- src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + .../components/right-sidebar/ChecksPanel.tsx | 68 ++++--- .../active-checks-status.test.ts | 23 +++ .../right-sidebar/active-checks-status.ts | 9 +- ...orktreeCard.branch-review-pending.test.tsx | 12 ++ .../src/components/sidebar/WorktreeCard.tsx | 16 +- .../src/store/slices/github-cache-key.ts | 10 ++ .../github-pr-refresh-owner-routing.test.ts | 89 ++++++++- src/renderer/src/store/slices/github.test.ts | 169 +++++++++++++++++- src/renderer/src/store/slices/github.ts | 55 ++++-- src/renderer/src/web/web-preload-api.ts | 1 + 20 files changed, 526 insertions(+), 105 deletions(-) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index e27029966f4..7736fbfff29 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -840,9 +840,7 @@ describe('getPRForBranch', () => { it('returns a merged PR by exact merge commit when branch lookup misses', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'aaaaaaaa\n', stderr: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ @@ -890,7 +888,9 @@ describe('getPRForBranch', () => { }) }) - const pr = await getPRForBranch('/repo-root', 'feature/test') + const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, null, { + currentHeadOid: 'aaaaaaaa' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, @@ -920,9 +920,7 @@ describe('getPRForBranch', () => { it('rejects open and merge-commit mismatched associated PRs', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'aaaaaaaa\n', stderr: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ @@ -953,10 +951,45 @@ describe('getPRForBranch', () => { ]) }) - await expect(getPRForBranch('/repo-root', 'feature/test')).resolves.toBeNull() + await expect( + getPRForBranch('/repo-root', 'feature/test', null, null, null, { + currentHeadOid: 'aaaaaaaa' + }) + ).resolves.toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + it('treats a missing GitHub commit association as no PR for local-only commits', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) + .mockRejectedValueOnce(new Error('gh: No commit found for SHA: aaaaaaaa (HTTP 422)')) + + await expect( + getPRForBranchOutcome('/repo-root', 'feature/test', null, null, null, { + currentHeadOid: 'aaaaaaaa' + }) + ).resolves.toMatchObject({ kind: 'no-pr' }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not discover merge-commit PRs from repo-root HEAD without an explicit head hint', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([]) }) + + await expect(getPRForBranchOutcome('/repo-root', 'feature/test')).resolves.toMatchObject({ + kind: 'no-pr' + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'], + { cwd: '/repo-root' } + ) + }) + it('reuses an unavailable HEAD lookup after hiding a stale merged branch PR', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) @@ -1002,7 +1035,6 @@ describe('getPRForBranch', () => { it('runs the merge-commit fallback for detached HEAD', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'dddddddd\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([ @@ -1037,7 +1069,9 @@ describe('getPRForBranch', () => { }) }) - const pr = await getPRForBranch('/repo-root', '') + const pr = await getPRForBranch('/repo-root', '', null, null, null, { + currentHeadOid: 'dddddddd' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, @@ -1160,7 +1194,6 @@ describe('getPRForBranch', () => { it('does not use invalid current HEAD hints for merge-commit lookup', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect( getPRForBranch('/repo-root', '', null, null, null, { @@ -1168,16 +1201,17 @@ describe('getPRForBranch', () => { }) ).resolves.toBeNull() - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', 'HEAD'], { cwd: '/repo-root' }) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) it('keeps unproven merge-commit 404s distinct from no-pr misses', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'dddddddd\n', stderr: '' }) ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found')) - const outcome = await getPRForBranchOutcome('/repo-root', '') + const outcome = await getPRForBranchOutcome('/repo-root', '', null, null, null, { + currentHeadOid: 'dddddddd' + }) expect(outcome).toMatchObject({ kind: 'upstream-error', @@ -1568,13 +1602,11 @@ describe('getPRForBranch', () => { getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'existing\0\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: '1111111\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'existing\0\nnew-feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ @@ -1750,10 +1782,8 @@ describe('getPRForBranch', () => { .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: '2222222\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ @@ -1814,13 +1844,11 @@ describe('getPRForBranch', () => { .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\0origin/old-upstream\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: '2222222\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { @@ -1863,10 +1891,8 @@ describe('getPRForBranch', () => { .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: '2222222\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ @@ -2330,7 +2356,6 @@ describe('getPRForBranch', () => { .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 83d9f41bbab..9af1dec5d2c 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2460,6 +2460,13 @@ async function probeCommitAssociatedPRForRepo(args: { cacheCommitAssociatedPRResult(args.cacheKey, result) return result } catch (err) { + if (isMissingCommitAssociatedPRError(err)) { + // Why: local/unpushed commits commonly do not exist in GitHub yet; that is + // an empty association, not a PR refresh failure. + const result: Extract = { kind: 'empty' } + cacheCommitAssociatedPRResult(args.cacheKey, result) + return result + } if (!isNotFoundGhError(err)) { throw err } @@ -2932,6 +2939,11 @@ function isNotFoundGhError(err: unknown): boolean { return classifyGhError(stderr).type === 'not_found' } +function isMissingCommitAssociatedPRError(err: unknown): boolean { + const stderr = err instanceof Error ? err.message : String(err) + return /no commit found for sha/i.test(stderr) && /http 422/i.test(stderr) +} + function shouldStopAfterExactLookupError(err: unknown): boolean { const stderr = err instanceof Error ? err.message : String(err) const type = classifyGhError(stderr).type @@ -2997,12 +3009,15 @@ export async function getPRForBranchOutcome( let currentHeadOidResolved = false let acceptedByExactMergeCommit = false const accessibleRepos = new Set() + const explicitCurrentHeadOid = isUsableHeadOid(options.currentHeadOid ?? null) + ? (options.currentHeadOid ?? null) + : null const getCurrentHeadOidForLookup = async (): Promise => { if (!currentHeadOidResolved) { - currentHeadOidForMergedImplicit = isUsableHeadOid(options.currentHeadOid ?? null) - ? (options.currentHeadOid ?? null) - : await getCurrentHeadOid(repoPath, connectionId, localGitOptions) + currentHeadOidForMergedImplicit = + explicitCurrentHeadOid ?? + (await getCurrentHeadOid(repoPath, connectionId, localGitOptions)) currentHeadOidResolved = true } return currentHeadOidForMergedImplicit ?? null @@ -3106,11 +3121,10 @@ export async function getPRForBranchOutcome( dataHeadRepo = headRepo } if (!data && typeof linkedPRNumber !== 'number') { - currentHeadOidForMergedImplicit = await getCurrentHeadOidForLookup() - if (isUsableHeadOid(currentHeadOidForMergedImplicit) && candidates.length > 0) { + if (explicitCurrentHeadOid && candidates.length > 0) { const mergeCommitLookup = await lookupPRByMergeCommit({ candidates, - headOid: currentHeadOidForMergedImplicit, + headOid: explicitCurrentHeadOid, ghOptions, repoPath, connectionId, diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index ce55cd317d1..b56354f68b9 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -790,6 +790,32 @@ describe('pr-refresh-coordinator', () => { }) }) + it('does not coalesce detached refreshes for different worktree HEADs', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock + .mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: Date.now() }) + .mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: Date.now() }) + + reportVisiblePRRefreshCandidates( + [ + makeCandidate({ branch: '', worktreeHead: '1111111', cacheKey: '/repo::head::1111111' }), + makeCandidate({ branch: '', worktreeHead: '2222222', cacheKey: '/repo::head::2222222' }) + ], + 1, + 1 + ) + await vi.runOnlyPendingTimersAsync() + await vi.advanceTimersByTimeAsync(10_000) + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith(1, '/repo', '', null, null, null, { + currentHeadOid: '1111111' + }) + expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith(2, '/repo', '', null, null, null, { + currentHeadOid: '2222222' + }) + }) + it('accounts for the extra core probe on background PR refreshes', 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 b9f60046db7..d3e32ec1552 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -178,6 +178,10 @@ function refreshKey(candidate: GitHubPRRefreshCandidate): string { if (typeof candidate.linkedPRNumber === 'number') { return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` } + const detachedHead = candidate.branch ? null : candidate.worktreeHead?.trim() + if (detachedHead) { + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::head::${detachedHead}` + } return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` } @@ -236,6 +240,7 @@ function validateCandidate( if (candidate.connectionId && candidate.connectionState === 'disconnected') { return 'disconnected' } + // Why: detached worktrees can still resolve a PR from the checked-out merge commit. if ( !candidate.branch && typeof candidate.linkedPRNumber !== 'number' && @@ -281,6 +286,7 @@ function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefres repoPath: candidate.repoPath, branch: candidate.branch, worktreeId: candidate.worktreeId, + worktreeHead: candidate.worktreeHead ?? null, connectionId: candidate.connectionId ?? null, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 2a808f60325..b2ce1dc02ad 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -278,23 +278,24 @@ 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 lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptionArgs[0] + const lookupOptions: GitHubPRBranchLookupOptions = hostedReviewOptionArgs[0] ? { ...hostedReviewOptionArgs[0] } - : args.acceptMergedFallbackPR === true - ? {} - : undefined - if (lookupOptions && args.acceptMergedFallbackPR === true) { + : {} + if (args.acceptMergedFallbackPR === true) { lookupOptions.acceptMergedFallbackPR = true } - const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions - ? [lookupOptions] - : [] + if (typeof args.currentHeadOid === 'string') { + lookupOptions.currentHeadOid = args.currentHeadOid + } + const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = + Object.keys(lookupOptions).length > 0 ? [lookupOptions] : [] const pr = await getPRForBranch( repo.path, args.branch, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3aa0b1f5688..31b2f1fc202 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -4244,6 +4244,16 @@ describe('OrcaRuntimeService', () => { ) }) + it('passes runtime PR branch current HEAD hints through to GitHub lookup', async () => { + const runtime = new OrcaRuntimeService(store as never) + + await runtime.getRepoPRForBranch('id:repo-1', '', null, null, false, 'abc1234') + + expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, '', null, null, null, { + currentHeadOid: 'abc1234' + }) + }) + it('rejects hosted review worktree selectors outside the selected repo', async () => { vi.mocked(listWorktrees).mockImplementation(async (repoPath: string) => { if (repoPath === '/tmp/repo-b') { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 4c421345834..90f9037a1f5 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10239,7 +10239,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) ?? {} @@ -10247,6 +10248,9 @@ export class OrcaRuntimeService { if (acceptMergedFallbackPR === true) { lookupOptions.acceptMergedFallbackPR = true } + if (typeof currentHeadOid === 'string') { + lookupOptions.currentHeadOid = currentHeadOid + } 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..4f3f3aad955 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -1,7 +1,12 @@ /* eslint-disable max-lines -- Why: GitHub runtime RPC keeps related repo, project, and mutation schemas beside their handlers so the method contract stays reviewable in one place. */ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { + OptionalFiniteNumber, + OptionalString, + requiredString, + requiredStringAllowingEmpty +} from '../schemas' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') @@ -50,10 +55,11 @@ const SlugAssignableUsers = SlugRepo.extend({ }) const PrForBranch = RepoSelector.extend({ - branch: requiredString('Missing branch'), + branch: requiredStringAllowingEmpty('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 +367,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 7ae75a59aa3..493a455d811 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1151,6 +1151,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 a3adca161ba..1603c55d7a8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -950,6 +950,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/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 7ea4eef93fc..613aeaf462e 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -20,7 +20,11 @@ import { prChecksCacheSuffix, prCommentsCacheSuffix } from '@/store/slices/github' -import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' +import { + getGitHubPRCacheBranch, + getGitHubPRCacheKey, + getGitHubRepoCacheKey +} from '@/store/slices/github-cache-key' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { openHttpLink } from '@/lib/http-link-routing' @@ -599,12 +603,14 @@ export default function ChecksPanel(): React.JSX.Element { // Find active worktree and repo const isFolder = repo ? isFolderRepo(repo) : false + const prCacheBranch = getGitHubPRCacheBranch(branch, activeWorktree?.head) + const hasReviewLookupIdentity = Boolean(branch || activeWorktree?.head) const prCacheKey = - repo && branch + repo && hasReviewLookupIdentity ? getGitHubPRCacheKey( repo.path, repo.id, - branch, + prCacheBranch, settings, repo.connectionId, repo.executionHostId, @@ -612,10 +618,10 @@ export default function ChecksPanel(): React.JSX.Element { ) : '' const hostedReviewCacheKey = - repo && branch + repo && hasReviewLookupIdentity ? getHostedReviewCacheKey( repo.path, - branch, + prCacheBranch, settings, repo.id, repo.connectionId, @@ -1186,17 +1192,19 @@ export default function ChecksPanel(): React.JSX.Element { }, [agentComposerState?.commentResolution, stateRequestKey]) useEffect(() => { - if (isPanelVisible && repo && !isFolder && branch) { - void fetchHostedReviewForBranch(repo.path, branch, { - repoId: repo.id, - linkedGitHubPR: linkedPR, - fallbackGitHubPR: fallbackGitHubPRNumber, - linkedGitLabMR, - linkedBitbucketPR, - linkedAzureDevOpsPR, - linkedGiteaPR, - staleWhileRevalidate: true - }) + if (isPanelVisible && repo && !isFolder && hasReviewLookupIdentity) { + if (branch) { + void fetchHostedReviewForBranch(repo.path, branch, { + repoId: repo.id, + linkedGitHubPR: linkedPR, + fallbackGitHubPR: fallbackGitHubPRNumber, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + staleWhileRevalidate: true + }) + } if (activeWorktreeId && !isGitLabReviewContext) { enqueueGitHubPRRefresh(activeWorktreeId, 'swr', 30) } @@ -1207,6 +1215,7 @@ export default function ChecksPanel(): React.JSX.Element { enqueueGitHubPRRefresh, fallbackGitHubPRNumber, fetchHostedReviewForBranch, + hasReviewLookupIdentity, isFolder, isGitLabReviewContext, isPanelVisible, @@ -1834,7 +1843,7 @@ export default function ChecksPanel(): React.JSX.Element { }, [activeGitLabReview, fetchComments, isPanelVisible, prNumber, repo]) const handleRefresh = useCallback(async () => { - if (!repo || !branch) { + if (!repo || !hasReviewLookupIdentity || (isGitLabReviewContext && !branch)) { return } const initialRequestKey = checksPanelAsyncResultKey( @@ -1920,17 +1929,19 @@ export default function ChecksPanel(): React.JSX.Element { if (!isCurrentRequest()) { return } - await refreshHostedReviewCard(fetchHostedReviewForBranch, { - repoPath: repo.path, - repoId: repo.id, - branch, - linkedGitHubPR: linkedPR, - fallbackGitHubPR: refreshedPR?.number ?? fallbackGitHubPRNumber, - linkedGitLabMR, - linkedBitbucketPR, - linkedAzureDevOpsPR, - linkedGiteaPR - }) + if (branch) { + await refreshHostedReviewCard(fetchHostedReviewForBranch, { + repoPath: repo.path, + repoId: repo.id, + branch, + linkedGitHubPR: linkedPR, + fallbackGitHubPR: refreshedPR?.number ?? fallbackGitHubPRNumber, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR + }) + } if (!isCurrentRequest()) { return } @@ -2057,6 +2068,7 @@ export default function ChecksPanel(): React.JSX.Element { linkedPR, fallbackGitHubPRNumber, fetchGitLabDetails, + hasReviewLookupIdentity, linkedAzureDevOpsPR, linkedBitbucketPR, linkedGiteaPR, diff --git a/src/renderer/src/components/right-sidebar/active-checks-status.test.ts b/src/renderer/src/components/right-sidebar/active-checks-status.test.ts index 7ee80e0e8c5..100d68b8b15 100644 --- a/src/renderer/src/components/right-sidebar/active-checks-status.test.ts +++ b/src/renderer/src/components/right-sidebar/active-checks-status.test.ts @@ -38,6 +38,29 @@ describe('getActiveChecksStatus', () => { expect(getActiveChecksStatus(state)).toBe('success') }) + it('uses detached-head PR cache entries for active checks status', () => { + const state = { + activeWorktreeId: 'wt-1', + repos: [{ id: 'repo-1', path: '/repo' }], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + branch: '', + head: 'abc1234' + } + ] + }, + prCache: { + 'repo-1::__detached_head__:abc1234': { data: makePR('failure'), fetchedAt: 2 }, + 'repo-1::': { data: makePR('success'), fetchedAt: 999 } + } + } as unknown as Pick + + expect(getActiveChecksStatus(state)).toBe('failure') + }) + it('uses GitLab MR pipeline status when the active branch has no GitHub PR cache entry', () => { const state = { activeWorktreeId: 'wt-1', diff --git a/src/renderer/src/components/right-sidebar/active-checks-status.ts b/src/renderer/src/components/right-sidebar/active-checks-status.ts index a7af63812ca..72dcdae8c34 100644 --- a/src/renderer/src/components/right-sidebar/active-checks-status.ts +++ b/src/renderer/src/components/right-sidebar/active-checks-status.ts @@ -1,7 +1,7 @@ import type { AppState } from '../../store/types' import { getRepoMapFromState, getWorktreeMapFromState } from '../../store/selectors' import type { CheckStatus } from '../../../../shared/types' -import { getGitHubPRCacheKey } from '../../store/slices/github-cache-key' +import { getGitHubPRCacheBranch, getGitHubPRCacheKey } from '../../store/slices/github-cache-key' import { getHostedReviewCacheKey } from '../../store/slices/hosted-review-cache-identity' type ActiveChecksStatusState = Pick< @@ -28,16 +28,17 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat } const branch = branchDisplayName(activeWorktree.branch) - if (!branch) { + if (!branch && !activeWorktree.head) { return null } + const prCacheBranch = getGitHubPRCacheBranch(branch, activeWorktree.head) // Why: PR refreshes are written under repo-id scoped keys so repo path // changes and legacy duplicates cannot leave the activity indicator stale. const prCacheKey = getGitHubPRCacheKey( activeRepo.path, activeRepo.id, - branch, + prCacheBranch, state.settings, activeRepo.connectionId, activeRepo.executionHostId, @@ -45,7 +46,7 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat ) const hostedReviewCacheKey = getHostedReviewCacheKey( activeRepo.path, - branch, + prCacheBranch, state.settings, activeRepo.id, activeRepo.connectionId, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.branch-review-pending.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.branch-review-pending.test.tsx index 15a973af37e..ef3d618c6d3 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.branch-review-pending.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.branch-review-pending.test.tsx @@ -155,4 +155,16 @@ describe('WorktreeCard branch review lookup pending state', () => { expect(markup).toContain('Branch') expect(markup).toContain('lucide-git-branch') }) + + it('keeps branch status on pathname-detected paired web clients', async () => { + vi.stubGlobal('window', { location: { pathname: '/web-index.html' } }) + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderWorktreeCardMarkup( + + ) + + expect(markup).toContain('Branch') + expect(markup).toContain('lucide-git-branch') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 51d2b8b417c..f841ea0330d 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useCallback, useState } from 'react' import { useAppStore } from '@/store' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' -import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubPRCacheBranch, getGitHubPRCacheKey } from '@/store/slices/github-cache-key' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -153,7 +153,10 @@ function isWebClient(): boolean { if (typeof window === 'undefined') { return false } - return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) + return ( + Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) || + window.location.pathname.endsWith('/web-index.html') + ) } function isCachedMergedBranchPRCurrentForWorktree( @@ -407,11 +410,12 @@ const WorktreeCard = React.memo(function WorktreeCard({ const folderMetaRowContent = newCardStyle ? hasPathIdentityEnabled && Boolean(folderPathIdentityDisplay) : isFolder + const prCacheBranch = getGitHubPRCacheBranch(branch, worktree.head) const hostedReviewCacheKey = - repo && branch + repo && prCacheBranch ? getHostedReviewCacheKey( repo.path, - branch, + prCacheBranch, settings, repo.id, repo.connectionId, @@ -420,11 +424,11 @@ const WorktreeCard = React.memo(function WorktreeCard({ ) : '' const prCacheKey = - repo && branch + repo && prCacheBranch ? getGitHubPRCacheKey( repo.path, repo.id, - branch, + prCacheBranch, settings, repo.connectionId, repo.executionHostId, diff --git a/src/renderer/src/store/slices/github-cache-key.ts b/src/renderer/src/store/slices/github-cache-key.ts index e9117f1f69f..8051498cdfb 100644 --- a/src/renderer/src/store/slices/github-cache-key.ts +++ b/src/renderer/src/store/slices/github-cache-key.ts @@ -80,6 +80,16 @@ export function getGitHubPRCacheKey( ) } +export function getGitHubPRCacheBranch(branch: string, worktreeHead?: string | null): string { + const trimmedHead = worktreeHead?.trim() + if (branch || !trimmedHead) { + return branch + } + // Why: detached worktrees have no branch, so PR lookup results are keyed by + // the commit whose GitHub merge association was probed. + return `__detached_head__:${trimmedHead}` +} + export function getLegacyGitHubPRCacheKey( repoPath: string, repoId: string | undefined, diff --git a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts index d53beb70488..c4f03e1647b 100644 --- a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts +++ b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts @@ -155,7 +155,7 @@ describe('GitHub PR refresh owner-host routing', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' }, timeoutMs: 30_000 }) }) @@ -228,7 +228,45 @@ describe('GitHub PR refresh owner-host routing', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' }, + timeoutMs: 30_000 + }) + }) + + it('routes detached post-push refreshes with the current HEAD hint', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 27 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + seed(store, { + repos: [ + makeRepo({ + id: 'repo-runtime', + path: '/runtime/repo', + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', '', 'wt-runtime-detached')] + } + }) + + store.getState().refreshGitHubForWorktree('wt-runtime-detached') + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { + repo: 'repo-runtime', + branch: '', + linkedPRNumber: null, + currentHeadOid: 'head-oid' + }, timeoutMs: 30_000 }) }) @@ -272,7 +310,52 @@ describe('GitHub PR refresh owner-host routing', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch: 'feature/runtime', linkedPRNumber: null }, + params: { + repo: 'repo-runtime', + branch: 'feature/runtime', + linkedPRNumber: null, + currentHeadOid: 'head-oid' + }, + timeoutMs: 30_000 + }) + }) + + it('routes detached runtime PR refreshes with the current HEAD hint', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 26 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + seed(store, { + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + repos: [ + makeRepo({ + id: 'repo-runtime', + path: '/runtime/repo', + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', '', 'wt-runtime-detached')] + }, + groupBy: 'pr-status' + }) + + store.getState().refreshGitHubForWorktreeIfStale('wt-runtime-detached') + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { + repo: 'repo-runtime', + branch: '', + linkedPRNumber: null, + currentHeadOid: 'head-oid' + }, timeoutMs: 30_000 }) }) diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index ff7ae5cfd16..e99b6ba5ee8 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -1755,6 +1755,149 @@ describe('createGitHubSlice.fetchPRForBranch', () => { }) }) + it('keys detached-head PR refresh cache entries by worktree head', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const firstPR = makePR({ number: 101, title: 'Detached first' }) + + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + [repoId]: [ + makePRRefreshWorktree({ + id: 'wt-detached-one', + repoId, + branch: '', + head: 'detached-head-one' + }), + makePRRefreshWorktree({ + id: 'wt-detached-two', + repoId, + branch: '', + head: 'detached-head-two' + }) + ] + } + } as unknown as Partial) + mockApi.gh.refreshPRNow + .mockResolvedValueOnce({ kind: 'found', pr: firstPR, fetchedAt: 10 }) + .mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: 20 }) + + await expect( + store.getState().fetchPRForBranch(repoPath, '', { + force: true, + repoId, + worktreeId: 'wt-detached-one' + }) + ).resolves.toMatchObject({ number: 101 }) + await expect( + store.getState().fetchPRForBranch(repoPath, '', { + force: true, + repoId, + worktreeId: 'wt-detached-two' + }) + ).resolves.toBeNull() + + expect(mockApi.gh.refreshPRNow).toHaveBeenNthCalledWith(1, { + candidate: expect.objectContaining({ + branch: '', + cacheKey: `${repoId}::__detached_head__:detached-head-one`, + worktreeHead: 'detached-head-one' + }) + }) + expect(mockApi.gh.refreshPRNow).toHaveBeenNthCalledWith(2, { + candidate: expect.objectContaining({ + branch: '', + cacheKey: `${repoId}::__detached_head__:detached-head-two`, + worktreeHead: 'detached-head-two' + }) + }) + expect( + store.getState().prCache[`${repoId}::__detached_head__:detached-head-one`] + ).toMatchObject({ + data: expect.objectContaining({ number: 101 }), + fetchedAt: 10 + }) + expect(store.getState().prCache[`${repoId}::__detached_head__:detached-head-two`]).toEqual({ + data: null, + fetchedAt: 20 + }) + }) + + it('does not reuse empty-branch hosted-review fallback across detached HEADs', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const staleEmptyBranchHostedKey = getHostedReviewCacheKey(repoPath, '', null, repoId) + const detachedHostedKey = getHostedReviewCacheKey( + repoPath, + '__detached_head__:detached-head-two', + null, + repoId + ) + const freshPR = makePR({ number: 202, title: 'Detached second' }) + + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + hostedReviewCache: { + [staleEmptyBranchHostedKey]: { + data: { + provider: 'github', + number: 101, + title: 'Detached first', + state: 'merged', + url: 'https://github.com/acme/repo/pull/101', + status: 'success', + updatedAt: '2026-05-20T00:00:00Z', + mergeable: 'UNKNOWN' + }, + fetchedAt: Date.now(), + linkedReviewHintKey: 'github:101' + } + }, + worktreesByRepo: { + [repoId]: [ + makePRRefreshWorktree({ + id: 'wt-detached-two', + repoId, + branch: '', + head: 'detached-head-two' + }) + ] + } + } as unknown as Partial) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: freshPR, + fetchedAt: 30 + }) + + await expect( + store.getState().fetchPRForBranch(repoPath, '', { + force: true, + repoId, + worktreeId: 'wt-detached-two' + }) + ).resolves.toMatchObject({ number: 202 }) + + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledWith({ + candidate: expect.objectContaining({ + branch: '', + cacheKey: `${repoId}::__detached_head__:detached-head-two`, + fallbackPRNumber: null, + fallbackPRSource: null, + worktreeHead: 'detached-head-two' + }) + }) + expect(store.getState().hostedReviewCache[staleEmptyBranchHostedKey]?.data).toMatchObject({ + number: 101 + }) + expect(store.getState().hostedReviewCache[detachedHostedKey]?.data).toMatchObject({ + number: 202 + }) + }) + it('does not reuse local fresh PR cache for SSH-backed repos', async () => { const store = createTestStore() const repoPath = '/repo' @@ -3917,18 +4060,38 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { connectionId: null, executionHostId: 'runtime:env-1' } - ] + ], + worktreesByRepo: { + 'repo-runtime': [ + makePRRefreshWorktree({ + id: 'wt-runtime-owner', + repoId: 'repo-runtime', + branch, + head: 'runtime-head-oid' + }) + ] + } } as unknown as Partial) await expect( - store.getState().fetchPRForBranch(repoPath, branch, { repoId: 'repo-runtime' }) + store + .getState() + .fetchPRForBranch(repoPath, branch, { + repoId: 'repo-runtime', + worktreeId: 'wt-runtime-owner' + }) ).resolves.toMatchObject({ number: 23 }) expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + params: { + repo: 'repo-runtime', + branch, + linkedPRNumber: null, + currentHeadOid: 'runtime-head-oid' + }, timeoutMs: 30_000 }) expect(store.getState().prCache[`runtime:env-1::repo-runtime::${branch}`]?.data).toMatchObject({ diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index fbd270f20aa..a3b38d94014 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -44,7 +44,11 @@ import { settingsForProjectRowOwner } from './github-project-row-owner' import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity' -import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key' +import { + getGitHubPRCacheBranch, + getGitHubPRCacheKey, + getGitHubRepoCacheKey +} from './github-cache-key' import { isGitHubWorkItemsQueryTooLarge } from './github-work-items-query-bounds' import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' import { translate } from '@/i18n/i18n' @@ -1018,10 +1022,11 @@ function buildPRRefreshCandidate( return null } const branch = worktree.branch.replace(/^refs\/heads\//, '') + const cacheBranch = getGitHubPRCacheBranch(branch, worktree.head) const cacheKey = prCacheKey( repoPath ?? repo.path, repo.id, - branch, + cacheBranch, settingsForGitHubRepoOwner(state.settings, repo), repo.connectionId, repo.executionHostId, @@ -1032,7 +1037,7 @@ function buildPRRefreshCandidate( state, repoPath ?? repo.path, repo.id, - branch, + cacheBranch, repo.connectionId, repo.executionHostId, true @@ -1156,6 +1161,7 @@ function syncHostedReviewCacheFromGitHubPRResult(args: { cache: AppState['hostedReviewCache'] repoPath: string branch: string + hostedReviewBranch?: string settings: AppState['settings'] repoId?: string connectionId?: string | null @@ -1171,7 +1177,7 @@ function syncHostedReviewCacheFromGitHubPRResult(args: { }): { cache: AppState['hostedReviewCache']; accepted: boolean } { const hostedReviewCacheKey = getHostedReviewCacheKey( args.repoPath, - args.branch, + args.hostedReviewBranch ?? args.branch, args.settings, args.repoId, args.connectionId, @@ -1343,6 +1349,7 @@ function setGitHubPRResultCaches( prCacheKey: string repoPath: string branch: string + hostedReviewBranch?: string settings: AppState['settings'] repoId?: string connectionId?: string | null @@ -1362,6 +1369,7 @@ function setGitHubPRResultCaches( cache: state.hostedReviewCache, repoPath: args.repoPath, branch: args.branch, + hostedReviewBranch: args.hostedReviewBranch, settings: args.settings, repoId: args.repoId, connectionId: args.connectionId, @@ -1377,7 +1385,7 @@ function setGitHubPRResultCaches( }) const hostedReviewCacheKey = getHostedReviewCacheKey( args.repoPath, - args.branch, + args.hostedReviewBranch ?? args.branch, args.settings, args.repoId, args.connectionId, @@ -1420,6 +1428,7 @@ function applyGitHubPRResultToCaches(args: { prCacheKey: string repoPath: string branch: string + hostedReviewBranch?: string settings: AppState['settings'] repoId?: string connectionId?: string | null @@ -1442,6 +1451,7 @@ function applyGitHubPRResultToCaches(args: { cache: args.hostedReviewCache, repoPath: args.repoPath, branch: args.branch, + hostedReviewBranch: args.hostedReviewBranch, settings: args.settings, repoId: args.repoId, connectionId: args.connectionId, @@ -1457,7 +1467,7 @@ function applyGitHubPRResultToCaches(args: { }) const hostedReviewCacheKey = getHostedReviewCacheKey( args.repoPath, - args.branch, + args.hostedReviewBranch ?? args.branch, args.settings, args.repoId, args.connectionId, @@ -2865,11 +2875,13 @@ export const createGitHubSlice: StateCreator = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id + const requestWorktree = options?.worktreeId ? findWorktreeById(get(), options.worktreeId) : null const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) + const cacheBranch = getGitHubPRCacheBranch(branch, requestWorktree?.head) const cacheKey = prCacheKey( repoPath, repoId, - branch, + cacheBranch, requestSettings, repo?.connectionId, repo?.executionHostId, @@ -2878,7 +2890,7 @@ export const createGitHubSlice: StateCreator = (s const cached = get().prCache[cacheKey] const hostedReviewCacheKey = getHostedReviewCacheKey( repoPath, - branch, + cacheBranch, requestSettings, repoId, repo?.connectionId, @@ -2895,7 +2907,7 @@ export const createGitHubSlice: StateCreator = (s get(), repoPath, repoId, - branch, + cacheBranch, repo?.connectionId, repo?.executionHostId, repo !== undefined @@ -2946,6 +2958,7 @@ export const createGitHubSlice: StateCreator = (s repo: runtimeRepo.repo.id, branch, linkedPRNumber, + ...(requestWorktree?.head ? { currentHeadOid: requestWorktree.head } : {}), ...(fallbackPRNumber !== null ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } : {}) @@ -2957,9 +2970,6 @@ export const createGitHubSlice: StateCreator = (s : ({ kind: 'no-pr', fetchedAt: Date.now() } as const) ) : await (async () => { - const requestWorktree = options?.worktreeId - ? findWorktreeById(get(), options.worktreeId) - : null const candidate: GitHubPRRefreshCandidate = { repoId: repoId ?? '', repoPath, @@ -2989,6 +2999,7 @@ export const createGitHubSlice: StateCreator = (s branch, linkedPRNumber, fallbackPRNumber, + currentHeadOid: requestWorktree?.head ?? null, acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null }) .then((pr) => @@ -3016,6 +3027,7 @@ export const createGitHubSlice: StateCreator = (s prCacheKey: cacheKey, repoPath, branch, + hostedReviewBranch: cacheBranch, settings: requestSettings, repoId, connectionId: repo?.connectionId, @@ -3877,12 +3889,14 @@ export const createGitHubSlice: StateCreator = (s if (isStaleExactLinkedPRLookup(s, alias.worktreeId, alias.linkedPRNumber)) { continue } + const hostedReviewBranch = getGitHubPRCacheBranch(alias.branch, alias.worktreeHead) const nextCaches = applyGitHubPRResultToCaches({ prCache: nextPRCache, hostedReviewCache: nextHostedReviewCache, prCacheKey: alias.cacheKey, repoPath: alias.repoPath, branch: alias.branch, + hostedReviewBranch, settings: s.settings, repoId: alias.repoId, connectionId: alias.connectionId, @@ -3909,9 +3923,10 @@ export const createGitHubSlice: StateCreator = (s deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) } if (event.status === 'in-flight' && event.requestStartedAt !== undefined) { + const hostedReviewBranch = getGitHubPRCacheBranch(alias.branch, alias.worktreeHead) const hostedReviewCacheKey = getHostedReviewCacheKey( alias.repoPath, - alias.branch, + hostedReviewBranch, s.settings, alias.repoId, alias.connectionId, @@ -3998,12 +4013,13 @@ export const createGitHubSlice: StateCreator = (s } const branch = wt.branch.replace(/^refs\/heads\//, '') - if (shouldRefreshPRs && !wt.isBare && branch) { + if (shouldRefreshPRs && !wt.isBare && (branch || wt.head)) { const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const cacheBranch = getGitHubPRCacheBranch(branch, wt.head) const prKey = prCacheKey( repo.path, repo.id, - branch, + cacheBranch, ownerSettings, repo.connectionId, repo.executionHostId @@ -4081,11 +4097,12 @@ export const createGitHubSlice: StateCreator = (s // Invalidate this worktree's cache entries const branch = worktree.branch.replace(/^refs\/heads\//, '') + const cacheBranch = getGitHubPRCacheBranch(branch, worktree.head) const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) const prKey = prCacheKey( repo.path, repo.id, - branch, + cacheBranch, ownerSettings, repo.connectionId, repo.executionHostId @@ -4116,8 +4133,8 @@ export const createGitHubSlice: StateCreator = (s return updates }) - // Re-fetch (skip when branch is empty — detached HEAD during rebase) - if (!worktree.isBare && branch) { + // Re-fetch; detached merged worktrees resolve PRs from the checked-out HEAD. + if (!worktree.isBare && (branch || worktree.head)) { const candidate = buildPRRefreshCandidate(get(), worktree) if (candidate) { if (getPRRefreshRuntimeRepoTarget(get(), candidate)) { @@ -4315,7 +4332,7 @@ export const createGitHubSlice: StateCreator = (s : cardProps.includes('pr') || rawCardProps.includes('ci')) || rightSidebarShowsPullRequestData(state) - if (shouldRefreshPR && !worktree.isBare && branch) { + if (shouldRefreshPR && !worktree.isBare && (branch || worktree.head)) { const candidate = buildPRRefreshCandidate(state, worktree) if (candidate) { if (getPRRefreshRuntimeRepoTarget(state, candidate)) { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index eee02f7627c..5799e603568 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1718,6 +1718,7 @@ function createGitHubApi(): WebGitHubApi { branch: candidate.branch, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, + ...(candidate.worktreeHead ? { currentHeadOid: candidate.worktreeHead } : {}), ...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}) }) return pr