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/changelog.py ADDED
@@ -0,0 +1,523 @@
1
+ """Changelog maintenance for staged commits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ import os
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass
10
+ from importlib import resources
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .diffing import smart_truncate_diff
15
+ from .errors import GitError, ValidationFailure
16
+ from .git import run_git
17
+ from .models import ChangelogCategory
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class UnreleasedSection:
22
+ """Parsed bounds and entries for a changelog's Unreleased section."""
23
+
24
+ path: Path
25
+ header_line: int
26
+ end_line: int
27
+ entries: dict[ChangelogCategory, list[str]]
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ChangelogBoundary:
32
+ """A changelog and the staged files governed by it."""
33
+
34
+ changelog_path: Path
35
+ files: tuple[str, ...]
36
+ diff: str = ""
37
+ stat: str = ""
38
+
39
+
40
+ async def run_changelog_flow(args: Any, config: Any) -> list[ChangelogBoundary]:
41
+ """Generate and stage changelog entries for currently staged files."""
42
+
43
+ repo_dir = Path(getattr(args, "dir", "."))
44
+ staged_files = _staged_files(repo_dir)
45
+ candidate_files = [path for path in staged_files if not path.lower().endswith("changelog.md")]
46
+ if not candidate_files:
47
+ return []
48
+
49
+ changelogs = _find_changelogs(repo_dir)
50
+ if not changelogs:
51
+ return []
52
+
53
+ boundaries = detect_boundaries(candidate_files, changelogs, repo_dir)
54
+ updated: list[ChangelogBoundary] = []
55
+ untracked_to_stage: list[str] = []
56
+
57
+ max_diff_length = int(getattr(config, "max_diff_length", 100_000))
58
+ for boundary in boundaries:
59
+ diff = _diff_for_files(boundary.files, repo_dir, max_diff_length)
60
+ if not diff.strip():
61
+ continue
62
+ if len(diff) > max_diff_length:
63
+ diff = smart_truncate_diff(diff, max_diff_length, config)
64
+ stat = _stat_for_files(boundary.files, repo_dir)
65
+
66
+ rel_path = _relative_to(boundary.changelog_path, repo_dir)
67
+ staged_content = _staged_changelog_content(rel_path, repo_dir)
68
+ worktree_content = boundary.changelog_path.read_text(encoding="utf-8")
69
+ is_tracked = staged_content is not None
70
+ changelog_content = staged_content if staged_content is not None else worktree_content
71
+
72
+ try:
73
+ unreleased = parse_unreleased_section(changelog_content, boundary.changelog_path)
74
+ except ValidationFailure:
75
+ continue
76
+
77
+ worktree_unreleased: UnreleasedSection | None = None
78
+ if is_tracked and worktree_content != changelog_content:
79
+ try:
80
+ worktree_unreleased = parse_unreleased_section(worktree_content, boundary.changelog_path)
81
+ except ValidationFailure:
82
+ continue
83
+
84
+ entries = await generate_changelog_entries(
85
+ boundary.changelog_path,
86
+ _is_package_changelog(boundary.changelog_path, repo_dir),
87
+ stat,
88
+ diff,
89
+ _format_existing_entries(unreleased),
90
+ config,
91
+ )
92
+ if not entries:
93
+ continue
94
+
95
+ updated_staged = write_entries(changelog_content, unreleased, entries)
96
+ updated_worktree = (
97
+ write_entries(worktree_content, worktree_unreleased, entries)
98
+ if worktree_unreleased is not None
99
+ else updated_staged
100
+ )
101
+ boundary.changelog_path.write_text(updated_worktree, encoding="utf-8")
102
+
103
+ if is_tracked:
104
+ stage_changelog_blob(rel_path, updated_staged, repo_dir)
105
+ else:
106
+ untracked_to_stage.append(rel_path)
107
+ updated.append(ChangelogBoundary(boundary.changelog_path, boundary.files, diff, stat))
108
+
109
+ if untracked_to_stage:
110
+ run_git(["add", "--", *untracked_to_stage], cwd=repo_dir)
111
+ return updated
112
+
113
+
114
+ def parse_unreleased_section(content: str, path: str | os.PathLike[str] = "CHANGELOG.md") -> UnreleasedSection:
115
+ """Parse the `[Unreleased]` section boundaries and existing entries."""
116
+
117
+ lines = content.splitlines()
118
+ header_line: int | None = None
119
+ for index, line in enumerate(lines):
120
+ trimmed = line.strip().lower()
121
+ if "[unreleased]" in trimmed or trimmed == "## unreleased":
122
+ header_line = index
123
+ break
124
+ if header_line is None:
125
+ raise ValidationFailure(f"No [Unreleased] section in {path}", field="changelog")
126
+
127
+ end_line = len(lines)
128
+ for index in range(header_line + 1, len(lines)):
129
+ trimmed = lines[index].strip()
130
+ if (trimmed.startswith("## [") and "]" in trimmed) or (
131
+ trimmed.startswith("## ")
132
+ and len(trimmed) > 3
133
+ and (trimmed[3].isdigit() or (trimmed[3] in "vV" and len(trimmed) > 4 and trimmed[4].isdigit()))
134
+ ):
135
+ end_line = index
136
+ break
137
+
138
+ entries: dict[ChangelogCategory, list[str]] = {}
139
+ current_category: ChangelogCategory | None = None
140
+ for line in lines[header_line + 1 : end_line]:
141
+ trimmed = line.strip()
142
+ if trimmed.startswith("### "):
143
+ category_name = trimmed.removeprefix("### ").strip()
144
+ current_category = _category_or_none(category_name)
145
+ elif current_category is not None and trimmed.startswith(("- ", "* ")):
146
+ normalized = normalize_changelog_entry(trimmed)
147
+ if normalized is not None:
148
+ entries.setdefault(current_category, []).append(normalized)
149
+ return UnreleasedSection(Path(path), header_line, end_line, entries)
150
+
151
+
152
+ def write_entries(
153
+ content: str,
154
+ unreleased: UnreleasedSection,
155
+ new_entries: Mapping[ChangelogCategory | str, Sequence[str]],
156
+ ) -> str:
157
+ """Insert new entries at the top of the Unreleased section."""
158
+
159
+ normalized_new = _coerce_entries(new_entries)
160
+ if not normalized_new:
161
+ return content
162
+
163
+ lines = content.split("\n")
164
+ result: list[str] = list(lines[: unreleased.header_line + 1])
165
+ result.append("")
166
+
167
+ existing = unreleased.entries
168
+ for category in ChangelogCategory.render_order():
169
+ fresh = normalized_new.get(category, [])
170
+ old = existing.get(category, [])
171
+ if not fresh and not old:
172
+ continue
173
+
174
+ result.append(f"### {category.value}")
175
+ result.append("")
176
+ result.extend(fresh)
177
+ if fresh and old:
178
+ result.append("")
179
+ result.extend(old)
180
+ result.append("")
181
+
182
+ while result and result[-1] == "":
183
+ result.pop()
184
+ result.append("")
185
+ if unreleased.end_line < len(lines):
186
+ result.extend(lines[unreleased.end_line :])
187
+
188
+ return "\n".join(result)
189
+
190
+
191
+ def stage_changelog_blob(
192
+ rel_path: str | os.PathLike[str],
193
+ content: str,
194
+ dir: str | os.PathLike[str] = ".",
195
+ ) -> str:
196
+ """Stage exact changelog content as an index blob without git-adding the worktree copy."""
197
+
198
+ path_text = os.fspath(rel_path)
199
+ oid = run_git(["hash-object", "-w", "--stdin"], cwd=dir, input_text=content).stdout.strip()
200
+ if not oid:
201
+ raise GitError(f"git hash-object returned no oid for {path_text}")
202
+ mode = _staged_changelog_mode(path_text, dir)
203
+ run_git(["update-index", "--add", "--cacheinfo", f"{mode},{oid},{path_text}"], cwd=dir)
204
+ return oid
205
+
206
+
207
+ def detect_boundaries(
208
+ files: Sequence[str],
209
+ changelogs: Sequence[str | os.PathLike[str]],
210
+ dir: str | os.PathLike[str] = ".",
211
+ ) -> list[ChangelogBoundary]:
212
+ """Group staged files under the nearest ancestor CHANGELOG.md."""
213
+
214
+ repo_dir = Path(dir)
215
+ dir_to_changelog: dict[str, Path] = {}
216
+ root_changelog: Path | None = None
217
+ for changelog in changelogs:
218
+ path = Path(changelog)
219
+ rel = _relative_to(path, repo_dir)
220
+ parent = str(Path(rel).parent).replace("\\", "/")
221
+ if parent in ("", "."):
222
+ root_changelog = path
223
+ else:
224
+ dir_to_changelog[parent] = path
225
+
226
+ grouped: dict[Path, list[str]] = {}
227
+ for file in files:
228
+ current = Path(file).parent
229
+ selected: Path | None = None
230
+ while True:
231
+ key = "" if str(current) == "." else str(current).replace("\\", "/")
232
+ if key in dir_to_changelog:
233
+ selected = dir_to_changelog[key]
234
+ break
235
+ if key == "":
236
+ break
237
+ parent = current.parent
238
+ if parent == current:
239
+ break
240
+ current = parent
241
+ if selected is None:
242
+ selected = root_changelog
243
+ if selected is not None:
244
+ grouped.setdefault(selected, []).append(file)
245
+
246
+ return [
247
+ ChangelogBoundary(path, tuple(paths)) for path, paths in sorted(grouped.items(), key=lambda item: str(item[0]))
248
+ ]
249
+
250
+
251
+ async def generate_changelog_entries(
252
+ changelog_path: str | os.PathLike[str],
253
+ is_package_changelog: bool,
254
+ stat: str,
255
+ diff: str,
256
+ existing_entries: str | None,
257
+ config: Any,
258
+ ) -> dict[ChangelogCategory, list[str]]:
259
+ """Ask the configured model for Keep a Changelog entries."""
260
+
261
+ variant = "markdown" if bool(getattr(config, "markdown_output", True)) else "default"
262
+ system_prompt, user_prompt = _render_prompt(
263
+ "changelog",
264
+ variant,
265
+ {
266
+ "changelog_path": os.fspath(changelog_path),
267
+ "is_package_changelog": is_package_changelog,
268
+ "stat": stat,
269
+ "diff": diff,
270
+ "existing_entries": existing_entries or "",
271
+ },
272
+ )
273
+ schema = {
274
+ "type": "object",
275
+ "properties": {
276
+ "entries": {
277
+ "type": "object",
278
+ "description": "Changelog entries grouped by category",
279
+ "properties": {
280
+ "Added": {
281
+ "type": "array",
282
+ "items": {"type": "string"},
283
+ "description": "New features or capabilities",
284
+ },
285
+ "Changed": {
286
+ "type": "array",
287
+ "items": {"type": "string"},
288
+ "description": "Changes to existing functionality",
289
+ },
290
+ "Fixed": {
291
+ "type": "array",
292
+ "items": {"type": "string"},
293
+ "description": "Bug fixes",
294
+ },
295
+ "Deprecated": {
296
+ "type": "array",
297
+ "items": {"type": "string"},
298
+ "description": "Features marked for removal",
299
+ },
300
+ "Removed": {
301
+ "type": "array",
302
+ "items": {"type": "string"},
303
+ "description": "Removed features",
304
+ },
305
+ "Security": {
306
+ "type": "array",
307
+ "items": {"type": "string"},
308
+ "description": "Security-related changes",
309
+ },
310
+ "Breaking Changes": {
311
+ "type": "array",
312
+ "items": {"type": "string"},
313
+ "description": "Breaking API or behavior changes",
314
+ },
315
+ },
316
+ "additionalProperties": False,
317
+ }
318
+ },
319
+ "required": ["entries"],
320
+ "additionalProperties": False,
321
+ }
322
+ output = await _call_run_oneshot(
323
+ config,
324
+ {
325
+ "operation": "changelog",
326
+ "model": getattr(config, "analysis_model", getattr(config, "model", None)),
327
+ "prompt_family": "changelog",
328
+ "prompt_variant": variant,
329
+ "system_prompt": system_prompt,
330
+ "user_prompt": user_prompt,
331
+ "tool_name": "create_changelog_entries",
332
+ "tool_description": "Generate changelog entries grouped by category",
333
+ "schema": schema,
334
+ "progress_label": "changelog",
335
+ "debug": None,
336
+ "cacheable": True,
337
+ },
338
+ )
339
+ payload = _parse_jsonish(output)
340
+ entries = payload.get("entries", payload) if isinstance(payload, dict) else {}
341
+ return _coerce_entries(entries if isinstance(entries, Mapping) else {})
342
+
343
+
344
+ def normalize_changelog_entry(entry: str) -> str | None:
345
+ """Normalize one model-emitted changelog line to a markdown bullet."""
346
+
347
+ stripped = entry.strip()
348
+ if stripped.startswith(("- ", "* ")):
349
+ stripped = stripped[2:].strip()
350
+ return f"- {stripped}" if stripped else None
351
+
352
+
353
+ def _staged_files(dir: Path) -> list[str]:
354
+ stdout = run_git(["diff", "--cached", "--name-only"], cwd=dir).stdout
355
+ return [line for line in stdout.splitlines() if line]
356
+
357
+
358
+ def _find_changelogs(dir: Path) -> list[Path]:
359
+ paths = {
360
+ dir / path
361
+ for path in run_git(["ls-files", "--", "CHANGELOG.md", "**/CHANGELOG.md"], cwd=dir).stdout.splitlines()
362
+ }
363
+ for path in dir.rglob("CHANGELOG.md"):
364
+ if ".git" not in path.parts:
365
+ paths.add(path)
366
+ return sorted(paths, key=lambda path: _relative_to(path, dir))
367
+
368
+
369
+ def _staged_changelog_content(rel_path: str, dir: str | os.PathLike[str]) -> str | None:
370
+ result = run_git(["show", f":{rel_path}"], cwd=dir, check=False, allow_exit_codes=(128,))
371
+ return result.stdout if result.returncode == 0 else None
372
+
373
+
374
+ def _staged_changelog_mode(rel_path: str, dir: str | os.PathLike[str]) -> str:
375
+ result = run_git(["ls-files", "-s", "--", rel_path], cwd=dir).stdout.strip()
376
+ return result.split(maxsplit=1)[0] if result else "100644"
377
+
378
+
379
+ def _diff_for_files(files: Sequence[str], dir: Path, max_len: int) -> str:
380
+ if not files:
381
+ return ""
382
+ diff = run_git(["diff", "--cached", "--", *files], cwd=dir).stdout
383
+ if len(diff) > max_len:
384
+ return run_git(["diff", "--cached", "-U1", "--", *files], cwd=dir).stdout
385
+ return diff
386
+
387
+
388
+ def _stat_for_files(files: Sequence[str], dir: Path) -> str:
389
+ if not files:
390
+ return ""
391
+ return run_git(["diff", "--cached", "--stat", "--", *files], cwd=dir).stdout
392
+
393
+
394
+ def _category_or_none(name: str) -> ChangelogCategory | None:
395
+ normalized = name.strip().lower()
396
+ valid = {category.value.lower() for category in ChangelogCategory} | {
397
+ category.name.lower() for category in ChangelogCategory
398
+ }
399
+ if normalized == "breaking":
400
+ return ChangelogCategory.BREAKING
401
+ return ChangelogCategory.from_name(name) if normalized in valid else None
402
+
403
+
404
+ def _coerce_entries(entries: Mapping[ChangelogCategory | str, Sequence[str]]) -> dict[ChangelogCategory, list[str]]:
405
+ coerced: dict[ChangelogCategory, list[str]] = {}
406
+ for key, values in entries.items():
407
+ category = key if isinstance(key, ChangelogCategory) else ChangelogCategory.from_name(str(key))
408
+ iterable = [values] if isinstance(values, str) else values
409
+ normalized = [line for value in iterable if (line := normalize_changelog_entry(str(value))) is not None]
410
+ if normalized:
411
+ coerced.setdefault(category, []).extend(normalized)
412
+ return coerced
413
+
414
+
415
+ def _format_existing_entries(unreleased: UnreleasedSection) -> str | None:
416
+ lines: list[str] = []
417
+ for category in ChangelogCategory.render_order():
418
+ entries = unreleased.entries.get(category, [])
419
+ if not entries:
420
+ continue
421
+ lines.append(f"### {category.value}")
422
+ lines.extend(entries)
423
+ lines.append("")
424
+ return "\n".join(lines).strip() or None
425
+
426
+
427
+ def _relative_to(path: Path, dir: Path | str | os.PathLike[str]) -> str:
428
+ repo_dir = Path(dir)
429
+ try:
430
+ return path.relative_to(repo_dir).as_posix()
431
+ except ValueError:
432
+ return path.as_posix()
433
+
434
+
435
+ def _is_package_changelog(path: Path, dir: Path) -> bool:
436
+ parent = path.parent
437
+ try:
438
+ return parent.resolve() != dir.resolve()
439
+ except OSError:
440
+ return parent != dir
441
+
442
+
443
+ def _render_prompt(family: str, variant: str, values: Mapping[str, Any]) -> tuple[str, str]:
444
+ text = (resources.files("lgit.resources") / "prompts" / family / f"{variant}.md").read_text(encoding="utf-8")
445
+ try:
446
+ from jinja2 import Template
447
+
448
+ text = Template(text).render(**values)
449
+ except Exception:
450
+ for key, value in values.items():
451
+ text = text.replace("{{ " + key + " }}", str(value))
452
+ text = text.replace("{{" + key + "}}", str(value))
453
+ marker = "======USER======"
454
+ if marker in text:
455
+ system, user = text.split(marker, 1)
456
+ else:
457
+ system, user = text, ""
458
+ return system.strip(), user.strip()
459
+
460
+
461
+ async def _call_run_oneshot(config: Any, spec_values: Mapping[str, Any]) -> Any:
462
+ from .api import run_oneshot
463
+
464
+ prompt = str(spec_values.get("user_prompt", ""))
465
+ cacheable = bool(spec_values.get("cacheable", True))
466
+ kwargs = {
467
+ "system_prompt": spec_values.get("system_prompt"),
468
+ "model": spec_values.get("model"),
469
+ "schema": spec_values.get("schema"),
470
+ "schema_name": spec_values.get("tool_name") or "response",
471
+ "tool_name": spec_values.get("tool_name"),
472
+ "tool_description": spec_values.get("tool_description"),
473
+ "operation": spec_values.get("operation"),
474
+ "prompt_family": spec_values.get("prompt_family", "custom"),
475
+ "prompt_variant": spec_values.get("prompt_variant", "default"),
476
+ "debug_label": spec_values.get("operation"),
477
+ "markdown_output": getattr(config, "markdown_output", None),
478
+ "cache": cacheable,
479
+ "cacheable": cacheable,
480
+ }
481
+ try:
482
+ result = run_oneshot(config, prompt, **kwargs)
483
+ except TypeError:
484
+ try:
485
+ result = run_oneshot(config=config, prompt=prompt, **kwargs)
486
+ except TypeError:
487
+ spec: Any = dict(spec_values)
488
+ try:
489
+ from .api import OneShotSpec
490
+
491
+ spec = OneShotSpec(**spec_values)
492
+ except Exception:
493
+ pass
494
+ result = run_oneshot(config, spec)
495
+ if inspect.isawaitable(result):
496
+ result = await result
497
+ return getattr(result, "output", result)
498
+
499
+
500
+ def _parse_jsonish(value: Any) -> Any:
501
+ if isinstance(value, str):
502
+ text = value.strip()
503
+ if text.startswith("```"):
504
+ text = text.strip("`").removeprefix("json").strip()
505
+ return json.loads(text)
506
+ if hasattr(value, "model_dump"):
507
+ return value.model_dump()
508
+ if hasattr(value, "__dict__"):
509
+ return vars(value)
510
+ return value
511
+
512
+
513
+ __all__ = [
514
+ "ChangelogBoundary",
515
+ "UnreleasedSection",
516
+ "detect_boundaries",
517
+ "generate_changelog_entries",
518
+ "normalize_changelog_entry",
519
+ "parse_unreleased_section",
520
+ "run_changelog_flow",
521
+ "stage_changelog_blob",
522
+ "write_entries",
523
+ ]