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.
- lgit/__init__.py +75 -0
- lgit/__main__.py +8 -0
- lgit/analysis.py +326 -0
- lgit/api.py +1077 -0
- lgit/cache.py +338 -0
- lgit/changelog.py +523 -0
- lgit/cli.py +1104 -0
- lgit/compose.py +2110 -0
- lgit/config.py +437 -0
- lgit/diffing.py +384 -0
- lgit/errors.py +137 -0
- lgit/git.py +852 -0
- lgit/map_reduce.py +508 -0
- lgit/markdown_output.py +709 -0
- lgit/models.py +924 -0
- lgit/normalization.py +411 -0
- lgit/patch.py +784 -0
- lgit/profile.py +426 -0
- lgit/py.typed +0 -0
- lgit/repo.py +287 -0
- lgit/resources/__init__.py +1 -0
- lgit/resources/commit_types.json +242 -0
- lgit/resources/prompts/analysis/default.md +237 -0
- lgit/resources/prompts/analysis/markdown.md +112 -0
- lgit/resources/prompts/changelog/default.md +89 -0
- lgit/resources/prompts/changelog/markdown.md +60 -0
- lgit/resources/prompts/compose-bind/default.md +40 -0
- lgit/resources/prompts/compose-bind/markdown.md +41 -0
- lgit/resources/prompts/compose-intent/default.md +63 -0
- lgit/resources/prompts/compose-intent/markdown.md +59 -0
- lgit/resources/prompts/fast/default.md +46 -0
- lgit/resources/prompts/fast/markdown.md +51 -0
- lgit/resources/prompts/map/default.md +67 -0
- lgit/resources/prompts/map/markdown.md +63 -0
- lgit/resources/prompts/reduce/default.md +81 -0
- lgit/resources/prompts/reduce/markdown.md +68 -0
- lgit/resources/prompts/summary/default.md +74 -0
- lgit/resources/prompts/summary/markdown.md +77 -0
- lgit/resources/validation_data.json +1 -0
- lgit/rewrite.py +392 -0
- lgit/style.py +295 -0
- lgit/templates.py +385 -0
- lgit/testing/__init__.py +62 -0
- lgit/testing/compare.py +57 -0
- lgit/testing/fixture.py +386 -0
- lgit/testing/report.py +201 -0
- lgit/testing/runner.py +256 -0
- lgit/tokens.py +90 -0
- lgit/validation.py +545 -0
- lgit_cli-3.7.0.dist-info/METADATA +288 -0
- lgit_cli-3.7.0.dist-info/RECORD +54 -0
- lgit_cli-3.7.0.dist-info/WHEEL +4 -0
- lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|