Fix detached PR status refresh

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-26 21:35:17 -07:00
co-authored by Orca
parent 1adc446bcd
commit 955c8d2d6f
20 changed files with 526 additions and 105 deletions
+48 -23
View File
@@ -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([
{
+20 -6
View File
@@ -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<CommitAssociatedPRProbeResult, { kind: 'empty' }> = { 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<string>()
const explicitCurrentHeadOid = isUsableHeadOid(options.currentHeadOid ?? null)
? (options.currentHeadOid ?? null)
: null
const getCurrentHeadOidForLookup = async (): Promise<string | null> => {
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,
@@ -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({
@@ -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:
+9 -8
View File
@@ -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,
+10
View File
@@ -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') {
+5 -1
View File
@@ -10239,7 +10239,8 @@ export class OrcaRuntimeService {
branch: string,
linkedPRNumber?: number | null,
fallbackPRNumber?: number | null,
acceptMergedFallbackPR?: boolean
acceptMergedFallbackPR?: boolean,
currentHeadOid?: string | null
): Promise<Awaited<ReturnType<typeof getPRForBranch>>> {
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(
+11 -4
View File
@@ -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({
+1
View File
@@ -1151,6 +1151,7 @@ export type PreloadApi = {
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
acceptMergedFallbackPR?: boolean
currentHeadOid?: string | null
}) => Promise<PRInfo | null>
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise<PRRefreshOutcome>
enqueuePRRefresh: (args: {
+1
View File
@@ -950,6 +950,7 @@ const api = {
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
acceptMergedFallbackPR?: boolean
currentHeadOid?: string | null
}): Promise<unknown> => ipcRenderer.invoke('gh:prForBranch', args),
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise<unknown> =>
@@ -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,
@@ -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<AppState, 'activeWorktreeId' | 'repos' | 'worktreesByRepo' | 'prCache'>
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',
@@ -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,
@@ -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(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('Branch')
expect(markup).toContain('lucide-git-branch')
})
})
@@ -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,
@@ -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,
@@ -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
})
})
+166 -3
View File
@@ -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<AppState>)
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<AppState>)
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<AppState>)
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({
+36 -19
View File
@@ -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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (s
const cached = get().prCache[cacheKey]
const hostedReviewCacheKey = getHostedReviewCacheKey(
repoPath,
branch,
cacheBranch,
requestSettings,
repoId,
repo?.connectionId,
@@ -2895,7 +2907,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
get(),
repoPath,
repoId,
branch,
cacheBranch,
repo?.connectionId,
repo?.executionHostId,
repo !== undefined
@@ -2946,6 +2958,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (s
branch,
linkedPRNumber,
fallbackPRNumber,
currentHeadOid: requestWorktree?.head ?? null,
acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null
})
.then((pr) =>
@@ -3016,6 +3027,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prCacheKey: cacheKey,
repoPath,
branch,
hostedReviewBranch: cacheBranch,
settings: requestSettings,
repoId,
connectionId: repo?.connectionId,
@@ -3877,12 +3889,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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)) {
+1
View File
@@ -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