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/models.py
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
"""Domain models for conventional commits, analysis, and compose mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
7
|
+
from dataclasses import InitVar, dataclass, field
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from typing import Any, Self
|
|
12
|
+
|
|
13
|
+
from .errors import InvalidCommitType, InvalidScope, SummaryTooLong, ValidationFailure
|
|
14
|
+
|
|
15
|
+
DEFAULT_SUMMARY_MAX_LENGTH = 128
|
|
16
|
+
SUMMARY_GUIDELINE_LENGTH = 72
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Mode(StrEnum):
|
|
20
|
+
"""Input mode for generating a commit message."""
|
|
21
|
+
|
|
22
|
+
STAGED = "staged"
|
|
23
|
+
COMMIT = "commit"
|
|
24
|
+
UNSTAGED = "unstaged"
|
|
25
|
+
COMPOSE = "compose"
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_raw(cls, raw: str | Self) -> Self:
|
|
29
|
+
"""Parse a mode token."""
|
|
30
|
+
if isinstance(raw, cls):
|
|
31
|
+
return raw
|
|
32
|
+
normalized = raw.strip().lower().replace("_", "-")
|
|
33
|
+
for mode in cls:
|
|
34
|
+
if mode.value == normalized:
|
|
35
|
+
return mode
|
|
36
|
+
raise ValidationFailure(f"unknown mode: {raw!r}", field="mode", value=raw)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ApiMode(StrEnum):
|
|
40
|
+
"""Configured API protocol selection."""
|
|
41
|
+
|
|
42
|
+
AUTO = "auto"
|
|
43
|
+
CHAT_COMPLETIONS = "chat-completions"
|
|
44
|
+
ANTHROPIC_MESSAGES = "anthropic-messages"
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_raw(cls, raw: str | Self) -> Self:
|
|
48
|
+
"""Parse an API mode token using the accepted config aliases."""
|
|
49
|
+
if isinstance(raw, cls):
|
|
50
|
+
return raw
|
|
51
|
+
match raw.strip().lower().replace("_", "-"):
|
|
52
|
+
case "auto":
|
|
53
|
+
return cls.AUTO
|
|
54
|
+
case "chat" | "chat-completions":
|
|
55
|
+
return cls.CHAT_COMPLETIONS
|
|
56
|
+
case "anthropic" | "messages" | "anthropic-messages":
|
|
57
|
+
return cls.ANTHROPIC_MESSAGES
|
|
58
|
+
case _:
|
|
59
|
+
raise ValidationFailure(f"unknown API mode: {raw!r}", field="api_mode", value=raw)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ResolvedApiMode(StrEnum):
|
|
63
|
+
"""Concrete API protocol after resolving auto mode."""
|
|
64
|
+
|
|
65
|
+
CHAT_COMPLETIONS = "chat-completions"
|
|
66
|
+
ANTHROPIC_MESSAGES = "anthropic-messages"
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_api_mode(cls, mode: ApiMode, api_base_url: str = "") -> Self:
|
|
70
|
+
"""Resolve an API mode with the same auto heuristic as the Rust implementation."""
|
|
71
|
+
match mode:
|
|
72
|
+
case ApiMode.CHAT_COMPLETIONS:
|
|
73
|
+
return cls.CHAT_COMPLETIONS
|
|
74
|
+
case ApiMode.ANTHROPIC_MESSAGES:
|
|
75
|
+
return cls.ANTHROPIC_MESSAGES
|
|
76
|
+
case ApiMode.AUTO:
|
|
77
|
+
if "anthropic" in api_base_url.lower():
|
|
78
|
+
return cls.ANTHROPIC_MESSAGES
|
|
79
|
+
return cls.CHAT_COMPLETIONS
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_MODEL_ALIASES = {
|
|
83
|
+
"sonnet": "claude-sonnet-4.5",
|
|
84
|
+
"s": "claude-sonnet-4.5",
|
|
85
|
+
"opus": "claude-opus-4.5",
|
|
86
|
+
"o": "claude-opus-4.5",
|
|
87
|
+
"o4.5": "claude-opus-4.5",
|
|
88
|
+
"haiku": "claude-haiku-4-5",
|
|
89
|
+
"h": "claude-haiku-4-5",
|
|
90
|
+
"3.5": "claude-3.5-sonnet",
|
|
91
|
+
"sonnet-3.5": "claude-3.5-sonnet",
|
|
92
|
+
"3.7": "claude-3.7-sonnet",
|
|
93
|
+
"sonnet-3.7": "claude-3.7-sonnet",
|
|
94
|
+
"gpt5": "gpt-5",
|
|
95
|
+
"g5": "gpt-5",
|
|
96
|
+
"gpt5-pro": "gpt-5-pro",
|
|
97
|
+
"gpt5-mini": "gpt-5-mini",
|
|
98
|
+
"gpt5-codex": "gpt-5-codex",
|
|
99
|
+
"o3": "o3",
|
|
100
|
+
"o3-pro": "o3-pro",
|
|
101
|
+
"o3-mini": "o3-mini",
|
|
102
|
+
"o1": "o1",
|
|
103
|
+
"o1-pro": "o1-pro",
|
|
104
|
+
"o1-mini": "o1-mini",
|
|
105
|
+
"gemini": "gemini-2.5-pro",
|
|
106
|
+
"g2.5": "gemini-2.5-pro",
|
|
107
|
+
"flash": "gemini-2.5-flash",
|
|
108
|
+
"g2.5-flash": "gemini-2.5-flash",
|
|
109
|
+
"flash-lite": "gemini-2.5-flash-lite",
|
|
110
|
+
"qwen": "qwen-3-coder-480b",
|
|
111
|
+
"q480b": "qwen-3-coder-480b",
|
|
112
|
+
"glm4.6": "glm-4.6",
|
|
113
|
+
"glm4.5": "glm-4.5",
|
|
114
|
+
"glm-air": "glm-4.5-air",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_model_name(name: str) -> str:
|
|
119
|
+
"""Resolve a short model alias to the full LiteLLM model name."""
|
|
120
|
+
return _MODEL_ALIASES.get(name, name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True)
|
|
124
|
+
class TypeConfig:
|
|
125
|
+
"""Classification guidance for one conventional commit type."""
|
|
126
|
+
|
|
127
|
+
description: str
|
|
128
|
+
diff_indicators: tuple[str, ...] = ()
|
|
129
|
+
file_patterns: tuple[str, ...] = ()
|
|
130
|
+
examples: tuple[str, ...] = ()
|
|
131
|
+
hint: str = ""
|
|
132
|
+
aliases: tuple[str, ...] = ()
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_mapping(cls, data: Mapping[str, Any]) -> Self:
|
|
136
|
+
"""Build a type config from JSON-compatible data."""
|
|
137
|
+
return cls(
|
|
138
|
+
description=str(data.get("description", "")),
|
|
139
|
+
diff_indicators=_string_tuple(data.get("diff_indicators", ())),
|
|
140
|
+
file_patterns=_string_tuple(data.get("file_patterns", ())),
|
|
141
|
+
examples=_string_tuple(data.get("examples", ())),
|
|
142
|
+
hint=str(data.get("hint", "")),
|
|
143
|
+
aliases=_string_tuple(data.get("aliases", ())),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class CategoryMatch:
|
|
149
|
+
"""Rules for mapping commit details to a changelog category."""
|
|
150
|
+
|
|
151
|
+
types: tuple[str, ...] = ()
|
|
152
|
+
body_contains: tuple[str, ...] = ()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True, slots=True)
|
|
156
|
+
class CategoryConfig:
|
|
157
|
+
"""Configurable changelog category mapping."""
|
|
158
|
+
|
|
159
|
+
name: str
|
|
160
|
+
header: str | None = None
|
|
161
|
+
match: CategoryMatch = field(default_factory=CategoryMatch)
|
|
162
|
+
default: bool = False
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def header_name(self) -> str:
|
|
166
|
+
"""Return the changelog section header for this category."""
|
|
167
|
+
return self.header or self.name
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class _Vocabulary:
|
|
172
|
+
types: dict[str, TypeConfig]
|
|
173
|
+
aliases: dict[str, str]
|
|
174
|
+
classifier_hint: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@lru_cache(maxsize=1)
|
|
178
|
+
def _vocabulary() -> _Vocabulary:
|
|
179
|
+
resource = resources.files("lgit.resources").joinpath("commit_types.json")
|
|
180
|
+
data = json.loads(resource.read_text(encoding="utf-8"))
|
|
181
|
+
types: dict[str, TypeConfig] = {}
|
|
182
|
+
aliases: dict[str, str] = {}
|
|
183
|
+
for entry in data.get("types", ()):
|
|
184
|
+
name = str(entry["name"]).strip().lower()
|
|
185
|
+
config = TypeConfig.from_mapping(entry)
|
|
186
|
+
types[name] = config
|
|
187
|
+
for alias in config.aliases:
|
|
188
|
+
aliases[alias.lower()] = name
|
|
189
|
+
return _Vocabulary(types=types, aliases=aliases, classifier_hint=str(data.get("classifier_hint", "")))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def default_types() -> dict[str, TypeConfig]:
|
|
193
|
+
"""Return the default commit-type vocabulary in priority order."""
|
|
194
|
+
return dict(_vocabulary().types)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def default_classifier_hint() -> str:
|
|
198
|
+
"""Return the global commit-type disambiguation hint."""
|
|
199
|
+
return _vocabulary().classifier_hint
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
203
|
+
class CommitType:
|
|
204
|
+
"""Validated conventional commit type, canonicalized through package resources."""
|
|
205
|
+
|
|
206
|
+
value: str
|
|
207
|
+
|
|
208
|
+
def __post_init__(self) -> None:
|
|
209
|
+
object.__setattr__(self, "value", _canonical_commit_type(self.value))
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def from_raw(cls, raw: str | Self) -> Self:
|
|
213
|
+
"""Create a commit type from a canonical name or known alias."""
|
|
214
|
+
if isinstance(raw, cls):
|
|
215
|
+
return raw
|
|
216
|
+
return cls(raw)
|
|
217
|
+
|
|
218
|
+
def __str__(self) -> str:
|
|
219
|
+
return self.value
|
|
220
|
+
|
|
221
|
+
def __repr__(self) -> str:
|
|
222
|
+
return f"CommitType({self.value!r})"
|
|
223
|
+
|
|
224
|
+
def as_str(self) -> str:
|
|
225
|
+
"""Return the canonical commit type string."""
|
|
226
|
+
return self.value
|
|
227
|
+
|
|
228
|
+
def __len__(self) -> int:
|
|
229
|
+
return len(self.value)
|
|
230
|
+
|
|
231
|
+
def __eq__(self, other: object) -> bool:
|
|
232
|
+
if isinstance(other, CommitType):
|
|
233
|
+
return self.value == other.value
|
|
234
|
+
if isinstance(other, str):
|
|
235
|
+
return self.value == other
|
|
236
|
+
return NotImplemented
|
|
237
|
+
|
|
238
|
+
def __hash__(self) -> int:
|
|
239
|
+
return hash(self.value)
|
|
240
|
+
|
|
241
|
+
def is_empty(self) -> bool:
|
|
242
|
+
"""Return whether the canonical value is empty."""
|
|
243
|
+
return not self.value
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _canonical_commit_type(raw: str) -> str:
|
|
247
|
+
normalized = raw.strip().lower()
|
|
248
|
+
vocab = _vocabulary()
|
|
249
|
+
if normalized in vocab.types:
|
|
250
|
+
return normalized
|
|
251
|
+
if normalized in vocab.aliases:
|
|
252
|
+
return vocab.aliases[normalized]
|
|
253
|
+
valid = ", ".join(vocab.types)
|
|
254
|
+
raise InvalidCommitType(
|
|
255
|
+
f"invalid commit type {raw!r}; must be one of: {valid}",
|
|
256
|
+
field="type",
|
|
257
|
+
value=raw,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def coerce_commit_type(raw: str | CommitType) -> CommitType:
|
|
262
|
+
"""Coerce a raw type token, falling back to ``chore`` when unknown."""
|
|
263
|
+
if isinstance(raw, CommitType):
|
|
264
|
+
return raw
|
|
265
|
+
try:
|
|
266
|
+
return CommitType.from_raw(raw)
|
|
267
|
+
except InvalidCommitType:
|
|
268
|
+
return CommitType.from_raw("chore")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
272
|
+
class Scope:
|
|
273
|
+
"""Validated conventional-commit scope."""
|
|
274
|
+
|
|
275
|
+
value: str
|
|
276
|
+
|
|
277
|
+
def __post_init__(self) -> None:
|
|
278
|
+
_validate_scope(self.value)
|
|
279
|
+
|
|
280
|
+
@classmethod
|
|
281
|
+
def from_raw(cls, raw: str | Self) -> Self:
|
|
282
|
+
"""Create a scope after strict validation."""
|
|
283
|
+
if isinstance(raw, cls):
|
|
284
|
+
return raw
|
|
285
|
+
return cls(raw)
|
|
286
|
+
|
|
287
|
+
def __str__(self) -> str:
|
|
288
|
+
return self.value
|
|
289
|
+
|
|
290
|
+
def __repr__(self) -> str:
|
|
291
|
+
return f"Scope({self.value!r})"
|
|
292
|
+
|
|
293
|
+
def as_str(self) -> str:
|
|
294
|
+
"""Return the scope string."""
|
|
295
|
+
return self.value
|
|
296
|
+
|
|
297
|
+
def __len__(self) -> int:
|
|
298
|
+
return _byte_len(self.value)
|
|
299
|
+
|
|
300
|
+
def __eq__(self, other: object) -> bool:
|
|
301
|
+
if isinstance(other, Scope):
|
|
302
|
+
return self.value == other.value
|
|
303
|
+
if isinstance(other, str):
|
|
304
|
+
return self.value == other
|
|
305
|
+
return NotImplemented
|
|
306
|
+
|
|
307
|
+
def __hash__(self) -> int:
|
|
308
|
+
return hash(self.value)
|
|
309
|
+
|
|
310
|
+
def is_empty(self) -> bool:
|
|
311
|
+
"""Return whether the scope is empty."""
|
|
312
|
+
return not self.value
|
|
313
|
+
|
|
314
|
+
def segments(self) -> tuple[str, ...]:
|
|
315
|
+
"""Split the scope into slash-delimited segments."""
|
|
316
|
+
return tuple(self.value.split("/"))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _validate_scope(scope: str) -> None:
|
|
320
|
+
if scope != scope.lower():
|
|
321
|
+
raise InvalidScope("scope must be lowercase", field="scope", value=scope)
|
|
322
|
+
segments = scope.split("/")
|
|
323
|
+
if len(segments) > 2:
|
|
324
|
+
raise InvalidScope(f"scope has {len(segments)} segments, max 2 allowed", field="scope", value=scope)
|
|
325
|
+
for segment in segments:
|
|
326
|
+
if not segment:
|
|
327
|
+
raise InvalidScope("scope contains empty segment", field="scope", value=scope)
|
|
328
|
+
if not all(ch.isascii() and (ch.isalnum() or ch in "-_") for ch in segment):
|
|
329
|
+
raise InvalidScope(f"invalid characters in scope segment: {segment}", field="scope", value=scope)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def coerce_optional_scope(raw: str | Scope | None) -> Scope | None:
|
|
333
|
+
"""Lossily coerce model-emitted scope text, returning ``None`` when unusable."""
|
|
334
|
+
null_markers = {"null", "none", "n/a"}
|
|
335
|
+
if raw is None or isinstance(raw, Scope):
|
|
336
|
+
return raw
|
|
337
|
+
trimmed = raw.strip()
|
|
338
|
+
if not trimmed or trimmed.lower() in null_markers:
|
|
339
|
+
return None
|
|
340
|
+
normalized = trimmed.replace("\\", "/").lower()
|
|
341
|
+
segments = []
|
|
342
|
+
for segment in normalized.split("/"):
|
|
343
|
+
cleaned = _sanitize_scope_segment(segment)
|
|
344
|
+
if cleaned:
|
|
345
|
+
segments.append(cleaned)
|
|
346
|
+
if len(segments) == 2:
|
|
347
|
+
break
|
|
348
|
+
if not segments:
|
|
349
|
+
return None
|
|
350
|
+
try:
|
|
351
|
+
return Scope.from_raw("/".join(segments))
|
|
352
|
+
except InvalidScope:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _sanitize_scope_segment(segment: str) -> str | None:
|
|
357
|
+
out: list[str] = []
|
|
358
|
+
last_was_separator = False
|
|
359
|
+
for char in segment.strip():
|
|
360
|
+
if char.isascii() and (char.islower() or char.isdigit()):
|
|
361
|
+
out.append(char)
|
|
362
|
+
last_was_separator = False
|
|
363
|
+
elif char in "-_":
|
|
364
|
+
if out and not last_was_separator:
|
|
365
|
+
out.append(char)
|
|
366
|
+
last_was_separator = True
|
|
367
|
+
elif (char.isspace() or char == ".") and out and not last_was_separator:
|
|
368
|
+
out.append("-")
|
|
369
|
+
last_was_separator = True
|
|
370
|
+
cleaned = "".join(out).strip("-_")
|
|
371
|
+
return cleaned or None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _byte_len(value: str) -> int:
|
|
375
|
+
return len(value.encode())
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass(frozen=True, slots=True)
|
|
379
|
+
class CommitSummary:
|
|
380
|
+
"""Validated first line of a conventional commit message."""
|
|
381
|
+
|
|
382
|
+
value: str
|
|
383
|
+
max_length: InitVar[int] = DEFAULT_SUMMARY_MAX_LENGTH
|
|
384
|
+
warnings: tuple[str, ...] = field(init=False, default=())
|
|
385
|
+
|
|
386
|
+
def __post_init__(self, max_length: int) -> None:
|
|
387
|
+
summary = self.value
|
|
388
|
+
summary_len = _byte_len(summary)
|
|
389
|
+
if not summary.strip():
|
|
390
|
+
raise ValidationFailure("commit summary cannot be empty", field="summary", value=summary)
|
|
391
|
+
if summary_len > max_length:
|
|
392
|
+
raise SummaryTooLong(summary_len, max_length)
|
|
393
|
+
warnings: list[str] = []
|
|
394
|
+
first = summary[0]
|
|
395
|
+
if first.isupper():
|
|
396
|
+
warnings.append("summary should start with lowercase")
|
|
397
|
+
if summary_len > SUMMARY_GUIDELINE_LENGTH:
|
|
398
|
+
warnings.append(f"summary exceeds {SUMMARY_GUIDELINE_LENGTH} character guideline")
|
|
399
|
+
if summary.rstrip().endswith("."):
|
|
400
|
+
warnings.append("summary should not end with a period")
|
|
401
|
+
object.__setattr__(self, "warnings", tuple(warnings))
|
|
402
|
+
|
|
403
|
+
@classmethod
|
|
404
|
+
def from_raw(cls, raw: str | Self, *, max_length: int = DEFAULT_SUMMARY_MAX_LENGTH) -> Self:
|
|
405
|
+
"""Create a summary with a configurable hard length limit."""
|
|
406
|
+
if isinstance(raw, cls):
|
|
407
|
+
return raw
|
|
408
|
+
return cls(raw, max_length=max_length)
|
|
409
|
+
|
|
410
|
+
def __str__(self) -> str:
|
|
411
|
+
return self.value
|
|
412
|
+
|
|
413
|
+
def __repr__(self) -> str:
|
|
414
|
+
return f"CommitSummary({self.value!r})"
|
|
415
|
+
|
|
416
|
+
def as_str(self) -> str:
|
|
417
|
+
"""Return the summary string."""
|
|
418
|
+
return self.value
|
|
419
|
+
|
|
420
|
+
def __len__(self) -> int:
|
|
421
|
+
return _byte_len(self.value)
|
|
422
|
+
|
|
423
|
+
def is_empty(self) -> bool:
|
|
424
|
+
"""Return whether the summary is empty."""
|
|
425
|
+
return not self.value
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass(frozen=True, slots=True)
|
|
429
|
+
class ConventionalCommit:
|
|
430
|
+
"""A complete conventional commit message."""
|
|
431
|
+
|
|
432
|
+
commit_type: CommitType
|
|
433
|
+
summary: CommitSummary
|
|
434
|
+
scope: Scope | None = None
|
|
435
|
+
body: tuple[str, ...] = ()
|
|
436
|
+
footers: tuple[str, ...] = ()
|
|
437
|
+
|
|
438
|
+
def __post_init__(self) -> None:
|
|
439
|
+
object.__setattr__(self, "commit_type", CommitType.from_raw(self.commit_type))
|
|
440
|
+
if self.scope is not None:
|
|
441
|
+
object.__setattr__(self, "scope", Scope.from_raw(self.scope))
|
|
442
|
+
object.__setattr__(self, "summary", CommitSummary.from_raw(self.summary))
|
|
443
|
+
object.__setattr__(self, "body", _string_tuple(self.body))
|
|
444
|
+
object.__setattr__(self, "footers", _string_tuple(self.footers))
|
|
445
|
+
|
|
446
|
+
@classmethod
|
|
447
|
+
def from_raw(
|
|
448
|
+
cls,
|
|
449
|
+
*,
|
|
450
|
+
commit_type: str | CommitType,
|
|
451
|
+
summary: str | CommitSummary,
|
|
452
|
+
scope: str | Scope | None = None,
|
|
453
|
+
body: Iterable[str] = (),
|
|
454
|
+
footers: Iterable[str] = (),
|
|
455
|
+
summary_max_length: int = DEFAULT_SUMMARY_MAX_LENGTH,
|
|
456
|
+
) -> Self:
|
|
457
|
+
"""Create a commit from raw model or CLI values."""
|
|
458
|
+
return cls(
|
|
459
|
+
commit_type=CommitType.from_raw(commit_type),
|
|
460
|
+
scope=None if scope is None else Scope.from_raw(scope),
|
|
461
|
+
summary=CommitSummary.from_raw(summary, max_length=summary_max_length),
|
|
462
|
+
body=_string_tuple(body),
|
|
463
|
+
footers=_string_tuple(footers),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def format_commit_message(self) -> str:
|
|
467
|
+
"""Render the conventional commit message."""
|
|
468
|
+
scope = f"({self.scope})" if self.scope else ""
|
|
469
|
+
lines = [f"{self.commit_type}{scope}: {self.summary}"]
|
|
470
|
+
if self.body:
|
|
471
|
+
lines.append("")
|
|
472
|
+
lines.extend(_format_body_line(line) for line in self.body)
|
|
473
|
+
if self.footers:
|
|
474
|
+
lines.append("")
|
|
475
|
+
lines.extend(self.footers)
|
|
476
|
+
return "\n".join(lines)
|
|
477
|
+
|
|
478
|
+
def __str__(self) -> str:
|
|
479
|
+
return self.format_commit_message()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@dataclass(frozen=True, slots=True)
|
|
483
|
+
class AnalysisDetail:
|
|
484
|
+
"""A single analyzed change with optional changelog metadata."""
|
|
485
|
+
|
|
486
|
+
text: str
|
|
487
|
+
changelog_category: ChangelogCategory | None = None
|
|
488
|
+
user_visible: bool = False
|
|
489
|
+
|
|
490
|
+
def __post_init__(self) -> None:
|
|
491
|
+
object.__setattr__(self, "text", str(self.text))
|
|
492
|
+
if self.changelog_category is not None and not isinstance(self.changelog_category, ChangelogCategory):
|
|
493
|
+
category = _strict_changelog_category(str(self.changelog_category))
|
|
494
|
+
object.__setattr__(self, "changelog_category", category)
|
|
495
|
+
object.__setattr__(self, "user_visible", bool(self.user_visible))
|
|
496
|
+
|
|
497
|
+
@classmethod
|
|
498
|
+
def simple(cls, text: str) -> Self:
|
|
499
|
+
"""Create a detail without changelog metadata."""
|
|
500
|
+
return cls(text=text)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _coerce_analysis_detail(value: Any) -> AnalysisDetail | None:
|
|
504
|
+
if isinstance(value, AnalysisDetail):
|
|
505
|
+
return value if value.text else None
|
|
506
|
+
if isinstance(value, Mapping):
|
|
507
|
+
raw_text = value.get("text")
|
|
508
|
+
text = "" if raw_text is None else str(raw_text)
|
|
509
|
+
if not text:
|
|
510
|
+
return None
|
|
511
|
+
raw_category = value.get("changelog_category")
|
|
512
|
+
category = _strict_changelog_category(raw_category) if isinstance(raw_category, str) else None
|
|
513
|
+
raw_visible = value.get("user_visible")
|
|
514
|
+
user_visible = raw_visible if isinstance(raw_visible, bool) else False
|
|
515
|
+
return AnalysisDetail(text=text, changelog_category=category, user_visible=user_visible)
|
|
516
|
+
if isinstance(value, str):
|
|
517
|
+
return AnalysisDetail.simple(value) if value else None
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _analysis_details_tuple(values: Any) -> tuple[AnalysisDetail, ...]:
|
|
522
|
+
if values is None:
|
|
523
|
+
return ()
|
|
524
|
+
if isinstance(values, str):
|
|
525
|
+
return (AnalysisDetail.simple(values),) if values else ()
|
|
526
|
+
if isinstance(values, Mapping):
|
|
527
|
+
return ()
|
|
528
|
+
return tuple(detail for value in values if (detail := _coerce_analysis_detail(value)) is not None)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _string_vec_tuple(value: Any) -> tuple[str, ...]:
|
|
532
|
+
return tuple(_value_to_strings(value))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _value_to_strings(value: Any) -> list[str]:
|
|
536
|
+
if value is None:
|
|
537
|
+
return []
|
|
538
|
+
if isinstance(value, str):
|
|
539
|
+
trimmed = value.strip()
|
|
540
|
+
if trimmed.startswith("["):
|
|
541
|
+
try:
|
|
542
|
+
decoded = json.loads(trimmed)
|
|
543
|
+
except json.JSONDecodeError:
|
|
544
|
+
decoded = None
|
|
545
|
+
if isinstance(decoded, list):
|
|
546
|
+
strings: list[str] = []
|
|
547
|
+
for item in decoded:
|
|
548
|
+
strings.extend(_value_to_strings(item))
|
|
549
|
+
return strings
|
|
550
|
+
return [line.strip() for line in value.splitlines() if line.strip()]
|
|
551
|
+
if isinstance(value, Mapping):
|
|
552
|
+
strings = []
|
|
553
|
+
for key, inner in value.items():
|
|
554
|
+
inner_values = _value_to_strings(inner)
|
|
555
|
+
strings.extend([str(key)] if not inner_values else [f"{key}: {item}" for item in inner_values])
|
|
556
|
+
return strings
|
|
557
|
+
if isinstance(value, Iterable):
|
|
558
|
+
strings = []
|
|
559
|
+
for item in value:
|
|
560
|
+
strings.extend(_value_to_strings(item))
|
|
561
|
+
return strings
|
|
562
|
+
return [str(value)]
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@dataclass(frozen=True, slots=True)
|
|
566
|
+
class ConventionalAnalysis:
|
|
567
|
+
"""Structured model analysis for one conventional commit."""
|
|
568
|
+
|
|
569
|
+
commit_type: CommitType
|
|
570
|
+
scope: Scope | None = None
|
|
571
|
+
summary: str | None = None
|
|
572
|
+
details: tuple[AnalysisDetail, ...] = ()
|
|
573
|
+
issue_refs: tuple[str, ...] = ()
|
|
574
|
+
|
|
575
|
+
def __post_init__(self) -> None:
|
|
576
|
+
object.__setattr__(self, "commit_type", CommitType.from_raw(self.commit_type))
|
|
577
|
+
object.__setattr__(self, "scope", coerce_optional_scope(self.scope))
|
|
578
|
+
object.__setattr__(self, "details", _analysis_details_tuple(self.details))
|
|
579
|
+
object.__setattr__(self, "issue_refs", _string_tuple(self.issue_refs))
|
|
580
|
+
|
|
581
|
+
@property
|
|
582
|
+
def type(self) -> CommitType:
|
|
583
|
+
"""Return the commit type under the JSON field name used by prompts."""
|
|
584
|
+
return self.commit_type
|
|
585
|
+
|
|
586
|
+
def body_texts(self) -> list[str]:
|
|
587
|
+
"""Return detail text for summary generation."""
|
|
588
|
+
return [detail.text for detail in self.details]
|
|
589
|
+
|
|
590
|
+
def changelog_entries(self) -> dict[ChangelogCategory, list[str]]:
|
|
591
|
+
"""Group user-visible detail text by changelog category."""
|
|
592
|
+
entries: dict[ChangelogCategory, list[str]] = {}
|
|
593
|
+
for detail in self.details:
|
|
594
|
+
if detail.user_visible and detail.changelog_category is not None:
|
|
595
|
+
entries.setdefault(detail.changelog_category, []).append(detail.text)
|
|
596
|
+
return entries
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@dataclass(frozen=True, slots=True)
|
|
600
|
+
class CommitMetadata:
|
|
601
|
+
"""Author, committer, message, parent, and tree metadata for a git commit."""
|
|
602
|
+
|
|
603
|
+
hash: str
|
|
604
|
+
message: str
|
|
605
|
+
author_name: str
|
|
606
|
+
author_email: str
|
|
607
|
+
author_date: str
|
|
608
|
+
committer_name: str
|
|
609
|
+
committer_email: str
|
|
610
|
+
committer_date: str
|
|
611
|
+
parents: tuple[str, ...]
|
|
612
|
+
tree_hash: str
|
|
613
|
+
|
|
614
|
+
def __post_init__(self) -> None:
|
|
615
|
+
object.__setattr__(self, "parents", _string_tuple(self.parents))
|
|
616
|
+
|
|
617
|
+
@property
|
|
618
|
+
def parent_hashes(self) -> tuple[str, ...]:
|
|
619
|
+
"""Return parent hashes using the Rust-era field name."""
|
|
620
|
+
return self.parents
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class ChangelogCategory(StrEnum):
|
|
624
|
+
"""Keep a Changelog section names in render order."""
|
|
625
|
+
|
|
626
|
+
ADDED = "Added"
|
|
627
|
+
CHANGED = "Changed"
|
|
628
|
+
FIXED = "Fixed"
|
|
629
|
+
DEPRECATED = "Deprecated"
|
|
630
|
+
REMOVED = "Removed"
|
|
631
|
+
SECURITY = "Security"
|
|
632
|
+
BREAKING = "Breaking Changes"
|
|
633
|
+
|
|
634
|
+
@classmethod
|
|
635
|
+
def from_name(cls, name: str) -> Self:
|
|
636
|
+
"""Parse a category name, falling back to Changed."""
|
|
637
|
+
normalized = name.strip().lower()
|
|
638
|
+
for category in cls:
|
|
639
|
+
if category.value.lower() == normalized or category.name.lower() == normalized:
|
|
640
|
+
return category
|
|
641
|
+
if normalized == "breaking":
|
|
642
|
+
return cls.BREAKING
|
|
643
|
+
return cls.CHANGED
|
|
644
|
+
|
|
645
|
+
@classmethod
|
|
646
|
+
def from_commit_type(cls, commit_type: str | CommitType, body: Sequence[str] = ()) -> Self:
|
|
647
|
+
"""Resolve the default category for a commit type and body."""
|
|
648
|
+
if any("breaking" in item.lower() or "incompatible" in item.lower() for item in body):
|
|
649
|
+
return cls.BREAKING
|
|
650
|
+
match str(commit_type):
|
|
651
|
+
case "feat":
|
|
652
|
+
return cls.ADDED
|
|
653
|
+
case "fix":
|
|
654
|
+
return cls.FIXED
|
|
655
|
+
case "revert":
|
|
656
|
+
return cls.REMOVED
|
|
657
|
+
case _:
|
|
658
|
+
return cls.CHANGED
|
|
659
|
+
|
|
660
|
+
@classmethod
|
|
661
|
+
def render_order(cls) -> tuple[Self, ...]:
|
|
662
|
+
"""Return changelog render order."""
|
|
663
|
+
return (
|
|
664
|
+
cls.BREAKING,
|
|
665
|
+
cls.ADDED,
|
|
666
|
+
cls.CHANGED,
|
|
667
|
+
cls.DEPRECATED,
|
|
668
|
+
cls.REMOVED,
|
|
669
|
+
cls.FIXED,
|
|
670
|
+
cls.SECURITY,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _strict_changelog_category(name: str) -> ChangelogCategory:
|
|
675
|
+
normalized = name.strip().lower()
|
|
676
|
+
for category in ChangelogCategory:
|
|
677
|
+
if category.value.lower() == normalized or category.name.lower() == normalized:
|
|
678
|
+
return category
|
|
679
|
+
if normalized == "breaking":
|
|
680
|
+
return ChangelogCategory.BREAKING
|
|
681
|
+
raise ValidationFailure(f"unknown changelog category: {name!r}", field="changelog_category", value=name)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def default_categories() -> list[CategoryConfig]:
|
|
685
|
+
"""Return changelog category defaults in render order."""
|
|
686
|
+
return [
|
|
687
|
+
CategoryConfig(
|
|
688
|
+
name="Breaking",
|
|
689
|
+
header="Breaking Changes",
|
|
690
|
+
match=CategoryMatch(body_contains=("breaking", "incompatible")),
|
|
691
|
+
),
|
|
692
|
+
CategoryConfig(name="Added", match=CategoryMatch(types=("feat",))),
|
|
693
|
+
CategoryConfig(name="Changed", default=True),
|
|
694
|
+
CategoryConfig(name="Deprecated"),
|
|
695
|
+
CategoryConfig(name="Removed", match=CategoryMatch(types=("revert",))),
|
|
696
|
+
CategoryConfig(name="Fixed", match=CategoryMatch(types=("fix",))),
|
|
697
|
+
CategoryConfig(name="Security"),
|
|
698
|
+
]
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@dataclass(frozen=True, slots=True)
|
|
702
|
+
class HunkSelector:
|
|
703
|
+
"""Selector for hunks included in a file change."""
|
|
704
|
+
|
|
705
|
+
kind: str
|
|
706
|
+
start: int | None = None
|
|
707
|
+
end: int | None = None
|
|
708
|
+
pattern: str | None = None
|
|
709
|
+
|
|
710
|
+
@classmethod
|
|
711
|
+
def all(cls) -> Self:
|
|
712
|
+
"""Select all hunks in a file."""
|
|
713
|
+
return cls(kind="ALL")
|
|
714
|
+
|
|
715
|
+
@classmethod
|
|
716
|
+
def lines(cls, start: int, end: int) -> Self:
|
|
717
|
+
"""Select a 1-indexed inclusive line range."""
|
|
718
|
+
return cls(kind="Lines", start=start, end=end)
|
|
719
|
+
|
|
720
|
+
@classmethod
|
|
721
|
+
def search(cls, pattern: str) -> Self:
|
|
722
|
+
"""Select hunks matching a search pattern."""
|
|
723
|
+
return cls(kind="Search", pattern=pattern)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
@dataclass(frozen=True, slots=True)
|
|
727
|
+
class FileChange:
|
|
728
|
+
"""A file path and the hunks selected from it."""
|
|
729
|
+
|
|
730
|
+
path: str
|
|
731
|
+
hunks: tuple[HunkSelector, ...]
|
|
732
|
+
|
|
733
|
+
def __post_init__(self) -> None:
|
|
734
|
+
object.__setattr__(self, "hunks", tuple(self.hunks))
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
@dataclass(frozen=True, slots=True)
|
|
738
|
+
class ChangeGroup:
|
|
739
|
+
"""A logical compose group emitted by planning."""
|
|
740
|
+
|
|
741
|
+
changes: tuple[FileChange, ...]
|
|
742
|
+
commit_type: CommitType
|
|
743
|
+
scope: Scope | None
|
|
744
|
+
rationale: str
|
|
745
|
+
dependencies: tuple[int, ...] = ()
|
|
746
|
+
|
|
747
|
+
def __post_init__(self) -> None:
|
|
748
|
+
object.__setattr__(self, "commit_type", CommitType.from_raw(self.commit_type))
|
|
749
|
+
if self.scope is not None:
|
|
750
|
+
object.__setattr__(self, "scope", Scope.from_raw(self.scope))
|
|
751
|
+
object.__setattr__(self, "changes", tuple(self.changes))
|
|
752
|
+
object.__setattr__(self, "dependencies", tuple(self.dependencies))
|
|
753
|
+
|
|
754
|
+
@property
|
|
755
|
+
def type(self) -> CommitType:
|
|
756
|
+
"""Return the commit type under the JSON field name used by prompts."""
|
|
757
|
+
return self.commit_type
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@dataclass(frozen=True, slots=True)
|
|
761
|
+
class ComposeAnalysis:
|
|
762
|
+
"""Result of compose grouping analysis."""
|
|
763
|
+
|
|
764
|
+
groups: tuple[ChangeGroup, ...]
|
|
765
|
+
dependency_order: tuple[int, ...]
|
|
766
|
+
|
|
767
|
+
def __post_init__(self) -> None:
|
|
768
|
+
object.__setattr__(self, "groups", tuple(self.groups))
|
|
769
|
+
object.__setattr__(self, "dependency_order", tuple(self.dependency_order))
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
@dataclass(frozen=True, slots=True)
|
|
773
|
+
class ComposeHunk:
|
|
774
|
+
"""A captured diff hunk in a compose snapshot."""
|
|
775
|
+
|
|
776
|
+
hunk_id: str
|
|
777
|
+
file_id: str
|
|
778
|
+
path: str
|
|
779
|
+
old_start: int
|
|
780
|
+
old_count: int
|
|
781
|
+
new_start: int
|
|
782
|
+
new_count: int
|
|
783
|
+
header: str
|
|
784
|
+
raw_patch: str
|
|
785
|
+
snippet: str
|
|
786
|
+
semantic_key: str
|
|
787
|
+
synthetic: bool = False
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@dataclass(frozen=True, slots=True)
|
|
791
|
+
class ComposeFile:
|
|
792
|
+
"""A file captured in a compose snapshot."""
|
|
793
|
+
|
|
794
|
+
file_id: str
|
|
795
|
+
path: str
|
|
796
|
+
patch_header: str
|
|
797
|
+
full_patch: str
|
|
798
|
+
summary: str
|
|
799
|
+
hunk_ids: tuple[str, ...]
|
|
800
|
+
additions: int
|
|
801
|
+
deletions: int
|
|
802
|
+
is_binary: bool = False
|
|
803
|
+
synthetic_only: bool = False
|
|
804
|
+
|
|
805
|
+
def __post_init__(self) -> None:
|
|
806
|
+
object.__setattr__(self, "hunk_ids", _string_tuple(self.hunk_ids))
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
class WorktreePinKind(StrEnum):
|
|
810
|
+
"""Kinds of worktree pins captured for compose staging."""
|
|
811
|
+
|
|
812
|
+
OBJECT = "object"
|
|
813
|
+
DELETED = "deleted"
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
@dataclass(frozen=True, slots=True)
|
|
817
|
+
class WorktreePin:
|
|
818
|
+
"""A captured worktree path state for compose snapshot staging."""
|
|
819
|
+
|
|
820
|
+
kind: WorktreePinKind
|
|
821
|
+
mode: str | None = None
|
|
822
|
+
oid: str | None = None
|
|
823
|
+
|
|
824
|
+
@classmethod
|
|
825
|
+
def object(cls, *, mode: str, oid: str) -> Self:
|
|
826
|
+
"""Pin a path to an object already written to the object database."""
|
|
827
|
+
return cls(kind=WorktreePinKind.OBJECT, mode=mode, oid=oid)
|
|
828
|
+
|
|
829
|
+
@classmethod
|
|
830
|
+
def deleted(cls) -> Self:
|
|
831
|
+
"""Pin a path as absent from the worktree."""
|
|
832
|
+
return cls(kind=WorktreePinKind.DELETED)
|
|
833
|
+
|
|
834
|
+
def __post_init__(self) -> None:
|
|
835
|
+
kind = WorktreePinKind(self.kind)
|
|
836
|
+
object.__setattr__(self, "kind", kind)
|
|
837
|
+
if kind is WorktreePinKind.OBJECT and (not self.mode or not self.oid):
|
|
838
|
+
raise ValidationFailure("object pins require mode and oid", field="pins")
|
|
839
|
+
if kind is WorktreePinKind.DELETED and (self.mode is not None or self.oid is not None):
|
|
840
|
+
raise ValidationFailure("deleted pins cannot include mode or oid", field="pins")
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
@dataclass(frozen=True, slots=True)
|
|
844
|
+
class ComposeSnapshot:
|
|
845
|
+
"""Diff, file, hunk, and pin data captured once for compose mode."""
|
|
846
|
+
|
|
847
|
+
diff: str
|
|
848
|
+
stat: str
|
|
849
|
+
files: tuple[ComposeFile, ...]
|
|
850
|
+
hunks: tuple[ComposeHunk, ...]
|
|
851
|
+
pins: Mapping[str, WorktreePin] = field(default_factory=dict)
|
|
852
|
+
|
|
853
|
+
def __post_init__(self) -> None:
|
|
854
|
+
object.__setattr__(self, "files", tuple(self.files))
|
|
855
|
+
object.__setattr__(self, "hunks", tuple(self.hunks))
|
|
856
|
+
object.__setattr__(self, "pins", dict(self.pins))
|
|
857
|
+
|
|
858
|
+
def file_by_id(self, file_id: str) -> ComposeFile | None:
|
|
859
|
+
"""Return a snapshot file by stable file id."""
|
|
860
|
+
return next((file for file in self.files if file.file_id == file_id), None)
|
|
861
|
+
|
|
862
|
+
def file_by_path(self, path: str) -> ComposeFile | None:
|
|
863
|
+
"""Return a snapshot file by path."""
|
|
864
|
+
return next((file for file in self.files if file.path == path), None)
|
|
865
|
+
|
|
866
|
+
def hunk_by_id(self, hunk_id: str) -> ComposeHunk | None:
|
|
867
|
+
"""Return a snapshot hunk by stable hunk id."""
|
|
868
|
+
return next((hunk for hunk in self.hunks if hunk.hunk_id == hunk_id), None)
|
|
869
|
+
|
|
870
|
+
def hunks_for_file(self, file_id: str) -> list[ComposeHunk]:
|
|
871
|
+
"""Return all hunks belonging to a snapshot file."""
|
|
872
|
+
return [hunk for hunk in self.hunks if hunk.file_id == file_id]
|
|
873
|
+
|
|
874
|
+
def all_hunk_ids(self) -> list[str]:
|
|
875
|
+
"""Return every hunk id in snapshot order."""
|
|
876
|
+
return [hunk.hunk_id for hunk in self.hunks]
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _format_body_line(line: str) -> str:
|
|
880
|
+
stripped = line.strip()
|
|
881
|
+
if stripped.startswith(("- ", "* ")):
|
|
882
|
+
return stripped
|
|
883
|
+
return f"- {stripped}"
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _string_tuple(values: Iterable[Any]) -> tuple[str, ...]:
|
|
887
|
+
if isinstance(values, str):
|
|
888
|
+
return (values,)
|
|
889
|
+
return tuple(str(value) for value in values)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
__all__ = [
|
|
893
|
+
"DEFAULT_SUMMARY_MAX_LENGTH",
|
|
894
|
+
"SUMMARY_GUIDELINE_LENGTH",
|
|
895
|
+
"Mode",
|
|
896
|
+
"ApiMode",
|
|
897
|
+
"ResolvedApiMode",
|
|
898
|
+
"resolve_model_name",
|
|
899
|
+
"TypeConfig",
|
|
900
|
+
"CategoryMatch",
|
|
901
|
+
"CategoryConfig",
|
|
902
|
+
"default_types",
|
|
903
|
+
"default_classifier_hint",
|
|
904
|
+
"default_categories",
|
|
905
|
+
"CommitType",
|
|
906
|
+
"coerce_commit_type",
|
|
907
|
+
"Scope",
|
|
908
|
+
"coerce_optional_scope",
|
|
909
|
+
"CommitSummary",
|
|
910
|
+
"ConventionalCommit",
|
|
911
|
+
"AnalysisDetail",
|
|
912
|
+
"ConventionalAnalysis",
|
|
913
|
+
"CommitMetadata",
|
|
914
|
+
"ChangelogCategory",
|
|
915
|
+
"HunkSelector",
|
|
916
|
+
"FileChange",
|
|
917
|
+
"ChangeGroup",
|
|
918
|
+
"ComposeAnalysis",
|
|
919
|
+
"ComposeHunk",
|
|
920
|
+
"ComposeFile",
|
|
921
|
+
"WorktreePinKind",
|
|
922
|
+
"WorktreePin",
|
|
923
|
+
"ComposeSnapshot",
|
|
924
|
+
]
|