diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 8493317a4c0..df021a7c1a0 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -733,6 +733,91 @@ describe('getPRForBranch', () => { }) }) + it('never attaches an open branch-matched PR to the primary worktree', async () => { + // Why: the primary (default-branch) worktree is categorically PR-free; an + // open PR whose head ref matches the default branch must not surface. + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify([ + { + number: 363, + title: 'RIQAPP-363: Phase 2', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/363', + updated_at: '2026-06-24T00:00:00Z', + draft: false, + mergeable: true, + head: { ref: 'main', sha: 'main-head-oid' }, + base: { ref: 'main', sha: 'base-oid' } + } + ]) + }) + + const pr = await getPRForBranch('/repo-root', 'main', null, null, null, { + isPrimaryWorktree: true + }) + + expect(pr).toBeNull() + // Guard short-circuits before any gh lookup runs. + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('ignores a stale persisted linked PR number on the primary worktree', async () => { + // Why: a primary worktree can carry a leftover linkedPR from an earlier + // build; the guard must drop it rather than hydrate the PR by number. + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + number: 363, + title: 'RIQAPP-363: Phase 2', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/363', + statusCheckRollup: [], + updatedAt: '2026-06-24T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature/phase-2', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + + const pr = await getPRForBranch('/repo-root', 'main', 363, null, null, { + isPrimaryWorktree: true + }) + + expect(pr).toBeNull() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('still attaches a matching open PR to a non-primary worktree', async () => { + // Regression guard: the primary short-circuit must not affect ordinary + // feature worktrees whose branch matches an open PR. + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 77, + title: 'Feature PR', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/77', + updated_at: '2026-06-24T00:00:00Z', + draft: false, + mergeable: true, + head: { ref: 'feature/test', sha: 'feature-head-oid' }, + base: { ref: 'main', sha: 'base-oid' } + } + ]) + }) + + const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, null, { + isPrimaryWorktree: false + }) + + expect(pr).toMatchObject({ number: 77, state: 'open' }) + }) + it('prefers branch lookup over a fallback PR number', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6bf0976bac3..8cb20b31730 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1991,6 +1991,11 @@ const PR_BRANCH_LIST_JSON_FIELDS = export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & { acceptMergedFallbackPR?: boolean + /** True for the primary (default-branch) worktree, which must never carry a + * PR — not an implicit branch match, not a stale persisted linked/fallback + * number. Provider-agnostic guard; see the early return in + * getPRForBranchOutcome. */ + isPrimaryWorktree?: boolean } function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState { @@ -2512,6 +2517,12 @@ export async function getPRForBranchOutcome( fallbackPRNumber?: number | null, options: GitHubPRBranchLookupOptions = {} ): Promise { + // Why: the primary (default-branch) worktree idles on the default branch and + // must never attach a PR. Short-circuit before any lookup so neither an + // implicit branch match nor a stale persisted linked/fallback number surfaces. + if (options.isPrimaryWorktree) { + return { kind: 'no-pr', fetchedAt: Date.now() } + } // Strip refs/heads/ prefix if present const branchName = branch.replace(/^refs\/heads\//, '') // Why: detached HEAD cannot use branch lookup, but an exact linked/fallback diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 8af4ee10b4b..a947ec45b3b 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -31,7 +31,7 @@ type PRRefreshOutcomeObserver = ( type PRBranchLookupCandidate = Pick< GitHubPRRefreshCandidate, - 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' + 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'isMainWorktree' > function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { @@ -52,6 +52,11 @@ function hostedReviewOptionArgs( if (shouldAcceptMergedFallbackPR(candidate)) { options.acceptMergedFallbackPR = true } + // Why: the primary worktree is categorically PR-free; flag it so the lookup + // ignores any stale persisted linked/fallback number and skips branch matching. + if (candidate.isMainWorktree) { + options.isPrimaryWorktree = true + } return Object.keys(options).length > 0 ? [options] : [] } diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index a9da7fcd3a6..614390c6866 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -438,6 +438,29 @@ describe('mergeWorktree', () => { expect(result.displayName).toBe('feature-x') }) + it('drops a stale linked PR on the primary worktree', () => { + // Why: the primary (default-branch) worktree must never surface a PR badge, + // even if persisted metadata still carries a stale linkedPR. + const mainGit = { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + const result = mergeWorktree('repo1', mainGit, { + linkedPR: 363 + } as Parameters[2]) + expect(result.linkedPR).toBeNull() + }) + + it('keeps the linked PR on a non-primary worktree', () => { + const result = mergeWorktree('repo1', baseGit, { + linkedPR: 77 + } as Parameters[2]) + expect(result.linkedPR).toBe(77) + }) + it('falls back to basename when bare worktree has no branch', () => { const bareGit = { path: '/workspaces/bare-repo', diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index d8382110588..49f22cab3b8 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -31,7 +31,9 @@ export function mergeWorktree( displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path), comment: meta?.comment || '', linkedIssue: meta?.linkedIssue ?? null, - linkedPR: meta?.linkedPR ?? null, + // Why: the primary (default-branch) worktree idles on main and must never + // show a PR badge, even if a stale linked PR lingers in persisted metadata. + linkedPR: git.isMainWorktree ? null : (meta?.linkedPR ?? null), linkedLinearIssue: meta?.linkedLinearIssue ?? null, linkedLinearIssueWorkspaceId: meta?.linkedLinearIssueWorkspaceId ?? null, linkedLinearIssueOrganizationUrlKey: meta?.linkedLinearIssueOrganizationUrlKey ?? null, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 4109cb2b2ab..327d48d0702 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -441,6 +441,31 @@ describe('Store', () => { }) }) + it('clears a stale linked PR persisted on the primary worktree', async () => { + // Why: the primary (default-branch) worktree must never carry a PR; an + // earlier build could persist one, so load-time cleanup drops it on disk. + const primaryWorktreeId = 'r1::/repo' + const featureWorktreeId = 'r1::/repo/.worktrees/feature' + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: 'r1', path: '/repo' })], + worktreeMeta: { + [primaryWorktreeId]: { linkedPR: 363 }, + [featureWorktreeId]: { linkedPR: 77 } + } + }) + + const store = await createStore() + + // Primary loses its stale PR; the feature worktree keeps its real link. + expect(store.getWorktreeMeta(primaryWorktreeId)?.linkedPR).toBeNull() + expect(store.getWorktreeMeta(featureWorktreeId)?.linkedPR).toBe(77) + store.flush() + const persisted = readDataFile() as PersistedState + expect(persisted.worktreeMeta[primaryWorktreeId]?.linkedPR).toBeNull() + expect(persisted.worktreeMeta[featureWorktreeId]?.linkedPR).toBe(77) + }) + it('migrates legacy WSL agent settings into the global Windows runtime default', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 6163cd9749e..766ba9c1f8e 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2248,6 +2248,7 @@ export class Store { const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() + const clearedPrimaryLinkedPRs = this.clearLinkedPRForPrimaryWorktrees() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -2268,7 +2269,12 @@ export class Store { this.state.legacyPaneKeyAliasEntries = entries this.scheduleSave() }) - if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) { + if ( + normalized.changed || + this.loadNeedsSave || + adaptedProjectGroups || + clearedPrimaryLinkedPRs + ) { // Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at // the main persistence boundary so older renderer writes cannot revive them. // Other one-shot load migrations also set loadNeedsSave to persist their @@ -2277,6 +2283,23 @@ export class Store { } } + private clearLinkedPRForPrimaryWorktrees(): boolean { + // Why: the primary (default-branch) worktree must never carry a PR. Earlier + // builds could persist a stale linkedPR on it; scrub those on load so the + // bad primary->PR association is dropped, not just hidden at render time. + // The primary worktree id is `${repo.id}::${repo.path}` (see mergeWorktree). + let changed = false + for (const repo of this.state.repos) { + const primaryWorktreeId = `${repo.id}::${repo.path}` + const meta = this.state.worktreeMeta[primaryWorktreeId] + if (meta && meta.linkedPR != null) { + this.state.worktreeMeta[primaryWorktreeId] = { ...meta, linkedPR: null } + changed = true + } + } + return changed + } + private adaptFlatFolderScanProjectGroups(): boolean { // Why: older folder imports persisted a real parent path but kept all repos // flat. Upgrade that shape into v1 sparse folder scopes on load. diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index cfd032f969d..0fa26f90297 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -976,6 +976,9 @@ function buildPRRefreshCandidate( branch, cacheKey, worktreeId: worktree.id, + // Why: the primary worktree is categorically PR-free; the lookup uses this + // to ignore any stale persisted linked/fallback number and skip matching. + isMainWorktree: worktree.isMainWorktree, // Why: persisted linked PR metadata is exact, while PR cache numbers are // only fallback hints after branch lookup misses. linkedPRNumber: worktree.linkedPR ?? null, diff --git a/src/shared/types.ts b/src/shared/types.ts index 44d96ae9a47..65c85eadb36 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1153,6 +1153,9 @@ export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { cachedMergeable?: PRMergeableState | null cachedMergeStateStatus?: string | null localGitOptions?: { wslDistro?: string } + /** True for the primary (default-branch) worktree, which must never attach a + * PR. Drives the provider-agnostic primary guard in the refresh lookup. */ + isMainWorktree?: boolean } export type GitHubPRRefreshSkippedReason =