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/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """Public package interface for llm-git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ try:
9
+ __version__ = metadata.version("llm-git")
10
+ except metadata.PackageNotFoundError:
11
+ __version__ = "3.7.0"
12
+
13
+ _CORE_MODEL_EXPORTS = (
14
+ "Mode",
15
+ "ApiMode",
16
+ "ResolvedApiMode",
17
+ "CommitType",
18
+ "Scope",
19
+ "CommitSummary",
20
+ "ConventionalCommit",
21
+ "AnalysisDetail",
22
+ "ConventionalAnalysis",
23
+ "TypeConfig",
24
+ "CategoryConfig",
25
+ "CategoryMatch",
26
+ "ChangelogCategory",
27
+ "FileChange",
28
+ "ChangeGroup",
29
+ "ComposeAnalysis",
30
+ "ComposeHunk",
31
+ "ComposeFile",
32
+ "ComposeSnapshot",
33
+ )
34
+ _CORE_MODEL_EXPORT_SET = frozenset(_CORE_MODEL_EXPORTS)
35
+
36
+ __all__ = ["__version__", *_CORE_MODEL_EXPORTS]
37
+
38
+ if TYPE_CHECKING:
39
+ from .models import ( # noqa: F401
40
+ AnalysisDetail,
41
+ ApiMode,
42
+ CategoryConfig,
43
+ CategoryMatch,
44
+ ChangeGroup,
45
+ ChangelogCategory,
46
+ CommitSummary,
47
+ CommitType,
48
+ ComposeAnalysis,
49
+ ComposeFile,
50
+ ComposeHunk,
51
+ ComposeSnapshot,
52
+ ConventionalAnalysis,
53
+ ConventionalCommit,
54
+ FileChange,
55
+ Mode,
56
+ ResolvedApiMode,
57
+ Scope,
58
+ TypeConfig,
59
+ )
60
+
61
+
62
+ def __getattr__(name: str) -> Any:
63
+ """Load public model exports on first access without importing CLI code."""
64
+ if name in _CORE_MODEL_EXPORT_SET:
65
+ from . import models
66
+
67
+ value = getattr(models, name)
68
+ globals()[name] = value
69
+ return value
70
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
71
+
72
+
73
+ def __dir__() -> list[str]:
74
+ """Return stable public names for interactive help."""
75
+ return sorted({*globals(), *_CORE_MODEL_EXPORTS})
lgit/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Module entry point for ``python -m lgit``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
lgit/analysis.py ADDED
@@ -0,0 +1,326 @@
1
+ """Scope candidate extraction from git numstat output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ try: # Import defensively while shared model work lands independently.
10
+ from .models import ScopeCandidate
11
+ except Exception: # pragma: no cover - bootstrap fallback
12
+
13
+ @dataclass(slots=True)
14
+ class ScopeCandidate: # type: ignore[no-redef]
15
+ """Candidate conventional-commit scope with confidence metadata."""
16
+
17
+ path: str
18
+ percentage: float
19
+ confidence: float
20
+
21
+
22
+ PLACEHOLDER_DIRS = {
23
+ "src",
24
+ "lib",
25
+ "bin",
26
+ "crates",
27
+ "benches",
28
+ "examples",
29
+ "internal",
30
+ "pkg",
31
+ "include",
32
+ "tests",
33
+ "test",
34
+ "docs",
35
+ "packages",
36
+ "modules",
37
+ }
38
+
39
+ SKIP_DIRS = {".test", "tests", "benches", "examples", "target", "build", "node_modules", ".github"}
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class ScopeAnalyzer:
44
+ """Accumulates changed-line totals per meaningful path component."""
45
+
46
+ component_lines: dict[str, int] = field(default_factory=dict)
47
+ total_lines: int = 0
48
+
49
+ @classmethod
50
+ def from_numstat(cls, numstat: str, config: object | None = None) -> ScopeAnalyzer:
51
+ """Build an analyzer from git diff --numstat output."""
52
+
53
+ analyzer = cls()
54
+ for line in numstat.splitlines():
55
+ analyzer.process_numstat_line(line, config)
56
+ return analyzer
57
+
58
+ def process_numstat_line(self, line: str, config: object | None = None) -> None:
59
+ """Process one added/deleted/path numstat row."""
60
+
61
+ parts = line.split("\t")
62
+ if len(parts) < 3:
63
+ return
64
+ added = _parse_count(parts[0])
65
+ deleted = _parse_count(parts[1])
66
+ lines_changed = added + deleted
67
+ if lines_changed == 0:
68
+ return
69
+
70
+ raw_path = "\t".join(parts[2:])
71
+ path = extract_path_from_rename(raw_path)
72
+ if _is_excluded(path, config):
73
+ return
74
+
75
+ self.total_lines += lines_changed
76
+ for component in extract_components_from_path(path):
77
+ if any("." in segment for segment in component.split("/")):
78
+ continue
79
+ self.component_lines[component] = self.component_lines.get(component, 0) + lines_changed
80
+
81
+ def build_scope_candidates(self) -> list[ScopeCandidate]:
82
+ """Return sorted candidates with percentage and confidence scores."""
83
+
84
+ if self.total_lines == 0:
85
+ return []
86
+ candidates: list[ScopeCandidate] = []
87
+ for path, lines in self.component_lines.items():
88
+ if "/" not in path and path in PLACEHOLDER_DIRS:
89
+ continue
90
+ percentage = lines / self.total_lines * 100.0
91
+ is_two_segment = "/" in path
92
+ if is_two_segment:
93
+ confidence = percentage * 1.2 if percentage > 60.0 else percentage * 0.8
94
+ else:
95
+ confidence = percentage
96
+ candidates.append(ScopeCandidate(path=path, percentage=percentage, confidence=confidence))
97
+ candidates.sort(key=lambda item: item.confidence, reverse=True)
98
+ return candidates
99
+
100
+ @staticmethod
101
+ def is_wide_change(candidates: Sequence[ScopeCandidate], config: object | None = None) -> bool:
102
+ """Return true when no scope dominates or many roots are touched."""
103
+
104
+ threshold = float(getattr(config, "wide_change_threshold", 0.5))
105
+ if candidates and candidates[0].percentage / 100.0 < threshold:
106
+ return True
107
+ roots = {candidate.path.split("/", 1)[0] for candidate in candidates}
108
+ return len(roots) >= 3
109
+
110
+ @staticmethod
111
+ def extract_scope(numstat: str, config: object | None = None) -> tuple[list[ScopeCandidate], int]:
112
+ """Return candidates plus total changed lines from numstat."""
113
+
114
+ analyzer = ScopeAnalyzer.from_numstat(numstat, config)
115
+ return analyzer.build_scope_candidates(), analyzer.total_lines
116
+
117
+ @staticmethod
118
+ def count_changed_lines(numstat: str, config: object | None = None) -> int:
119
+ """Count changed non-binary, non-excluded lines in numstat."""
120
+
121
+ return ScopeAnalyzer.from_numstat(numstat, config).total_lines
122
+
123
+ @staticmethod
124
+ def analyze_wide_change(numstat: str) -> str | None:
125
+ """Detect an abstract category for cross-cutting changes."""
126
+
127
+ paths = [_path_from_numstat_line(line) for line in numstat.splitlines()]
128
+ paths = [path for path in paths if path]
129
+ if not paths:
130
+ return None
131
+
132
+ total = len(paths)
133
+ md_count = 0
134
+ test_count = 0
135
+ config_count = 0
136
+ has_cargo_toml = False
137
+ has_package_json = False
138
+ error_keywords = 0
139
+ type_keywords = 0
140
+
141
+ for path in paths:
142
+ lower = path.lower()
143
+ suffix = Path(path).suffix.lower()
144
+ if suffix == ".md":
145
+ md_count += 1
146
+ if "/test" in path or "test_" in path or "_test." in path or ".test." in path:
147
+ test_count += 1
148
+ if suffix in {".toml", ".yaml", ".yml", ".json"}:
149
+ config_count += 1
150
+ if "Cargo.toml" in path:
151
+ has_cargo_toml = True
152
+ if "package.json" in path:
153
+ has_package_json = True
154
+ if any(keyword in lower for keyword in ("error", "exception", "fail")):
155
+ error_keywords += 1
156
+ if any(keyword in lower for keyword in ("type", "struct", "enum")):
157
+ type_keywords += 1
158
+
159
+ if has_cargo_toml or has_package_json:
160
+ return "deps"
161
+ if md_count * 100 / total > 70:
162
+ return "docs"
163
+ if test_count * 100 / total > 60:
164
+ return "tests"
165
+ if error_keywords * 100 / total > 40:
166
+ return "error-handling"
167
+ if type_keywords * 100 / total > 40:
168
+ return "type-refactor"
169
+ if config_count * 100 / total > 50:
170
+ return "config"
171
+ return None
172
+
173
+
174
+ def extract_scope_candidates(
175
+ source: object,
176
+ target: str | None = None,
177
+ dir: str = ".",
178
+ config: object | None = None,
179
+ ) -> tuple[str, bool]:
180
+ """Extract a scope prompt string and wide-change flag.
181
+
182
+ `source` may be raw numstat text or a mode value. When a mode is passed,
183
+ this function imports `lgit.git.get_git_numstat` lazily to avoid a module
184
+ cycle and fetches numstat for that mode.
185
+ """
186
+
187
+ if isinstance(source, str) and _looks_like_numstat(source):
188
+ numstat = source
189
+ else:
190
+ from .git import get_git_numstat
191
+
192
+ numstat = get_git_numstat(source, target, dir, config)
193
+
194
+ candidates, total_lines = ScopeAnalyzer.extract_scope(numstat, config)
195
+ if total_lines == 0:
196
+ return "(none - no meaningful scopes)", False
197
+
198
+ is_wide = ScopeAnalyzer.is_wide_change(candidates, config)
199
+ if is_wide:
200
+ if bool(getattr(config, "wide_change_abstract", False)):
201
+ pattern = ScopeAnalyzer.analyze_wide_change(numstat)
202
+ scope_str = f"(cross-cutting: {pattern})" if pattern else "(none - multi-component change)"
203
+ else:
204
+ scope_str = "(none - multi-component change)"
205
+ else:
206
+ parts: list[str] = []
207
+ for candidate in candidates[:5]:
208
+ if candidate.percentage < 10.0:
209
+ continue
210
+ if "/" in candidate.path and candidate.percentage > 60.0:
211
+ confidence_label = "high confidence"
212
+ else:
213
+ confidence_label = "moderate confidence"
214
+ parts.append(f"{candidate.path} ({candidate.percentage:.0f}%, {confidence_label})")
215
+ if parts:
216
+ scope_str = ", ".join(parts)
217
+ if any("/" in candidate.path and candidate.percentage > 60.0 for candidate in candidates[:5]):
218
+ scope_str += "\nPrefer 2-segment scopes marked 'high confidence'."
219
+ else:
220
+ scope_str = "(none - unclear component)"
221
+
222
+ return scope_str, is_wide
223
+
224
+
225
+ def extract_scope_candidates_from_numstat(
226
+ numstat: str,
227
+ config: object | None = None,
228
+ ) -> tuple[list[ScopeCandidate], bool, int]:
229
+ """Return raw candidate objects, wide-change flag, and total lines."""
230
+
231
+ candidates, total = ScopeAnalyzer.extract_scope(numstat, config)
232
+ return candidates, ScopeAnalyzer.is_wide_change(candidates, config), total
233
+
234
+
235
+ def extract_path_from_rename(path_part: str) -> str:
236
+ """Return the destination path from git numstat rename syntax."""
237
+
238
+ path_part = path_part.strip()
239
+ brace_start = path_part.find("{")
240
+ if brace_start != -1:
241
+ arrow_pos = path_part.find(" => ", brace_start)
242
+ if arrow_pos != -1:
243
+ brace_end = path_part.find("}", arrow_pos)
244
+ if brace_end != -1:
245
+ prefix = path_part[:brace_start]
246
+ new_name = path_part[arrow_pos + 4 : brace_end].strip()
247
+ suffix = path_part[brace_end + 1 :]
248
+ return f"{prefix}{new_name}{suffix}".strip()
249
+
250
+ return path_part
251
+ if " => " in path_part:
252
+ return path_part.split(" => ", 1)[1].strip()
253
+ return path_part
254
+
255
+
256
+ def extract_components_from_path(path: str) -> list[str]:
257
+ """Extract single- and two-segment meaningful components from a path."""
258
+
259
+ segments = [segment for segment in path.replace("\\", "/").split("/") if segment]
260
+ meaningful: list[str] = []
261
+
262
+ for index, segment in enumerate(segments):
263
+ if segment in PLACEHOLDER_DIRS:
264
+ if len(segments) > index + 1:
265
+ continue
266
+ break
267
+ if _is_file_segment(segment):
268
+ continue
269
+ if segment in SKIP_DIRS:
270
+ continue
271
+ stripped = _strip_extension(segment)
272
+ if stripped and not stripped.startswith("."):
273
+ meaningful.append(stripped)
274
+
275
+ if not meaningful:
276
+ return []
277
+ components = [meaningful[0]]
278
+ if len(meaningful) >= 2:
279
+ components.append(f"{meaningful[0]}/{meaningful[1]}")
280
+ return components
281
+
282
+
283
+ def _path_from_numstat_line(line: str) -> str | None:
284
+ parts = line.split("\t")
285
+ if len(parts) < 3:
286
+ return None
287
+ return extract_path_from_rename("\t".join(parts[2:]))
288
+
289
+
290
+ def _parse_count(raw: str) -> int:
291
+ try:
292
+ return int(raw)
293
+ except ValueError:
294
+ return 0
295
+
296
+
297
+ def _strip_extension(segment: str) -> str:
298
+ if "." not in segment:
299
+ return segment
300
+ stem, _ = segment.rsplit(".", 1)
301
+ return stem
302
+
303
+
304
+ def _is_file_segment(segment: str) -> bool:
305
+ return "." in segment and not segment.startswith(".") and segment.rfind(".") > 0
306
+
307
+
308
+ def _is_excluded(path: str, config: object | None) -> bool:
309
+ excluded = getattr(config, "excluded_files", ())
310
+ return any(path.endswith(str(pattern)) for pattern in excluded)
311
+
312
+
313
+ def _looks_like_numstat(text: str) -> bool:
314
+ if "\n" in text or "\t" in text:
315
+ first = next((line for line in text.splitlines() if line.strip()), "")
316
+ parts = first.split("\t")
317
+ return len(parts) >= 3 and (_is_numstat_count(parts[0]) or parts[0] == "-")
318
+ return False
319
+
320
+
321
+ def _is_numstat_count(value: str) -> bool:
322
+ try:
323
+ int(value)
324
+ except ValueError:
325
+ return False
326
+ return True