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/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
+ ]