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/__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
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
|