jj-stack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. jj_stack/__init__.py +11 -0
  2. jj_stack/__main__.py +8 -0
  3. jj_stack/bootstrap.py +213 -0
  4. jj_stack/cli.py +1297 -0
  5. jj_stack/cli_help.py +680 -0
  6. jj_stack/commands/__init__.py +1 -0
  7. jj_stack/commands/_cleanup_actions.py +389 -0
  8. jj_stack/commands/_json_status.py +84 -0
  9. jj_stack/commands/checkout.py +668 -0
  10. jj_stack/commands/cleanup/command.py +702 -0
  11. jj_stack/commands/cleanup/shared.py +62 -0
  12. jj_stack/commands/cleanup/stale.py +79 -0
  13. jj_stack/commands/doctor.py +358 -0
  14. jj_stack/commands/in_use.py +31 -0
  15. jj_stack/commands/list_.py +667 -0
  16. jj_stack/commands/merge/__init__.py +1 -0
  17. jj_stack/commands/merge/command.py +385 -0
  18. jj_stack/commands/merge/github_stack.py +333 -0
  19. jj_stack/commands/merge/models.py +97 -0
  20. jj_stack/commands/merge/plan.py +109 -0
  21. jj_stack/commands/merge/preconditions.py +162 -0
  22. jj_stack/commands/merge/render.py +52 -0
  23. jj_stack/commands/relink.py +210 -0
  24. jj_stack/commands/submit/__init__.py +1 -0
  25. jj_stack/commands/submit/auto_close.py +108 -0
  26. jj_stack/commands/submit/changes.py +90 -0
  27. jj_stack/commands/submit/command.py +780 -0
  28. jj_stack/commands/submit/descriptions.py +570 -0
  29. jj_stack/commands/submit/github_stack.py +139 -0
  30. jj_stack/commands/submit/inputs.py +168 -0
  31. jj_stack/commands/submit/models.py +198 -0
  32. jj_stack/commands/submit/overview_comments.py +178 -0
  33. jj_stack/commands/submit/prs.py +461 -0
  34. jj_stack/commands/submit/render.py +129 -0
  35. jj_stack/commands/sync.py +510 -0
  36. jj_stack/commands/sync_apply.py +474 -0
  37. jj_stack/commands/unstack.py +321 -0
  38. jj_stack/commands/view.py +1151 -0
  39. jj_stack/completion.py +484 -0
  40. jj_stack/concurrency.py +93 -0
  41. jj_stack/config.py +170 -0
  42. jj_stack/console.py +708 -0
  43. jj_stack/errors.py +152 -0
  44. jj_stack/formatting.py +95 -0
  45. jj_stack/github/__init__.py +1 -0
  46. jj_stack/github/auth.py +34 -0
  47. jj_stack/github/client.py +1098 -0
  48. jj_stack/github/error_messages.py +89 -0
  49. jj_stack/github/overview_comments.py +35 -0
  50. jj_stack/github/pr_refs.py +70 -0
  51. jj_stack/github/resolution.py +208 -0
  52. jj_stack/github/stack_availability.py +30 -0
  53. jj_stack/identifiers.py +7 -0
  54. jj_stack/jj/__init__.py +1 -0
  55. jj_stack/jj/cli_args.py +23 -0
  56. jj_stack/jj/client.py +1459 -0
  57. jj_stack/jj/colors.py +199 -0
  58. jj_stack/models/__init__.py +1 -0
  59. jj_stack/models/git.py +15 -0
  60. jj_stack/models/github.py +243 -0
  61. jj_stack/models/stack.py +70 -0
  62. jj_stack/models/tracking.py +115 -0
  63. jj_stack/pr_branch_namespace.py +78 -0
  64. jj_stack/stack/__init__.py +1 -0
  65. jj_stack/stack/change_status.py +216 -0
  66. jj_stack/stack/convergence.py +501 -0
  67. jj_stack/stack/convergence_models.py +69 -0
  68. jj_stack/stack/convergence_observation.py +162 -0
  69. jj_stack/stack/github_stack_safety.py +132 -0
  70. jj_stack/stack/global_convergence.py +270 -0
  71. jj_stack/stack/path.py +235 -0
  72. jj_stack/stack/pr_branches.py +126 -0
  73. jj_stack/stack/pr_facts.py +165 -0
  74. jj_stack/stack/repo.py +95 -0
  75. jj_stack/stack/selected.py +358 -0
  76. jj_stack/stack/selection.py +124 -0
  77. jj_stack/stack/status.py +711 -0
  78. jj_stack/stack/trunk_evidence.py +174 -0
  79. jj_stack/state/__init__.py +1 -0
  80. jj_stack/state/operation_lock.py +220 -0
  81. jj_stack/state/store.py +260 -0
  82. jj_stack/ui.py +230 -0
  83. jj_stack-0.1.0.dist-info/METADATA +170 -0
  84. jj_stack-0.1.0.dist-info/RECORD +88 -0
  85. jj_stack-0.1.0.dist-info/WHEEL +4 -0
  86. jj_stack-0.1.0.dist-info/entry_points.txt +2 -0
  87. jj_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
  88. jj_stack-0.1.0.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,668 @@
1
+ """Check out an existing stack of pull requests.
2
+
3
+ Use `--pull-request` to select a GitHub pull request, `--revset` to select a locally tracked head,
4
+ or `--pick` to choose from local and GitHub stacks in an interactive numbered list. When the
5
+ selected stack's submitted commits are not present locally, the command fetches them
6
+ automatically. It records which pull request belongs to each local change, then runs `jj edit` on
7
+ the selected change.
8
+
9
+ `jj-stack` changes the working copy only after validating the entire stack and saving any new
10
+ pull request links. `checkout` does not rebase changes or modify GitHub. To create a new change
11
+ on top of the checked-out change, run `jj new` afterward.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import sys
18
+ from collections import Counter
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ import jj_stack.console as console
23
+ import jj_stack.ui as ui
24
+ from jj_stack.bootstrap import CommandContext, bootstrap_context
25
+ from jj_stack.errors import CliError, UsageError
26
+ from jj_stack.github.client import GithubClient, GithubClientError, build_github_client
27
+ from jj_stack.github.pr_refs import parse_repo_pr_reference
28
+ from jj_stack.github.resolution import (
29
+ GithubRepoAddress,
30
+ require_github_repo,
31
+ select_submit_remote,
32
+ )
33
+ from jj_stack.github.stack_availability import github_stacks_unavailable_error
34
+ from jj_stack.jj.cli_args import JjCliArgs
35
+ from jj_stack.jj.client import JjClient, UnsupportedStackError
36
+ from jj_stack.models.github import GithubPR, GithubStack
37
+ from jj_stack.models.stack import LocalCommit, LocalStack
38
+ from jj_stack.models.tracking import PRIdentity, SubmittedBaseline, TrackingState
39
+ from jj_stack.pr_branch_namespace import current_pr_branch_namespace, pr_branch_matches_change
40
+ from jj_stack.stack.pr_branches import prepare_visible_pr_snapshots
41
+ from jj_stack.stack.pr_facts import duplicate_pr_claim_change_ids
42
+ from jj_stack.stack.repo import observe_repo_paths
43
+ from jj_stack.stack.selected import select_stack_path
44
+ from jj_stack.stack.status import status_preparation_cli_error
45
+ from jj_stack.state.operation_lock import acquire_operation_lock
46
+
47
+ HELP = "Check out an existing stack of pull requests"
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class CheckoutResult:
52
+ """Outcome of attaching one exact pull-request stack."""
53
+
54
+ adopted_count: int
55
+ fetched_tip_commit: str | None
56
+ stack: LocalStack
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class CheckoutPickerChoice:
61
+ """One local or GitHub stack offered by the interactive picker."""
62
+
63
+ details: tuple[str, ...]
64
+ heading: str
65
+ pr: str | None = None
66
+ revset: str | None = None
67
+
68
+
69
+ def checkout(
70
+ *,
71
+ cli_args: JjCliArgs,
72
+ debug: bool,
73
+ pick: bool,
74
+ pr: str | None,
75
+ repo: Path | None,
76
+ revset: str | None,
77
+ ) -> int:
78
+ """CLI entrypoint for `checkout`."""
79
+
80
+ context = bootstrap_context(repo=repo, cli_args=cli_args, debug=debug)
81
+ if pick:
82
+ choice = asyncio.run(_pick_stack(context))
83
+ pr = choice.pr
84
+ revset = choice.revset
85
+ with acquire_operation_lock(context.state_store.require_writable(), command="checkout"):
86
+ result = asyncio.run(
87
+ _run_checkout_async(
88
+ context=context,
89
+ pr_reference=pr,
90
+ revset=revset,
91
+ )
92
+ )
93
+ if result.stack.changes:
94
+ if result.adopted_count:
95
+ prepare_visible_pr_snapshots(
96
+ jj_client=context.jj_client,
97
+ state=context.state_store.load(),
98
+ )
99
+ context.jj_client.edit_commit(result.stack.head.commit_id)
100
+ if result.fetched_tip_commit is not None:
101
+ console.output(ui.prefixed_line("Fetched tip commit: ", result.fetched_tip_commit))
102
+ if result.adopted_count:
103
+ noun = "PR" if result.adopted_count == 1 else "PRs"
104
+ console.output(f"Updated local tracking for {result.adopted_count} {noun}.")
105
+ elif result.stack.changes:
106
+ console.output("Local tracking is already up to date for this stack.")
107
+ else:
108
+ console.output("The selected stack has no changes to check out.")
109
+ if result.stack.changes:
110
+ console.output(
111
+ t"Working copy now edits {ui.change_id(result.stack.head.change_id)} "
112
+ t"({result.stack.head.subject})."
113
+ )
114
+ return 0
115
+
116
+
117
+ async def _run_checkout_async(
118
+ *,
119
+ context: CommandContext,
120
+ pr_reference: str | None,
121
+ revset: str | None,
122
+ ) -> CheckoutResult:
123
+ if pr_reference is not None and revset is not None:
124
+ raise UsageError(
125
+ t"{ui.cmd('checkout')} accepts at most one selector: "
126
+ t"{ui.cmd('--pull-request')} or {ui.cmd('--revset')}."
127
+ )
128
+ if pr_reference is None:
129
+ return _checkout_saved_stack(context=context, revset=revset)
130
+ return await _checkout_pr_stack(
131
+ context=context,
132
+ pr_reference=pr_reference,
133
+ )
134
+
135
+
136
+ def _checkout_saved_stack(
137
+ *,
138
+ context: CommandContext,
139
+ revset: str | None,
140
+ ) -> CheckoutResult:
141
+ client = context.jj_client
142
+ state = context.state_store.load()
143
+ stack = select_stack_path(
144
+ jj_client=client,
145
+ revset=revset,
146
+ state=state,
147
+ ).stack
148
+ incomplete = tuple(
149
+ change for change in stack.changes if state.pr_identities.get(change.change_id) is None
150
+ )
151
+ if incomplete:
152
+ raise CliError(
153
+ t"jj-stack has no saved pull request for some changes in this stack: "
154
+ t"{ui.join(ui.change_id, (change.change_id for change in incomplete))}.",
155
+ hint=t"Attach it with {ui.cmd('checkout --pull-request PR')}.",
156
+ )
157
+ return CheckoutResult(adopted_count=0, fetched_tip_commit=None, stack=stack)
158
+
159
+
160
+ async def _checkout_pr_stack(
161
+ *,
162
+ context: CommandContext,
163
+ pr_reference: str,
164
+ ) -> CheckoutResult:
165
+ client = context.jj_client
166
+ state = context.state_store.load()
167
+ remote = select_submit_remote(client.list_git_remotes())
168
+ repo = require_github_repo(remote)
169
+ pr_number = parse_repo_pr_reference(
170
+ reference=pr_reference,
171
+ github_repo=repo,
172
+ )
173
+ async with build_github_client(repo=repo) as github_client:
174
+ top_pr = await _load_pr(
175
+ github_client=github_client,
176
+ pr_number=pr_number,
177
+ )
178
+ _validate_same_repo_managed_pr(
179
+ pr=top_pr,
180
+ repo=repo,
181
+ )
182
+ top_head_sha = _require_pr_head_sha(top_pr)
183
+ observed_top = client.list_remote_branches(
184
+ remote=remote.name,
185
+ patterns=(f"refs/heads/{top_pr.head.ref}",),
186
+ ).get(top_pr.head.ref)
187
+ if observed_top != top_head_sha:
188
+ raise CliError(
189
+ t"PR #{pr_number} and remote branch "
190
+ t"{ui.bookmark(top_pr.head.ref)} no longer identify the same commit."
191
+ )
192
+ prs = await _load_pr_chain(
193
+ github_client=github_client,
194
+ repo=repo,
195
+ top=top_pr,
196
+ )
197
+
198
+ for pr in reversed(prs):
199
+ _reject_locally_rewritten_change(
200
+ client=client,
201
+ head_sha=_require_pr_head_sha(pr),
202
+ pr_number=pr.number,
203
+ remote_name=remote.name,
204
+ )
205
+ matches = client.query_commits_by_ids((top_head_sha,))
206
+ fetched = not matches
207
+ if fetched:
208
+ client.fetch_remote(
209
+ remote=remote.name,
210
+ )
211
+ with client.import_remote_pr_branch_ref(
212
+ remote=remote.name,
213
+ branch=top_pr.head.ref,
214
+ expected_target=top_head_sha,
215
+ ) as imported:
216
+ _require_branch_matches_change(
217
+ branch=top_pr.head.ref,
218
+ change=imported,
219
+ )
220
+ stack = _discover_checkout_stack(
221
+ client=client,
222
+ commit_id=imported.commit_id,
223
+ state=state,
224
+ )
225
+ else:
226
+ _require_branch_matches_change(
227
+ branch=top_pr.head.ref,
228
+ change=matches[0],
229
+ )
230
+ stack = _discover_checkout_stack(
231
+ client=client,
232
+ commit_id=top_head_sha,
233
+ state=state,
234
+ )
235
+
236
+ adopted_count = _save_checkout_tracking(
237
+ context=context,
238
+ prs=prs,
239
+ remote_name=remote.name,
240
+ repo=repo,
241
+ stack=stack,
242
+ state=state,
243
+ )
244
+ return CheckoutResult(
245
+ adopted_count=adopted_count,
246
+ fetched_tip_commit=(top_head_sha if fetched else None),
247
+ stack=stack,
248
+ )
249
+
250
+
251
+ def _reject_locally_rewritten_change(
252
+ *,
253
+ client: JjClient,
254
+ head_sha: str,
255
+ pr_number: int,
256
+ remote_name: str,
257
+ ) -> None:
258
+ """Reject a submitted snapshot that disagrees with a visible local change.
259
+
260
+ The remote commit's change ID is read without creating a ref. On a fresh checkout that costs
261
+ one extra object fetch; reading the same header inside the import primitive would instead
262
+ give that shared primitive a second policy path. The check also covers an already visible
263
+ submitted snapshot, where editing it would silently choose against the rewritten local change.
264
+ """
265
+
266
+ change_id = client.read_remote_git_change_id(
267
+ remote=remote_name,
268
+ commit_id=head_sha,
269
+ )
270
+ if change_id is None:
271
+ return
272
+ # `change_id()` rather than an exact symbol: it tolerates a change that is already
273
+ # divergent, which is the state this check exists to keep the tool out of.
274
+ local_commits = client.query_commits(f"change_id({change_id})")
275
+ if not any(commit.commit_id != head_sha for commit in local_commits):
276
+ return
277
+ if len(local_commits) > 1:
278
+ raise CliError(
279
+ t"Change {ui.change_id(change_id)} already has more than one visible commit "
280
+ t"here, so PR #{pr_number} cannot be attached to one of them.",
281
+ hint=t"Inspect them with {ui.cmd('jj log -r')} "
282
+ t"{ui.revset(f'change_id({change_id})')}, abandon the copies you do not want, "
283
+ t"then retry.",
284
+ )
285
+ raise CliError(
286
+ t"Change {ui.change_id(change_id)} is already here at a different commit than "
287
+ t"PR #{pr_number}'s head, so checkout cannot choose between them.",
288
+ hint=t"Attach the pull request to the local change with "
289
+ t"{ui.cmd(f'jj-stack relink {pr_number} {change_id}')}.",
290
+ )
291
+
292
+
293
+ def _discover_checkout_stack(
294
+ *,
295
+ client: JjClient,
296
+ commit_id: str,
297
+ state: TrackingState,
298
+ ) -> LocalStack:
299
+ """Resolve the PR stack, translating shape failures into repair guidance."""
300
+
301
+ try:
302
+ return select_stack_path(
303
+ jj_client=client,
304
+ revset=commit_id,
305
+ state=state,
306
+ ).stack
307
+ except UnsupportedStackError as error:
308
+ raise status_preparation_cli_error(error) from error
309
+
310
+
311
+ async def _load_pr(
312
+ *,
313
+ github_client: GithubClient,
314
+ pr_number: int,
315
+ ) -> GithubPR:
316
+ try:
317
+ return await github_client.get_pr(pr_number=pr_number)
318
+ except GithubClientError as error:
319
+ raise CliError(f"Could not load pull request #{pr_number}") from error
320
+
321
+
322
+ async def _load_pr_chain(
323
+ *,
324
+ github_client: GithubClient,
325
+ repo: GithubRepoAddress,
326
+ top: GithubPR,
327
+ ) -> tuple[GithubPR, ...]:
328
+ """Walk exact managed base refs from the selected PR to trunk."""
329
+
330
+ namespace = current_pr_branch_namespace()
331
+ top_down = [top]
332
+ seen = {top.head.ref}
333
+ base = top.base.ref
334
+ while namespace.contains(base):
335
+ if base in seen:
336
+ raise CliError(
337
+ t"Pull request base branches point at each other in a loop, starting at "
338
+ t"{ui.bookmark(base)}.",
339
+ hint=t"Retarget one of those pull requests on GitHub so the stack has a "
340
+ t"bottom, then rerun {ui.cmd('jj-stack checkout')}.",
341
+ )
342
+ seen.add(base)
343
+ try:
344
+ matches = (await github_client.get_open_prs_by_head_refs(head_refs=(base,))).get(
345
+ base,
346
+ (),
347
+ )
348
+ except GithubClientError as error:
349
+ raise CliError(f"Could not inspect pull request branch {base}") from error
350
+ if len(matches) != 1:
351
+ raise CliError(
352
+ t"Expected one pull request for managed base branch {ui.bookmark(base)}, "
353
+ t"but GitHub reports {len(matches)}.",
354
+ hint=t"Select the intended PR explicitly and repair it with {ui.cmd('relink')}.",
355
+ )
356
+ parent = matches[0]
357
+ _validate_same_repo_managed_pr(
358
+ pr=parent,
359
+ repo=repo,
360
+ )
361
+ top_down.append(parent)
362
+ base = parent.base.ref
363
+ return tuple(reversed(top_down))
364
+
365
+
366
+ def _save_checkout_tracking(
367
+ *,
368
+ context: CommandContext,
369
+ prs: tuple[GithubPR, ...],
370
+ remote_name: str,
371
+ repo: GithubRepoAddress,
372
+ stack: LocalStack,
373
+ state: TrackingState,
374
+ ) -> int:
375
+ pr_heads = tuple(_require_pr_head_sha(pr) for pr in prs)
376
+ if len(prs) != len(stack.changes) or pr_heads != tuple(
377
+ change.commit_id for change in stack.changes
378
+ ):
379
+ raise CliError(
380
+ "The selected pull requests do not describe the stack that was just fetched.",
381
+ hint=t"Run {ui.cmd('jj-stack view')} to compare them, then submit or "
382
+ t"relink the pull requests that should match this history.",
383
+ )
384
+ remote_targets = context.jj_client.list_remote_branches(
385
+ remote=remote_name,
386
+ patterns=tuple(f"refs/heads/{pr.head.ref}" for pr in prs),
387
+ )
388
+ replacements: dict[str, tuple[PRIdentity, SubmittedBaseline]] = {}
389
+ for pr, head_sha, change in zip(
390
+ prs,
391
+ pr_heads,
392
+ stack.changes,
393
+ strict=True,
394
+ ):
395
+ _require_branch_matches_change(branch=pr.head.ref, change=change)
396
+ if remote_targets.get(pr.head.ref) != head_sha:
397
+ raise CliError(
398
+ t"PR #{pr.number} and branch "
399
+ t"{ui.bookmark(pr.head.ref)} no longer identify the same commit."
400
+ )
401
+ replacements[change.change_id] = (
402
+ PRIdentity(
403
+ repo_owner=repo.owner,
404
+ repo_name=repo.repo,
405
+ pr_number=pr.number,
406
+ head_owner=repo.owner,
407
+ head_ref=pr.head.ref,
408
+ ),
409
+ SubmittedBaseline(commit_id=head_sha),
410
+ )
411
+ _reject_duplicate_checkout_claims(
412
+ current=state.pr_identities,
413
+ replacements={change_id: pair[0] for change_id, pair in replacements.items()},
414
+ )
415
+ changed_count = sum(
416
+ (
417
+ state.pr_identities.get(change_id),
418
+ state.submitted_baselines.get(change_id),
419
+ )
420
+ != replacement
421
+ for change_id, replacement in replacements.items()
422
+ )
423
+ if not changed_count:
424
+ return 0
425
+ context.state_store.relink_prs(
426
+ replacements=replacements,
427
+ )
428
+ return changed_count
429
+
430
+
431
+ def _reject_duplicate_checkout_claims(
432
+ *,
433
+ current: dict[str, PRIdentity],
434
+ replacements: dict[str, PRIdentity],
435
+ ) -> None:
436
+ combined = dict(current)
437
+ combined.update(replacements)
438
+ if duplicate_pr_claim_change_ids(combined).intersection(replacements):
439
+ raise CliError(
440
+ "Another saved change already claims one of those pull request numbers or branches.",
441
+ hint=t"Run {ui.cmd('jj-stack list')} to find the claiming change, then drop its "
442
+ t"tracking with {ui.cmd('jj-stack unstack --local')} or clean it up with "
443
+ t"{ui.cmd('jj-stack cleanup')}.",
444
+ )
445
+
446
+
447
+ def _validate_same_repo_managed_pr(
448
+ *,
449
+ pr: GithubPR,
450
+ repo: GithubRepoAddress,
451
+ ) -> None:
452
+ namespace = current_pr_branch_namespace()
453
+ expected_label = f"{repo.owner}:{pr.head.ref}"
454
+ if pr.head.label != expected_label:
455
+ raise CliError(
456
+ t"Pull request #{pr.number} head "
457
+ t"{ui.bookmark(pr.head.label or pr.head.ref)} does not "
458
+ t"belong to {repo.full_name}."
459
+ )
460
+ if not namespace.contains(pr.head.ref):
461
+ raise CliError(
462
+ t"Pull request #{pr.number} head "
463
+ t"{ui.bookmark(pr.head.ref)} is not in the reserved "
464
+ t"{ui.bookmark(namespace.branch_prefix)} namespace."
465
+ )
466
+
467
+
468
+ def _require_pr_head_sha(pr: GithubPR) -> str:
469
+ head_sha = pr.head.sha
470
+ if head_sha is None:
471
+ raise CliError(
472
+ t"GitHub did not report a head commit for PR #{pr.number}.",
473
+ hint="Refresh the pull request on GitHub, then retry.",
474
+ )
475
+ return head_sha
476
+
477
+
478
+ def _require_branch_matches_change(*, branch: str, change: LocalCommit) -> None:
479
+ if not pr_branch_matches_change(branch, change.change_id):
480
+ raise CliError(
481
+ t"PR branch {ui.bookmark(branch)} does not match change "
482
+ t"{ui.change_id(change.change_id)}."
483
+ )
484
+
485
+
486
+ async def _pick_stack(context: CommandContext) -> CheckoutPickerChoice:
487
+ """Prompt for one local or GitHub stack without holding the operation lock."""
488
+
489
+ state = context.state_store.load()
490
+ if not state.pr_identities:
491
+ local_stacks: list[LocalStack] = []
492
+ else:
493
+ repo_paths = observe_repo_paths(
494
+ jj_client=context.jj_client,
495
+ state=state,
496
+ )
497
+ local_stacks = sorted(
498
+ (path.stack for path in repo_paths.paths if path.tracked_change_ids),
499
+ key=lambda stack: stack.head.change_id,
500
+ )
501
+ remote = select_submit_remote(context.jj_client.list_git_remotes())
502
+ repo = require_github_repo(remote)
503
+ async with build_github_client(repo=repo) as github_client:
504
+ repo_result, stacks_result = await asyncio.gather(
505
+ github_client.get_repo(),
506
+ github_client.list_stacks(),
507
+ return_exceptions=True,
508
+ )
509
+ if isinstance(repo_result, GithubClientError):
510
+ raise CliError(f"Could not inspect GitHub repo {repo.full_name}") from repo_result
511
+ if isinstance(repo_result, BaseException):
512
+ raise repo_result
513
+ if isinstance(stacks_result, GithubClientError):
514
+ unavailable = github_stacks_unavailable_error(
515
+ error=stacks_result,
516
+ repo=repo.full_name,
517
+ )
518
+ if unavailable is not None:
519
+ raise unavailable from None
520
+ raise CliError("Could not list GitHub stacks for checkout.") from stacks_result
521
+ if isinstance(stacks_result, BaseException):
522
+ raise stacks_result
523
+ github_stacks = stacks_result
524
+ try:
525
+ prs = await github_client.get_prs_by_numbers(
526
+ pr_numbers=tuple(
527
+ member.number for stack in github_stacks for member in stack.prs
528
+ ),
529
+ )
530
+ except GithubClientError as error:
531
+ raise CliError("Could not list GitHub stacks for checkout.") from error
532
+ choices = _picker_choices(
533
+ github_stacks=github_stacks,
534
+ local_stacks=local_stacks,
535
+ prs=prs,
536
+ repo=repo,
537
+ state=state,
538
+ visible_commit_ids={
539
+ commit.commit_id
540
+ for commit in context.jj_client.query_commits_by_ids(
541
+ tuple(member.head.sha for stack in github_stacks for member in stack.prs)
542
+ )
543
+ if not commit.hidden
544
+ },
545
+ )
546
+ return _prompt_picker_choice(choices)
547
+
548
+
549
+ def _prompt_picker_choice(
550
+ choices: tuple[CheckoutPickerChoice, ...],
551
+ ) -> CheckoutPickerChoice:
552
+ """Read one validated numbered selection from the interactive picker."""
553
+
554
+ if not choices:
555
+ raise CliError(
556
+ "No active local or GitHub stacks to pick from.",
557
+ hint=t"Use {ui.cmd('checkout --pull-request PR')} to attach a pull request directly.",
558
+ )
559
+ console.output("Available stacks:")
560
+ for index, choice in enumerate(choices, start=1):
561
+ console.output(f" [{index}] {choice.heading}")
562
+ for detail in choice.details:
563
+ console.output(f" {detail}")
564
+ console.output(t"Pick a stack [1-{len(choices)}]: ")
565
+ selection = sys.stdin.readline().strip()
566
+ if not selection.isdigit() or not 1 <= int(selection) <= len(choices):
567
+ raise UsageError(
568
+ t"{ui.cmd(selection or '(empty)')} is not a valid stack number; "
569
+ t"expected 1-{len(choices)}."
570
+ )
571
+ return choices[int(selection) - 1]
572
+
573
+
574
+ def _picker_choices(
575
+ *,
576
+ github_stacks: tuple[GithubStack, ...],
577
+ local_stacks: list[LocalStack],
578
+ prs: dict[int, GithubPR | None],
579
+ repo: GithubRepoAddress,
580
+ state: TrackingState,
581
+ visible_commit_ids: set[str],
582
+ ) -> tuple[CheckoutPickerChoice, ...]:
583
+ saved_by_pr = {
584
+ identity.pr_number: (change_id, identity)
585
+ for change_id, identity in state.pr_identities.items()
586
+ if identity.repo_key == repo.repo_key
587
+ }
588
+ choices: list[CheckoutPickerChoice] = []
589
+ listed_pr_numbers: set[int] = set()
590
+ for stack in sorted(github_stacks, key=lambda candidate: candidate.number):
591
+ active_numbers = stack.active_pr_numbers
592
+ if not active_numbers:
593
+ continue
594
+ numbers = stack.pr_numbers
595
+ members = tuple(prs.get(number) for number in numbers)
596
+ if any(member is None for member in members):
597
+ missing = next(
598
+ number for number, member in zip(numbers, members, strict=True) if member is None
599
+ )
600
+ raise CliError(f"GitHub stack #{stack.number} refers to missing PR #{missing}.")
601
+ resolved = tuple(member for member in members if member is not None)
602
+ if not all(_picker_pr_is_adoptable(member, repo) for member in resolved):
603
+ continue
604
+ bottom = resolved[0]
605
+ top = next(member for member in reversed(resolved) if member.number in active_numbers)
606
+ statuses = Counter(_picker_pr_status(member) for member in resolved)
607
+ status = ", ".join(
608
+ f"{count} {name}"
609
+ for name in ("open", "draft", "closed", "merged")
610
+ if (count := statuses[name])
611
+ )
612
+ active_members = tuple(member for member in resolved if member.number in active_numbers)
613
+ change_id_by_pr = {
614
+ member.number: saved[0]
615
+ for member in active_members
616
+ if (saved := saved_by_pr.get(member.number)) is not None
617
+ and saved[1].matches_pr(member)
618
+ }
619
+ local = len(change_id_by_pr) == len(active_members) and all(
620
+ member.head.sha in visible_commit_ids for member in active_members
621
+ )
622
+ visible_count = sum(member.head.sha in visible_commit_ids for member in active_members)
623
+ locality = "local" if local else "partly local" if visible_count else "GitHub only"
624
+ noun = "PR" if len(numbers) == 1 else "PRs"
625
+ choices.append(
626
+ CheckoutPickerChoice(
627
+ heading=f"GitHub stack #{stack.number} ({locality})",
628
+ details=(
629
+ f"Top: PR #{top.number} {top.title}",
630
+ f"Base: {bottom.base.ref}",
631
+ f"Size: {len(numbers)} {noun}",
632
+ f"Status: {status}",
633
+ ),
634
+ revset=change_id_by_pr[top.number] if local else None,
635
+ pr=None if local else str(top.number),
636
+ )
637
+ )
638
+ listed_pr_numbers.update(numbers)
639
+ for stack in local_stacks:
640
+ identity = state.pr_identities.get(stack.head.change_id)
641
+ if identity is not None and identity.pr_number in listed_pr_numbers:
642
+ continue
643
+ count = len(stack.changes)
644
+ noun = "change" if count == 1 else "changes"
645
+ choices.append(
646
+ CheckoutPickerChoice(
647
+ heading=f"Local stack {stack.head.change_id}",
648
+ details=(f"Head: {stack.head.subject}", f"Size: {count} {noun}"),
649
+ revset=stack.head.change_id,
650
+ )
651
+ )
652
+ return tuple(choices)
653
+
654
+
655
+ def _picker_pr_status(pr: GithubPR) -> str:
656
+ normalized = pr.normalize_state()
657
+ if normalized.state == "open" and normalized.is_draft:
658
+ return "draft"
659
+ return normalized.state
660
+
661
+
662
+ def _picker_pr_is_adoptable(
663
+ pr: GithubPR,
664
+ repo: GithubRepoAddress,
665
+ ) -> bool:
666
+ return current_pr_branch_namespace().contains(pr.head.ref) and (
667
+ pr.head.label == f"{repo.owner}:{pr.head.ref}"
668
+ )