Show a merged branch PR when the worktree head is one of its own commits

A worktree can end up behind its own merged PR's final head — update-branch
merges or web-committed suggestions land on the remote branch and merge
without a local pull. The merged-implicit visibility rule required exact
head equality, so the Checks panel dropped to 'No pull request found —
Create a pull request…' and the sidebar merged badge vanished right after
a successful merge.

Keep the reused-branch guard, but before hiding a merged branch-matched PR
ask GitHub whether the worktree head is one of the PR's own commits
(commits/{sha}/pulls, bounded immutable-friendly cache, core rate-limit
guard, fail-safe to the old hide). Confirmed heads are stamped on PRInfo/
HostedReviewInfo as confirmedContainedHeadOid so the renderer agreement
points (hosted-review staleness, refresh fallback number, no-PR preserve,
sidebar merged badge) accept the same worktrees without refetch loops.

Old main commits carry no association with the PR, so reused branch names
stay hidden; commits never pushed to GitHub fail the probe and stay hidden.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-03 17:24:33 -07:00
co-authored by Orca
parent 2789a67604
commit 96d139fe86
13 changed files with 527 additions and 31 deletions
+175 -1
View File
@@ -33,7 +33,9 @@ const {
getRemoteUrlForRepoMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
getRateLimitMock: vi.fn(),
rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })),
rateLimitGuardMock: vi.fn<(bucket?: string) => RateLimitGuardResult>(() => ({
blocked: false
})),
noteRateLimitSpendMock: vi.fn(),
ghRepoExecOptionsMock: vi.fn((context) =>
context.connectionId
@@ -121,6 +123,7 @@ import {
__resetTrackedUpstreamBranchCacheForTests
} from './client'
import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary'
import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership'
describe('checkOrcaStarred', () => {
beforeEach(() => {
@@ -192,6 +195,7 @@ describe('getPRForBranch', () => {
_resetMergeQueueCacheForTests()
__resetTrackedUpstreamBranchCacheForTests()
__resetPRConflictSummaryGitCapabilityCacheForTests()
resetMergedPRCommitMembershipCacheForTest()
})
it('queries GitHub by head branch when the remote is on github.com', async () => {
@@ -746,6 +750,176 @@ describe('getPRForBranch', () => {
})
})
const mockMergedBranchPRLookupBehindHead = (prNumber = 6011): void => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: prNumber,
title: 'Merged PR with unpulled final head',
state: 'closed',
merged_at: '2026-07-03T21:27:36Z',
html_url: `https://github.com/acme/widgets/pull/${prNumber}`,
updated_at: '2026-07-03T21:27:36Z',
draft: false,
mergeable_state: 'clean',
head: { ref: 'fix-hibernation-wake', sha: 'aaaa1111aaaa1111' },
base: { ref: 'main', sha: 'base-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: prNumber,
title: 'Merged PR with unpulled final head',
state: 'MERGED',
url: `https://github.com/acme/widgets/pull/${prNumber}`,
statusCheckRollup: [],
updatedAt: '2026-07-03T21:27:36Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'fix-hibernation-wake',
baseRefOid: 'base-oid',
headRefOid: 'aaaa1111aaaa1111'
})
})
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'bbbb2222bbbb2222\n',
stderr: ''
})
}
it('shows a merged branch PR when the worktree head is one of its own commits', async () => {
mockMergedBranchPRLookupBehindHead()
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([{ number: 6011 }, { number: 42 }])
})
const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100'],
expect.anything()
)
expect(pr).toMatchObject({
number: 6011,
state: 'merged',
headSha: 'aaaa1111aaaa1111',
confirmedContainedHeadOid: 'bbbb2222bbbb2222'
})
})
it('keeps hiding a merged branch PR when the head belongs to a different PR (reused branch)', async () => {
mockMergedBranchPRLookupBehindHead()
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([{ number: 42 }])
})
const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
expect(pr).toBeNull()
})
it('keeps hiding a merged branch PR when the commit membership probe fails', async () => {
mockMergedBranchPRLookupBehindHead()
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: No commit found'))
const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
expect(pr).toBeNull()
})
it('skips the membership probe while the core rate-limit budget is blocked', async () => {
mockMergedBranchPRLookupBehindHead()
rateLimitGuardMock.mockImplementation((bucket?: string) =>
bucket === 'core'
? { blocked: true, remaining: 0, limit: 5000, resetAt: 0 }
: { blocked: false }
)
const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
expect(pr).toBeNull()
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('reuses the cached membership answer instead of re-querying per poll', async () => {
mockMergedBranchPRLookupBehindHead()
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([{ number: 6011 }])
})
const first = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
expect(first).toMatchObject({ number: 6011, confirmedContainedHeadOid: 'bbbb2222bbbb2222' })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
mockMergedBranchPRLookupBehindHead()
const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake')
expect(second).toMatchObject({ number: 6011, confirmedContainedHeadOid: 'bbbb2222bbbb2222' })
// No fourth membership call: the confirmed answer is immutable and cached.
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(5)
})
it('uses the caller-supplied worktree head for the membership probe without shelling out', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 6012,
title: 'Merged PR queried by checks panel',
state: 'closed',
merged_at: '2026-07-03T21:27:36Z',
html_url: 'https://github.com/acme/widgets/pull/6012',
updated_at: '2026-07-03T21:27:36Z',
draft: false,
mergeable_state: 'clean',
head: { ref: 'fix-hibernation-wake', sha: 'aaaa1111aaaa1111' },
base: { ref: 'main', sha: 'base-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 6012,
title: 'Merged PR queried by checks panel',
state: 'MERGED',
url: 'https://github.com/acme/widgets/pull/6012',
statusCheckRollup: [],
updatedAt: '2026-07-03T21:27:36Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'fix-hibernation-wake',
baseRefOid: 'base-oid',
headRefOid: 'aaaa1111aaaa1111'
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify([{ number: 6012 }])
})
const outcome = await getPRForBranchOutcome(
'/repo-root',
'fix-hibernation-wake',
null,
null,
null,
{
currentHeadOid: 'cccc3333cccc3333'
}
)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(outcome).toMatchObject({
kind: 'found',
pr: { number: 6012, confirmedContainedHeadOid: 'cccc3333cccc3333' }
})
})
it('prefers branch lookup over a fallback PR number', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
+42 -5
View File
@@ -62,6 +62,7 @@ import {
type LocalGitExecOptions,
type OwnerRepo
} from './gh-utils'
import { isCommitPartOfMergedPR } from './merged-pr-commit-membership'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
hasHostedReviewLocalGitOptions,
@@ -2808,7 +2809,30 @@ export async function getPRForBranchOutcome(
typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0
? options.currentHeadOid.trim()
: null
const hideMergedImplicitPR = async (candidate: PullRequestLookupData | null) => {
let confirmedContainedHeadOid: string | null = null
const mergedPRContainsHead = async (
candidate: PullRequestLookupData,
candidateRepo: OwnerRepo | null,
headOid: string | null
): Promise<boolean> => {
if (!candidateRepo || !headOid) {
return false
}
const contained = await isCommitPartOfMergedPR({
ownerRepo: candidateRepo,
prNumber: candidate.number,
commitOid: headOid,
ghOptions
})
if (contained) {
confirmedContainedHeadOid = headOid
}
return contained
}
const hideMergedImplicitPR = async (
candidate: PullRequestLookupData | null,
candidateRepo: OwnerRepo | null
) => {
if (!candidate || !isMergedImplicitPR(candidate, linkedPRNumber)) {
return false
}
@@ -2819,7 +2843,18 @@ export async function getPRForBranchOutcome(
explicitCurrentHeadOid !== null
? explicitCurrentHeadOid
: await getCurrentHeadOid(repoPath, connectionId, localGitOptions)
return shouldHideMergedImplicitPR(candidate, linkedPRNumber, currentHeadOidForMergedImplicit)
if (!shouldHideMergedImplicitPR(candidate, linkedPRNumber, currentHeadOidForMergedImplicit)) {
return false
}
// Why: a worktree can sit behind its own PR's final head (update-branch
// merges, web-committed suggestions). A head that is one of the PR's own
// commits is the same line of work, not a reused branch name — keep the
// merged PR visible instead of offering "create a pull request".
return !(await mergedPRContainsHead(
candidate,
candidateRepo,
currentHeadOidForMergedImplicit
))
}
if (typeof linkedPRNumber === 'number') {
@@ -2886,7 +2921,7 @@ export async function getPRForBranchOutcome(
}
}
let mergedBranchLookupNumber: number | null = null
if (await hideMergedImplicitPR(data)) {
if (await hideMergedImplicitPR(data, dataRepo)) {
mergedBranchLookupNumber = data?.number ?? null
data = null
dataRepo = null
@@ -2913,14 +2948,15 @@ export async function getPRForBranchOutcome(
data.number === fallbackPRNumber
const explicitHeadHidesMergedImplicitPR =
explicitCurrentHeadOid !== null &&
shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid)
shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) &&
!(await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid))
const shouldPreserveMergedFallback =
!explicitHeadHidesMergedImplicitPR &&
(fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true)
// Why: a currently visible PR can be merged outside Orca; when the caller
// marks the fallback as visible review state, keep its lifecycle fresh even
// if GitHub no longer reports it by branch (for example deleted heads).
if ((await hideMergedImplicitPR(data)) && !shouldPreserveMergedFallback) {
if ((await hideMergedImplicitPR(data, dataRepo)) && !shouldPreserveMergedFallback) {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
@@ -2962,6 +2998,7 @@ export async function getPRForBranchOutcome(
: {}),
...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}),
headSha: data.headRefOid,
...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}),
...(data.baseRefName ? { baseRefName: data.baseRefName } : {}),
prRepo: dataRepo ?? undefined,
headRepo: dataHeadRepo ?? undefined,
@@ -0,0 +1,95 @@
import { ghExecFileAsync } from './gh-utils'
import { noteRateLimitSpend, rateLimitGuard } from './rate-limit'
import type { OwnerRepo } from './github-repository-identity'
type GhExecOptions = Parameters<typeof ghExecFileAsync>[1]
// Why: a merged PR's commit set is immutable, so confirmations never expire in
// practice; the TTLs only bound memory and let "commit not on GitHub yet"
// answers retry after a future push.
const MEMBERSHIP_CACHE_MAX_ENTRIES = 200
const MEMBERSHIP_CONFIRMED_TTL_MS = 6 * 60 * 60 * 1000
const MEMBERSHIP_NEGATIVE_TTL_MS = 5 * 60 * 1000
const membershipCache = new Map<string, { value: boolean; expiresAt: number }>()
function pruneMergedPRCommitMembershipCache(now = Date.now()): void {
for (const [cacheKey, cached] of membershipCache) {
if (cached.expiresAt <= now) {
membershipCache.delete(cacheKey)
}
}
while (membershipCache.size > MEMBERSHIP_CACHE_MAX_ENTRIES) {
const oldestKey = membershipCache.keys().next().value
if (oldestKey === undefined) {
break
}
membershipCache.delete(oldestKey)
}
}
export function resetMergedPRCommitMembershipCacheForTest(): void {
membershipCache.clear()
}
/**
* Whether `commitOid` is part of pull request `prNumber` on GitHub — i.e. the
* commit belongs to that PR's history rather than merely sharing a branch name.
* A worktree sitting on such a commit is on the PR's own line of work (for
* example behind web-committed suggestions or an update-branch merge), not a
* reused branch name. Conservative on any failure: returns false.
*/
export async function isCommitPartOfMergedPR(args: {
ownerRepo: OwnerRepo
prNumber: number
commitOid: string
ghOptions: GhExecOptions
}): Promise<boolean> {
const oid = args.commitOid.trim().toLowerCase()
if (!/^[0-9a-f]{4,64}$/.test(oid) || !Number.isInteger(args.prNumber)) {
return false
}
const owner = args.ownerRepo.owner
const repo = args.ownerRepo.repo
const cacheKey = `${owner.toLowerCase()}/${repo.toLowerCase()}#${args.prNumber}@${oid}`
const now = Date.now()
pruneMergedPRCommitMembershipCache(now)
const cached = membershipCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
return cached.value
}
// Why blocked → uncached false: keep the merged PR hidden without burning
// budget; the next poll re-asks once the rate-limit window recovers.
if (rateLimitGuard('core').blocked) {
return false
}
try {
noteRateLimitSpend('core')
const { stdout } = await ghExecFileAsync(
['api', `repos/${owner}/${repo}/commits/${oid}/pulls?per_page=100`],
args.ghOptions
)
const parsed = JSON.parse(stdout) as unknown
const value =
Array.isArray(parsed) &&
parsed.some(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
(entry as { number?: unknown }).number === args.prNumber
)
membershipCache.set(cacheKey, {
value,
expiresAt: now + (value ? MEMBERSHIP_CONFIRMED_TTL_MS : MEMBERSHIP_NEGATIVE_TTL_MS)
})
return value
} catch {
// Why: the common failure is 422 for a commit never pushed to GitHub —
// definitive "new local work" today, but a push can change it; retry later.
membershipCache.set(cacheKey, {
value: false,
expiresAt: now + MEMBERSHIP_NEGATIVE_TTL_MS
})
return false
}
}