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
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())
|