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,385 @@
1
+ """Ask GitHub to merge pull requests at the bottom of a stack.
2
+
3
+ Starting at the bottom of the stack, `jj-stack` selects consecutive open, non-draft pull
4
+ requests. Each must still match the commit that was last submitted; GitHub decides whether
5
+ reviews, checks, conflicts, and repo rules allow the merge.
6
+
7
+ For a direct merge, the command waits for GitHub to finish. It then fetches trunk, removes the
8
+ merged changes from the local stack, rebases any remaining changes onto the updated trunk, and
9
+ updates their existing pull requests.
10
+
11
+ When the trunk branch uses a merge queue, the command adds the pull requests to the queue and
12
+ exits once GitHub accepts them. It does not wait for them to merge or update the local stack.
13
+ After GitHub finishes, run `jj-stack sync <head-change-id>`.
14
+
15
+ Common examples:
16
+
17
+ - `jj-stack merge --dry-run` previews the merge without changing GitHub.
18
+
19
+ - `jj-stack merge` asks GitHub to merge the ready PRs at the bottom of the stack.
20
+
21
+ - `jj-stack merge --pull-request 123 --method squash` selects PR 123 as the last PR to merge and
22
+ chooses the merge method explicitly.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ from pathlib import Path
29
+
30
+ import jj_stack.console as console
31
+ import jj_stack.ui as ui
32
+ from jj_stack.bootstrap import CommandContext, bootstrap_context
33
+ from jj_stack.commands.sync import run_stack_convergence
34
+ from jj_stack.config import MergeMethod
35
+ from jj_stack.errors import CliError
36
+ from jj_stack.github.client import GithubClientError, build_github_client
37
+ from jj_stack.github.resolution import resolve_trunk_branch
38
+ from jj_stack.jj.cli_args import JjCliArgs
39
+ from jj_stack.models.github import GithubRepo
40
+ from jj_stack.stack.github_stack_safety import GithubStackSelection
41
+ from jj_stack.stack.pr_facts import observe_prs
42
+ from jj_stack.stack.selection import (
43
+ resolve_linked_change_for_pr,
44
+ resolve_selected_revset,
45
+ )
46
+ from jj_stack.stack.status import prepare_status
47
+ from jj_stack.state.operation_lock import acquire_operation_lock
48
+
49
+ from .github_stack import (
50
+ build_async_merge_plan,
51
+ execute_async_merge,
52
+ validate_terminal_retry,
53
+ )
54
+ from .models import MergeExecutionInputs, MergeResult, PreparedMerge
55
+ from .plan import build_merge_plan
56
+ from .render import print_merge_result
57
+
58
+ HELP = "Merge pull requests at the bottom of a stack"
59
+
60
+
61
+ def merge(
62
+ *,
63
+ cli_args: JjCliArgs,
64
+ debug: bool,
65
+ dry_run: bool,
66
+ merge_method: str | None,
67
+ pr: str | None,
68
+ repo: Path | None,
69
+ revset: str | None,
70
+ ) -> int:
71
+ context = bootstrap_context(
72
+ repo=repo,
73
+ cli_args=cli_args,
74
+ debug=debug,
75
+ )
76
+ with acquire_operation_lock(
77
+ context.state_store.require_writable(),
78
+ command="merge",
79
+ ):
80
+ return _run_merge(
81
+ context=context,
82
+ dry_run=dry_run,
83
+ merge_method=merge_method,
84
+ pr=pr,
85
+ revset=revset,
86
+ )
87
+
88
+
89
+ def _run_merge(
90
+ *,
91
+ context: CommandContext,
92
+ dry_run: bool,
93
+ merge_method: str | None,
94
+ pr: str | None,
95
+ revset: str | None,
96
+ ) -> int:
97
+ selected_revset, target_change_id = _resolve_merge_target(
98
+ context=context,
99
+ pr=pr,
100
+ revset=revset,
101
+ )
102
+ with console.spinner(description="Inspecting jj stack"):
103
+ prepared_merge = _prepare_merge(
104
+ context=context,
105
+ dry_run=dry_run,
106
+ merge_method=merge_method,
107
+ revset=selected_revset,
108
+ target_change_id=target_change_id,
109
+ )
110
+ result = asyncio.run(_stream_merge_async(prepared_merge=prepared_merge))
111
+ print_merge_result(result)
112
+ if result.blocked:
113
+ return 1
114
+ if result.enqueued or not result.applied:
115
+ return 0
116
+ sync_change_id = prepared_merge.prepared_status.prepared.stack.head.change_id
117
+ console.output("Updating the local stack after the completed merge:")
118
+ try:
119
+ exit_code = run_stack_convergence(
120
+ context=context,
121
+ dry_run=False,
122
+ fetch_remote_state=True,
123
+ revset=sync_change_id,
124
+ )
125
+ except BaseException as error:
126
+ _warn_incomplete_post_merge_sync(sync_change_id)
127
+ if isinstance(error, GithubClientError):
128
+ raise CliError(
129
+ "Could not update the local stack after the completed merge.",
130
+ hint=t"Resolve the GitHub error, then run "
131
+ t"{ui.cmd('jj-stack sync')} {ui.change_id(sync_change_id)}",
132
+ ) from error
133
+ raise
134
+ if exit_code:
135
+ _warn_incomplete_post_merge_sync(sync_change_id)
136
+ return exit_code
137
+
138
+
139
+ def _warn_incomplete_post_merge_sync(sync_change_id: str) -> None:
140
+ console.warning(
141
+ t"GitHub completed the merge, but the local stack update did not finish. Do not run "
142
+ t"{ui.cmd('jj-stack merge')} again. Continue with "
143
+ t"{ui.cmd('jj-stack sync')} {ui.change_id(sync_change_id)}."
144
+ )
145
+
146
+
147
+ def _resolve_merge_target(
148
+ *,
149
+ context: CommandContext,
150
+ pr: str | None,
151
+ revset: str | None,
152
+ ) -> tuple[str | None, str | None]:
153
+ if pr is not None:
154
+ pr_number, resolved_revset = resolve_linked_change_for_pr(
155
+ jj_client=context.jj_client,
156
+ pr_reference=pr,
157
+ revset=revset,
158
+ )
159
+ console.note(t"Using PR #{pr_number} -> {ui.change_id(resolved_revset)}")
160
+ return None, resolved_revset
161
+ return (
162
+ resolve_selected_revset(
163
+ command_label="merge",
164
+ default_revset=None,
165
+ require_explicit=False,
166
+ revset=revset,
167
+ ),
168
+ None,
169
+ )
170
+
171
+
172
+ def _prepare_merge(
173
+ *,
174
+ context: CommandContext,
175
+ dry_run: bool,
176
+ merge_method: str | None,
177
+ revset: str | None,
178
+ target_change_id: str | None,
179
+ ) -> PreparedMerge:
180
+ prepared_status = prepare_status(
181
+ containing_change_id=target_change_id,
182
+ context=context,
183
+ fetch_remote_state=True,
184
+ observe_remote_targets=False,
185
+ revset=revset,
186
+ )
187
+ prepared = prepared_status.prepared
188
+ if prepared.remote is None:
189
+ message = prepared.remote_error or t"Could not determine which Git remote to use."
190
+ raise CliError(
191
+ message,
192
+ hint=t"Configure one GitHub remote, then rerun. "
193
+ t"{ui.cmd('jj-stack doctor')} reports what it found.",
194
+ )
195
+ if prepared_status.github_repo is None:
196
+ message = prepared_status.github_repo_error or t"Could not resolve GitHub target."
197
+ raise CliError(
198
+ message,
199
+ hint=t"Point jj-stack at a GitHub remote, then rerun. "
200
+ t"{ui.cmd('jj-stack doctor')} reports what it found.",
201
+ )
202
+
203
+ if not dry_run:
204
+ context.state_store.require_writable()
205
+ return PreparedMerge(
206
+ context=context,
207
+ dry_run=dry_run,
208
+ merge_method=merge_method,
209
+ prepared_status=prepared_status,
210
+ target_change_id=target_change_id,
211
+ )
212
+
213
+
214
+ async def _stream_merge_async(
215
+ *,
216
+ prepared_merge: PreparedMerge,
217
+ ) -> MergeResult:
218
+ prepared_status = prepared_merge.prepared_status
219
+ prepared = prepared_status.prepared
220
+ github_repo = prepared_status.github_repo
221
+ remote = prepared.remote
222
+ if github_repo is None or remote is None:
223
+ raise AssertionError("Prepared merge requires resolved GitHub and remote targets.")
224
+
225
+ async with build_github_client(repo=github_repo) as github_client:
226
+ with console.spinner(description="Loading remote branches"):
227
+ try:
228
+ github_repo_state, branches_at_trunk = await asyncio.gather(
229
+ github_client.get_repo(),
230
+ github_client.list_branches_for_head_commit(
231
+ commit_sha=prepared.stack.trunk.commit_id
232
+ ),
233
+ )
234
+ except GithubClientError as error:
235
+ raise CliError(
236
+ t"Could not inspect GitHub repo {github_repo.full_name}",
237
+ hint="Resolve the GitHub error above, then rerun merge.",
238
+ ) from error
239
+ trunk_branch, _trunk_targets = resolve_trunk_branch(
240
+ branches_at_trunk=branches_at_trunk,
241
+ github_repo_state=github_repo_state,
242
+ remote=remote,
243
+ trunk_commit_id=prepared.stack.trunk.commit_id,
244
+ )
245
+ try:
246
+ uses_merge_queue = await github_client.base_branch_uses_merge_queue(
247
+ branch=trunk_branch
248
+ )
249
+ except GithubClientError:
250
+ uses_merge_queue = False
251
+ if uses_merge_queue:
252
+ if prepared_merge.merge_method is not None:
253
+ console.warning(
254
+ t"The base branch {ui.bookmark(trunk_branch)} uses a merge queue; ignoring "
255
+ t"{ui.cmd('--method')}."
256
+ )
257
+ merge_action = "merge_queue"
258
+ resolved_merge_method = None
259
+ else:
260
+ merge_action = "direct_merge"
261
+ resolved_merge_method = _resolve_merge_method(
262
+ configured=prepared_merge.context.config.merge_method,
263
+ merge_method=prepared_merge.merge_method,
264
+ repo_state=github_repo_state,
265
+ )
266
+ try:
267
+ observation = await observe_prs(
268
+ change_ids=tuple(change.change_id for change in prepared.stack.changes),
269
+ context=prepared_merge.context,
270
+ github_client=github_client,
271
+ github_repo_snapshot=github_repo_state,
272
+ remote_name=remote.name,
273
+ )
274
+ except GithubClientError as error:
275
+ raise CliError(
276
+ "Could not inspect GitHub state for merge.",
277
+ hint="Resolve the GitHub error above, then rerun merge.",
278
+ ) from error
279
+
280
+ plan = build_merge_plan(
281
+ observation=observation,
282
+ remote_name=remote.name,
283
+ repo=github_repo,
284
+ changes=prepared.stack.changes,
285
+ state=prepared.state,
286
+ target_change_id=prepared_merge.target_change_id,
287
+ trunk_branch=trunk_branch,
288
+ )
289
+ selection = GithubStackSelection(
290
+ github_client,
291
+ tuple(change.identity.pr_number for change in plan.linked_changes),
292
+ )
293
+ stacks = await selection.observe()
294
+ async_merge = build_async_merge_plan(
295
+ plan,
296
+ stacks,
297
+ prepared_merge.target_change_id,
298
+ )
299
+ execution = MergeExecutionInputs(
300
+ remote_name=remote.name,
301
+ selected_revset=prepared.stack.selected_revset,
302
+ trunk_branch=trunk_branch,
303
+ trunk_subject=prepared.stack.trunk.subject,
304
+ )
305
+ if async_merge.planned and async_merge.terminal_retry:
306
+ validate_terminal_retry(
307
+ execution=execution,
308
+ github=github_client,
309
+ merge=async_merge,
310
+ observation=observation,
311
+ )
312
+ if prepared_merge.dry_run:
313
+ if async_merge.planned:
314
+ action = async_merge.action(
315
+ merge_action=merge_action,
316
+ method=resolved_merge_method,
317
+ trunk_branch=trunk_branch,
318
+ )
319
+ actions = (
320
+ (action, async_merge.boundary_action)
321
+ if async_merge.boundary_action is not None
322
+ else (action,)
323
+ )
324
+ return execution.result(actions=actions)
325
+ return execution.result(
326
+ actions=(
327
+ () if async_merge.boundary_action is None else (async_merge.boundary_action,)
328
+ )
329
+ )
330
+ return await execute_async_merge(
331
+ execution=execution,
332
+ github=github_client,
333
+ merge_action=merge_action,
334
+ merge_method=resolved_merge_method,
335
+ merge=async_merge,
336
+ )
337
+
338
+
339
+ def _resolve_merge_method(
340
+ *,
341
+ configured: MergeMethod | None,
342
+ merge_method: str | None,
343
+ repo_state: GithubRepo,
344
+ ) -> str:
345
+ """Choose the merge method, preferring this run's flag over the repo's configuration.
346
+
347
+ GitHub reports which methods a repo allows but never which one to prefer, so a repo that
348
+ allows several needs the choice made here.
349
+ """
350
+
351
+ settings = {
352
+ "merge": repo_state.allow_merge_commit,
353
+ "rebase": repo_state.allow_rebase_merge,
354
+ "squash": repo_state.allow_squash_merge,
355
+ }
356
+ if any(allowed is None for allowed in settings.values()):
357
+ if merge_method is not None:
358
+ return merge_method
359
+ raise CliError(
360
+ "GitHub did not report which merge methods this repo allows.",
361
+ hint=t"Pass {ui.cmd('--method')} explicitly.",
362
+ )
363
+ allowed_methods = sorted(method for method, allowed in settings.items() if allowed)
364
+ if not allowed_methods:
365
+ raise CliError(
366
+ "This repo does not allow any pull request merge method.",
367
+ hint="Fix the repo merge settings on GitHub before merging.",
368
+ )
369
+ chosen = merge_method or configured
370
+ if chosen is not None:
371
+ if chosen not in allowed_methods:
372
+ source = ui.cmd("--method") if merge_method else ui.code("jj-stack.merge_method")
373
+ raise CliError(
374
+ t"This repo does not allow {ui.cmd(chosen)} merges; it allows "
375
+ t"{ui.join(ui.cmd, allowed_methods)}.",
376
+ hint=t"Change {source} to one it allows, or enable {ui.cmd(chosen)} on GitHub.",
377
+ )
378
+ return chosen
379
+ if len(allowed_methods) == 1:
380
+ return allowed_methods[0]
381
+ raise CliError(
382
+ t"This repo allows more than one merge method ({ui.join(ui.cmd, allowed_methods)}).",
383
+ hint=t"Pass {ui.cmd('--method')}, or set it once with "
384
+ t"{ui.cmd('jj config set --repo jj-stack.merge_method squash')}.",
385
+ )