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
jj_stack/cli.py ADDED
@@ -0,0 +1,1297 @@
1
+ """CLI entrypoint for the standalone `jj-stack` executable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from argparse import (
10
+ SUPPRESS,
11
+ ArgumentParser,
12
+ ArgumentTypeError,
13
+ HelpFormatter,
14
+ Namespace,
15
+ _SubParsersAction,
16
+ )
17
+ from collections.abc import Callable, Sequence
18
+ from contextlib import contextmanager
19
+ from dataclasses import dataclass
20
+ from inspect import signature
21
+ from pathlib import Path
22
+ from typing import Any, NoReturn, cast
23
+
24
+ import jj_stack.bootstrap as bootstrap
25
+ import jj_stack.commands.checkout as checkout_command
26
+ import jj_stack.commands.cleanup.command as cleanup_command
27
+ import jj_stack.commands.doctor as doctor_command
28
+ import jj_stack.commands.in_use as in_use_command
29
+ import jj_stack.commands.list_ as list_command
30
+ import jj_stack.commands.merge.command as merge_command
31
+ import jj_stack.commands.relink as relink_command
32
+ import jj_stack.commands.submit.command as submit_command
33
+ import jj_stack.commands.sync as sync_command
34
+ import jj_stack.commands.unstack as unstack_command
35
+ import jj_stack.commands.view as view_command
36
+ import jj_stack.console as console
37
+ import jj_stack.ui as ui
38
+ from jj_stack import __version__
39
+ from jj_stack.cli_help import (
40
+ HelpCommand,
41
+ add_help_argument,
42
+ add_help_section,
43
+ emit_command_help,
44
+ emit_top_level_help,
45
+ normalized_help_text,
46
+ render_all_in_one_markdown,
47
+ )
48
+ from jj_stack.completion import emit_shell_completion, validate_jj_alias
49
+ from jj_stack.console import RequestedColorMode, configured_console, rich_color_mode
50
+ from jj_stack.errors import (
51
+ EXIT_INTERRUPTED,
52
+ CliError,
53
+ UsageError,
54
+ error_hint,
55
+ error_message,
56
+ resolve_exit_code,
57
+ )
58
+ from jj_stack.jj.cli_args import JjCliArgs
59
+
60
+ logger = logging.getLogger(__name__)
61
+ _COLOR_CHOICES: tuple[RequestedColorMode, ...] = ("always", "never", "debug", "auto")
62
+ _TOP_LEVEL_HELP_USAGE = "jj-stack [--help] [--color WHEN] [--version] [<command> ...]"
63
+ _TOP_LEVEL_HELP_DESCRIPTION = """
64
+ `jj-stack` lets you submit a stack of `jj` changes for review on GitHub.
65
+
66
+ Use it to submit and refresh pull requests, inspect their status, merge pull
67
+ requests, and clean up after they close or merge. Keep creating and editing changes with `jj`;
68
+ `jj-stack` submits and updates their pull requests on GitHub.
69
+
70
+ Running `jj-stack` with no command shows the current stack. A typical workflow is
71
+ `jj-stack submit`, `jj-stack view`, then `jj-stack merge`. After a direct merge, `jj-stack`
72
+ updates the local stack; apply a queued or externally completed merge later with `jj-stack sync`.
73
+ """
74
+ _REORDERABLE_GLOBAL_FLAGS = frozenset({"--debug", "--time-output"})
75
+ _REORDERABLE_GLOBAL_OPTIONS_WITH_VALUES = frozenset({"--repository", "--color"})
76
+ _HELP_FLAGS = frozenset({"-h", "--help"})
77
+ _COMPLETION_HELP = "Print shell completion setup for bash, zsh, or fish"
78
+ _HELP_HELP = "Show top-level help, or help for one command"
79
+ _COMPLETION_DESCRIPTION = """
80
+ Print the shell completion script for bash, zsh, or fish. It does not inspect the repo or
81
+ GitHub.
82
+
83
+ If you already have a `jj` alias that runs `jj-stack`, such as `jj stack`, and use `jj`'s built-in
84
+ shell completion, pass `--jj-alias stack` here. Then typing `jj sta` and pressing Tab completes it
85
+ to `jj stack`; completion after `jj stack` also offers `jj-stack` commands and options. Other `jj`
86
+ completions remain available.
87
+ """
88
+ _HELP_DESCRIPTION = """
89
+ Show top-level help or the detailed help for one command. Use `--all` to show every command and
90
+ global option in top-level help. Use `--all-in-one` to generate one Markdown reference containing
91
+ every command and option.
92
+ """
93
+
94
+
95
+ _TOP_LEVEL_HELP_GROUPS: tuple[tuple[str, tuple[HelpCommand, ...]], ...] = (
96
+ (
97
+ "Core commands",
98
+ (
99
+ HelpCommand("submit", submit_command.HELP),
100
+ HelpCommand("view", view_command.HELP),
101
+ HelpCommand("list", list_command.HELP),
102
+ HelpCommand("merge", merge_command.HELP),
103
+ HelpCommand("unstack", unstack_command.HELP),
104
+ ),
105
+ ),
106
+ (
107
+ "Support commands",
108
+ (
109
+ HelpCommand("cleanup", cleanup_command.HELP),
110
+ HelpCommand("sync", sync_command.HELP),
111
+ HelpCommand("checkout", checkout_command.HELP),
112
+ HelpCommand("doctor", doctor_command.HELP),
113
+ HelpCommand("in-use", in_use_command.HELP),
114
+ ),
115
+ ),
116
+ (
117
+ "Advanced repair",
118
+ (HelpCommand("relink", relink_command.HELP, hidden=True),),
119
+ ),
120
+ (
121
+ "Configuration",
122
+ (HelpCommand("completion", _COMPLETION_HELP, hidden=True),),
123
+ ),
124
+ (
125
+ "Help",
126
+ (HelpCommand("help", _HELP_HELP, hidden=True),),
127
+ ),
128
+ )
129
+ _PR_OPTION_STRINGS = ("-p", "--pull-request")
130
+ _COMMAND_ALIASES: dict[str, tuple[str, ...]] = {
131
+ "submit": ("sub",),
132
+ "view": ("status", "st", "v"),
133
+ "list": ("ls",),
134
+ }
135
+ _VIEW_COMMANDS = frozenset(("view", *_COMMAND_ALIASES["view"]))
136
+ _KNOWN_COMMANDS = frozenset(
137
+ name
138
+ for _, entries in _TOP_LEVEL_HELP_GROUPS
139
+ for entry in entries
140
+ for name in (entry.name, *_COMMAND_ALIASES.get(entry.name, ()))
141
+ )
142
+ type _ArgSource = str | Callable[[Namespace], Any]
143
+
144
+
145
+ _VIEW_HANDLER_ARGS = tuple(
146
+ name
147
+ for name, parameter in signature(view_command.view).parameters.items()
148
+ if parameter.kind is not parameter.VAR_KEYWORD
149
+ )
150
+
151
+
152
+ class _TopLevelArgumentParser(ArgumentParser):
153
+ """ArgumentParser with custom grouped help for the top-level CLI."""
154
+
155
+ def format_usage(self) -> str:
156
+ return f"usage: {_TOP_LEVEL_HELP_USAGE}\n"
157
+
158
+ def error(self, message: str) -> NoReturn:
159
+ raise _cli_parse_error(message)
160
+
161
+
162
+ class _TitleCaseHelpFormatter(HelpFormatter):
163
+ """Help formatter that title-cases the usage heading."""
164
+
165
+ def add_usage(self, usage, actions, groups, prefix=None):
166
+ return super().add_usage(usage, actions, groups, prefix="Usage: ")
167
+
168
+
169
+ class _CommandArgumentParser(ArgumentParser):
170
+ """ArgumentParser with title-cased built-in help headings."""
171
+
172
+ def __init__(self, *args, **kwargs):
173
+ kwargs.setdefault("formatter_class", _TitleCaseHelpFormatter)
174
+ super().__init__(*args, **kwargs)
175
+ self._positionals.title = "Positional Arguments"
176
+ self._optionals.title = "Options"
177
+
178
+ def error(self, message: str) -> NoReturn:
179
+ raise _cli_parse_error(message, prog=self.prog)
180
+
181
+
182
+ def build_parser() -> ArgumentParser:
183
+ """Build the top-level CLI parser and subcommands."""
184
+
185
+ parser = _TopLevelArgumentParser(
186
+ prog="jj-stack",
187
+ description=normalized_help_text(_TOP_LEVEL_HELP_DESCRIPTION),
188
+ )
189
+ _add_common_options(parser, suppress_defaults=False)
190
+ parser.set_defaults(command="view", handler=_default_view_handler)
191
+ _normalize_help_action_text(parser)
192
+ add_help_argument(
193
+ parser,
194
+ "--version",
195
+ action="version",
196
+ version=f"%(prog)s {__version__}",
197
+ help=t"Show the {ui.code('jj-stack')} version and exit",
198
+ )
199
+
200
+ subcommands = parser.add_subparsers(
201
+ dest="command",
202
+ parser_class=_CommandArgumentParser,
203
+ )
204
+ submit_parser = _add_revset_command(
205
+ subcommands,
206
+ command="submit",
207
+ aliases=_COMMAND_ALIASES["submit"],
208
+ help_text=normalized_help_text(submit_command.HELP),
209
+ description_text=submit_command.__doc__ or "",
210
+ handler=_forward_handler(submit_command.submit, open_="open"),
211
+ revset_help=(
212
+ t"Revset selecting the stack to submit; defaults to {ui.revset('@')} when the "
213
+ t"working-copy change is described and nonempty, otherwise {ui.revset('@-')}"
214
+ ),
215
+ )
216
+ add_help_section(
217
+ submit_parser,
218
+ title="Supplying descriptions",
219
+ body=submit_command.DESCRIPTION_HELP,
220
+ )
221
+ add_help_argument(
222
+ submit_parser,
223
+ "--base",
224
+ metavar="REVSET",
225
+ help=(
226
+ "Submit only changes after this submitted ancestor, using its PR branch as the base"
227
+ ),
228
+ )
229
+ add_help_argument(
230
+ submit_parser,
231
+ "--dry-run",
232
+ action="store_true",
233
+ help="Preview submission without pushing branches or changing pull requests",
234
+ )
235
+ submit_description_mode = submit_parser.add_mutually_exclusive_group()
236
+ add_help_argument(
237
+ submit_description_mode,
238
+ "--describe",
239
+ dest="descriptions",
240
+ metavar="TARGET=FILE",
241
+ action="append",
242
+ help=(
243
+ t"Read a pull request body for {ui.metavar('CHANGE')}, or a stack overview, from "
244
+ t"{ui.metavar('FILE')}"
245
+ ),
246
+ )
247
+ add_help_argument(
248
+ submit_description_mode,
249
+ "--describe-with",
250
+ metavar="HELPER",
251
+ help=(
252
+ t"Generate pull request titles, bodies, and the stack overview with "
253
+ t"{ui.metavar('HELPER')}"
254
+ ),
255
+ )
256
+ add_help_argument(
257
+ submit_parser,
258
+ "--edit",
259
+ action="store_true",
260
+ help=(
261
+ "Open planned pull request titles, bodies, and draft states in your editor before "
262
+ "submitting"
263
+ ),
264
+ )
265
+ submit_draft_mode = submit_parser.add_mutually_exclusive_group()
266
+ add_help_argument(
267
+ submit_draft_mode,
268
+ "--draft",
269
+ action="store_true",
270
+ help=(
271
+ t"Create pull requests as drafts; use {ui.option('--draft=all')} to "
272
+ t"return existing pull requests to draft"
273
+ ),
274
+ )
275
+ submit_draft_mode.add_argument(
276
+ "--draft-all",
277
+ action="store_true",
278
+ help=SUPPRESS,
279
+ )
280
+ submit_draft_mode.add_argument(
281
+ "--open",
282
+ dest="open",
283
+ action="store_true",
284
+ help="Mark existing draft pull requests ready for review when submitting",
285
+ )
286
+ add_help_argument(
287
+ submit_parser,
288
+ "--label",
289
+ dest="labels",
290
+ action="append",
291
+ help="Apply GitHub labels to submitted pull requests",
292
+ )
293
+ add_help_argument(
294
+ submit_parser,
295
+ "--reviewers",
296
+ dest="reviewers",
297
+ action="append",
298
+ metavar="USERS",
299
+ help="Request reviews from GitHub users on submitted pull requests",
300
+ )
301
+ add_help_argument(
302
+ submit_parser,
303
+ "--team-reviewers",
304
+ dest="team_reviewers",
305
+ action="append",
306
+ metavar="TEAMS",
307
+ help="Ask for reviews from GitHub teams on submitted pull requests",
308
+ )
309
+ add_help_argument(
310
+ submit_parser,
311
+ "--re-request",
312
+ action="store_true",
313
+ help=(
314
+ "Request another review from users who last approved or requested changes on an "
315
+ "existing pull request"
316
+ ),
317
+ )
318
+ view_parser = _add_revset_command(
319
+ subcommands,
320
+ command="view",
321
+ aliases=_COMMAND_ALIASES["view"],
322
+ help_text=normalized_help_text(view_command.HELP),
323
+ description_text=view_command.__doc__ or "",
324
+ handler=_forward_handler(
325
+ view_command.view,
326
+ *_VIEW_HANDLER_ARGS,
327
+ selectors=lambda args: args.view_selectors,
328
+ ),
329
+ revset_help=(
330
+ t"Select a stack by its head revset or by any change ID it contains. Combine either "
331
+ t"with {ui.option('--pull-request')}; defaults to the current stack"
332
+ ),
333
+ revset_nargs="*",
334
+ )
335
+ add_help_argument(
336
+ view_parser,
337
+ *_PR_OPTION_STRINGS,
338
+ dest="pr",
339
+ metavar="PR",
340
+ action="append",
341
+ help=(
342
+ "Inspect the full stack containing this PR number or URL; repeat to inspect "
343
+ "several stacks"
344
+ ),
345
+ )
346
+ view_parser.add_argument(
347
+ "--json",
348
+ dest="as_json",
349
+ action="store_true",
350
+ help="Output stack status as JSON",
351
+ )
352
+ view_parser.add_argument(
353
+ "-v",
354
+ "--verbose",
355
+ action="store_true",
356
+ help="Show every change instead of collapsing the middle of a long stack",
357
+ )
358
+ list_parser = _add_command_parser(
359
+ subcommands,
360
+ command="list",
361
+ aliases=list(_COMMAND_ALIASES["list"]),
362
+ help_text=normalized_help_text(list_command.HELP),
363
+ description_text=list_command.__doc__ or "",
364
+ handler=_forward_handler(list_command.list_),
365
+ )
366
+ list_parser.add_argument(
367
+ "--json",
368
+ dest="as_json",
369
+ action="store_true",
370
+ help="Output stack list as JSON",
371
+ )
372
+ _add_relink_parser(
373
+ subcommands,
374
+ command="relink",
375
+ help_text=normalized_help_text(relink_command.HELP),
376
+ description_text=relink_command.__doc__ or "",
377
+ handler=_forward_handler(relink_command.relink),
378
+ )
379
+ merge_parser = _add_revset_command(
380
+ subcommands,
381
+ command="merge",
382
+ help_text=normalized_help_text(merge_command.HELP),
383
+ description_text=merge_command.__doc__ or "",
384
+ handler=_forward_handler(merge_command.merge),
385
+ revset_help=(
386
+ t"Revset selecting the stack to merge; defaults to {ui.revset('@')} when the "
387
+ t"working-copy change is described and nonempty, otherwise {ui.revset('@-')}; "
388
+ t"cannot be combined with {ui.option('--pull-request')}"
389
+ ),
390
+ )
391
+ merge_parser.add_argument(
392
+ "--dry-run",
393
+ action="store_true",
394
+ help="Preview the merge without asking GitHub to merge anything",
395
+ )
396
+ add_help_argument(
397
+ merge_parser,
398
+ *_PR_OPTION_STRINGS,
399
+ dest="pr",
400
+ metavar="PR",
401
+ help=("Merge this PR and all PRs below it; after a direct merge, sync the entire stack"),
402
+ )
403
+ add_help_argument(
404
+ merge_parser,
405
+ "--method",
406
+ dest="merge_method",
407
+ choices=("merge", "rebase", "squash"),
408
+ metavar="METHOD",
409
+ help=(
410
+ t"GitHub merge method: {ui.metavar('merge')}, {ui.metavar('rebase')}, or "
411
+ t"{ui.metavar('squash')}. Defaults to {ui.code('jj-stack.merge_method')}, or to "
412
+ t"the repo's only allowed method"
413
+ ),
414
+ )
415
+ unstack_parser = _add_revset_command(
416
+ subcommands,
417
+ command="unstack",
418
+ help_text=normalized_help_text(unstack_command.HELP),
419
+ description_text=unstack_command.__doc__ or "",
420
+ handler=_forward_handler(unstack_command.unstack),
421
+ revset_help=(
422
+ t"Revset selecting the stack to unstack; defaults to {ui.revset('@')} when the "
423
+ t"working-copy change is described and nonempty, otherwise {ui.revset('@-')}; "
424
+ t"cannot be combined with {ui.option('--pull-request')} or {ui.option('--stack')}"
425
+ ),
426
+ )
427
+ unstack_parser.add_argument(
428
+ "--dry-run",
429
+ action="store_true",
430
+ help="Preview separating the GitHub stack or forgetting saved links locally",
431
+ )
432
+ unstack_parser.add_argument(
433
+ "--local",
434
+ action="store_true",
435
+ help="Only forget saved pull request links; do not change GitHub",
436
+ )
437
+ add_help_argument(
438
+ unstack_parser,
439
+ *_PR_OPTION_STRINGS,
440
+ dest="pr",
441
+ metavar="PR",
442
+ help="Select the local stack linked to this pull request number or URL",
443
+ )
444
+ add_help_argument(
445
+ unstack_parser,
446
+ "--stack",
447
+ type=int,
448
+ metavar="NUMBER",
449
+ help="Separate this GitHub stack even when no matching local stack is available",
450
+ )
451
+ _add_checkout_parser(
452
+ subcommands,
453
+ command="checkout",
454
+ help_text=normalized_help_text(checkout_command.HELP),
455
+ description_text=checkout_command.__doc__ or "",
456
+ handler=_forward_handler(checkout_command.checkout),
457
+ )
458
+
459
+ cleanup_parser = _add_revset_command(
460
+ subcommands,
461
+ command="cleanup",
462
+ help_text=normalized_help_text(cleanup_command.HELP),
463
+ description_text=cleanup_command.__doc__ or "",
464
+ handler=_forward_handler(cleanup_command.cleanup),
465
+ revset_help=(
466
+ t"Revset selecting the stack to clean up; omit it to check every "
467
+ t"tracked pull request; cannot be combined with {ui.option('--pull-request')}"
468
+ ),
469
+ )
470
+ cleanup_parser.add_argument(
471
+ "--dry-run",
472
+ action="store_true",
473
+ help="Preview the cleanup without deleting PR branches, comments, or tracking",
474
+ )
475
+ add_help_argument(
476
+ cleanup_parser,
477
+ "--close",
478
+ action="store_true",
479
+ help=(
480
+ t"Close selected open pull requests before cleanup; requires "
481
+ t"{ui.option('--pull-request')}"
482
+ ),
483
+ )
484
+ add_help_argument(
485
+ cleanup_parser,
486
+ *_PR_OPTION_STRINGS,
487
+ dest="pr",
488
+ metavar="PR",
489
+ help=(
490
+ t"Clean up this tracked pull request, or use {ui.metavar('orphans')} for every "
491
+ t"tracked pull request whose local change is gone"
492
+ ),
493
+ )
494
+
495
+ sync_parser = _add_revset_command(
496
+ subcommands,
497
+ command="sync",
498
+ help_text=normalized_help_text(sync_command.HELP),
499
+ description_text=sync_command.__doc__ or "",
500
+ handler=_forward_handler(sync_command.sync, all_="all"),
501
+ revset_help=(
502
+ t"Revset selecting the stack to sync; defaults to {ui.revset('@')} when the "
503
+ t"working-copy change is described and nonempty, otherwise {ui.revset('@-')}"
504
+ ),
505
+ )
506
+ add_help_argument(
507
+ sync_parser,
508
+ "--dry-run",
509
+ action="store_true",
510
+ help=(
511
+ "Preview the sync without changing pull requests, local changes, PR branches, "
512
+ "or tracking"
513
+ ),
514
+ )
515
+ add_help_argument(
516
+ sync_parser,
517
+ "-a",
518
+ "--all",
519
+ action="store_true",
520
+ help=(
521
+ "Finish merged PRs across all stacks when their submitted commits are already on "
522
+ "trunk and remove their saved links; never rebases or rewrites local changes"
523
+ ),
524
+ )
525
+
526
+ doctor_parser = _add_command_parser(
527
+ subcommands,
528
+ command="doctor",
529
+ help_text=normalized_help_text(doctor_command.HELP),
530
+ description_text=doctor_command.__doc__ or "",
531
+ handler=_forward_handler(doctor_command.doctor),
532
+ )
533
+ doctor_parser.add_argument(
534
+ "--fix",
535
+ action="store_true",
536
+ help="Apply safe local repairs instead of only reporting problems",
537
+ )
538
+
539
+ _add_command_parser(
540
+ subcommands,
541
+ command="in-use",
542
+ help_text=normalized_help_text(in_use_command.HELP),
543
+ description_text=in_use_command.__doc__ or "",
544
+ handler=_forward_handler(in_use_command.in_use),
545
+ )
546
+
547
+ completion_parser = _add_command_parser(
548
+ subcommands,
549
+ command="completion",
550
+ help_text=_COMPLETION_HELP,
551
+ description_text=_COMPLETION_DESCRIPTION,
552
+ handler=_completion_handler,
553
+ common_options=False,
554
+ )
555
+ completion_parser.add_argument(
556
+ "shell",
557
+ choices=("bash", "zsh", "fish"),
558
+ help="Shell to generate completion support for",
559
+ )
560
+ add_help_argument(
561
+ completion_parser,
562
+ "--jj-alias",
563
+ metavar="NAME",
564
+ type=_parse_jj_alias,
565
+ help=t"Name of an existing {ui.code('jj')} alias that runs {ui.code('jj-stack')}",
566
+ )
567
+ help_parser = _add_command_parser(
568
+ subcommands,
569
+ command="help",
570
+ help_text=SUPPRESS,
571
+ description_text=_HELP_DESCRIPTION,
572
+ handler=_help_handler,
573
+ common_options=False,
574
+ )
575
+ help_scope = help_parser.add_mutually_exclusive_group()
576
+ add_help_argument(
577
+ help_scope,
578
+ "--all",
579
+ action="store_true",
580
+ help="Show every command and global option in top-level help",
581
+ )
582
+ add_help_argument(
583
+ help_scope,
584
+ "--all-in-one",
585
+ action="store_true",
586
+ help="Generate one Markdown reference containing every command and option",
587
+ )
588
+ help_parser.add_argument(
589
+ "command",
590
+ nargs="?",
591
+ help="Command to describe",
592
+ )
593
+ return parser
594
+
595
+
596
+ def _help_handler(args: Namespace) -> int:
597
+ parser = build_parser()
598
+ if args.all_in_one:
599
+ if args.command is not None:
600
+ raise UsageError("help --all-in-one cannot be combined with a command")
601
+ console.output(
602
+ render_all_in_one_markdown(
603
+ parser,
604
+ groups=_TOP_LEVEL_HELP_GROUPS,
605
+ aliases=_COMMAND_ALIASES,
606
+ ),
607
+ end="",
608
+ soft_wrap=True,
609
+ )
610
+ return 0
611
+ if args.command is None:
612
+ emit_top_level_help(
613
+ parser,
614
+ groups=_TOP_LEVEL_HELP_GROUPS,
615
+ aliases=_COMMAND_ALIASES,
616
+ include_hidden=args.all,
617
+ )
618
+ return 0
619
+
620
+ command_parser = _find_subcommand_parser(parser, args.command)
621
+ if command_parser is None:
622
+ raise _unknown_command_error(args.command)
623
+ emit_command_help(command_parser)
624
+ return 0
625
+
626
+
627
+ def _find_subcommand_parser(
628
+ parser: ArgumentParser,
629
+ command_name: str,
630
+ ) -> ArgumentParser | None:
631
+ for action in parser._actions:
632
+ if isinstance(action, _SubParsersAction):
633
+ parser_choice = action.choices.get(command_name)
634
+ return parser_choice if isinstance(parser_choice, ArgumentParser) else None
635
+ return None
636
+
637
+
638
+ def _print_cli_error(error: CliError) -> None:
639
+ message = error_message(error)
640
+ if str(error).startswith("Error:"):
641
+ console.error(message, soft_wrap=True)
642
+ else:
643
+ console.error(("Error: ", message), soft_wrap=True)
644
+ hint = error_hint(error)
645
+ if hint is not None:
646
+ console.stderr_output(
647
+ (ui.semantic_text("Hint: ", "hint", "heading"), hint),
648
+ soft_wrap=True,
649
+ )
650
+
651
+
652
+ def _print_early_cli_error(
653
+ error: CliError,
654
+ *,
655
+ cli_args: JjCliArgs,
656
+ normalized_argv: Sequence[str],
657
+ ) -> None:
658
+ requested_color_mode = _color_arg_from_argv(normalized_argv)
659
+ with configured_console(
660
+ cli_args=cli_args,
661
+ color_mode=rich_color_mode(requested_color_mode),
662
+ repo=None,
663
+ requested_color_mode=requested_color_mode,
664
+ time_output=False,
665
+ ):
666
+ _print_cli_error(error)
667
+
668
+
669
+ def _cli_parse_error(message: str, *, prog: str | None = None) -> CliError:
670
+ message = message.strip()
671
+ invalid_choice = re.match(
672
+ r"argument (?P<argument>[^:]+): invalid choice: '(?P<value>[^']+)'(?: .*)?$",
673
+ message,
674
+ )
675
+ if invalid_choice is not None and invalid_choice.group("argument") == "command":
676
+ return _unknown_command_error(invalid_choice.group("value"))
677
+ unrecognized = message.lower().startswith("unrecognized argument")
678
+ if message and not message.endswith("."):
679
+ message = f"{message}."
680
+ if message:
681
+ message = f"{message[0].upper()}{message[1:]}"
682
+ if unrecognized:
683
+ command = prog.split()[-1] if prog and " " in prog else None
684
+ listing = f"jj-stack help {command}" if command else "jj-stack help <command>"
685
+ return UsageError(
686
+ message,
687
+ hint=t"Run {ui.cmd(listing)} to list the options a command accepts.",
688
+ )
689
+ return UsageError(message)
690
+
691
+
692
+ def _unknown_command_error(command_name: str) -> CliError:
693
+ return UsageError(
694
+ t"Unknown command {ui.cmd(command_name)}.",
695
+ hint=t"Run {ui.cmd('jj-stack help')} to list commands.",
696
+ )
697
+
698
+
699
+ def _color_arg_from_argv(argv: Sequence[str]) -> RequestedColorMode | None:
700
+ for index, arg in enumerate(argv):
701
+ if arg.startswith("--color="):
702
+ value = arg.partition("=")[2]
703
+ elif arg == "--color" and index + 1 < len(argv):
704
+ value = argv[index + 1]
705
+ else:
706
+ continue
707
+ if value in _COLOR_CHOICES:
708
+ return cast(RequestedColorMode, value)
709
+ return None
710
+ return None
711
+
712
+
713
+ def _load_configured_jj_color(
714
+ *,
715
+ repo: Path | None,
716
+ cli_args: JjCliArgs,
717
+ ) -> RequestedColorMode | None:
718
+ """Read `ui.color` from `jj` config without requiring repo bootstrap."""
719
+
720
+ cwd = repo if repo is not None and repo.exists() and repo.is_dir() else Path.cwd()
721
+ try:
722
+ completed = subprocess.run(
723
+ ["jj", *cli_args.to_argv(), "--ignore-working-copy", "config", "get", "ui.color"],
724
+ capture_output=True,
725
+ check=False,
726
+ cwd=cwd,
727
+ text=True,
728
+ )
729
+ except FileNotFoundError, OSError:
730
+ return None
731
+
732
+ if completed.returncode != 0:
733
+ return None
734
+
735
+ configured = completed.stdout.strip()
736
+ if configured in _COLOR_CHOICES:
737
+ return cast(RequestedColorMode, configured)
738
+ return None
739
+
740
+
741
+ def main(argv: Sequence[str] | None = None) -> int:
742
+ """Run the CLI and return a process exit code."""
743
+
744
+ parser = build_parser()
745
+ cli_args = JjCliArgs()
746
+ normalized_argv = list(sys.argv[1:] if argv is None else argv)
747
+ view_args: _ParsedViewCommandArgs | None = None
748
+ try:
749
+ cli_args, stripped_argv = _extract_config_overrides(normalized_argv)
750
+ normalized_argv = _normalize_cli_args(stripped_argv)
751
+ view_args = _parse_view_command_args(normalized_argv)
752
+ if view_args is not None:
753
+ normalized_argv = list(view_args.argv)
754
+ args = parser.parse_args(normalized_argv)
755
+ except CliError as error:
756
+ _print_early_cli_error(
757
+ error,
758
+ cli_args=cli_args,
759
+ normalized_argv=normalized_argv,
760
+ )
761
+ return resolve_exit_code(error)
762
+ args.cli_args = cli_args
763
+ args.normalized_argv = tuple(normalized_argv)
764
+ if args.command in _VIEW_COMMANDS:
765
+ args.view_selectors = () if view_args is None else view_args.selectors
766
+ effective_color = "never" if args.command == "in-use" else args.color
767
+ if effective_color is None:
768
+ effective_color = _load_configured_jj_color(
769
+ repo=args.repo,
770
+ cli_args=cli_args,
771
+ )
772
+ with configured_console(
773
+ cli_args=cli_args,
774
+ color_mode=rich_color_mode(effective_color),
775
+ repo=args.repo,
776
+ requested_color_mode=args.color,
777
+ time_output=args.time_output,
778
+ ):
779
+ with _time_output(enabled=args.time_output):
780
+ handler = args.handler
781
+ try:
782
+ return handler(args)
783
+ except CliError as error:
784
+ _print_cli_error(error)
785
+ return resolve_exit_code(error)
786
+ except KeyboardInterrupt:
787
+ console.stderr_output("Interrupted.")
788
+ return EXIT_INTERRUPTED
789
+
790
+
791
+ def _default_view_handler(args: Namespace) -> int:
792
+ """Run bare `jj-stack` as the default `view` command."""
793
+
794
+ return view_command.view(
795
+ cli_args=args.cli_args,
796
+ debug=args.debug,
797
+ as_json=False,
798
+ pr=None,
799
+ repo=args.repo,
800
+ revset=None,
801
+ selectors=(),
802
+ verbose=False,
803
+ )
804
+
805
+
806
+ @dataclass(frozen=True)
807
+ class _ParsedViewCommandArgs:
808
+ argv: tuple[str, ...]
809
+ selectors: tuple[view_command.ViewSelector, ...]
810
+
811
+
812
+ def _parse_view_command_args(argv: Sequence[str]) -> _ParsedViewCommandArgs | None:
813
+ """Rewrite `view` argv and preserve explicit selector order."""
814
+
815
+ command_index = _find_subcommand_index(argv)
816
+ if command_index is None or argv[command_index] not in _VIEW_COMMANDS:
817
+ return None
818
+
819
+ prefix = list(argv[: command_index + 1])
820
+ command_argv = argv[command_index + 1 :]
821
+ options: list[str] = []
822
+ revsets: list[str] = []
823
+ selectors: list[view_command.ViewSelector] = []
824
+ index = 0
825
+ while index < len(command_argv):
826
+ arg = command_argv[index]
827
+ if arg == "--":
828
+ trailing_revsets = command_argv[index + 1 :]
829
+ revsets.extend(trailing_revsets)
830
+ selectors.extend(
831
+ view_command.ViewSelector(kind="revset", value=value)
832
+ for value in trailing_revsets
833
+ )
834
+ break
835
+ if arg in {*_PR_OPTION_STRINGS, "--repository", "--color"}:
836
+ if index + 1 >= len(command_argv):
837
+ options.extend(command_argv[index:])
838
+ break
839
+ value = command_argv[index + 1]
840
+ options.extend((arg, value))
841
+ if arg in _PR_OPTION_STRINGS:
842
+ selectors.append(
843
+ view_command.ViewSelector(
844
+ kind="pr",
845
+ value=value,
846
+ )
847
+ )
848
+ index += 2
849
+ continue
850
+ if (
851
+ arg.startswith("--pull-request=")
852
+ or (arg.startswith("-p") and len(arg) > 2)
853
+ or arg.startswith("--repository=")
854
+ or arg.startswith("--color=")
855
+ ):
856
+ options.append(arg)
857
+ if arg.startswith("--pull-request="):
858
+ value = arg.partition("=")[2]
859
+ elif arg.startswith("-p") and len(arg) > 2:
860
+ value = arg[2:].removeprefix("=")
861
+ else:
862
+ value = None
863
+ if value is not None:
864
+ selectors.append(
865
+ view_command.ViewSelector(
866
+ kind="pr",
867
+ value=value,
868
+ )
869
+ )
870
+ index += 1
871
+ continue
872
+ if arg in _VIEW_SELECTOR_FLAGS or (
873
+ arg.startswith("-") and not arg.startswith("--") and set(arg[1:]) <= {"f", "v", "h"}
874
+ ):
875
+ options.append(arg)
876
+ index += 1
877
+ continue
878
+ if arg.startswith("-"):
879
+ options.append(arg)
880
+ index += 1
881
+ continue
882
+ revsets.append(arg)
883
+ selectors.append(view_command.ViewSelector(kind="revset", value=arg))
884
+ index += 1
885
+ normalized = [*prefix, *options]
886
+ if revsets:
887
+ normalized.extend(["--", *revsets])
888
+ return _ParsedViewCommandArgs(argv=tuple(normalized), selectors=tuple(selectors))
889
+
890
+
891
+ _VIEW_SELECTOR_FLAGS = frozenset({"-v", "--verbose", "-h", "--help", "--debug", "--time-output"})
892
+
893
+
894
+ def _find_subcommand_index(argv: Sequence[str]) -> int | None:
895
+ """Return the index of the top-level subcommand, if present."""
896
+
897
+ index = 0
898
+ while index < len(argv):
899
+ arg = argv[index]
900
+ if arg == "--":
901
+ return None
902
+ if arg in {"--version", *_HELP_FLAGS, *_REORDERABLE_GLOBAL_FLAGS}:
903
+ index += 1
904
+ continue
905
+ if arg in _REORDERABLE_GLOBAL_OPTIONS_WITH_VALUES:
906
+ index += 2
907
+ continue
908
+ if any(arg.startswith(f"{opt}=") for opt in _REORDERABLE_GLOBAL_OPTIONS_WITH_VALUES):
909
+ index += 1
910
+ continue
911
+ if arg.startswith("-"):
912
+ index += 1
913
+ continue
914
+ return index
915
+ return None
916
+
917
+
918
+ def _add_command_parser(
919
+ subcommands: _SubParsersAction[Any],
920
+ *,
921
+ command: str,
922
+ aliases: Sequence[str] = (),
923
+ help_text: str,
924
+ description_text: str,
925
+ handler: Callable[[Namespace], int],
926
+ common_options: bool = True,
927
+ ) -> ArgumentParser:
928
+ description = normalized_help_text(description_text)
929
+ if aliases:
930
+ spelled = ", ".join(f"jj-stack {alias}" for alias in aliases)
931
+ description = f"{description}\n\nAlso spelled {spelled}."
932
+ parser = subcommands.add_parser(
933
+ command,
934
+ aliases=list(aliases),
935
+ help=help_text,
936
+ description=description,
937
+ )
938
+ if common_options:
939
+ _add_common_options(parser)
940
+ _normalize_help_action_text(parser)
941
+ parser.set_defaults(handler=handler)
942
+ return parser
943
+
944
+
945
+ def _add_revset_command(
946
+ subcommands: _SubParsersAction[Any],
947
+ *,
948
+ command: str,
949
+ aliases: Sequence[str] = (),
950
+ help_text: str,
951
+ description_text: str,
952
+ handler: Callable[[Namespace], int],
953
+ revset_nargs: str | int | None = "?",
954
+ revset_help: ui.Message | str = "Revset selecting the stack to operate on",
955
+ ) -> ArgumentParser:
956
+ parser = _add_command_parser(
957
+ subcommands,
958
+ command=command,
959
+ aliases=aliases,
960
+ help_text=help_text,
961
+ description_text=description_text,
962
+ handler=handler,
963
+ )
964
+ add_help_argument(parser, "revset", nargs=revset_nargs, help=revset_help)
965
+ return parser
966
+
967
+
968
+ def _add_relink_parser(
969
+ subcommands: _SubParsersAction[Any],
970
+ *,
971
+ command: str,
972
+ help_text: str,
973
+ description_text: str,
974
+ handler: Callable[[Namespace], int],
975
+ ) -> ArgumentParser:
976
+ parser = _add_command_parser(
977
+ subcommands,
978
+ command=command,
979
+ help_text=help_text,
980
+ description_text=description_text,
981
+ handler=handler,
982
+ )
983
+ add_help_argument(parser, "pr", metavar="PR", help="Pull request number or URL")
984
+ add_help_argument(
985
+ parser,
986
+ "revset",
987
+ metavar="REVSET",
988
+ help="Revset selecting the local change to reconnect to the pull request",
989
+ )
990
+ return parser
991
+
992
+
993
+ def _add_checkout_parser(
994
+ subcommands: _SubParsersAction[Any],
995
+ *,
996
+ command: str,
997
+ help_text: str,
998
+ description_text: str,
999
+ handler: Callable[[Namespace], int],
1000
+ ) -> ArgumentParser:
1001
+ parser = _add_command_parser(
1002
+ subcommands,
1003
+ command=command,
1004
+ help_text=help_text,
1005
+ description_text=description_text,
1006
+ handler=handler,
1007
+ )
1008
+ selector = parser.add_mutually_exclusive_group(required=False)
1009
+ add_help_argument(
1010
+ selector,
1011
+ *_PR_OPTION_STRINGS,
1012
+ dest="pr",
1013
+ metavar="PR",
1014
+ help="Pull request to check out, by number or URL",
1015
+ )
1016
+ add_help_argument(
1017
+ selector,
1018
+ "--revset",
1019
+ help=(
1020
+ t"Revset selecting the local stack head; defaults to {ui.revset('@')} when the "
1021
+ t"working-copy change is described and nonempty, otherwise {ui.revset('@-')}"
1022
+ ),
1023
+ )
1024
+ add_help_argument(
1025
+ selector,
1026
+ "--pick",
1027
+ action="store_true",
1028
+ help="Interactively choose a local or GitHub stack to check out",
1029
+ )
1030
+ return parser
1031
+
1032
+
1033
+ def _add_common_options(
1034
+ parser: ArgumentParser,
1035
+ *,
1036
+ suppress_defaults: bool = True,
1037
+ ) -> None:
1038
+ parser.add_argument(
1039
+ "--repository",
1040
+ dest="repo",
1041
+ type=Path,
1042
+ metavar="REPO",
1043
+ default=SUPPRESS if suppress_defaults else None,
1044
+ help="Workspace path to operate on; defaults to the current directory",
1045
+ )
1046
+ # --config and --config-file are extracted from argv by
1047
+ # `_extract_config_overrides` before argparse runs, because argparse
1048
+ # subparsers create fresh namespaces and would otherwise clobber any
1049
+ # overrides passed before the subcommand. These registrations exist only
1050
+ # so the flags appear in --help output.
1051
+ add_help_argument(
1052
+ parser,
1053
+ "--config",
1054
+ action="store",
1055
+ default=SUPPRESS,
1056
+ dest=SUPPRESS,
1057
+ metavar="NAME=VALUE",
1058
+ help=(
1059
+ t"Additional {ui.code('jj')} config option as a TOML dotted-key assignment "
1060
+ t"(e.g. {ui.code('ui.color=always')})"
1061
+ ),
1062
+ )
1063
+ add_help_argument(
1064
+ parser,
1065
+ "--config-file",
1066
+ action="store",
1067
+ default=SUPPRESS,
1068
+ dest=SUPPRESS,
1069
+ metavar="PATH",
1070
+ help=t"Additional {ui.code('jj')} config file to load; repeat for several",
1071
+ )
1072
+ parser.add_argument(
1073
+ "--debug",
1074
+ action="store_true",
1075
+ default=SUPPRESS if suppress_defaults else False,
1076
+ help="Enable debug logging",
1077
+ )
1078
+ add_help_argument(
1079
+ parser,
1080
+ "--color",
1081
+ choices=_COLOR_CHOICES,
1082
+ default=SUPPRESS if suppress_defaults else None,
1083
+ metavar="WHEN",
1084
+ help=(t"When to colorize output; possible values: {ui.join(ui.metavar, _COLOR_CHOICES)}"),
1085
+ )
1086
+ parser.add_argument(
1087
+ "--time-output",
1088
+ action="store_true",
1089
+ default=SUPPRESS if suppress_defaults else False,
1090
+ help="Prefix each printed line with the seconds elapsed since the process started",
1091
+ )
1092
+
1093
+
1094
+ def _normalize_help_action_text(parser: ArgumentParser) -> None:
1095
+ for action in parser._actions:
1096
+ if action.option_strings == ["-h", "--help"]:
1097
+ action.help = "Show help"
1098
+ return
1099
+
1100
+
1101
+ _CONFIG_OVERRIDE_FLAGS = frozenset({"--config", "--config-file"})
1102
+
1103
+
1104
+ def _extract_config_overrides(argv: Sequence[str]) -> tuple[JjCliArgs, list[str]]:
1105
+ """Pull ``--config`` / ``--config-file`` out of argv, preserving argv order.
1106
+
1107
+ Runs before argparse because argparse dispatches subcommands into a fresh
1108
+ namespace and copies it back over the top-level namespace, which drops any
1109
+ overrides passed before the subcommand. Scanning argv ourselves keeps the
1110
+ full interleaved order regardless of where each flag appears relative to
1111
+ the subcommand. ``--config-file`` paths are resolved against the caller's
1112
+ cwd so they survive jj's subprocess cwd of ``repo_root``.
1113
+
1114
+ The extractor mirrors argparse/jj semantics for malformed uses: a bare
1115
+ ``--config`` (no value), or one whose next token is another option, is left
1116
+ in argv so argparse raises its usual "expected one argument" error; and
1117
+ everything after the ``--`` end-of-options marker is treated as positional
1118
+ and passed through untouched.
1119
+ """
1120
+
1121
+ parts: list[str] = []
1122
+ remaining: list[str] = []
1123
+ index = 0
1124
+ while index < len(argv):
1125
+ arg = argv[index]
1126
+ if arg == "--":
1127
+ remaining.extend(argv[index:])
1128
+ break
1129
+ flag: str | None = None
1130
+ value: str | None = None
1131
+ if arg in _CONFIG_OVERRIDE_FLAGS:
1132
+ next_arg = argv[index + 1] if index + 1 < len(argv) else None
1133
+ if next_arg is None or next_arg.startswith("-"):
1134
+ remaining.append(arg)
1135
+ index += 1
1136
+ else:
1137
+ flag = arg
1138
+ value = next_arg
1139
+ index += 2
1140
+ elif "=" in arg:
1141
+ head, _, tail = arg.partition("=")
1142
+ if head in _CONFIG_OVERRIDE_FLAGS:
1143
+ flag = head
1144
+ value = tail
1145
+ index += 1
1146
+ else:
1147
+ remaining.append(arg)
1148
+ index += 1
1149
+ continue
1150
+ else:
1151
+ remaining.append(arg)
1152
+ index += 1
1153
+ continue
1154
+
1155
+ if flag is None or value is None:
1156
+ continue
1157
+ if flag == "--config-file":
1158
+ value = str(Path(value).resolve())
1159
+ parts.extend((flag, value))
1160
+
1161
+ return JjCliArgs(argv=tuple(parts)), remaining
1162
+
1163
+
1164
+ def _forward_handler(
1165
+ function: Callable[..., int],
1166
+ *fallback_arg_names: str,
1167
+ **arg_sources: _ArgSource,
1168
+ ) -> Callable[[Namespace], int]:
1169
+ """Build a command handler that forwards argparse values as keyword arguments."""
1170
+
1171
+ parameters = signature(function).parameters
1172
+ if any(parameter.kind is parameter.VAR_KEYWORD for parameter in parameters.values()):
1173
+ parameter_names = fallback_arg_names
1174
+ else:
1175
+ parameter_names = tuple(
1176
+ name
1177
+ for name, parameter in parameters.items()
1178
+ if parameter.kind is not parameter.VAR_KEYWORD
1179
+ )
1180
+ parameter_sources: dict[str, _ArgSource] = dict(arg_sources)
1181
+ for name in parameter_names:
1182
+ parameter_sources[name] = arg_sources.get(
1183
+ name,
1184
+ name,
1185
+ )
1186
+
1187
+ def handler(args: Namespace) -> int:
1188
+ values = vars(args)
1189
+ return function(
1190
+ **{
1191
+ name: source(args) if not isinstance(source, str) else values[source]
1192
+ for name, source in parameter_sources.items()
1193
+ }
1194
+ )
1195
+
1196
+ return handler
1197
+
1198
+
1199
+ def _completion_handler(args: Namespace) -> int:
1200
+ # soft_wrap keeps the shell from receiving a script wrapped to the console width, which
1201
+ # splits long case patterns mid-word and makes it unparseable.
1202
+ console.output(
1203
+ emit_shell_completion(build_parser(), args.shell, jj_alias=args.jj_alias),
1204
+ end="",
1205
+ soft_wrap=True,
1206
+ )
1207
+ return 0
1208
+
1209
+
1210
+ def _parse_jj_alias(value: str) -> str:
1211
+ try:
1212
+ return validate_jj_alias(value)
1213
+ except ValueError as error:
1214
+ raise ArgumentTypeError(str(error)) from error
1215
+
1216
+
1217
+ @contextmanager
1218
+ def _time_output(*, enabled: bool):
1219
+ if not enabled:
1220
+ yield
1221
+ return
1222
+
1223
+ bootstrap.time_output_active = True
1224
+ try:
1225
+ yield
1226
+ finally:
1227
+ bootstrap.time_output_active = False
1228
+
1229
+
1230
+ def _normalize_cli_args(argv: Sequence[str]) -> list[str]:
1231
+ normalized = list(argv)
1232
+ for index, arg in enumerate(normalized):
1233
+ if not arg.startswith("--draft="):
1234
+ continue
1235
+ draft_mode = arg.removeprefix("--draft=")
1236
+ if draft_mode == "new":
1237
+ normalized[index] = "--draft"
1238
+ continue
1239
+ if draft_mode == "all":
1240
+ normalized[index] = "--draft-all"
1241
+ continue
1242
+ raise UsageError(
1243
+ t"Invalid value for {ui.cmd('--draft')}: {draft_mode}. Expected new or all."
1244
+ )
1245
+ return _rewrite_help_args(normalized)
1246
+
1247
+
1248
+ def _extract_reorderable_global_options(argv: Sequence[str]) -> tuple[list[str], list[str]]:
1249
+ globals_: list[str] = []
1250
+ rest: list[str] = []
1251
+ index = 0
1252
+ while index < len(argv):
1253
+ arg = argv[index]
1254
+ if arg in _REORDERABLE_GLOBAL_FLAGS or any(
1255
+ arg.startswith(f"{opt}=") for opt in _REORDERABLE_GLOBAL_OPTIONS_WITH_VALUES
1256
+ ):
1257
+ globals_.append(arg)
1258
+ index += 1
1259
+ elif arg in _REORDERABLE_GLOBAL_OPTIONS_WITH_VALUES and index + 1 < len(argv):
1260
+ globals_.extend((arg, argv[index + 1]))
1261
+ index += 2
1262
+ else:
1263
+ rest.append(arg)
1264
+ index += 1
1265
+ return globals_, rest
1266
+
1267
+
1268
+ def _rewrite_help_args(argv: list[str]) -> list[str]:
1269
+ if not argv:
1270
+ return argv
1271
+ starts_with_help = argv[0] == "help"
1272
+ scan_limit = argv.index("--") if "--" in argv else len(argv)
1273
+ if not starts_with_help and not any(arg in _HELP_FLAGS for arg in argv[:scan_limit]):
1274
+ return argv
1275
+
1276
+ source = argv[1:] if starts_with_help else argv
1277
+ globals_, rest = _extract_reorderable_global_options(source)
1278
+
1279
+ if starts_with_help:
1280
+ return [*globals_, "help", *(arg for arg in rest if arg not in _HELP_FLAGS)]
1281
+
1282
+ subcommands = _KNOWN_COMMANDS - {"help"}
1283
+ for arg in rest:
1284
+ if arg in _HELP_FLAGS:
1285
+ break
1286
+ if arg.startswith("-"):
1287
+ continue
1288
+ if arg in subcommands:
1289
+ return [*globals_, "help", arg]
1290
+ return argv
1291
+
1292
+ tail = ["--all"] if "--all" in argv else []
1293
+ return [*globals_, "help", *tail]
1294
+
1295
+
1296
+ if __name__ == "__main__":
1297
+ raise SystemExit(main())