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/templates.py ADDED
@@ -0,0 +1,385 @@
1
+ """Prompt template loading and rendering helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterable, Mapping
7
+ from dataclasses import dataclass
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from jinja2 import Environment
13
+
14
+ from .errors import ConfigError
15
+
16
+ USER_SEPARATOR_MARKER = "======USER======="
17
+ PROMPT_CATEGORIES = (
18
+ "analysis",
19
+ "summary",
20
+ "changelog",
21
+ "map",
22
+ "reduce",
23
+ "fast",
24
+ "compose-intent",
25
+ "compose-bind",
26
+ )
27
+
28
+ _ENV = Environment(autoescape=False, keep_trailing_newline=True)
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class PromptParts:
33
+ """Rendered prompt split into static system text and rendered user text."""
34
+
35
+ system: str
36
+ user: str
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class AnalysisParams:
41
+ """Parameters for rendering the analysis prompt."""
42
+
43
+ variant: str = "default"
44
+ stat: str = ""
45
+ diff: str = ""
46
+ scope_candidates: str = ""
47
+ recent_commits: str | None = None
48
+ common_scopes: str | None = None
49
+ types_description: str | None = None
50
+ project_context: str | None = None
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class MapFile:
55
+ """One file diff passed to the map prompt."""
56
+
57
+ path: str
58
+ diff: str
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class ComposeIntentPromptParams:
63
+ """Parameters for rendering the compose-intent prompt."""
64
+
65
+ variant: str = "default"
66
+ max_commits: int = 1
67
+ stat: str = ""
68
+ snapshot_summary: str = ""
69
+ planning_targets: str = ""
70
+ planning_notes: str = ""
71
+ split_bias: str = ""
72
+ types_description: str | None = None
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class ComposeBindPromptParams:
77
+ """Parameters for rendering the compose-bind prompt."""
78
+
79
+ variant: str = "default"
80
+ groups: str = ""
81
+ ambiguous_files: str = ""
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class FastPromptParams:
86
+ """Parameters for rendering the fast-mode prompt."""
87
+
88
+ variant: str = "default"
89
+ stat: str = ""
90
+ diff: str = ""
91
+ scope_candidates: str = ""
92
+ user_context: str | None = None
93
+ types_description: str | None = None
94
+
95
+
96
+ def get_user_prompts_dir() -> Path | None:
97
+ """Return the user prompt override directory, if a home directory is known."""
98
+ home = os.environ.get("HOME") or os.environ.get("USERPROFILE")
99
+ if not home:
100
+ return None
101
+ return Path(home).joinpath(".llm-git", "prompts")
102
+
103
+
104
+ def ensure_prompts_dir() -> Path | None:
105
+ """Create the user prompt directory and unpack package prompt files."""
106
+ user_prompts_dir = get_user_prompts_dir()
107
+ if user_prompts_dir is None:
108
+ return None
109
+ try:
110
+ user_prompts_dir.mkdir(parents=True, exist_ok=True)
111
+ except OSError as exc:
112
+ raise ConfigError(f"Failed to create prompts directory {user_prompts_dir}: {exc}") from exc
113
+
114
+ for relative_path, content in _iter_package_prompt_files():
115
+ destination = user_prompts_dir.joinpath(relative_path)
116
+ try:
117
+ destination.parent.mkdir(parents=True, exist_ok=True)
118
+ if not destination.exists():
119
+ destination.write_text(content, encoding="utf-8")
120
+ except OSError as exc:
121
+ raise ConfigError(f"Failed to write prompt file {destination}: {exc}") from exc
122
+ return user_prompts_dir
123
+
124
+
125
+ def split_prompt_template(template_content: str) -> tuple[str | None, str]:
126
+ """Split a prompt into optional system text and templated user text."""
127
+ separator = _find_user_separator(template_content)
128
+ if separator is None:
129
+ return None, template_content
130
+ system_end, user_start = separator
131
+ return template_content[:system_end], template_content[user_start:]
132
+
133
+
134
+ def render_prompt_parts(
135
+ template_name: str,
136
+ template_content: str,
137
+ context: Mapping[str, Any],
138
+ ) -> PromptParts:
139
+ """Render one prompt while enforcing a static system section."""
140
+ system_template, user_template = split_prompt_template(template_content)
141
+ system = ""
142
+ if system_template is not None:
143
+ _ensure_static_system_prompt(system_template, template_name)
144
+ system = system_template.strip()
145
+ try:
146
+ rendered_user = _ENV.from_string(user_template).render(**context)
147
+ except Exception as exc: # jinja2 has several template-specific subclasses.
148
+ raise ConfigError(f"Failed to render {template_name} prompt template: {exc}") from exc
149
+ return PromptParts(system=system, user=rendered_user.strip())
150
+
151
+
152
+ def render_analysis_prompt(params: AnalysisParams | None = None, **kwargs: Any) -> PromptParts:
153
+ """Render the analysis prompt variant."""
154
+ p = params if params is not None else AnalysisParams(**kwargs)
155
+ template_content = load_template_file("analysis", p.variant)
156
+ context = {
157
+ "stat": p.stat,
158
+ "diff": p.diff,
159
+ "scope_candidates": p.scope_candidates,
160
+ "recent_commits": p.recent_commits,
161
+ "common_scopes": p.common_scopes,
162
+ "types_description": p.types_description,
163
+ "project_context": p.project_context,
164
+ }
165
+ return render_prompt_parts(f"analysis/{p.variant}.md", template_content, context)
166
+
167
+
168
+ def render_summary_prompt(
169
+ variant: str,
170
+ commit_type: str,
171
+ scope: str,
172
+ chars: str,
173
+ details: str,
174
+ stat: str,
175
+ user_context: str | None = None,
176
+ ) -> PromptParts:
177
+ """Render the summary prompt variant."""
178
+ template_content = load_template_file("summary", variant)
179
+ context = {
180
+ "commit_type": commit_type,
181
+ "scope": scope,
182
+ "chars": chars,
183
+ "details": details,
184
+ "stat": stat,
185
+ "user_context": user_context,
186
+ }
187
+ return render_prompt_parts(f"summary/{variant}.md", template_content, context)
188
+
189
+
190
+ def render_changelog_prompt(
191
+ variant: str,
192
+ changelog_path: str,
193
+ is_package_changelog: bool,
194
+ stat: str,
195
+ diff: str,
196
+ existing_entries: str | None = None,
197
+ ) -> PromptParts:
198
+ """Render the changelog prompt variant."""
199
+ template_content = load_template_file("changelog", variant)
200
+ context = {
201
+ "changelog_path": changelog_path,
202
+ "is_package_changelog": is_package_changelog,
203
+ "stat": stat,
204
+ "diff": diff,
205
+ "existing_entries": existing_entries,
206
+ }
207
+ return render_prompt_parts(f"changelog/{variant}.md", template_content, context)
208
+
209
+
210
+ def render_map_prompt(
211
+ variant: str,
212
+ files: Iterable[MapFile | Mapping[str, str]],
213
+ context_header: str = "",
214
+ ) -> PromptParts:
215
+ """Render the map prompt for batched file-observation extraction."""
216
+ template_content = load_template_file("map", variant)
217
+ context = {
218
+ "files": [_mapping_for_file(file) for file in files],
219
+ "context_header": context_header,
220
+ }
221
+ return render_prompt_parts(f"map/{variant}.md", template_content, context)
222
+
223
+
224
+ def render_reduce_prompt(
225
+ variant: str,
226
+ observations: str,
227
+ stat: str,
228
+ scope_candidates: str,
229
+ types_description: str | None = None,
230
+ ) -> PromptParts:
231
+ """Render the reduce prompt for synthesizing map observations."""
232
+ template_content = load_template_file("reduce", variant)
233
+ context = {
234
+ "observations": observations,
235
+ "stat": stat,
236
+ "scope_candidates": scope_candidates,
237
+ "types_description": types_description,
238
+ }
239
+ return render_prompt_parts(f"reduce/{variant}.md", template_content, context)
240
+
241
+
242
+ def render_fast_prompt(params: FastPromptParams | None = None, **kwargs: Any) -> PromptParts:
243
+ """Render the fast-mode single-call prompt variant."""
244
+ p = params if params is not None else FastPromptParams(**kwargs)
245
+ template_content = load_template_file("fast", p.variant)
246
+ context = {
247
+ "stat": p.stat,
248
+ "diff": p.diff,
249
+ "scope_candidates": p.scope_candidates,
250
+ "user_context": p.user_context,
251
+ "types_description": p.types_description,
252
+ }
253
+ return render_prompt_parts(f"fast/{p.variant}.md", template_content, context)
254
+
255
+
256
+ def render_compose_intent_prompt(
257
+ params: ComposeIntentPromptParams | None = None,
258
+ **kwargs: Any,
259
+ ) -> PromptParts:
260
+ """Render the compose-intent planning prompt variant."""
261
+ p = params if params is not None else ComposeIntentPromptParams(**kwargs)
262
+ template_content = load_template_file("compose-intent", p.variant)
263
+ context = {
264
+ "max_commits": p.max_commits,
265
+ "stat": p.stat,
266
+ "snapshot_summary": p.snapshot_summary,
267
+ "planning_targets": p.planning_targets,
268
+ "planning_notes": p.planning_notes,
269
+ "split_bias": p.split_bias,
270
+ "types_description": p.types_description,
271
+ }
272
+ return render_prompt_parts(f"compose-intent/{p.variant}.md", template_content, context)
273
+
274
+
275
+ def render_compose_bind_prompt(
276
+ params: ComposeBindPromptParams | None = None,
277
+ **kwargs: Any,
278
+ ) -> PromptParts:
279
+ """Render the compose-bind hunk-assignment prompt variant."""
280
+ p = params if params is not None else ComposeBindPromptParams(**kwargs)
281
+ template_content = load_template_file("compose-bind", p.variant)
282
+ context = {"groups": p.groups, "ambiguous_files": p.ambiguous_files}
283
+ return render_prompt_parts(f"compose-bind/{p.variant}.md", template_content, context)
284
+
285
+
286
+ def load_template_file(category: str, variant: str) -> str:
287
+ """Load a user override prompt first, then the packaged prompt resource."""
288
+ if category not in PROMPT_CATEGORIES:
289
+ raise ConfigError(f"Unknown prompt category {category!r}")
290
+ _validate_variant(variant)
291
+ if prompts_dir := get_user_prompts_dir():
292
+ user_template = prompts_dir.joinpath(category, f"{variant}.md")
293
+ if user_template.exists():
294
+ try:
295
+ return user_template.read_text(encoding="utf-8")
296
+ except OSError as exc:
297
+ raise ConfigError(f"Failed to read template file {user_template}: {exc}") from exc
298
+
299
+ resource = resources.files("lgit.resources").joinpath("prompts", category, f"{variant}.md")
300
+ if resource.is_file():
301
+ try:
302
+ return resource.read_text(encoding="utf-8")
303
+ except OSError as exc:
304
+ raise ConfigError(f"Failed to read package template {category}/{variant}.md: {exc}") from exc
305
+ raise ConfigError(
306
+ f"Template variant {variant!r} in category {category!r} not found as user override or package resource"
307
+ )
308
+
309
+
310
+ def _find_user_separator(content: str) -> tuple[int, int] | None:
311
+ marker_pos = content.find(USER_SEPARATOR_MARKER)
312
+ if marker_pos == -1:
313
+ return None
314
+ if marker_pos >= 2 and content[marker_pos - 2 : marker_pos] == "\r\n":
315
+ system_end = marker_pos - 2
316
+ elif marker_pos >= 1 and content[marker_pos - 1 : marker_pos] == "\n":
317
+ system_end = marker_pos - 1
318
+ else:
319
+ system_end = marker_pos
320
+ after_marker = marker_pos + len(USER_SEPARATOR_MARKER)
321
+ if content[after_marker : after_marker + 2] == "\r\n":
322
+ user_start = after_marker + 2
323
+ elif content[after_marker : after_marker + 1] == "\n":
324
+ user_start = after_marker + 1
325
+ else:
326
+ user_start = after_marker
327
+ return system_end, user_start
328
+
329
+
330
+ def _ensure_static_system_prompt(system_template: str, template_name: str) -> None:
331
+ if "{{" in system_template or "{%" in system_template or "{#" in system_template:
332
+ raise ConfigError(
333
+ f"Template {template_name!r} contains dynamic tags in system section. "
334
+ f"Move interpolated content below {USER_SEPARATOR_MARKER}."
335
+ )
336
+
337
+
338
+ def _validate_variant(variant: str) -> None:
339
+ if not variant or "/" in variant or "\\" in variant or variant in {".", ".."}:
340
+ raise ConfigError(f"Invalid prompt variant {variant!r}")
341
+
342
+
343
+ def _mapping_for_file(file: MapFile | Mapping[str, str]) -> Mapping[str, str]:
344
+ if isinstance(file, MapFile):
345
+ return {"path": file.path, "diff": file.diff}
346
+ return file
347
+
348
+
349
+ def _iter_package_prompt_files() -> Iterable[tuple[Path, str]]:
350
+ prompts_root = resources.files("lgit.resources").joinpath("prompts")
351
+ yield from _walk_prompt_resources(prompts_root, Path())
352
+
353
+
354
+ def _walk_prompt_resources(root: Any, prefix: Path) -> Iterable[tuple[Path, str]]:
355
+ for child in sorted(root.iterdir(), key=lambda item: item.name):
356
+ child_prefix = prefix / child.name
357
+ if child.is_dir():
358
+ yield from _walk_prompt_resources(child, child_prefix)
359
+ elif child.name.endswith(".md"):
360
+ yield child_prefix, child.read_text(encoding="utf-8")
361
+
362
+
363
+ __all__ = [
364
+ "USER_SEPARATOR_MARKER",
365
+ "PROMPT_CATEGORIES",
366
+ "PromptParts",
367
+ "AnalysisParams",
368
+ "MapFile",
369
+ "ComposeIntentPromptParams",
370
+ "ComposeBindPromptParams",
371
+ "FastPromptParams",
372
+ "get_user_prompts_dir",
373
+ "ensure_prompts_dir",
374
+ "split_prompt_template",
375
+ "render_prompt_parts",
376
+ "load_template_file",
377
+ "render_analysis_prompt",
378
+ "render_summary_prompt",
379
+ "render_changelog_prompt",
380
+ "render_map_prompt",
381
+ "render_reduce_prompt",
382
+ "render_fast_prompt",
383
+ "render_compose_intent_prompt",
384
+ "render_compose_bind_prompt",
385
+ ]
@@ -0,0 +1,62 @@
1
+ """Fixture-based testing harness for lgit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .compare import CompareResult, compare_analysis
8
+ from .fixture import (
9
+ FIXTURES_DIR,
10
+ Fixture,
11
+ FixtureContext,
12
+ FixtureEntry,
13
+ FixtureInput,
14
+ FixtureMeta,
15
+ Golden,
16
+ Manifest,
17
+ add_fixture,
18
+ discover_fixtures,
19
+ load_fixtures,
20
+ )
21
+ from .report import generate_html_report
22
+ from .runner import RunResult, TestRunner, TestSummary, run_test_mode
23
+
24
+
25
+ def fixtures_dir() -> Path:
26
+ """Return the default fixtures directory path."""
27
+
28
+ return Path(FIXTURES_DIR)
29
+
30
+
31
+ def list_fixtures(path: str | Path | None = None) -> list[str]:
32
+ """List available fixtures from the manifest when present, else discover directories."""
33
+
34
+ root = fixtures_dir() if path is None else Path(path)
35
+ manifest = Manifest.load(root)
36
+ if manifest.fixtures:
37
+ return sorted(manifest.fixtures)
38
+ return discover_fixtures(root)
39
+
40
+
41
+ __all__ = [
42
+ "CompareResult",
43
+ "FIXTURES_DIR",
44
+ "Fixture",
45
+ "FixtureContext",
46
+ "FixtureEntry",
47
+ "FixtureInput",
48
+ "FixtureMeta",
49
+ "Golden",
50
+ "Manifest",
51
+ "RunResult",
52
+ "TestRunner",
53
+ "TestSummary",
54
+ "add_fixture",
55
+ "compare_analysis",
56
+ "discover_fixtures",
57
+ "fixtures_dir",
58
+ "generate_html_report",
59
+ "list_fixtures",
60
+ "load_fixtures",
61
+ "run_test_mode",
62
+ ]
@@ -0,0 +1,57 @@
1
+ """Comparison logic for fixture golden analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from lgit.models import ConventionalAnalysis
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class CompareResult:
12
+ """Result of comparing actual analysis to a golden analysis."""
13
+
14
+ type_match: bool
15
+ scope_match: bool
16
+ scope_diff: str | None
17
+ golden_detail_count: int
18
+ actual_detail_count: int
19
+ passed: bool
20
+ summary: str
21
+
22
+
23
+ def compare_analysis(golden: ConventionalAnalysis, actual: ConventionalAnalysis) -> CompareResult:
24
+ """Compare two analyses using Rust harness-compatible pass rules."""
25
+
26
+ type_match = golden.commit_type == actual.commit_type
27
+ scope_match = golden.scope == actual.scope
28
+ scope_diff = None if scope_match else f"{_scope_text(golden)} -> {_scope_text(actual)}"
29
+ golden_detail_count = len(golden.details)
30
+ actual_detail_count = len(actual.details)
31
+
32
+ passed = type_match
33
+ if passed and scope_match:
34
+ summary = (
35
+ f"PASS {actual.commit_type} | {_scope_text(actual, none='(no scope)')} | {actual_detail_count} details"
36
+ )
37
+ elif passed:
38
+ summary = f"WARN {actual.commit_type} | scope: {scope_diff} | {actual_detail_count} details"
39
+ else:
40
+ summary = f"FAIL type: {golden.commit_type} -> {actual.commit_type} | {actual_detail_count} details"
41
+
42
+ return CompareResult(
43
+ type_match=type_match,
44
+ scope_match=scope_match,
45
+ scope_diff=scope_diff,
46
+ golden_detail_count=golden_detail_count,
47
+ actual_detail_count=actual_detail_count,
48
+ passed=passed,
49
+ summary=summary,
50
+ )
51
+
52
+
53
+ def _scope_text(analysis: ConventionalAnalysis, *, none: str = "null") -> str:
54
+ return none if analysis.scope is None else str(analysis.scope)
55
+
56
+
57
+ __all__ = ["CompareResult", "compare_analysis"]