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.
- jj_stack/__init__.py +11 -0
- jj_stack/__main__.py +8 -0
- jj_stack/bootstrap.py +213 -0
- jj_stack/cli.py +1297 -0
- jj_stack/cli_help.py +680 -0
- jj_stack/commands/__init__.py +1 -0
- jj_stack/commands/_cleanup_actions.py +389 -0
- jj_stack/commands/_json_status.py +84 -0
- jj_stack/commands/checkout.py +668 -0
- jj_stack/commands/cleanup/command.py +702 -0
- jj_stack/commands/cleanup/shared.py +62 -0
- jj_stack/commands/cleanup/stale.py +79 -0
- jj_stack/commands/doctor.py +358 -0
- jj_stack/commands/in_use.py +31 -0
- jj_stack/commands/list_.py +667 -0
- jj_stack/commands/merge/__init__.py +1 -0
- jj_stack/commands/merge/command.py +385 -0
- jj_stack/commands/merge/github_stack.py +333 -0
- jj_stack/commands/merge/models.py +97 -0
- jj_stack/commands/merge/plan.py +109 -0
- jj_stack/commands/merge/preconditions.py +162 -0
- jj_stack/commands/merge/render.py +52 -0
- jj_stack/commands/relink.py +210 -0
- jj_stack/commands/submit/__init__.py +1 -0
- jj_stack/commands/submit/auto_close.py +108 -0
- jj_stack/commands/submit/changes.py +90 -0
- jj_stack/commands/submit/command.py +780 -0
- jj_stack/commands/submit/descriptions.py +570 -0
- jj_stack/commands/submit/github_stack.py +139 -0
- jj_stack/commands/submit/inputs.py +168 -0
- jj_stack/commands/submit/models.py +198 -0
- jj_stack/commands/submit/overview_comments.py +178 -0
- jj_stack/commands/submit/prs.py +461 -0
- jj_stack/commands/submit/render.py +129 -0
- jj_stack/commands/sync.py +510 -0
- jj_stack/commands/sync_apply.py +474 -0
- jj_stack/commands/unstack.py +321 -0
- jj_stack/commands/view.py +1151 -0
- jj_stack/completion.py +484 -0
- jj_stack/concurrency.py +93 -0
- jj_stack/config.py +170 -0
- jj_stack/console.py +708 -0
- jj_stack/errors.py +152 -0
- jj_stack/formatting.py +95 -0
- jj_stack/github/__init__.py +1 -0
- jj_stack/github/auth.py +34 -0
- jj_stack/github/client.py +1098 -0
- jj_stack/github/error_messages.py +89 -0
- jj_stack/github/overview_comments.py +35 -0
- jj_stack/github/pr_refs.py +70 -0
- jj_stack/github/resolution.py +208 -0
- jj_stack/github/stack_availability.py +30 -0
- jj_stack/identifiers.py +7 -0
- jj_stack/jj/__init__.py +1 -0
- jj_stack/jj/cli_args.py +23 -0
- jj_stack/jj/client.py +1459 -0
- jj_stack/jj/colors.py +199 -0
- jj_stack/models/__init__.py +1 -0
- jj_stack/models/git.py +15 -0
- jj_stack/models/github.py +243 -0
- jj_stack/models/stack.py +70 -0
- jj_stack/models/tracking.py +115 -0
- jj_stack/pr_branch_namespace.py +78 -0
- jj_stack/stack/__init__.py +1 -0
- jj_stack/stack/change_status.py +216 -0
- jj_stack/stack/convergence.py +501 -0
- jj_stack/stack/convergence_models.py +69 -0
- jj_stack/stack/convergence_observation.py +162 -0
- jj_stack/stack/github_stack_safety.py +132 -0
- jj_stack/stack/global_convergence.py +270 -0
- jj_stack/stack/path.py +235 -0
- jj_stack/stack/pr_branches.py +126 -0
- jj_stack/stack/pr_facts.py +165 -0
- jj_stack/stack/repo.py +95 -0
- jj_stack/stack/selected.py +358 -0
- jj_stack/stack/selection.py +124 -0
- jj_stack/stack/status.py +711 -0
- jj_stack/stack/trunk_evidence.py +174 -0
- jj_stack/state/__init__.py +1 -0
- jj_stack/state/operation_lock.py +220 -0
- jj_stack/state/store.py +260 -0
- jj_stack/ui.py +230 -0
- jj_stack-0.1.0.dist-info/METADATA +170 -0
- jj_stack-0.1.0.dist-info/RECORD +88 -0
- jj_stack-0.1.0.dist-info/WHEEL +4 -0
- jj_stack-0.1.0.dist-info/entry_points.txt +2 -0
- jj_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|