Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/main/github/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/main/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2512,6 +2517,12 @@ export async function getPRForBranchOutcome(
fallbackPRNumber?: number | null,
options: GitHubPRBranchLookupOptions = {}
): Promise<PRRefreshOutcome> {
// 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
Expand Down
7 changes: 6 additions & 1 deletion src/main/github/pr-refresh-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type PRRefreshOutcomeObserver = (

type PRBranchLookupCandidate = Pick<
GitHubPRRefreshCandidate,
'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource'
'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'isMainWorktree'
>

function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean {
Expand All @@ -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] : []
}

Expand Down
23 changes: 23 additions & 0 deletions src/main/ipc/worktree-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof mergeWorktree>[2])
expect(result.linkedPR).toBeNull()
})

it('keeps the linked PR on a non-primary worktree', () => {
const result = mergeWorktree('repo1', baseGit, {
linkedPR: 77
} as Parameters<typeof mergeWorktree>[2])
expect(result.linkedPR).toBe(77)
})

it('falls back to basename when bare worktree has no branch', () => {
const bareGit = {
path: '/workspaces/bare-repo',
Expand Down
4 changes: 3 additions & 1 deletion src/main/ipc/worktree-metadata-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/main/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 24 additions & 1 deletion src/main/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/src/store/slices/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down