Skip to content
Closed
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
9 changes: 7 additions & 2 deletions docs/design/worktree-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,12 @@ Intents:
`ResourceResolver` proves a resource for that path. The core ships no
resolver.
- `DiffIntent {repository, worktreePath, ref, head}` for a future #332 caller.
- Related PR is always `unavailable: No pull request evidence yet` (#338).
- Related PR (`p`, `openPullRequest`): available only with evidence from the host's cached `/github` listing
(`ControllerOptions.pullRequests`, never fetched by the manager). `relatedPullRequests` counts a PR only when its
head branch, in the PR's own repository, is exactly the worktree's branch: fork PRs with the same branch name,
PRs whose head repository GitHub did not report, detached worktrees and partial names are not evidence. Whether
the worktree's HEAD is the PR's head commit is reported separately (`atHead`). Without a listing the action says
`No pull request evidence yet`.

Rendering: selection is a marker (`›`/`>`) plus weight, never color alone;
`level: 'none'` emits no escape bytes; Safe glyphs are pure ASCII (and use
Expand Down Expand Up @@ -212,6 +217,6 @@ terminals window whole entries around the selection with an `a-b/N` indicator.
- NMSh session focus, tmux focus, and opening a new session for a worktree.
- #331 sidebar quick navigation (can reuse `discoverWorktrees` and the model).
- #332 diff panel (consumes `DiffIntent`).
- #338 related PR evidence.
- Passing the `/github` listing to `/worktrees` as `pullRequests` and opening the PR in `/github` on `openPullRequest` (TerminalApp).
- #329 managed-target focus context.
- Physical terminal QA.
1 change: 1 addition & 0 deletions src/githubWorkspace/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ for (let i = 0; i < 20; i++) ISSUES.push({number: 100 - i, title: `Backlog item
function rawPr(pr: RawPr): Record<string, unknown> {
return {__typename: 'PullRequest', number: pr.number, title: pr.title, url: `https://github.com/${OWNER}/${REPO}/pull/${pr.number}`, state: pr.state,
isDraft: pr.isDraft ?? false, updatedAt: hoursAgo(pr.updated), headRefName: pr.head, baseRefName: pr.base ?? 'master',
headRepository: {nameWithOwner: pr.number === 315 ? 'contributor/notMyShell' : `${OWNER}/${REPO}`},
additions: pr.additions, deletions: pr.deletions, changedFiles: pr.changedFiles, reviewDecision: pr.review, mergeable: pr.mergeable,
author: pr.author === null ? null : {login: pr.author ?? OWNER}, repository,
commits: pr.rollup === undefined ? {nodes: []} : {nodes: [{commit: {statusCheckRollup: pr.rollup === null ? null : {state: pr.rollup}}}]}};
Expand Down
4 changes: 4 additions & 0 deletions src/githubWorkspace/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export interface PrSummary {
state: PrState;
draft: boolean;
headRef?: string;
/** `owner/name` of the repository holding the head branch; differs from the PR's repository for a fork. */
headRepository?: string;
/** The head commit GitHub reports (40 hex), when reported. */
headSha?: string;
baseRef?: string;
updatedAt?: string;
reviewDecision: ReviewDecision;
Expand Down
3 changes: 2 additions & 1 deletion src/githubWorkspace/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const SEARCH_QUERY = `query($q: String!, $first: Int!, $after: String) {
nodes {
__typename
... on PullRequest {
number title url state isDraft updatedAt headRefName baseRefName
number title url state isDraft updatedAt headRefName baseRefName headRefOid
headRepository { nameWithOwner }
additions deletions changedFiles reviewDecision mergeable
author { login }
repository { name owner { login } }
Expand Down
2 changes: 2 additions & 0 deletions src/githubWorkspace/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ function mapPrSummary(node: Json): PrSummary | undefined {
state: mapPrState(node.state),
draft: node.isDraft === true,
headRef: optionalLine(node.headRefName, LIMITS.nameLength * 2),
headRepository: optionalLine(obj(node.headRepository).nameWithOwner, 200),
headSha: /^[0-9a-f]{40}$/u.test(String(node.headRefOid)) ? String(node.headRefOid) : undefined,
baseRef: optionalLine(node.baseRefName, LIMITS.nameLength * 2),
updatedAt: str(node.updatedAt),
reviewDecision: 'reviewDecision' in node ? mapReviewDecision(node.reviewDecision) : 'unknown',
Expand Down
22 changes: 19 additions & 3 deletions src/worktrees/controller.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type {PrSummary} from '../githubWorkspace/model.js';
import {relatedPullRequests, type RelatedPr} from './relatedPr.js';
import type {Key} from '../terminal/keys.js';
import type {GitRunner} from './git.js';
import {discoverWorktrees, type DiscoveryOptions} from './discovery.js';
Expand Down Expand Up @@ -37,7 +39,9 @@ export type KeyOutcome =
| {readonly kind: 'requestNewWorktree'}
| {readonly kind: 'removed'; readonly path: string}
| {readonly kind: 'created'; readonly path: string}
| {readonly kind: 'back'};
| {readonly kind: 'back'}
/** Open the related pull request in /github (the host decides how). */
| {readonly kind: 'openPullRequest'; readonly pr: PrSummary};

export type Review =
| {readonly kind: 'remove'; readonly plan: RemovalPlan}
Expand All @@ -63,6 +67,11 @@ export interface ControllerOptions {
readonly currentPath?: () => string | undefined;
readonly resources?: ResourceResolver;
readonly discovery?: DiscoveryOptions;
/**
* Pull requests the host already holds (the /github service's cached listing; never fetched here), for related-PR
* evidence. Without it the related-PR action stays unavailable.
*/
readonly pullRequests?: () => {readonly repository: string; readonly prs: readonly PrSummary[]} | undefined;
}

export function searchText(worktree: WorktreeRecord): string {
Expand Down Expand Up @@ -131,6 +140,12 @@ export class WorktreeManagerController {
return {kind: 'diff', repository, worktreePath: worktree.path, ref: worktree.ref, head: worktree.head};
}

/** Related pull requests for the selected worktree, from the host's cached listing (see relatedPullRequests). */
relatedPrs(worktree = this.selected()): RelatedPr[] {
const listing = this.options.pullRequests?.();
return worktree && listing ? relatedPullRequests(worktree, listing.repository, listing.prs) : [];
}

/** Availability from current facts only; drives footers and key dispatch alike. */
actions(): ActionState[] {
const worktree = this.selected();
Expand All @@ -141,7 +156,7 @@ export class WorktreeManagerController {
this.navigationIntent() ? {id: 'navigate', available: true} : none('navigate', 'Path is not available'),
this.current.snapshot && !stale ? {id: 'new', available: true} : none('new', 'Refresh first'),
this.diffIntent() ? {id: 'diff', available: true} : none('diff', 'No HEAD or path'),
none('relatedPr', 'No pull request evidence yet'),
this.relatedPrs().length ? {id: 'relatedPr', available: true} : none('relatedPr', this.options.pullRequests?.() ? 'No pull request has this branch as its head' : 'No pull request evidence yet'),
stale ? none('remove', 'List is stale; refresh first') : removal.available ? {id: 'remove', available: true} : none('remove', removal.reason),
{id: 'refresh', available: true},
this.current.snapshot ? {id: 'search', available: true} : none('search', 'Nothing loaded'),
Expand Down Expand Up @@ -188,7 +203,7 @@ export class WorktreeManagerController {
cancelReview(): void { this.set({review: undefined}); }

/**
* Keyboard model: ↑/↓ select, Enter navigate, n new, x preview removal,
* Keyboard model: ↑/↓ select, Enter navigate, n new, x preview removal, p related pull request,
* r refresh, / search, Esc clears search, then review, then goes back.
*/
async handleKey(key: Key): Promise<KeyOutcome> {
Expand Down Expand Up @@ -222,6 +237,7 @@ export class WorktreeManagerController {
if (value === 'r') { await this.refresh(); return {kind: 'changed'}; }
if (value === 'n') return this.available('new') ? {kind: 'requestNewWorktree'} : {kind: 'none'};
if (value === 'x') { await this.previewRemoval(); return {kind: 'changed'}; }
if (value === 'p') { const related = this.relatedPrs()[0]; return related ? {kind: 'openPullRequest', pr: related.pr} : {kind: 'none'}; }
if (value === '/' && this.available('search')) { this.set({searching: true}); return {kind: 'changed'}; }
return {kind: 'none'};
}
Expand Down
26 changes: 26 additions & 0 deletions src/worktrees/relatedPr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type {PrSummary} from '../githubWorkspace/model.js';
import type {WorktreeRecord} from './model.js';

/**
* Pull requests related to a worktree, from evidence only: a PR whose head branch, in the same repository as the PR,
* is exactly the worktree's checked-out branch. A fork's PR with a branch of the same name is not related (its head
* lives elsewhere), a PR whose head repository GitHub did not report is not counted (fail closed), and nothing is
* inferred from titles, folder names or partial branch matches. Whether the worktree is at the PR's head commit is
* reported separately, because a local branch can be ahead of or behind what GitHub has.
*/

export interface RelatedPr {
pr: PrSummary;
/** The worktree's HEAD is the PR's head commit; undefined when either is unknown. */
atHead?: boolean;
}

export function relatedPullRequests(worktree: WorktreeRecord, repository: string, prs: readonly PrSummary[]): RelatedPr[] {
if (!worktree.branch || worktree.detached || worktree.bare) return [];
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
return prs
.filter(pr => same(`${pr.ref.owner}/${pr.ref.repo}`, repository) && pr.headRepository !== undefined && same(pr.headRepository, repository) && pr.headRef === worktree.branch)
.map(pr => ({pr, ...(pr.headSha && worktree.head ? {atHead: pr.headSha === worktree.head} : {})}))
// Open first, then most recently updated.
.sort((a, b) => Number(b.pr.state === 'open') - Number(a.pr.state === 'open') || (b.pr.updatedAt ?? '').localeCompare(a.pr.updatedAt ?? ''));
}
41 changes: 41 additions & 0 deletions tests/worktreeRelatedPr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mapSearch} from '../src/githubWorkspace/source.js';
import {SEARCH_QUERY} from '../src/githubWorkspace/queries.js';
import type {PrSummary} from '../src/githubWorkspace/model.js';
import {relatedPullRequests} from '../src/worktrees/relatedPr.js';
import type {WorktreeRecord} from '../src/worktrees/model.js';

const HEAD = 'a'.repeat(40);
const pr = (number: number, extra: Partial<PrSummary> = {}): PrSummary => ({ref: {kind: 'pr', owner: 'raiseCatError', repo: 'notMyShell', number}, title: `PR ${number}`,
state: 'open', draft: false, headRef: 'feature/x', headRepository: 'raiseCatError/notMyShell', headSha: HEAD, reviewDecision: 'none', mergeable: 'unknown', checks: 'unknown', ...extra});
const worktree = (extra: Partial<WorktreeRecord> = {}): WorktreeRecord => ({id: '/w', path: '/w', ref: 'refs/heads/feature/x', branch: 'feature/x', head: HEAD, detached: false,
bare: false, main: false, locked: false, prunable: false, pathState: 'present', status: {kind: 'clean'}, ...extra});

test('related pull requests: same-repository head branch only; forks, unknown heads and partial names are not evidence', () => {
const prs = [
pr(1),
pr(2, {headRepository: 'someone/notMyShell'}),
pr(3, {headRepository: undefined}),
pr(4, {headRef: 'feature/x-2'}),
pr(5, {state: 'merged', updatedAt: '2026-10-11T00:00:00Z', headSha: 'b'.repeat(40)}),
pr(6, {ref: {kind: 'pr', owner: 'other', repo: 'repo', number: 6}, headRepository: 'other/repo'}),
];
const related = relatedPullRequests(worktree(), 'raiseCatError/notMyShell', prs);
assert.deepEqual(related.map(item => [item.pr.ref.number, item.atHead]), [[1, true], [5, false]], 'open first; the merged PR is related but its head is not this HEAD');
assert.deepEqual(relatedPullRequests(worktree({detached: true, branch: undefined}), 'raiseCatError/notMyShell', prs), [], 'a detached HEAD has no branch to match');
assert.deepEqual(relatedPullRequests(worktree(), 'RAISECATERROR/NOTMYSHELL', prs).length, 2, 'owner/name compare case-insensitively, as GitHub does');
assert.equal(relatedPullRequests(worktree({head: undefined}), 'raiseCatError/notMyShell', [pr(1)])[0]!.atHead, undefined, 'unknown, not false');
});

test('PR summaries carry the head repository and head commit from the search query', () => {
assert.match(SEARCH_QUERY, /headRefOid\s+headRepository \{ nameWithOwner \}/u);
const page = mapSearch({search: {issueCount: 1, pageInfo: {hasNextPage: false}, nodes: [{__typename: 'PullRequest', number: 7, title: 't', state: 'OPEN', headRefName: 'b',
headRefOid: HEAD, headRepository: {nameWithOwner: 'fork/notMyShell\u001b[31m'}, repository: {name: 'notMyShell', owner: {login: 'raiseCatError'}}}, {__typename: 'PullRequest', number: 8, title: 't', state: 'OPEN',
headRefOid: 'not-a-sha', repository: {name: 'notMyShell', owner: {login: 'raiseCatError'}}}]}});
const [first, second] = page.items as PrSummary[];
assert.equal(first!.headSha, HEAD);
assert.doesNotMatch(first!.headRepository!, /\u001b/u, 'hostile text is sanitized');
assert.equal(second!.headSha, undefined, 'a malformed commit id is not kept');
assert.equal(second!.headRepository, undefined);
});
Loading