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_help.py ADDED
@@ -0,0 +1,680 @@
1
+ """Rendering for `jj-stack`'s semantic, Rich-styled `--help` output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import textwrap
7
+ from argparse import SUPPRESS, ArgumentParser, _SubParsersAction
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass
10
+ from html import escape
11
+ from string.templatelib import Template
12
+ from typing import Any
13
+
14
+ import jj_stack.console as console
15
+ import jj_stack.ui as ui
16
+
17
+ _TOP_LEVEL_HIDDEN_OPTION_STRINGS = frozenset(
18
+ {"--repository", "--config", "--config-file", "--debug", "--time-output"}
19
+ )
20
+ _COMMON_OPTION_STRINGS = frozenset(
21
+ {
22
+ "-h",
23
+ "--help",
24
+ "--repository",
25
+ "--config",
26
+ "--config-file",
27
+ "--debug",
28
+ "--color",
29
+ "--time-output",
30
+ }
31
+ )
32
+ _INLINE_OPTION_PATTERN = re.compile(r"(?<![\w-])(--?[A-Za-z][\w-]*(?:=[^\s]+)?)(?![\w-])")
33
+ _INLINE_METAVAR_PATTERN = re.compile(r"<[^>\s]+>")
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class HelpCommand:
38
+ """One command entry in the grouped top-level help."""
39
+
40
+ name: str
41
+ summary: str
42
+ hidden: bool = False
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class HelpSection:
47
+ """A titled block of detailed guidance appended to one command's help."""
48
+
49
+ title: str
50
+ body: str
51
+
52
+
53
+ def normalized_help_text(content: ui.Message | str) -> str:
54
+ return textwrap.dedent(ui.plain_text(content)).strip()
55
+
56
+
57
+ _ACTION_HELP_RENDERABLE_ATTRIBUTE = "_jj_stack_help_renderable"
58
+ _HELP_SECTIONS_ATTRIBUTE = "_jj_stack_help_sections"
59
+
60
+
61
+ def add_help_argument(
62
+ parser: Any,
63
+ *name_or_flags: str,
64
+ help: ui.Message | str,
65
+ **kwargs: Any,
66
+ ) -> Any:
67
+ """Add an argument whose help text keeps its semantic styling when rendered."""
68
+
69
+ action = parser.add_argument(*name_or_flags, **kwargs)
70
+ action.help = normalized_help_text(help)
71
+ if not isinstance(help, str):
72
+ setattr(action, _ACTION_HELP_RENDERABLE_ATTRIBUTE, help)
73
+ return action
74
+
75
+
76
+ def add_help_section(
77
+ parser: ArgumentParser,
78
+ *,
79
+ title: str,
80
+ body: ui.Message | str,
81
+ ) -> None:
82
+ """Append a detailed prose section to a command's help."""
83
+
84
+ sections = (*_help_sections(parser), HelpSection(title, normalized_help_text(body)))
85
+ setattr(parser, _HELP_SECTIONS_ATTRIBUTE, sections)
86
+
87
+
88
+ def emit_top_level_help(
89
+ parser: ArgumentParser,
90
+ *,
91
+ groups: Sequence[tuple[str, tuple[HelpCommand, ...]]],
92
+ aliases: Mapping[str, tuple[str, ...]],
93
+ include_hidden: bool,
94
+ ) -> None:
95
+ console.output(
96
+ ui.prefixed_line(
97
+ _help_heading("Usage: "),
98
+ _top_level_usage_message(include_hidden=include_hidden),
99
+ )
100
+ )
101
+
102
+ if parser.description:
103
+ console.output()
104
+ _emit_help_paragraphs(parser.description)
105
+
106
+ for title, entries in groups:
107
+ visible_entries = [entry for entry in entries if include_hidden or not entry.hidden]
108
+ if not visible_entries:
109
+ continue
110
+ console.output()
111
+ _emit_help_table_section(
112
+ title,
113
+ tuple(
114
+ (
115
+ ui.cmd(_command_label(entry, aliases, include_aliases=include_hidden)),
116
+ normalized_help_text(entry.summary),
117
+ )
118
+ for entry in visible_entries
119
+ ),
120
+ )
121
+
122
+ if not include_hidden:
123
+ console.output()
124
+ console.output(
125
+ t"Run {ui.cmd('jj-stack help --all')} to show advanced commands and options."
126
+ )
127
+
128
+ option_actions = _top_level_option_actions(parser, include_hidden=include_hidden)
129
+ option_rows = _action_rows(option_actions)
130
+ if option_rows is not None:
131
+ console.output()
132
+ _emit_help_table_section("Options", option_rows)
133
+
134
+
135
+ def emit_command_help(parser: ArgumentParser) -> None:
136
+ console.output(
137
+ ui.prefixed_line(
138
+ _help_heading("Usage: "),
139
+ _command_usage_message(parser),
140
+ )
141
+ )
142
+
143
+ if parser.description:
144
+ console.output()
145
+ _emit_help_paragraphs(parser.description)
146
+
147
+ positional_rows = _action_rows(parser._positionals._group_actions)
148
+ if positional_rows is not None:
149
+ console.output()
150
+ _emit_help_table_section(
151
+ parser._positionals.title or "Positional Arguments",
152
+ positional_rows,
153
+ )
154
+
155
+ option_actions = [
156
+ action for action in parser._optionals._group_actions if action.help is not SUPPRESS
157
+ ]
158
+ command_option_rows = _action_rows(
159
+ [action for action in option_actions if not _is_common_option_action(action)]
160
+ )
161
+ global_option_rows = _action_rows(
162
+ [action for action in option_actions if _is_common_option_action(action)]
163
+ )
164
+ if command_option_rows is not None:
165
+ console.output()
166
+ title = "Command Options" if global_option_rows is not None else "Options"
167
+ _emit_help_table_section(title, command_option_rows)
168
+ if global_option_rows is not None:
169
+ console.output()
170
+ _emit_help_table_section("Global Options", global_option_rows)
171
+ for section in _help_sections(parser):
172
+ console.output()
173
+ console.output(_help_heading(f"{section.title}:"))
174
+ _emit_help_paragraphs(section.body)
175
+
176
+
177
+ def render_all_in_one_markdown(
178
+ parser: ArgumentParser,
179
+ *,
180
+ groups: Sequence[tuple[str, tuple[HelpCommand, ...]]],
181
+ aliases: Mapping[str, tuple[str, ...]],
182
+ ) -> str:
183
+ """Render every command's semantic help as one Markdown and HTML fragment."""
184
+
185
+ lines = ["<!-- Generated by jj-stack; do not edit. -->", ""]
186
+ _append_markdown_overview(
187
+ lines,
188
+ parser,
189
+ groups=groups,
190
+ aliases=aliases,
191
+ include_hidden=True,
192
+ )
193
+ lines.extend(("", "## Commands"))
194
+ for title, entries in groups:
195
+ command_parsers = _command_parsers(parser, entries, include_hidden=True)
196
+ if command_parsers:
197
+ lines.extend(("", f"### {title}"))
198
+ for entry, child_parser in command_parsers:
199
+ _append_markdown_command(
200
+ lines,
201
+ child_parser,
202
+ name=entry.name,
203
+ aliases=aliases.get(entry.name, ()),
204
+ )
205
+ return "\n".join(lines).rstrip() + "\n"
206
+
207
+
208
+ def _append_markdown_overview(
209
+ lines: list[str],
210
+ parser: ArgumentParser,
211
+ *,
212
+ groups: Sequence[tuple[str, tuple[HelpCommand, ...]]],
213
+ aliases: Mapping[str, tuple[str, ...]],
214
+ include_hidden: bool,
215
+ ) -> None:
216
+ lines.extend(
217
+ (
218
+ "## Usage",
219
+ "",
220
+ _usage_html(parser, _top_level_usage_message(include_hidden=include_hidden)),
221
+ )
222
+ )
223
+ if parser.description:
224
+ lines.extend(("", _description_html(parser.description)))
225
+
226
+ for title, entries in groups:
227
+ visible = tuple(entry for entry in entries if include_hidden or not entry.hidden)
228
+ if visible:
229
+ lines.extend(("", f"### {title}", "", _command_list_html(visible, aliases)))
230
+
231
+ options = _top_level_option_actions(parser, include_hidden=include_hidden)
232
+ if options:
233
+ lines.extend(("", "## Global options", "", _action_list_html(options)))
234
+
235
+
236
+ def _append_markdown_command(
237
+ lines: list[str],
238
+ parser: ArgumentParser,
239
+ *,
240
+ name: str,
241
+ aliases: Sequence[str],
242
+ ) -> None:
243
+ lines.extend(("", f"#### {name}"))
244
+ if aliases:
245
+ alias_html = ", ".join(
246
+ f'<code class="cli-alias">{escape(alias)}</code>' for alias in aliases
247
+ )
248
+ lines.extend(("", f'<p class="cli-aliases"><strong>Aliases:</strong> {alias_html}</p>'))
249
+ lines.extend(("", _usage_html(parser, _markdown_command_usage_message(parser))))
250
+ if parser.description:
251
+ description = parser.description
252
+ if aliases:
253
+ spelled = ", ".join(f"jj-stack {alias}" for alias in aliases)
254
+ description = description.removesuffix(f"\n\nAlso spelled {spelled}.")
255
+ lines.extend(("", _description_html(description)))
256
+
257
+ positionals = _visible_actions(parser._positionals._group_actions)
258
+ if positionals:
259
+ lines.extend(("", "##### Positional arguments", "", _action_list_html(positionals)))
260
+ options = tuple(
261
+ action
262
+ for action in _visible_actions(parser._optionals._group_actions)
263
+ if not _is_common_option_action(action)
264
+ )
265
+ if options:
266
+ lines.extend(("", "##### Options", "", _action_list_html(options)))
267
+ for section in _help_sections(parser):
268
+ lines.extend(("", f"##### {section.title}", "", _description_html(section.body)))
269
+
270
+
271
+ def _help_sections(parser: ArgumentParser) -> tuple[HelpSection, ...]:
272
+ return getattr(parser, _HELP_SECTIONS_ATTRIBUTE, ())
273
+
274
+
275
+ def _command_parsers(
276
+ parser: ArgumentParser,
277
+ entries: Sequence[HelpCommand],
278
+ *,
279
+ include_hidden: bool,
280
+ ) -> tuple[tuple[HelpCommand, ArgumentParser], ...]:
281
+ subparsers = next(
282
+ action for action in parser._actions if isinstance(action, _SubParsersAction)
283
+ )
284
+ return tuple(
285
+ (entry, subparsers.choices[entry.name])
286
+ for entry in entries
287
+ if include_hidden or not entry.hidden
288
+ )
289
+
290
+
291
+ def _command_list_html(
292
+ entries: Sequence[HelpCommand],
293
+ aliases: Mapping[str, tuple[str, ...]],
294
+ ) -> str:
295
+ rows = []
296
+ for entry in entries:
297
+ label = f'<code class="cli-command">{escape(entry.name)}</code>'
298
+ entry_aliases = aliases.get(entry.name, ())
299
+ alias_note = ""
300
+ if entry_aliases:
301
+ alias_labels = ", ".join(
302
+ f'<code class="cli-alias">{escape(alias)}</code>' for alias in entry_aliases
303
+ )
304
+ alias_note = (
305
+ f'<span class="cli-aliases"><strong>Aliases:</strong> {alias_labels}</span>'
306
+ )
307
+ rows.append(
308
+ f'<div class="cli-reference-row"><dt>{label}</dt>'
309
+ f"<dd>{_inline_help_html(normalized_help_text(entry.summary))}{alias_note}</dd></div>"
310
+ )
311
+ return '<dl class="cli-reference-list">\n' + "\n".join(rows) + "\n</dl>"
312
+
313
+
314
+ def _action_list_html(actions: Sequence[Any]) -> str:
315
+ rows = [
316
+ f'<div class="cli-reference-row"><dt>{_action_label_html(action)}</dt>'
317
+ f"<dd>{_action_help_html(action)}</dd></div>"
318
+ for action in actions
319
+ ]
320
+ return '<dl class="cli-reference-list">\n' + "\n".join(rows) + "\n</dl>"
321
+
322
+
323
+ def _action_label_html(action: Any) -> str:
324
+ if not action.option_strings:
325
+ label = escape(str(action.metavar or action.dest))
326
+ return f'<code class="cli-argument"><var class="cli-metavar">{label}</var></code>'
327
+ options = ", ".join(
328
+ f'<span class="cli-option">{escape(option)}</span>' for option in action.option_strings
329
+ )
330
+ if action.nargs == 0:
331
+ return f'<code class="cli-argument">{options}</code>'
332
+ metavar = escape(str(action.metavar or action.dest.upper()))
333
+ return f'<code class="cli-argument">{options} <var class="cli-metavar">{metavar}</var></code>'
334
+
335
+
336
+ def _action_help_html(action: Any) -> str:
337
+ content = getattr(action, _ACTION_HELP_RENDERABLE_ATTRIBUTE, action.help or "")
338
+ return _message_html(content)
339
+
340
+
341
+ def _description_html(text: str) -> str:
342
+ blocks: list[str] = []
343
+ bullets: list[str] = []
344
+ for paragraph in _help_paragraphs(text):
345
+ if paragraph.startswith("- "):
346
+ bullets.append(f"<li>{_inline_help_html(paragraph[2:])}</li>")
347
+ continue
348
+ if bullets:
349
+ blocks.append("<ul>\n" + "\n".join(bullets) + "\n</ul>")
350
+ bullets = []
351
+ blocks.append(f"<p>{_inline_help_html(paragraph)}</p>")
352
+ if bullets:
353
+ blocks.append("<ul>\n" + "\n".join(bullets) + "\n</ul>")
354
+ return "\n".join(blocks)
355
+
356
+
357
+ def _message_html(content: ui.Message) -> str:
358
+ if isinstance(content, tuple):
359
+ return "".join(_message_html(part) for part in content)
360
+ if isinstance(content, Template):
361
+ return "".join(
362
+ _inline_help_html(part)
363
+ if isinstance(part, str)
364
+ else _message_html(ui.resolve_interpolation(part))
365
+ for part in content
366
+ )
367
+ if isinstance(content, ui.SemanticText):
368
+ class_name = "cli-inline"
369
+ if "revset" in content.labels or "metavar" in content.labels:
370
+ class_name = "cli-metavar"
371
+ elif "option" in content.labels:
372
+ return _inline_option_html(content.text)
373
+ elif "local_bookmarks" in content.labels or "remote_bookmarks" in content.labels:
374
+ class_name = "cli-bookmark"
375
+ elif "command" in content.labels and (
376
+ _INLINE_OPTION_PATTERN.search(content.text)
377
+ or _INLINE_METAVAR_PATTERN.search(content.text)
378
+ ):
379
+ return _inline_command_html(content.text)
380
+ return f'<code class="{class_name}">{escape(content.text)}</code>'
381
+ return _inline_help_html(str(content))
382
+
383
+
384
+ def _inline_option_html(text: str) -> str:
385
+ match = re.fullmatch(
386
+ r"(?P<option>--?[A-Za-z][\w-]*)(?:(?P<separator>=|\s+)(?P<value>.+))?",
387
+ text,
388
+ )
389
+ if match is None or match.group("value") is None:
390
+ return f'<code class="cli-inline cli-option">{escape(text)}</code>'
391
+ return (
392
+ '<code class="cli-inline cli-option-expression">'
393
+ f'<span class="cli-option">{escape(match.group("option"))}</span>'
394
+ f"{escape(match.group('separator'))}"
395
+ f'<span class="cli-metavar">{escape(match.group("value"))}</span>'
396
+ "</code>"
397
+ )
398
+
399
+
400
+ def _inline_command_html(text: str) -> str:
401
+ parts: list[str] = []
402
+ last_index = 0
403
+ tokens: list[tuple[int, int, str]] = []
404
+ for match in _INLINE_OPTION_PATTERN.finditer(text):
405
+ option = match.group(1)
406
+ if "=" in option:
407
+ option_name, value = option.split("=", 1)
408
+ value_start = match.start(1) + len(option_name) + 1
409
+ tokens.append((match.start(1), value_start - 1, "cli-option"))
410
+ tokens.append((value_start, match.end(1), "cli-metavar"))
411
+ continue
412
+ tokens.append((match.start(1), match.end(1), "cli-option"))
413
+ following = re.match(r"\s+(?P<value>(?!-)\S+)", text[match.end(1) :])
414
+ if following is not None and not _INLINE_METAVAR_PATTERN.fullmatch(
415
+ following.group("value")
416
+ ):
417
+ value_start = match.end(1) + following.start("value")
418
+ value_end = match.end(1) + following.end("value")
419
+ tokens.append((value_start, value_end, "cli-metavar"))
420
+ tokens.extend(
421
+ (match.start(), match.end(), "cli-metavar")
422
+ for match in _INLINE_METAVAR_PATTERN.finditer(text)
423
+ )
424
+ for start, end, class_name in sorted(tokens):
425
+ if start < last_index:
426
+ continue
427
+ parts.append(escape(text[last_index:start]))
428
+ parts.append(f'<span class="{class_name}">{escape(text[start:end])}</span>')
429
+ last_index = end
430
+ parts.append(escape(text[last_index:]))
431
+ return '<code class="cli-inline cli-command-snippet">' + "".join(parts) + "</code>"
432
+
433
+
434
+ def _inline_help_html(text: str) -> str:
435
+ parts: list[str] = []
436
+ last_index = 0
437
+ for match in re.finditer(r"`([^`]+)`", text):
438
+ parts.append(escape(text[last_index : match.start()]))
439
+ parts.append(_message_html(_help_inline_code(match.group(1))))
440
+ last_index = match.end()
441
+ parts.append(escape(text[last_index:]))
442
+ return "".join(parts)
443
+
444
+
445
+ def _usage_html(parser: ArgumentParser, usage: ui.Message | str) -> str:
446
+ text = ui.plain_text(usage) if not isinstance(usage, str) else usage
447
+ token_classes: dict[str, str] = {parser.prog: "cli-command"}
448
+ for action in parser._actions:
449
+ token_classes.update((option, "cli-option") for option in action.option_strings)
450
+ if action.nargs != 0:
451
+ if action.choices is not None and action.metavar is None:
452
+ metavar = "{" + ",".join(str(choice) for choice in action.choices) + "}"
453
+ else:
454
+ metavar = action.metavar or (
455
+ action.dest.upper() if action.option_strings else action.dest
456
+ )
457
+ token_classes[str(metavar)] = "cli-metavar"
458
+ token_classes["<command>"] = "cli-metavar"
459
+ alternatives = "|".join(
460
+ re.escape(token) for token in sorted(token_classes, key=len, reverse=True)
461
+ )
462
+ pattern = re.compile(rf"(?<![\w-])({alternatives})(?![\w-])")
463
+ matches = tuple(pattern.finditer(text))
464
+ parts: list[str] = []
465
+ last_index = 0
466
+ match_index = 0
467
+ while match_index < len(matches):
468
+ match = matches[match_index]
469
+ parts.append(escape(text[last_index : match.start()]))
470
+ token = match.group(1)
471
+ next_match = matches[match_index + 1] if match_index + 1 < len(matches) else None
472
+ if (
473
+ token_classes[token] == "cli-option"
474
+ and next_match is not None
475
+ and token_classes[next_match.group(1)] == "cli-metavar"
476
+ and text[match.end() : next_match.start()] == " "
477
+ ):
478
+ next_token = next_match.group(1)
479
+ parts.append(
480
+ '<span class="cli-argument">'
481
+ f'<span class="cli-option">{escape(token)}</span> '
482
+ f'<span class="cli-metavar">{escape(next_token)}</span>'
483
+ "</span>"
484
+ )
485
+ last_index = next_match.end()
486
+ match_index += 2
487
+ continue
488
+ parts.append(f'<span class="{token_classes[token]}">{escape(token)}</span>')
489
+ last_index = match.end()
490
+ match_index += 1
491
+ parts.append(escape(text[last_index:]))
492
+ return '<pre class="cli-synopsis"><code>' + "".join(parts) + "</code></pre>"
493
+
494
+
495
+ def _command_label(
496
+ entry: HelpCommand,
497
+ aliases: Mapping[str, tuple[str, ...]],
498
+ *,
499
+ include_aliases: bool,
500
+ ) -> str:
501
+ entry_aliases = aliases.get(entry.name, ())
502
+ if not include_aliases or not entry_aliases:
503
+ return entry.name
504
+ return ", ".join((entry.name, *entry_aliases))
505
+
506
+
507
+ def _top_level_usage_message(*, include_hidden: bool) -> ui.Message:
508
+ if include_hidden:
509
+ return (
510
+ t"{ui.cmd('jj-stack')} [{ui.cmd('--help')}] "
511
+ t"[{ui.cmd('--repository REPO')}] "
512
+ t"[{ui.cmd('--config NAME=VALUE')}] [{ui.cmd('--config-file PATH')}] "
513
+ t"[{ui.cmd('--debug')}] [{ui.cmd('--color WHEN')}] "
514
+ t"[{ui.cmd('--time-output')}] [{ui.cmd('--version')}] "
515
+ t"[{ui.cmd('<command>')} ...]"
516
+ )
517
+ return (
518
+ t"{ui.cmd('jj-stack')} [{ui.cmd('--help')}] [{ui.cmd('--color WHEN')}] "
519
+ t"[{ui.cmd('--version')}] [{ui.cmd('<command>')} ...]"
520
+ )
521
+
522
+
523
+ def _command_usage_message(parser: ArgumentParser) -> ui.Message | str:
524
+ body = " ".join(parser.format_usage().split())
525
+ body = re.sub(r"^(?:[Uu]sage:\s*)+", "", body)
526
+ body = re.sub(r"\[-h\]", "[--help]", body)
527
+ if body.startswith(parser.prog):
528
+ return (ui.cmd(parser.prog), body.removeprefix(parser.prog))
529
+ return body
530
+
531
+
532
+ def _markdown_command_usage_message(parser: ArgumentParser) -> ui.Message | str:
533
+ actions = tuple(action for action in parser._actions if not _is_common_option_action(action))
534
+ groups = tuple(
535
+ group
536
+ for group in parser._mutually_exclusive_groups
537
+ if any(action in actions for action in group._group_actions)
538
+ )
539
+ formatter: Any = parser._get_formatter()
540
+ body = " ".join(formatter._format_usage(None, actions, groups, "").split())
541
+ for action in actions:
542
+ if not action.option_strings or action.option_strings[0].startswith("--"):
543
+ continue
544
+ long_option = next(
545
+ (option for option in action.option_strings if option.startswith("--")), None
546
+ )
547
+ if long_option is not None:
548
+ body = re.sub(
549
+ rf"(?<![\w-]){re.escape(action.option_strings[0])}(?![\w-])",
550
+ long_option,
551
+ body,
552
+ )
553
+ if body.startswith(parser.prog):
554
+ return (ui.cmd(parser.prog), body.removeprefix(parser.prog))
555
+ return body
556
+
557
+
558
+ def _help_paragraphs(text: str) -> tuple[str, ...]:
559
+ normalized = normalized_help_text(text)
560
+ if not normalized:
561
+ return ()
562
+ return tuple(" ".join(paragraph.split()) for paragraph in re.split(r"\n\s*\n", normalized))
563
+
564
+
565
+ def _help_inline_code(text: str) -> ui.SemanticText:
566
+ if text.startswith("jj-stack/"):
567
+ return ui.bookmark(text)
568
+ if text.startswith("@") or ("(" in text and text.endswith(")")):
569
+ return ui.revset(text)
570
+ if text.startswith("-"):
571
+ return ui.option(text)
572
+ return ui.cmd(text)
573
+
574
+
575
+ def _help_rich_text(text: str) -> ui.Message:
576
+ parts: list[ui.Message] = []
577
+ last_index = 0
578
+ for match in re.finditer(r"`([^`]+)`", text):
579
+ start, end = match.span()
580
+ if start > last_index:
581
+ parts.append(text[last_index:start])
582
+ parts.append(_help_inline_code(match.group(1)))
583
+ last_index = end
584
+ if last_index == 0:
585
+ return text
586
+ if last_index < len(text):
587
+ parts.append(text[last_index:])
588
+ return tuple(parts)
589
+
590
+
591
+ def _help_heading(text: str) -> ui.SemanticText:
592
+ return ui.semantic_text(text, "hint", "heading")
593
+
594
+
595
+ def _action_help_body(action: Any) -> ui.Message | str:
596
+ content = getattr(action, _ACTION_HELP_RENDERABLE_ATTRIBUTE, None)
597
+ if content is not None:
598
+ return content
599
+ return "\n\n".join(_help_paragraphs(action.help or ""))
600
+
601
+
602
+ def _action_label_message(action) -> ui.Message:
603
+ if not action.option_strings:
604
+ return ui.cmd(str(action.metavar or action.dest))
605
+ label = ", ".join(action.option_strings)
606
+ if action.nargs != 0:
607
+ label = f"{label} {action.metavar or action.dest.upper()}"
608
+ return ui.cmd(label)
609
+
610
+
611
+ def _help_table(
612
+ rows: Sequence[tuple[ui.Message, ui.TableCell]],
613
+ ) -> ui.DataTable:
614
+ label_width = max(len(ui.plain_text(label)) for label, _ in rows) + 2
615
+ return ui.DataTable(
616
+ columns=(
617
+ ui.TableColumn("", no_wrap=True, width=label_width),
618
+ ui.TableColumn(""),
619
+ ),
620
+ rows=tuple(rows),
621
+ box="",
622
+ show_header=False,
623
+ )
624
+
625
+
626
+ def _emit_help_table_section(title: str, rows: Sequence[tuple[ui.Message, ui.TableCell]]) -> None:
627
+ console.output(_help_heading(f"{title}:"))
628
+ console.output(_help_table(rows))
629
+
630
+
631
+ def _is_common_option_action(action: Any) -> bool:
632
+ return bool(action.option_strings) and all(
633
+ option in _COMMON_OPTION_STRINGS for option in action.option_strings
634
+ )
635
+
636
+
637
+ def _action_rows(actions: Sequence[Any]) -> tuple[tuple[ui.Message, ui.TableCell], ...] | None:
638
+ visible_actions = _visible_actions(actions)
639
+ if not visible_actions:
640
+ return None
641
+ return tuple(
642
+ (
643
+ _action_label_message(action),
644
+ _action_help_body(action),
645
+ )
646
+ for action in visible_actions
647
+ )
648
+
649
+
650
+ def _visible_actions(actions: Sequence[Any]) -> tuple[Any, ...]:
651
+ return tuple(action for action in actions if action.help is not SUPPRESS)
652
+
653
+
654
+ def _top_level_option_actions(
655
+ parser: ArgumentParser,
656
+ *,
657
+ include_hidden: bool,
658
+ ) -> tuple[Any, ...]:
659
+ return tuple(
660
+ action
661
+ for action in parser._actions
662
+ if action.option_strings
663
+ and action.help is not SUPPRESS
664
+ and (
665
+ include_hidden
666
+ or not any(
667
+ option in _TOP_LEVEL_HIDDEN_OPTION_STRINGS for option in action.option_strings
668
+ )
669
+ )
670
+ )
671
+
672
+
673
+ def _emit_help_paragraphs(text: str) -> None:
674
+ for index, paragraph in enumerate(_help_paragraphs(text)):
675
+ if index:
676
+ console.output()
677
+ if paragraph.startswith("- "):
678
+ console.output(ui.prefixed_line("- ", _help_rich_text(paragraph[2:])))
679
+ else:
680
+ console.output(_help_rich_text(paragraph))
@@ -0,0 +1 @@
1
+ """Command package for the standalone CLI."""