lgit-cli 3.7.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 (54) hide show
  1. lgit/__init__.py +75 -0
  2. lgit/__main__.py +8 -0
  3. lgit/analysis.py +326 -0
  4. lgit/api.py +1077 -0
  5. lgit/cache.py +338 -0
  6. lgit/changelog.py +523 -0
  7. lgit/cli.py +1104 -0
  8. lgit/compose.py +2110 -0
  9. lgit/config.py +437 -0
  10. lgit/diffing.py +384 -0
  11. lgit/errors.py +137 -0
  12. lgit/git.py +852 -0
  13. lgit/map_reduce.py +508 -0
  14. lgit/markdown_output.py +709 -0
  15. lgit/models.py +924 -0
  16. lgit/normalization.py +411 -0
  17. lgit/patch.py +784 -0
  18. lgit/profile.py +426 -0
  19. lgit/py.typed +0 -0
  20. lgit/repo.py +287 -0
  21. lgit/resources/__init__.py +1 -0
  22. lgit/resources/commit_types.json +242 -0
  23. lgit/resources/prompts/analysis/default.md +237 -0
  24. lgit/resources/prompts/analysis/markdown.md +112 -0
  25. lgit/resources/prompts/changelog/default.md +89 -0
  26. lgit/resources/prompts/changelog/markdown.md +60 -0
  27. lgit/resources/prompts/compose-bind/default.md +40 -0
  28. lgit/resources/prompts/compose-bind/markdown.md +41 -0
  29. lgit/resources/prompts/compose-intent/default.md +63 -0
  30. lgit/resources/prompts/compose-intent/markdown.md +59 -0
  31. lgit/resources/prompts/fast/default.md +46 -0
  32. lgit/resources/prompts/fast/markdown.md +51 -0
  33. lgit/resources/prompts/map/default.md +67 -0
  34. lgit/resources/prompts/map/markdown.md +63 -0
  35. lgit/resources/prompts/reduce/default.md +81 -0
  36. lgit/resources/prompts/reduce/markdown.md +68 -0
  37. lgit/resources/prompts/summary/default.md +74 -0
  38. lgit/resources/prompts/summary/markdown.md +77 -0
  39. lgit/resources/validation_data.json +1 -0
  40. lgit/rewrite.py +392 -0
  41. lgit/style.py +295 -0
  42. lgit/templates.py +385 -0
  43. lgit/testing/__init__.py +62 -0
  44. lgit/testing/compare.py +57 -0
  45. lgit/testing/fixture.py +386 -0
  46. lgit/testing/report.py +201 -0
  47. lgit/testing/runner.py +256 -0
  48. lgit/tokens.py +90 -0
  49. lgit/validation.py +545 -0
  50. lgit_cli-3.7.0.dist-info/METADATA +288 -0
  51. lgit_cli-3.7.0.dist-info/RECORD +54 -0
  52. lgit_cli-3.7.0.dist-info/WHEEL +4 -0
  53. lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
  54. lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
lgit/rewrite.py ADDED
@@ -0,0 +1,392 @@
1
+ """History rewrite orchestration for regenerating conventional commit messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ import os
9
+ import sys
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from types import SimpleNamespace
14
+ from typing import Any
15
+
16
+ from . import style
17
+ from .analysis import extract_scope_candidates
18
+ from .diffing import smart_truncate_diff
19
+ from .errors import ValidationFailure
20
+ from .git import (
21
+ check_working_tree_clean,
22
+ get_commit_list,
23
+ get_commit_metadata,
24
+ get_git_diff,
25
+ get_git_stat,
26
+ get_head_hash,
27
+ rewrite_history,
28
+ run_git,
29
+ )
30
+ from .models import ConventionalAnalysis
31
+ from .normalization import format_commit_message, post_process_commit_message
32
+ from .validation import validate_commit_message
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class RewriteConversion:
37
+ """One old-to-new commit-message conversion result."""
38
+
39
+ index: int
40
+ commit_hash: str
41
+ old_subject: str
42
+ new_subject: str
43
+ old_message: str
44
+ new_message: str
45
+ error: str | None = None
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class RewriteResult:
50
+ """Result of a rewrite-mode run."""
51
+
52
+ conversions: tuple[RewriteConversion, ...]
53
+ applied: bool
54
+ dry_run: bool
55
+ preview: int | None
56
+ backup_branch: str | None = None
57
+ error: str | None = None
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class _RewriteFailure:
62
+ index: int
63
+ commit_hash: str
64
+ error: str
65
+
66
+
67
+ class _GeneratedMessages(list[str]):
68
+ __slots__ = ("failures",)
69
+
70
+ failures: tuple[_RewriteFailure, ...]
71
+
72
+ def __init__(self, messages: Sequence[str], failures: tuple[_RewriteFailure, ...]) -> None:
73
+ super().__init__(messages)
74
+ self.failures = failures
75
+
76
+
77
+ async def run_rewrite_mode(args: Any, config: Any) -> RewriteResult:
78
+ """Regenerate commit messages and optionally rewrite history."""
79
+
80
+ repo_dir = os.fspath(getattr(args, "dir", "."))
81
+ preview = _optional_int(getattr(args, "rewrite_preview", None))
82
+ dry_run = bool(getattr(args, "rewrite_dry_run", False) or getattr(args, "dry_run", False))
83
+
84
+ if not dry_run and preview is None and not check_working_tree_clean(repo_dir):
85
+ raise ValidationFailure("Working directory not clean. Commit or stash changes first.", field="rewrite")
86
+
87
+ hashes = get_commit_list(getattr(args, "rewrite_start", None), repo_dir)
88
+ if preview is not None:
89
+ hashes = hashes[:preview]
90
+ commits = [get_commit_metadata(commit_hash, repo_dir) for commit_hash in hashes]
91
+
92
+ if dry_run and preview is not None:
93
+ conversions = tuple(
94
+ RewriteConversion(
95
+ index=index,
96
+ commit_hash=commit.hash,
97
+ old_subject=_subject(commit.message, bool(getattr(args, "rewrite_hide_old_types", False))),
98
+ new_subject="",
99
+ old_message=commit.message,
100
+ new_message=commit.message,
101
+ )
102
+ for index, commit in enumerate(commits, start=1)
103
+ )
104
+ return RewriteResult(conversions, applied=False, dry_run=True, preview=preview)
105
+
106
+ rewrite_config = _rewrite_config(config)
107
+ messages = await generate_messages_parallel(commits, rewrite_config, args, repo_dir)
108
+ failures = {failure.index: failure.error for failure in getattr(messages, "failures", ())}
109
+ hide_old_types = bool(getattr(args, "rewrite_hide_old_types", False))
110
+ conversions = tuple(
111
+ RewriteConversion(
112
+ index=index,
113
+ commit_hash=commit.hash,
114
+ old_subject=_subject(commit.message, hide_old_types),
115
+ new_subject=_subject(new_message, False),
116
+ old_message=commit.message,
117
+ new_message=new_message,
118
+ error=failures.get(index - 1),
119
+ )
120
+ for index, (commit, new_message) in enumerate(zip(commits, messages, strict=True), start=1)
121
+ )
122
+ error = _rewrite_error(len(failures))
123
+
124
+ if dry_run or preview is not None or not commits:
125
+ return RewriteResult(conversions, applied=False, dry_run=dry_run, preview=preview, error=error)
126
+
127
+ backup = create_backup_branch(repo_dir)
128
+ rewrite_history(commits, messages, repo_dir)
129
+ return RewriteResult(conversions, applied=True, dry_run=False, preview=None, backup_branch=backup, error=error)
130
+
131
+
132
+ async def generate_messages_parallel(
133
+ commits: Sequence[Any],
134
+ config: Any,
135
+ args: Any,
136
+ dir: str | os.PathLike[str] = ".",
137
+ ) -> list[str]:
138
+ """Generate replacement commit messages with bounded concurrency."""
139
+
140
+ limit = max(1, int(getattr(args, "rewrite_parallel", 10) or 10))
141
+ semaphore = asyncio.Semaphore(limit)
142
+ results: list[str] = [""] * len(commits)
143
+ failures: list[_RewriteFailure | None] = [None] * len(commits)
144
+
145
+ async def worker(index: int, commit: Any) -> None:
146
+ async with semaphore:
147
+ try:
148
+ results[index] = await generate_for_commit(commit, config, dir)
149
+ except Exception as exc:
150
+ results[index] = commit.message
151
+ failures[index] = _RewriteFailure(
152
+ index=index,
153
+ commit_hash=str(commit.hash),
154
+ error=str(exc) or type(exc).__name__,
155
+ )
156
+
157
+ await asyncio.gather(*(worker(index, commit) for index, commit in enumerate(commits)))
158
+ failure_records = tuple(failure for failure in failures if failure is not None)
159
+ for failure in failure_records:
160
+ _print_conversion_failure(failure, len(commits))
161
+ if failure_records:
162
+ _print_conversion_summary(len(failure_records))
163
+ return _GeneratedMessages(results, failure_records)
164
+
165
+
166
+ async def generate_for_commit(commit: Any, config: Any, dir: str | os.PathLike[str] = ".") -> str:
167
+ """Generate and validate one replacement conventional commit message."""
168
+
169
+ commit_hash = commit.hash
170
+ diff = get_git_diff("commit", commit_hash, dir, config)
171
+ stat = get_git_stat("commit", commit_hash, dir, config)
172
+ max_diff_length = int(getattr(config, "max_diff_length", 100_000))
173
+ if len(diff) > max_diff_length:
174
+ diff = smart_truncate_diff(diff, max_diff_length, config)
175
+
176
+ scope_candidates, _ = extract_scope_candidates("commit", commit_hash, dir, config)
177
+ analysis = await _generate_analysis(stat, diff, scope_candidates, config)
178
+ commit_type, scope, details, analysis_summary = _analysis_parts(analysis)
179
+ summary = analysis_summary or await _generate_summary(stat, analysis, commit_type, scope, details, config)
180
+
181
+ message = SimpleNamespace(commit_type=commit_type, scope=scope, summary=summary, body=list(details), footers=[])
182
+ post_process_commit_message(message, config)
183
+ report = validate_commit_message(message, config, stat=stat)
184
+ if not report.ok:
185
+ joined = "; ".join(issue.message for issue in report.errors)
186
+ raise ValidationFailure(joined or "invalid generated commit message", field="rewrite")
187
+ return format_commit_message(message)
188
+
189
+
190
+ def create_backup_branch(dir: str | os.PathLike[str] = ".") -> str:
191
+ """Create a timestamped backup branch at the current HEAD."""
192
+
193
+ head = get_head_hash(dir)
194
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
195
+ branch = f"backup-rewrite-{timestamp}"
196
+ run_git(["branch", branch, head], cwd=dir)
197
+ return branch
198
+
199
+
200
+ async def _generate_analysis(stat: str, diff: str, scope_candidates: str, config: Any) -> Any:
201
+ try:
202
+ from .api import generate_conventional_analysis
203
+ except Exception:
204
+ generate_conventional_analysis = None
205
+
206
+ if generate_conventional_analysis is not None:
207
+ result = generate_conventional_analysis(
208
+ config,
209
+ stat,
210
+ diff,
211
+ scope_candidates,
212
+ user_context=None,
213
+ debug_output=None,
214
+ )
215
+ if inspect.isawaitable(result):
216
+ result = await result
217
+ return result
218
+
219
+ schema = {
220
+ "type": "object",
221
+ "properties": {
222
+ "type": {"type": "string"},
223
+ "commit_type": {"type": "string"},
224
+ "scope": {"type": ["string", "null"]},
225
+ "summary": {"type": ["string", "null"]},
226
+ "details": {"type": "array"},
227
+ },
228
+ "additionalProperties": True,
229
+ }
230
+ prompt = (
231
+ "Produce JSON conventional-commit analysis for this commit. "
232
+ "Use fields type, scope, summary, and details.\n\n"
233
+ f"Scope candidates:\n{scope_candidates}\n\nStat:\n{stat}\n\nDiff:\n{diff}"
234
+ )
235
+ return await _run_oneshot(
236
+ config,
237
+ prompt,
238
+ system_prompt="You classify git diffs into conventional commit metadata.",
239
+ model=getattr(config, "analysis_model", getattr(config, "model", None)),
240
+ schema=schema,
241
+ schema_name="conventional_analysis",
242
+ debug_label="rewrite-analysis",
243
+ cache=True,
244
+ )
245
+
246
+
247
+ async def _generate_summary(
248
+ stat: str,
249
+ analysis: Any,
250
+ commit_type: str,
251
+ scope: str | None,
252
+ details: Sequence[str],
253
+ config: Any,
254
+ ) -> str:
255
+ try:
256
+ from .api import generate_summary_from_analysis
257
+ except Exception:
258
+ generate_summary_from_analysis = None
259
+
260
+ if generate_summary_from_analysis is not None:
261
+ result = generate_summary_from_analysis(config, analysis, stat=stat, user_context=None)
262
+ if inspect.isawaitable(result):
263
+ result = await result
264
+ return str(result)
265
+
266
+ prompt = (
267
+ "Write only the lowercase, past-tense summary text for this conventional commit. "
268
+ "No type prefix and no trailing period.\n\n"
269
+ f"Type: {commit_type}\nScope: {scope or '(none)'}\nDetails:\n"
270
+ + "\n".join(f"- {detail}" for detail in details)
271
+ + f"\n\nStat:\n{stat}"
272
+ )
273
+ result = await _run_oneshot(
274
+ config,
275
+ prompt,
276
+ system_prompt="You write concise conventional commit summaries.",
277
+ model=getattr(config, "summary_model", getattr(config, "analysis_model", getattr(config, "model", None))),
278
+ schema=None,
279
+ schema_name="create_commit_summary",
280
+ debug_label="rewrite-summary",
281
+ cache=True,
282
+ )
283
+ return str(result).strip()
284
+
285
+
286
+ def _analysis_parts(analysis: Any) -> tuple[str, str | None, tuple[str, ...], str | None]:
287
+ if isinstance(analysis, ConventionalAnalysis):
288
+ return (
289
+ str(analysis.commit_type),
290
+ None if analysis.scope is None else str(analysis.scope),
291
+ tuple(analysis.body_texts()),
292
+ analysis.summary,
293
+ )
294
+ payload = _jsonish(analysis)
295
+ if not isinstance(payload, Mapping):
296
+ raise ValidationFailure("analysis response was not an object", field="rewrite")
297
+ commit_type = str(payload.get("commit_type") or payload.get("type") or "chore")
298
+ raw_scope = payload.get("scope")
299
+ scope = None if raw_scope in (None, "", "null") else str(raw_scope)
300
+ summary = payload.get("summary")
301
+ details = payload.get("details") or payload.get("body") or []
302
+ texts: list[str] = []
303
+ for detail in details:
304
+ if isinstance(detail, Mapping):
305
+ text = detail.get("text") or detail.get("description")
306
+ else:
307
+ text = detail
308
+ if text is not None and str(text).strip():
309
+ texts.append(str(text).strip())
310
+ return commit_type, scope, tuple(texts), None if summary in (None, "") else str(summary)
311
+
312
+
313
+ async def _run_oneshot(config: Any, prompt: str, **kwargs: Any) -> Any:
314
+ from .api import run_oneshot
315
+
316
+ try:
317
+ result = run_oneshot(config, prompt, **kwargs)
318
+ except TypeError:
319
+ result = run_oneshot(config=config, prompt=prompt, **kwargs)
320
+ if inspect.isawaitable(result):
321
+ result = await result
322
+ return getattr(result, "output", result)
323
+
324
+
325
+ def _jsonish(value: Any) -> Any:
326
+ if isinstance(value, str):
327
+ text = value.strip()
328
+ if text.startswith("```"):
329
+ text = text.strip("`").removeprefix("json").strip()
330
+ return json.loads(text)
331
+ if hasattr(value, "model_dump"):
332
+ return value.model_dump()
333
+ if hasattr(value, "__dict__"):
334
+ return vars(value)
335
+ return value
336
+
337
+
338
+ def _rewrite_config(config: Any) -> Any:
339
+ return _ExcludeOldMessageProxy(config)
340
+
341
+
342
+ @dataclass(frozen=True, slots=True)
343
+ class _ExcludeOldMessageProxy:
344
+ base: Any
345
+ exclude_old_message: bool = True
346
+
347
+ def __getattr__(self, name: str) -> Any:
348
+ return getattr(self.base, name)
349
+
350
+
351
+ def _print_conversion_failure(failure: _RewriteFailure, total: int) -> None:
352
+ print(
353
+ f"[{failure.index + 1:3}/{total:3}] "
354
+ f"{style.dim(failure.commit_hash[:8])} {style.error('❌ ERROR:')} {failure.error}",
355
+ file=sys.stderr,
356
+ )
357
+
358
+
359
+ def _print_conversion_summary(failure_count: int) -> None:
360
+ print(
361
+ f"\n{style.warning('⚠️')} {style.bold(str(failure_count))} commits failed, kept original messages",
362
+ file=sys.stderr,
363
+ )
364
+
365
+
366
+ def _rewrite_error(failure_count: int) -> str | None:
367
+ if failure_count == 0:
368
+ return None
369
+ return f"{failure_count} commits failed, kept original messages"
370
+
371
+
372
+ def _subject(message: str, hide_type: bool) -> str:
373
+ first = message.splitlines()[0] if message else ""
374
+ if not hide_type or ":" not in first:
375
+ return first
376
+ return first.split(":", 1)[1].strip()
377
+
378
+
379
+ def _optional_int(value: Any) -> int | None:
380
+ if value in (None, False):
381
+ return None
382
+ return int(value)
383
+
384
+
385
+ __all__ = [
386
+ "RewriteConversion",
387
+ "RewriteResult",
388
+ "create_backup_branch",
389
+ "generate_for_commit",
390
+ "generate_messages_parallel",
391
+ "run_rewrite_mode",
392
+ ]
lgit/style.py ADDED
@@ -0,0 +1,295 @@
1
+ """Terminal styling utilities for consistent CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shutil
8
+ import sys
9
+ from collections.abc import Awaitable
10
+ from typing import TextIO
11
+
12
+ _COLOR_ENABLED: bool | None = None
13
+ _PIPE_MODE: bool | None = None
14
+
15
+
16
+ class icons:
17
+ """Status icon constants used by CLI output."""
18
+
19
+ SUCCESS = "✓"
20
+ WARNING = "⚠"
21
+ ERROR = "✗"
22
+ INFO = "ℹ"
23
+ ARROW = "→"
24
+ BULLET = "•"
25
+ CLIPBOARD = "📋"
26
+ SEARCH = "🔍"
27
+ ROBOT = "🤖"
28
+ SAVE = "💾"
29
+
30
+
31
+ class box_chars:
32
+ """Unicode box drawing characters."""
33
+
34
+ TOP_LEFT = "╭"
35
+ TOP_RIGHT = "╮"
36
+ BOTTOM_LEFT = "╰"
37
+ BOTTOM_RIGHT = "╯"
38
+ HORIZONTAL = "─"
39
+ VERTICAL = "│"
40
+
41
+
42
+ def colors_enabled() -> bool:
43
+ """Return true when ANSI colors should be emitted."""
44
+
45
+ global _COLOR_ENABLED
46
+ if _COLOR_ENABLED is None:
47
+ term = os.environ.get("TERM", "")
48
+ _COLOR_ENABLED = "NO_COLOR" not in os.environ and sys.stdout.isatty() and term.lower() != "dumb"
49
+ return _COLOR_ENABLED
50
+
51
+
52
+ def pipe_mode() -> bool:
53
+ """Return true when stdout is not a terminal."""
54
+
55
+ global _PIPE_MODE
56
+ if _PIPE_MODE is None:
57
+ _PIPE_MODE = not sys.stdout.isatty()
58
+ return _PIPE_MODE
59
+
60
+
61
+ def success(s: str) -> str:
62
+ """Style success text."""
63
+
64
+ return _ansi(s, "32", bold_text=True)
65
+
66
+
67
+ def warning(s: str) -> str:
68
+ """Style warning text."""
69
+
70
+ return _ansi(s, "33")
71
+
72
+
73
+ def error(s: str) -> str:
74
+ """Style error text."""
75
+
76
+ return _ansi(s, "31", bold_text=True)
77
+
78
+
79
+ def info(s: str) -> str:
80
+ """Style informational text."""
81
+
82
+ return _ansi(s, "36")
83
+
84
+
85
+ def dim(s: str) -> str:
86
+ """Style low-emphasis text."""
87
+
88
+ return _ansi(s, "2")
89
+
90
+
91
+ def bold(s: str) -> str:
92
+ """Style bold text."""
93
+
94
+ return _ansi(s, "1")
95
+
96
+
97
+ def model(s: str) -> str:
98
+ """Style model names."""
99
+
100
+ return _ansi(s, "35")
101
+
102
+
103
+ def commit_type(s: str) -> str:
104
+ """Style conventional commit types."""
105
+
106
+ return _ansi(s, "34", bold_text=True)
107
+
108
+
109
+ def scope(s: str) -> str:
110
+ """Style conventional commit scopes."""
111
+
112
+ return _ansi(s, "36")
113
+
114
+
115
+ def warn(msg: str) -> None:
116
+ """Print a warning, clearing an active spinner line first."""
117
+
118
+ if not pipe_mode():
119
+ print("\r\x1b[K", end="", file=sys.stdout, flush=True)
120
+ print(f"{warning(icons.WARNING)} {warning(msg)}", file=sys.stderr)
121
+
122
+
123
+ def _status_stream() -> TextIO:
124
+ return sys.stderr if pipe_mode() else sys.stdout
125
+
126
+
127
+ def _clear_status_line(stream: TextIO) -> None:
128
+ if stream.isatty() and colors_enabled():
129
+ print("\r\x1b[K", end="", file=stream, flush=True)
130
+
131
+
132
+ def status(msg: str = "") -> None:
133
+ """Print a status line to stderr in pipe mode, stdout otherwise."""
134
+
135
+ stream = _status_stream()
136
+ _clear_status_line(stream)
137
+ print(msg, file=stream)
138
+
139
+
140
+ def status_text(text: str) -> None:
141
+ """Write raw status text to stderr in pipe mode, stdout otherwise."""
142
+
143
+ stream = _status_stream()
144
+ _clear_status_line(stream)
145
+ stream.write(text)
146
+ stream.flush()
147
+
148
+
149
+ def print_info(msg: str) -> None:
150
+ """Print an informational message, clearing an active spinner line first."""
151
+
152
+ prefix = info(icons.INFO) if colors_enabled() else icons.INFO
153
+ if sys.stderr.isatty() and colors_enabled():
154
+ print(f"\r\x1b[K{prefix} {msg}", file=sys.stderr)
155
+ else:
156
+ print(f"{prefix} {msg}", file=sys.stderr)
157
+
158
+
159
+ def term_width() -> int:
160
+ """Return terminal width capped at 120 columns."""
161
+
162
+ return min(shutil.get_terminal_size((80, 24)).columns, 120)
163
+
164
+
165
+ def boxed_message(title: str, content: str, width: int) -> str:
166
+ """Render ``content`` inside a titled Unicode box."""
167
+
168
+ width = max(width, 4)
169
+ inner_width = max(0, width - 4)
170
+ border_width = max(0, width - 2)
171
+ title_text = bold(title) if colors_enabled() else title
172
+ title_len = len(title)
173
+ padding = max(0, border_width - title_len - 2)
174
+ left_pad = padding // 2
175
+ right_pad = padding - left_pad
176
+ lines = [
177
+ f"{box_chars.TOP_LEFT}{box_chars.HORIZONTAL * left_pad} {title_text} "
178
+ f"{box_chars.HORIZONTAL * right_pad}{box_chars.TOP_RIGHT}"
179
+ ]
180
+ for raw_line in content.splitlines() or [""]:
181
+ for wrapped in _wrap_line(raw_line, inner_width):
182
+ pad = max(0, inner_width - len(wrapped))
183
+ lines.append(f"{box_chars.VERTICAL} {wrapped}{' ' * pad} {box_chars.VERTICAL}")
184
+ lines.append(f"{box_chars.BOTTOM_LEFT}{box_chars.HORIZONTAL * border_width}{box_chars.BOTTOM_RIGHT}")
185
+ return "\n".join(lines)
186
+
187
+
188
+ def separator(width: int) -> str:
189
+ """Return a horizontal separator line."""
190
+
191
+ line = box_chars.HORIZONTAL * max(0, width)
192
+ return dim(line) if colors_enabled() else line
193
+
194
+
195
+ def section_header(title: str, width: int) -> str:
196
+ """Return a centered section header with decorative lines."""
197
+
198
+ line_len = max(0, (width - len(title) - 2) // 2)
199
+ line = box_chars.HORIZONTAL * line_len
200
+ if colors_enabled():
201
+ return f"{dim(line)} {bold(title)} {dim(line)}"
202
+ return f"{line} {title} {line}"
203
+
204
+
205
+ async def with_spinner[T](message: str, awaitable: Awaitable[T]) -> T:
206
+ """Await an operation while displaying a simple terminal spinner."""
207
+
208
+ if not colors_enabled() or not sys.stdout.isatty():
209
+ print(message, file=sys.stderr)
210
+ return await awaitable
211
+ task = asyncio.ensure_future(awaitable)
212
+ spinner_task = asyncio.create_task(_spin(message, task))
213
+ try:
214
+ return await task
215
+ finally:
216
+ spinner_task.cancel()
217
+ try:
218
+ await spinner_task
219
+ except asyncio.CancelledError:
220
+ pass
221
+ outcome = icons.SUCCESS if not task.cancelled() and task.exception() is None else icons.ERROR
222
+ print(
223
+ f"\r\x1b[K{success(outcome) if outcome == icons.SUCCESS else error(outcome)} {message}",
224
+ file=sys.stdout,
225
+ flush=True,
226
+ )
227
+
228
+
229
+ async def with_spinner_result[T](message: str, awaitable: Awaitable[T]) -> T:
230
+ """Alias for ``with_spinner`` for call sites expecting result-aware naming."""
231
+
232
+ return await with_spinner(message, awaitable)
233
+
234
+
235
+ def _ansi(s: str, code: str, *, bold_text: bool = False) -> str:
236
+ if not colors_enabled():
237
+ return s
238
+ prefix = f"1;{code}" if bold_text and code != "1" else code
239
+ return f"\x1b[{prefix}m{s}\x1b[0m"
240
+
241
+
242
+ def _wrap_line(line: str, max_width: int) -> list[str]:
243
+ if line == "":
244
+ return [""]
245
+ if max_width <= 0:
246
+ return [line]
247
+ wrapped: list[str] = []
248
+ current = ""
249
+ for word in line.split():
250
+ if not current:
251
+ current = word
252
+ elif len(current) + 1 + len(word) <= max_width:
253
+ current += " " + word
254
+ else:
255
+ wrapped.append(current)
256
+ current = word
257
+ if current:
258
+ wrapped.append(current)
259
+ return wrapped or [""]
260
+
261
+
262
+ async def _spin(message: str, task: asyncio.Future[object]) -> None:
263
+ frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
264
+ index = 0
265
+ while not task.done():
266
+ print(f"\r{info(frames[index])} {message}", end="", file=sys.stdout, flush=True)
267
+ index = (index + 1) % len(frames)
268
+ await asyncio.sleep(0.08)
269
+
270
+
271
+ __all__ = [
272
+ "bold",
273
+ "box_chars",
274
+ "boxed_message",
275
+ "colors_enabled",
276
+ "commit_type",
277
+ "dim",
278
+ "error",
279
+ "icons",
280
+ "info",
281
+ "model",
282
+ "pipe_mode",
283
+ "print_info",
284
+ "scope",
285
+ "section_header",
286
+ "separator",
287
+ "status",
288
+ "status_text",
289
+ "success",
290
+ "term_width",
291
+ "warn",
292
+ "warning",
293
+ "with_spinner",
294
+ "with_spinner_result",
295
+ ]