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/console.py ADDED
@@ -0,0 +1,708 @@
1
+ """Rich-backed terminal output helpers."""
2
+
3
+ # Design notes:
4
+ #
5
+ # - The public API is intentionally just the top-level helper functions and the
6
+ # `configured_console()` context manager. Command modules should not need to manage
7
+ # console objects directly.
8
+ #
9
+ # - The module keeps stdout and stderr console setup in one place so command
10
+ # modules do not spread Rich policy across the codebase.
11
+ #
12
+ # - `markup=False` remains the default so arbitrary user-facing text does not
13
+ # need per-call Rich escaping.
14
+ #
15
+ # - Optional time-prefixing lives here alongside the output it affects.
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ import time
21
+ from collections.abc import Generator
22
+ from contextlib import contextmanager
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from string.templatelib import Template
26
+ from typing import IO, Literal, Protocol
27
+
28
+ from rich import box as rich_box
29
+ from rich.console import Console, ConsoleRenderable, Group, NewLine, RenderableType, RichCast
30
+ from rich.progress import (
31
+ BarColumn,
32
+ Progress,
33
+ SpinnerColumn,
34
+ TaskID,
35
+ TaskProgressColumn,
36
+ TextColumn,
37
+ )
38
+ from rich.segment import Segment
39
+ from rich.status import Status
40
+ from rich.style import Style
41
+ from rich.table import Table
42
+ from rich.text import Text
43
+
44
+ import jj_stack
45
+ import jj_stack.ui as ui
46
+ from jj_stack.jj.cli_args import JjCliArgs
47
+ from jj_stack.jj.colors import SemanticStyles, load_semantic_styles
48
+
49
+ _NO_CLI_ARGS = JjCliArgs()
50
+
51
+ SIMPLE = rich_box.SIMPLE
52
+
53
+ ColorMode = Literal["auto", "always", "never"]
54
+ RequestedColorMode = Literal["always", "auto", "debug", "never"]
55
+ StyleArg = Style | str
56
+ type ConsoleObject = ui.Renderable | ConsoleRenderable | RichCast
57
+
58
+
59
+ class ProgressLike(Protocol):
60
+ """Minimal progress-handle protocol used by command helpers."""
61
+
62
+ def advance(self, amount: int = 1) -> None: ...
63
+
64
+
65
+ class SpinnerLike(Protocol):
66
+ """Minimal spinner-handle protocol used by command helpers."""
67
+
68
+ def update(self, description: str) -> None: ...
69
+
70
+
71
+ @dataclass(slots=True)
72
+ class _TimePrefixedRenderable:
73
+ """Render content with a Rich-managed elapsed-time prefix on every line."""
74
+
75
+ renderable: RenderableType
76
+ end: str
77
+ prefix_style: Style | None
78
+ start: float
79
+
80
+ def __rich_console__(self, console, options):
81
+ prefix = f"[{time.perf_counter() - self.start:0.6f}] "
82
+ prefix_width = len(prefix)
83
+ inner_width = max(1, options.max_width - prefix_width)
84
+ inner_options = options.update(width=inner_width, max_width=inner_width)
85
+ lines = console.render_lines(self.renderable, inner_options, pad=False)
86
+ prefix_segment = Segment(prefix, self.prefix_style)
87
+ for index, line in enumerate(lines):
88
+ yield prefix_segment
89
+ yield from line
90
+ if index < len(lines) - 1:
91
+ yield Segment.line()
92
+ if self.end == "\n":
93
+ yield Segment.line()
94
+ elif self.end:
95
+ yield from console.render(self.end, options)
96
+
97
+
98
+ @dataclass(slots=True)
99
+ class _HangingIndentRenderable:
100
+ """Render a prefix once and indent wrapped body lines to the same column."""
101
+
102
+ prefix: RenderableType
103
+ prefix_width: int
104
+ body: RenderableType
105
+ end: str = "\n"
106
+
107
+ def __rich_console__(self, console, options):
108
+ if options.no_wrap:
109
+ no_wrap_options = options.update(no_wrap=True, overflow="ignore")
110
+ yield from console.render(self.prefix, no_wrap_options)
111
+ yield from console.render(self.body, no_wrap_options)
112
+ return
113
+
114
+ prefix_options = options.update(width=self.prefix_width, max_width=self.prefix_width)
115
+ body_width = max(1, options.max_width - self.prefix_width)
116
+ body_options = options.update(width=body_width, max_width=body_width)
117
+
118
+ prefix_lines = console.render_lines(self.prefix, prefix_options, pad=False)
119
+ prefix_line = prefix_lines[0] if prefix_lines else []
120
+ body_lines = console.render_lines(self.body, body_options, pad=False)
121
+ if not body_lines:
122
+ body_lines = [[]]
123
+
124
+ indent = Segment(" " * self.prefix_width)
125
+ for index, line in enumerate(body_lines):
126
+ if index == 0:
127
+ yield from prefix_line
128
+ else:
129
+ yield indent
130
+ yield from line
131
+ if index < len(body_lines) - 1:
132
+ yield Segment.line()
133
+ if self.end == "\n":
134
+ yield Segment.line()
135
+ elif self.end:
136
+ yield from console.render(self.end, options)
137
+
138
+
139
+ @dataclass(slots=True)
140
+ class _TrimmedRenderable:
141
+ """Render content with trailing whitespace removed from each line."""
142
+
143
+ renderable: RenderableType
144
+ end: str = "\n"
145
+
146
+ def __rich_console__(self, console, options):
147
+ lines = [
148
+ _rstrip_line_segments(line)
149
+ for line in console.render_lines(self.renderable, options, pad=False)
150
+ ]
151
+ while lines and not _line_text(lines[0]):
152
+ lines.pop(0)
153
+ while lines and not _line_text(lines[-1]):
154
+ lines.pop()
155
+
156
+ for index, line in enumerate(lines):
157
+ yield from line
158
+ if index < len(lines) - 1:
159
+ yield Segment.line()
160
+ if self.end == "\n":
161
+ yield Segment.line()
162
+ elif self.end:
163
+ yield from console.render(self.end, options)
164
+
165
+
166
+ class _ConfiguredConsole:
167
+ """Wrap a Rich console with optional jj-style elapsed-time prefixes."""
168
+
169
+ def __init__(
170
+ self,
171
+ console: Console,
172
+ *,
173
+ prefix_style: Style | None,
174
+ start: float | None,
175
+ ) -> None:
176
+ self._console = console
177
+ self._prefix_style = prefix_style
178
+ self._start = start
179
+
180
+ def print(
181
+ self,
182
+ *objects,
183
+ sep: str = " ",
184
+ end: str = "\n",
185
+ style=None,
186
+ justify=None,
187
+ overflow=None,
188
+ no_wrap=None,
189
+ emoji=None,
190
+ markup=None,
191
+ highlight=None,
192
+ width=None,
193
+ height=None,
194
+ crop: bool = True,
195
+ soft_wrap=None,
196
+ new_line_start: bool = False,
197
+ ) -> None:
198
+ if self._start is None:
199
+ self._console.print(
200
+ *objects,
201
+ sep=sep,
202
+ end=end,
203
+ style=style,
204
+ justify=justify,
205
+ overflow=overflow,
206
+ no_wrap=no_wrap,
207
+ emoji=emoji,
208
+ markup=markup,
209
+ highlight=highlight,
210
+ width=width,
211
+ height=height,
212
+ crop=crop,
213
+ soft_wrap=soft_wrap,
214
+ new_line_start=new_line_start,
215
+ )
216
+ return
217
+
218
+ if not objects:
219
+ objects = (NewLine(),)
220
+
221
+ renderables = self._console._collect_renderables(
222
+ objects,
223
+ sep,
224
+ "",
225
+ justify=justify,
226
+ emoji=emoji,
227
+ markup=markup,
228
+ highlight=highlight,
229
+ )
230
+ wrapped = _TimePrefixedRenderable(
231
+ renderable=Group(*renderables),
232
+ end=end,
233
+ prefix_style=self._prefix_style,
234
+ start=self._start,
235
+ )
236
+ self._console.print(
237
+ wrapped,
238
+ end="",
239
+ style=style,
240
+ justify=justify,
241
+ overflow=overflow,
242
+ no_wrap=no_wrap,
243
+ width=width,
244
+ height=height,
245
+ crop=crop,
246
+ soft_wrap=soft_wrap,
247
+ new_line_start=new_line_start,
248
+ )
249
+
250
+
251
+ class _NullProgress:
252
+ """No-op progress handle returned when live progress is disabled."""
253
+
254
+ def advance(self, amount: int = 1) -> None:
255
+ del amount
256
+
257
+
258
+ class _NullSpinner:
259
+ """No-op spinner handle returned when live progress is disabled."""
260
+
261
+ def update(self, description: str) -> None:
262
+ del description
263
+
264
+
265
+ @dataclass(slots=True)
266
+ class _RichProgressHandle:
267
+ """Advance one Rich progress task."""
268
+
269
+ progress: Progress
270
+ task_id: TaskID
271
+
272
+ def advance(self, amount: int = 1) -> None:
273
+ self.progress.advance(self.task_id, amount)
274
+
275
+
276
+ @dataclass(slots=True)
277
+ class _RichSpinnerHandle:
278
+ """Update one Rich status spinner."""
279
+
280
+ status: Status
281
+
282
+ def update(self, description: str) -> None:
283
+ self.status.update(description)
284
+
285
+
286
+ def _raw_console(stream: IO[str], *, color_mode: ColorMode) -> Console:
287
+ if color_mode == "always":
288
+ return Console(file=stream, force_terminal=True, no_color=False)
289
+ if color_mode == "never":
290
+ return Console(file=stream, no_color=True)
291
+ return Console(file=stream)
292
+
293
+
294
+ def _console_emits_color(console: Console) -> bool:
295
+ return console.is_terminal and not console.no_color
296
+
297
+
298
+ def _build_console(
299
+ console: Console,
300
+ *,
301
+ semantic_styles: SemanticStyles | None,
302
+ time_output: bool,
303
+ ) -> _ConfiguredConsole:
304
+ return _ConfiguredConsole(
305
+ console,
306
+ prefix_style=(
307
+ None
308
+ if semantic_styles is None
309
+ else semantic_styles.for_labels(("prefix", "timestamp"))
310
+ ),
311
+ start=jj_stack.PROCESS_START if time_output else None,
312
+ )
313
+
314
+
315
+ def _build_consoles(
316
+ *,
317
+ cli_args: JjCliArgs,
318
+ color_mode: ColorMode = "auto",
319
+ load_styles: bool = True,
320
+ repo: Path | None = None,
321
+ stderr: IO[str] | None = None,
322
+ stdout: IO[str] | None = None,
323
+ time_output: bool = False,
324
+ ) -> tuple[_ConfiguredConsole, _ConfiguredConsole, SemanticStyles | None]:
325
+ stdout_console = _raw_console(sys.stdout if stdout is None else stdout, color_mode=color_mode)
326
+ stderr_console = _raw_console(sys.stderr if stderr is None else stderr, color_mode=color_mode)
327
+ # Semantic styles exist only to color output, and reading them costs one
328
+ # jj invocation, so skip the read when neither console can emit color
329
+ # (piped or captured output).
330
+ semantic_styles = None
331
+ if load_styles and (
332
+ _console_emits_color(stdout_console) or _console_emits_color(stderr_console)
333
+ ):
334
+ semantic_styles = load_semantic_styles(repo=repo, cli_args=cli_args)
335
+ return (
336
+ _build_console(
337
+ stdout_console,
338
+ semantic_styles=semantic_styles,
339
+ time_output=time_output,
340
+ ),
341
+ _build_console(
342
+ stderr_console,
343
+ semantic_styles=semantic_styles,
344
+ time_output=time_output,
345
+ ),
346
+ semantic_styles,
347
+ )
348
+
349
+
350
+ _STDOUT_CONSOLE: _ConfiguredConsole
351
+ _STDERR_CONSOLE: _ConfiguredConsole
352
+ _SEMANTIC_STYLES: SemanticStyles | None
353
+ _REQUESTED_COLOR_MODE: RequestedColorMode | None = None
354
+ _ACTIVE_COLOR_MODE: ColorMode = "auto"
355
+ _STDOUT_STREAM: IO[str] = sys.stdout
356
+ _STDERR_STREAM: IO[str] = sys.stderr
357
+
358
+
359
+ def rich_color_mode(color_mode: RequestedColorMode | None) -> ColorMode:
360
+ """Map `jj`-style color modes onto Rich's supported console modes."""
361
+
362
+ if color_mode in {"always", "debug"}:
363
+ return "always"
364
+ if color_mode == "never":
365
+ return "never"
366
+ return "auto"
367
+
368
+
369
+ @contextmanager
370
+ def configured_console(
371
+ *,
372
+ cli_args: JjCliArgs = _NO_CLI_ARGS,
373
+ color_mode: ColorMode = "auto",
374
+ repo: Path | None = None,
375
+ requested_color_mode: RequestedColorMode | None = None,
376
+ stderr: IO[str] | None = None,
377
+ stdout: IO[str] | None = None,
378
+ time_output: bool = False,
379
+ ):
380
+ """Temporarily install shared stdout and stderr consoles."""
381
+
382
+ global _STDOUT_CONSOLE
383
+ global _STDERR_CONSOLE
384
+ global _SEMANTIC_STYLES
385
+ global _REQUESTED_COLOR_MODE
386
+ global _ACTIVE_COLOR_MODE
387
+ global _STDOUT_STREAM
388
+ global _STDERR_STREAM
389
+ previous_stdout = _STDOUT_CONSOLE
390
+ previous_stderr = _STDERR_CONSOLE
391
+ previous_semantic_styles = _SEMANTIC_STYLES
392
+ previous_requested_color_mode = _REQUESTED_COLOR_MODE
393
+ previous_active_color_mode = _ACTIVE_COLOR_MODE
394
+ previous_stdout_stream = _STDOUT_STREAM
395
+ previous_stderr_stream = _STDERR_STREAM
396
+ _STDOUT_CONSOLE, _STDERR_CONSOLE, _SEMANTIC_STYLES = _build_consoles(
397
+ cli_args=cli_args,
398
+ color_mode=color_mode,
399
+ repo=repo,
400
+ stderr=stderr,
401
+ stdout=stdout,
402
+ time_output=time_output,
403
+ )
404
+ _REQUESTED_COLOR_MODE = requested_color_mode
405
+ _ACTIVE_COLOR_MODE = color_mode
406
+ _STDOUT_STREAM = sys.stdout if stdout is None else stdout
407
+ _STDERR_STREAM = sys.stderr if stderr is None else stderr
408
+ try:
409
+ yield
410
+ finally:
411
+ _STDOUT_CONSOLE = previous_stdout
412
+ _STDERR_CONSOLE = previous_stderr
413
+ _SEMANTIC_STYLES = previous_semantic_styles
414
+ _REQUESTED_COLOR_MODE = previous_requested_color_mode
415
+ _ACTIVE_COLOR_MODE = previous_active_color_mode
416
+ _STDOUT_STREAM = previous_stdout_stream
417
+ _STDERR_STREAM = previous_stderr_stream
418
+
419
+
420
+ def requested_color_mode() -> RequestedColorMode | None:
421
+ """Return the active CLI `--color` override, if one was supplied."""
422
+
423
+ return _REQUESTED_COLOR_MODE
424
+
425
+
426
+ def semantic_style(*labels: str) -> Style | None:
427
+ """Resolve jj semantic color labels into the active Rich style."""
428
+
429
+ if _SEMANTIC_STYLES is None:
430
+ return None
431
+ return _SEMANTIC_STYLES.for_labels(labels)
432
+
433
+
434
+ def _render_status_badge(status: ui.StatusBadge) -> Text:
435
+ """Render a semantic status badge into Rich text."""
436
+
437
+ labels = {
438
+ "ok": ("hint heading",),
439
+ "warn": ("warning heading",),
440
+ "fail": ("error heading",),
441
+ "fixed": ("hint heading",),
442
+ "skip": ("hint heading",),
443
+ }
444
+ return rich_text(ui.semantic_text(status.value, *labels[status.value]))
445
+
446
+
447
+ def rich_text(
448
+ content: ui.Message,
449
+ *,
450
+ style: StyleArg | None = None,
451
+ ) -> Text:
452
+ """Render semantic template content into Rich `Text`."""
453
+
454
+ rendered = Text("") if style is None else Text("", style=style)
455
+ _append_rich_text(rendered, content, base_style=style)
456
+ return rendered
457
+
458
+
459
+ def style_time_prefix(text: str) -> str:
460
+ """Style the `--time-output` prefix using the active semantic theme."""
461
+
462
+ style = semantic_style("prefix", "timestamp")
463
+ if style is None:
464
+ return text
465
+ if not isinstance(_STDERR_CONSOLE, _ConfiguredConsole):
466
+ return text
467
+ rich_console = _STDERR_CONSOLE._console
468
+ with rich_console.capture() as capture:
469
+ rich_console.print(Text(text, style=style), end="")
470
+ return capture.get()
471
+
472
+
473
+ def _coerce_renderable(value: ConsoleObject) -> RenderableType:
474
+ if isinstance(value, str) and "\x1b[" in value:
475
+ return Text.from_ansi(value)
476
+ if isinstance(value, ui.StatusBadge):
477
+ return _render_status_badge(value)
478
+ if isinstance(value, ui.PrefixedLine):
479
+ return _render_prefixed_line(value)
480
+ if isinstance(value, ui.DataTable):
481
+ return _render_data_table(value)
482
+ if isinstance(value, str | Template | ui.SemanticText | tuple):
483
+ return rich_text(value)
484
+ if isinstance(value, str | ConsoleRenderable | RichCast):
485
+ return value
486
+ return str(value)
487
+
488
+
489
+ def output(*objects: ConsoleObject, **kwargs) -> None:
490
+ """Write plain user-facing output to stdout."""
491
+
492
+ kwargs.setdefault("markup", False)
493
+ _STDOUT_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
494
+
495
+
496
+ def machine_output(text: str) -> None:
497
+ """Write one machine-readable payload without terminal rendering."""
498
+
499
+ _STDOUT_STREAM.write(text)
500
+ if not text.endswith("\n"):
501
+ _STDOUT_STREAM.write("\n")
502
+ _STDOUT_STREAM.flush()
503
+
504
+
505
+ def error(*objects: ConsoleObject, **kwargs) -> None:
506
+ """Write styled error output to stderr."""
507
+
508
+ kwargs.setdefault("markup", False)
509
+ kwargs.setdefault("style", semantic_style("error heading") or "red")
510
+ _STDERR_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
511
+
512
+
513
+ def stderr_output(*objects: ConsoleObject, **kwargs) -> None:
514
+ """Write plain user-facing output to stderr."""
515
+
516
+ kwargs.setdefault("markup", False)
517
+ _STDERR_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
518
+
519
+
520
+ def warning(*objects: ConsoleObject, **kwargs) -> None:
521
+ """Write styled warning output to stderr."""
522
+
523
+ kwargs.setdefault("markup", False)
524
+ kwargs.setdefault("style", semantic_style("warning heading") or "yellow")
525
+ _STDERR_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
526
+
527
+
528
+ def note(*objects: ConsoleObject, **kwargs) -> None:
529
+ """Write styled note output to stdout."""
530
+
531
+ kwargs.setdefault("markup", False)
532
+ kwargs.setdefault("style", semantic_style("hint heading") or "cyan")
533
+ _STDOUT_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
534
+
535
+
536
+ @contextmanager
537
+ def spinner(*, description: str) -> Generator[SpinnerLike]:
538
+ """Render a TTY-only transient spinner on stderr."""
539
+
540
+ if not _stream_supports_live_progress(_STDERR_STREAM):
541
+ yield _NullSpinner()
542
+ return
543
+
544
+ progress_console = _progress_console(stream=_STDERR_STREAM, color_mode=_ACTIVE_COLOR_MODE)
545
+ with progress_console.status(description) as status:
546
+ yield _RichSpinnerHandle(status=status)
547
+
548
+
549
+ @contextmanager
550
+ def progress(*, description: str, total: int) -> Generator[ProgressLike]:
551
+ """Render a TTY-only transient progress bar on stderr."""
552
+
553
+ if total <= 0 or not _stream_supports_live_progress(_STDERR_STREAM):
554
+ yield _NullProgress()
555
+ return
556
+
557
+ progress_console = _progress_console(stream=_STDERR_STREAM, color_mode=_ACTIVE_COLOR_MODE)
558
+ with Progress(
559
+ SpinnerColumn(),
560
+ TextColumn("{task.description}"),
561
+ BarColumn(),
562
+ TaskProgressColumn(),
563
+ console=progress_console,
564
+ transient=True,
565
+ ) as progress_render:
566
+ task_id = progress_render.add_task(description, total=total)
567
+ yield _RichProgressHandle(progress=progress_render, task_id=task_id)
568
+
569
+
570
+ def _stream_supports_live_progress(stream: IO[str]) -> bool:
571
+ try:
572
+ return bool(stream.isatty())
573
+ except OSError:
574
+ return False
575
+
576
+
577
+ def _progress_console(*, stream: IO[str], color_mode: ColorMode) -> Console:
578
+ if color_mode == "always":
579
+ return Console(file=stream, force_terminal=True, no_color=False)
580
+ if color_mode == "never":
581
+ return Console(file=stream, no_color=True)
582
+ return Console(file=stream)
583
+
584
+
585
+ def _append_rich_text(
586
+ rendered: Text,
587
+ content: ui.Message,
588
+ *,
589
+ base_style: StyleArg | None,
590
+ ) -> None:
591
+ if isinstance(content, tuple):
592
+ for item in content:
593
+ _append_rich_text(rendered, item, base_style=base_style)
594
+ return
595
+ if isinstance(content, Template):
596
+ for part in content:
597
+ if isinstance(part, str):
598
+ rendered.append(part, style=base_style)
599
+ else:
600
+ _append_rich_text(
601
+ rendered,
602
+ ui.resolve_interpolation(part),
603
+ base_style=base_style,
604
+ )
605
+ return
606
+ if isinstance(content, ui.SemanticText):
607
+ rendered.append(
608
+ content.text,
609
+ style=_combine_styles(base_style, semantic_style(*content.labels)),
610
+ )
611
+ return
612
+ if isinstance(content, Text):
613
+ appended = content.copy()
614
+ if base_style is not None and appended.plain:
615
+ appended.stylize(base_style, 0, len(appended.plain))
616
+ rendered.append_text(appended)
617
+ return
618
+ rendered.append(content, style=base_style)
619
+
620
+
621
+ def _combine_styles(
622
+ base_style: StyleArg | None,
623
+ extra_style: StyleArg | None,
624
+ ) -> StyleArg | None:
625
+ if base_style is None:
626
+ return extra_style
627
+ if extra_style is None:
628
+ return base_style
629
+ return _to_rich_style(base_style) + _to_rich_style(extra_style)
630
+
631
+
632
+ def _to_rich_style(style: StyleArg) -> Style:
633
+ if isinstance(style, Style):
634
+ return style
635
+ return Style.parse(style)
636
+
637
+
638
+ def _render_prefixed_line(line: ui.PrefixedLine) -> _HangingIndentRenderable:
639
+ """Render one semantic hanging-indent line."""
640
+
641
+ prefix_width = max(1, len(ui.plain_text(line.prefix)))
642
+ message_style = (
643
+ semantic_style(*line.message_labels) if line.message_labels is not None else None
644
+ )
645
+ if isinstance(line.body, ui.StatusBadge):
646
+ message_cell = _coerce_renderable(line.body)
647
+ else:
648
+ message_cell: RenderableType = rich_text(line.body, style=message_style)
649
+
650
+ prefix_style = semantic_style(*line.prefix_labels) if line.prefix_labels is not None else None
651
+ prefix_cell: RenderableType = rich_text(line.prefix, style=prefix_style)
652
+ return _HangingIndentRenderable(
653
+ prefix=prefix_cell,
654
+ prefix_width=prefix_width,
655
+ body=message_cell,
656
+ )
657
+
658
+
659
+ def _render_data_table(table_data: ui.DataTable) -> ConsoleRenderable:
660
+ """Render a lightweight semantic table into a Rich table."""
661
+
662
+ box = SIMPLE if table_data.box == "simple" else None
663
+ table = Table(
664
+ box=box,
665
+ expand=table_data.expand,
666
+ header_style=table_data.header_style,
667
+ pad_edge=table_data.pad_edge,
668
+ padding=table_data.padding,
669
+ show_edge=table_data.show_edge,
670
+ show_header=table_data.show_header,
671
+ )
672
+ for column in table_data.columns:
673
+ table.add_column(column.header, no_wrap=column.no_wrap, width=column.width)
674
+ for row in table_data.rows:
675
+ table.add_row(*(_coerce_renderable(cell) for cell in row))
676
+ return _TrimmedRenderable(table)
677
+
678
+
679
+ def _line_text(line: list[Segment]) -> str:
680
+ return "".join(segment.text for segment in line if not segment.control)
681
+
682
+
683
+ def _rstrip_line_segments(line: list[Segment]) -> list[Segment]:
684
+ trimmed = list(line)
685
+ while trimmed and not trimmed[-1].control and not trimmed[-1].text.strip():
686
+ trimmed.pop()
687
+ if not trimmed:
688
+ return []
689
+
690
+ last = trimmed[-1]
691
+ if not last.control:
692
+ right_trimmed = last.text.rstrip()
693
+ if right_trimmed != last.text:
694
+ if right_trimmed:
695
+ trimmed[-1] = Segment(right_trimmed, last.style, last.control)
696
+ else:
697
+ trimmed.pop()
698
+ return trimmed
699
+
700
+
701
+ # The import-time consoles only serve output emitted before a command
702
+ # installs repo-scoped consoles via `configured_console`, so they never load
703
+ # jj semantic styles: that would cost a jj invocation in the wrong directory
704
+ # on every import.
705
+ _STDOUT_CONSOLE, _STDERR_CONSOLE, _SEMANTIC_STYLES = _build_consoles(
706
+ cli_args=_NO_CLI_ARGS,
707
+ load_styles=False,
708
+ )