Skip to content

Commit 6d164eb

Browse files
big-guyclaude
andcommitted
feat: treat any PR base branch as a merge point, surface at top of Active
Extends the default-branch guard to any branch a PR is currently targeting (develop, integration, release/*, …). After resolving PRs we collect the union of baseBranch values; any worktree sitting on one of those branches gets its PR assignment cleared. Self-derives — no config or hardcoded list — and free, since every PR node already carries baseRefName. Renderer mirror: the no-PR / Active sidebar group now sorts merge-point worktrees (main + any branch that's a PR base) immediately after the main worktree, before feature worktrees, so the long-lived merge points stay reachable at the top. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 56cbbe4 commit 6d164eb

4 files changed

Lines changed: 98 additions & 4 deletions

File tree

src/main/github.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,43 @@ describe('fetchPRStatusesForRepo matcher', () => {
481481
expect(result.get('/wt')).toBeNull()
482482
})
483483

484+
it('does not claim a PR for a worktree on a non-default merge-point branch (develop)', async () => {
485+
// 'develop' isn't the default branch but PR #50 targets it as base.
486+
// A worktree sitting on develop shouldn't be credited with a PR even
487+
// if search-by-SHA surfaces a recently squashed candidate.
488+
const developSha = 'a'.repeat(40)
489+
const featureSha = 'b'.repeat(40)
490+
fetchSpy.mockResolvedValueOnce(
491+
mockResponse(200, {
492+
data: {
493+
repository: {
494+
defaultBranchRef: { name: 'main' },
495+
milestones: { totalCount: 0 },
496+
// Worktree on develop: search returns the most-recently-squashed
497+
// PR which still claims our origin via sameRepo fallback if we
498+
// weren't already excluding it.
499+
prBr0: { nodes: [] },
500+
// Worktree on feature: legitimately ours, baseRefName=develop.
501+
prBr1: { nodes: [gqlPR({ number: 50, baseRefName: 'develop', headRefOid: featureSha })] }
502+
},
503+
prSearch0: {
504+
nodes: [gqlPR({ number: 99, state: 'MERGED', headRefOid: 'c'.repeat(40) })]
505+
},
506+
prSearch1: { nodes: [] }
507+
}
508+
})
509+
)
510+
const result = await fetchPRStatusesForRepo(
511+
{ origin: { owner: 'o', repo: 'r' }, upstream: { owner: 'o', repo: 'r' } },
512+
[
513+
{ worktreePath: '/wt-dev', branch: 'develop', headSha: developSha },
514+
{ worktreePath: '/wt-feat', branch: 'feature', headSha: featureSha }
515+
]
516+
)
517+
expect(result.get('/wt-dev')).toBeNull()
518+
expect(result.get('/wt-feat')?.number).toBe(50)
519+
})
520+
484521
it('does not claim a PR for a worktree on master when that is the default branch', async () => {
485522
const masterSha = 'c'.repeat(40)
486523
const mergedPRHeadSha = 'd'.repeat(40)

src/main/github.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ ${PR_FRAGMENT}`
453453
// not the head of any PR — skip the resolution entirely to avoid
454454
// misattributing the latest squash-merged PR's status to it.
455455
if (defaultBranchName && req.branch === defaultBranchName) {
456-
return { worktreePath: req.worktreePath, status: null as PRStatus | null }
456+
return { worktreePath: req.worktreePath, branch: req.branch, status: null as PRStatus | null }
457457
}
458458
const brAlias = repoData[`prBr${i}`] as { nodes: GraphQLPR[] | null } | null | undefined
459459
const searchAlias = topData[`prSearch${i}`] as
@@ -466,7 +466,7 @@ ${PR_FRAGMENT}`
466466
(n): n is GraphQLPR => !!n && typeof (n as GraphQLPR).number === 'number'
467467
)
468468
const pr = resolvePRForWorktree(branchNodes, searchNodes, req.headSha, originFull)
469-
if (!pr) return { worktreePath: req.worktreePath, status: null as PRStatus | null }
469+
if (!pr) return { worktreePath: req.worktreePath, branch: req.branch, status: null as PRStatus | null }
470470
const [behindBy, firstReleaseTag] = await Promise.all([
471471
pr.state === 'MERGED' || pr.state === 'CLOSED'
472472
? Promise.resolve(null)
@@ -476,9 +476,19 @@ ${PR_FRAGMENT}`
476476
: Promise.resolve(null)
477477
])
478478
const status = buildPRStatus(pr, req.branch, behindBy, firstReleaseTag, hasMilestones)
479-
return { worktreePath: req.worktreePath, status }
479+
return { worktreePath: req.worktreePath, branch: req.branch, status }
480480
})
481481
)
482+
483+
// Any branch that some PR is targeting as base (develop / integration /
484+
// release/*, etc.) is a merge point, not a PR head. Null out attributions
485+
// for worktrees sitting on one of those.
486+
const baseBranches = new Set<string>()
487+
for (const b of built) if (b.status) baseBranches.add(b.status.baseBranch)
488+
for (const b of built) {
489+
if (b.status && baseBranches.has(b.branch)) b.status = null
490+
}
491+
482492
for (const b of built) result.set(b.worktreePath, b.status)
483493
return result
484494
}

src/renderer/worktree-sort.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,26 @@ describe('worktree-sort snoozed group', () => {
155155
it('getGroupKey returns snoozed when isSnoozed regardless of merged', () => {
156156
expect(getGroupKey(wt('/a'), mergedPR, true, true)).toBe('snoozed')
157157
})
158+
159+
it('no-pr group lists merge-point worktrees (PR bases) above feature worktrees', () => {
160+
const main = stubWorktree({ path: '/main', branch: 'main', isMain: true, createdAt: 100 })
161+
const develop = stubWorktree({ path: '/develop', branch: 'develop', createdAt: 50 })
162+
const feature = stubWorktree({ path: '/feat', branch: 'feature/x', createdAt: 200 })
163+
const featureWithPR = stubWorktree({ path: '/other', branch: 'feature/y', createdAt: 300 })
164+
// Active PR targets develop — so develop counts as a base branch.
165+
const groups = groupWorktrees(
166+
[feature, develop, main, featureWithPR],
167+
{
168+
'/main': null,
169+
'/develop': null,
170+
'/feat': null,
171+
'/other': stubPRStatus({ branch: 'feature/y', baseBranch: 'develop' })
172+
},
173+
{},
174+
{}
175+
)
176+
const noPR = groups.find((g) => g.key === 'no-pr')!
177+
// main pinned to top, then develop (a base branch), then features by createdAt desc.
178+
expect(noPR.worktrees.map((w) => w.path)).toEqual(['/main', '/develop', '/feat'])
179+
})
158180
})

src/renderer/worktree-sort.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,27 @@ function sortByCreatedAt(worktrees: Worktree[]): Worktree[] {
4949
})
5050
}
5151

52+
/** Sort the no-PR / Active group: main first, then any worktree whose
53+
* branch is being targeted by an open PR (merge points like
54+
* develop/integration/release), then everything else by createdAt desc. */
55+
function sortNoPRGroup(worktrees: Worktree[], baseBranches: Set<string>): Worktree[] {
56+
return [...worktrees].sort((a, b) => {
57+
if (a.isMain !== b.isMain) return a.isMain ? -1 : 1
58+
const aBase = baseBranches.has(a.branch) ? 1 : 0
59+
const bBase = baseBranches.has(b.branch) ? 1 : 0
60+
if (aBase !== bBase) return bBase - aBase
61+
return (b.createdAt || 0) - (a.createdAt || 0)
62+
})
63+
}
64+
65+
function collectBaseBranches(prStatuses: Record<string, PRStatus | null>): Set<string> {
66+
const out = new Set<string>()
67+
for (const status of Object.values(prStatuses)) {
68+
if (status?.baseBranch) out.add(status.baseBranch)
69+
}
70+
return out
71+
}
72+
5273
/** Group worktrees by PR status, sorted by creation time within each group */
5374
export function groupWorktrees(
5475
worktrees: Worktree[],
@@ -77,12 +98,16 @@ export function groupWorktrees(
7798
grouped[key].push(wt)
7899
}
79100

101+
const baseBranches = collectBaseBranches(prStatuses)
80102
return GROUP_ORDER
81103
.filter((key) => grouped[key].length > 0)
82104
.map((key) => ({
83105
key,
84106
label: GROUP_LABELS[key],
85-
worktrees: sortByCreatedAt(grouped[key])
107+
worktrees:
108+
key === 'no-pr'
109+
? sortNoPRGroup(grouped[key], baseBranches)
110+
: sortByCreatedAt(grouped[key])
86111
}))
87112
}
88113

0 commit comments

Comments
 (0)