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/compose.py ADDED
@@ -0,0 +1,2110 @@
1
+ """Compose-mode planning and isolated execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ import os
9
+ import warnings
10
+ from collections import defaultdict
11
+ from collections.abc import Callable, Iterable, Mapping, Sequence
12
+ from dataclasses import asdict, dataclass, is_dataclass
13
+ from enum import StrEnum
14
+ from importlib import import_module
15
+ from pathlib import Path
16
+ from types import SimpleNamespace
17
+ from typing import Any
18
+
19
+ from . import git, style
20
+ from .errors import GitError, NoChanges, ValidationFailure
21
+ from .models import (
22
+ AnalysisDetail,
23
+ CommitSummary,
24
+ CommitType,
25
+ ComposeSnapshot,
26
+ ConventionalAnalysis,
27
+ Scope,
28
+ coerce_optional_scope,
29
+ )
30
+ from .normalization import format_commit_message, post_process_commit_message
31
+ from .patch import (
32
+ StageResult,
33
+ build_compose_snapshot,
34
+ create_executable_group_patch,
35
+ force_stage_file_from_base_in_index,
36
+ pin_snapshot_worktree_state,
37
+ stage_executable_group_in_index,
38
+ )
39
+ from .validation import validate_commit_message
40
+
41
+ COMPOSE_PLAN_SCHEMA_VERSION = "v3"
42
+ COMPOSE_MESSAGE_PARALLELISM = 8
43
+ # Compose planning intentionally switches representation as snapshots grow:
44
+ # small/medium snapshots preserve per-file detail, while large snapshots plan by
45
+ # path area to keep prompts bounded and avoid monolithic LLM output.
46
+ MAX_OBSERVATIONS_PER_FILE = 3
47
+ COMPOSE_SUMMARY_MEDIUM_FILE_THRESHOLD = 60
48
+ COMPOSE_SUMMARY_MEDIUM_HUNK_THRESHOLD = 200
49
+ COMPOSE_SUMMARY_LARGE_FILE_THRESHOLD = 150
50
+ COMPOSE_SUMMARY_LARGE_HUNK_THRESHOLD = 500
51
+ COMPOSE_AREA_TARGET_MAX_FILES = 60
52
+ COMPOSE_AREA_TARGET_MAX_HUNKS = 140
53
+ COMPOSE_AREA_TARGET_MAX_DEPTH = 6
54
+ COMPOSE_MONOLITH_FALLBACK_TARGET_THRESHOLD = 8
55
+ COMPOSE_MONOLITH_FALLBACK_WORKSTREAM_THRESHOLD = 3
56
+ MAX_BIND_FILES_PER_REQUEST = 18
57
+ MAX_BIND_HUNKS_PER_REQUEST = 120
58
+ _DEPENDENCY_MANIFESTS = {
59
+ "Cargo.toml",
60
+ "Cargo.lock",
61
+ "package.json",
62
+ "package-lock.json",
63
+ "pnpm-lock.yaml",
64
+ "yarn.lock",
65
+ "bun.lock",
66
+ "bun.lockb",
67
+ "go.mod",
68
+ "go.sum",
69
+ "requirements.txt",
70
+ "Pipfile",
71
+ "Pipfile.lock",
72
+ "pyproject.toml",
73
+ "Gemfile",
74
+ "Gemfile.lock",
75
+ "composer.json",
76
+ "composer.lock",
77
+ "build.gradle",
78
+ "build.gradle.kts",
79
+ "gradle.properties",
80
+ "pom.xml",
81
+ }
82
+
83
+
84
+ class ComposeAnalysisStrategy(StrEnum):
85
+ DIRECT = "direct"
86
+ SMART_TRUNCATE = "smart_truncate"
87
+ MAP_REDUCE = "map_reduce"
88
+
89
+
90
+ class PlanningMode(StrEnum):
91
+ FILE = "file"
92
+ AREA = "area"
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class ComposeBaseState:
97
+ """HEAD, symbolic ref, and live index tree captured before LLM calls."""
98
+
99
+ head_hash: str
100
+ head_ref: str
101
+ index_tree: str
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class SnapshotSummaryBudget:
106
+ max_observations_per_file: int
107
+ max_hunks_per_file: int | None = None
108
+
109
+ @property
110
+ def is_compacted(self) -> bool:
111
+ return self.max_hunks_per_file is not None
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class PlanningTarget:
116
+ target_id: str
117
+ label: str
118
+ file_ids: tuple[str, ...]
119
+ hunk_count: int
120
+ additions: int
121
+ deletions: int
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class PlanningIndex:
126
+ mode: PlanningMode
127
+ targets: tuple[PlanningTarget, ...]
128
+ aliases: Mapping[str, str]
129
+
130
+ def expand_target_ids(self, target_ids: Sequence[str]) -> list[str]:
131
+ expanded: list[str] = []
132
+ seen: set[str] = set()
133
+ targets = {target.target_id: target for target in self.targets}
134
+ for target_id in target_ids:
135
+ target = targets.get(target_id)
136
+ if target is None:
137
+ continue
138
+ for file_id in target.file_ids:
139
+ if file_id not in seen:
140
+ expanded.append(file_id)
141
+ seen.add(file_id)
142
+ return expanded
143
+
144
+
145
+ @dataclass(frozen=True, slots=True)
146
+ class ComposeIntentGroup:
147
+ group_id: str
148
+ commit_type: CommitType
149
+ scope: Scope | None
150
+ file_ids: tuple[str, ...]
151
+ rationale: str
152
+ dependencies: tuple[str, ...] = ()
153
+
154
+ def __post_init__(self) -> None:
155
+ object.__setattr__(self, "group_id", str(self.group_id))
156
+ object.__setattr__(self, "commit_type", CommitType.from_raw(self.commit_type))
157
+ if self.scope is not None:
158
+ object.__setattr__(self, "scope", Scope.from_raw(self.scope))
159
+ object.__setattr__(self, "file_ids", tuple(str(value) for value in self.file_ids))
160
+ object.__setattr__(self, "dependencies", tuple(str(value) for value in self.dependencies))
161
+
162
+ @property
163
+ def type(self) -> CommitType:
164
+ return self.commit_type
165
+
166
+
167
+ @dataclass(frozen=True, slots=True)
168
+ class ComposeExecutableGroup:
169
+ """A fully bound compose group with file and hunk ids ready to stage."""
170
+
171
+ group_id: str
172
+ commit_type: CommitType
173
+ scope: Scope | None
174
+ file_ids: tuple[str, ...]
175
+ rationale: str
176
+ dependencies: tuple[str, ...] = ()
177
+ hunk_ids: tuple[str, ...] = ()
178
+
179
+ def __post_init__(self) -> None:
180
+ object.__setattr__(self, "group_id", str(self.group_id))
181
+ object.__setattr__(self, "commit_type", CommitType.from_raw(self.commit_type))
182
+ if self.scope is not None:
183
+ object.__setattr__(self, "scope", Scope.from_raw(self.scope))
184
+ object.__setattr__(self, "file_ids", tuple(str(value) for value in self.file_ids))
185
+ object.__setattr__(self, "dependencies", tuple(str(value) for value in self.dependencies))
186
+ object.__setattr__(self, "hunk_ids", tuple(str(value) for value in self.hunk_ids))
187
+
188
+ @property
189
+ def type(self) -> CommitType:
190
+ """Return the commit type under the prompt-facing JSON field name."""
191
+ return self.commit_type
192
+
193
+
194
+ @dataclass(frozen=True, slots=True)
195
+ class ComposeExecutablePlan:
196
+ """Executable compose plan ordered by dependency index."""
197
+
198
+ groups: tuple[ComposeExecutableGroup, ...]
199
+ dependency_order: tuple[int, ...]
200
+
201
+ def __post_init__(self) -> None:
202
+ object.__setattr__(self, "groups", tuple(self.groups))
203
+ object.__setattr__(self, "dependency_order", tuple(int(value) for value in self.dependency_order))
204
+
205
+
206
+ def compute_dependency_order(
207
+ groups: Sequence[Any],
208
+ group_id: Callable[[Any], str] | None = None,
209
+ dependencies: Callable[[Any], Iterable[str | int]] | None = None,
210
+ ) -> tuple[int, ...]:
211
+ """Return a topological order for compose groups, validating ids and cycles."""
212
+ id_for = group_id or (lambda group: str(getattr(group, "group_id", getattr(group, "id", ""))))
213
+ deps_for = dependencies or (lambda group: getattr(group, "dependencies", ()))
214
+ index_by_id: dict[str, int] = {}
215
+ ids: list[str] = []
216
+ for idx, group in enumerate(groups):
217
+ raw_id = id_for(group).strip() or f"G{idx + 1:03d}"
218
+ if raw_id in index_by_id:
219
+ raise ValidationFailure(f"duplicate compose group_id {raw_id!r}", field="compose")
220
+ index_by_id[raw_id] = idx
221
+ ids.append(raw_id)
222
+
223
+ in_degree = [0] * len(groups)
224
+ adjacency: list[list[int]] = [[] for _ in groups]
225
+ for idx, group in enumerate(groups):
226
+ for dependency in deps_for(group):
227
+ if isinstance(dependency, int):
228
+ dep_idx = dependency
229
+ if dep_idx < 0 or dep_idx >= len(groups):
230
+ raise ValidationFailure(f"group {ids[idx]} depends on unknown index {dep_idx}", field="compose")
231
+ else:
232
+ dep_id = str(dependency)
233
+ if dep_id not in index_by_id:
234
+ raise ValidationFailure(f"group {ids[idx]} depends on unknown group_id {dep_id!r}", field="compose")
235
+ dep_idx = index_by_id[dep_id]
236
+ if dep_idx == idx:
237
+ raise ValidationFailure(f"group {ids[idx]} depends on itself", field="compose")
238
+ adjacency[dep_idx].append(idx)
239
+ in_degree[idx] += 1
240
+
241
+ queue = [idx for idx, degree in enumerate(in_degree) if degree == 0]
242
+ order: list[int] = []
243
+ while queue:
244
+ node = queue.pop(0)
245
+ order.append(node)
246
+ for neighbor in adjacency[node]:
247
+ in_degree[neighbor] -= 1
248
+ if in_degree[neighbor] == 0:
249
+ queue.append(neighbor)
250
+ if len(order) != len(groups):
251
+ raise ValidationFailure("circular dependency detected in compose groups", field="compose")
252
+ return tuple(order)
253
+
254
+
255
+ def capture_compose_base_state(dir: str | os.PathLike[str] = ".") -> ComposeBaseState:
256
+ """Capture HEAD/ref/index state once before compose planning or LLM calls."""
257
+ return ComposeBaseState(
258
+ head_hash=git.get_head_hash(dir),
259
+ head_ref=git.current_head_ref(dir),
260
+ index_tree=git.write_real_index_tree(dir),
261
+ )
262
+
263
+
264
+ async def run_compose_mode(args: Any, config: Any) -> list[str]:
265
+ """Run compose rounds until preview, no changes remain, or max rounds is reached."""
266
+ max_rounds = int(getattr(config, "compose_max_rounds", 5) or 5)
267
+ all_hashes: list[str] = []
268
+ for round_number in range(1, max_rounds + 1):
269
+ hashes = await run_compose_round(args, config, round_number)
270
+ all_hashes.extend(hashes)
271
+ if bool(getattr(args, "compose_preview", False)):
272
+ break
273
+ try:
274
+ git.get_compose_diff_with_config(_arg_dir(args), config)
275
+ except NoChanges:
276
+ break
277
+ if round_number == max_rounds:
278
+ break
279
+ return all_hashes
280
+
281
+
282
+ def _print_executable_plan(snapshot: ComposeSnapshot, plan: ComposeExecutablePlan) -> None:
283
+ """Print compose groups in dependency order for preview and execution."""
284
+ print(f"\n{style.section_header('Proposed Commit Groups', 80)}")
285
+ for display_idx, group_idx in enumerate(plan.dependency_order, start=1):
286
+ group = plan.groups[group_idx]
287
+ scope = f"({style.scope(group.scope.as_str())})" if group.scope is not None else ""
288
+ print(
289
+ f"\n{display_idx}. {style.bold(group.group_id)} "
290
+ f"[{style.commit_type(group.commit_type.as_str())}{scope}] {group.rationale}"
291
+ )
292
+
293
+ print(" Files:")
294
+ for file_id in group.file_ids:
295
+ file = snapshot.file_by_id(file_id)
296
+ if file is None:
297
+ continue
298
+ selected_hunk_ids = [hunk_id for hunk_id in group.hunk_ids if hunk_id in file.hunk_ids]
299
+ selection = "all hunks" if len(selected_hunk_ids) == len(file.hunk_ids) else ", ".join(selected_hunk_ids)
300
+ print(f" - {file.file_id} {file.path} ({selection})")
301
+
302
+ if group.dependencies:
303
+ print(f" Depends on: {', '.join(group.dependencies)}")
304
+
305
+
306
+ async def run_compose_round(args: Any, config: Any, round: int = 1) -> list[str]:
307
+ """Plan one immutable compose snapshot and optionally execute it in isolation."""
308
+ dir = _arg_dir(args)
309
+ base_state = capture_compose_base_state(dir)
310
+ diff = git.get_compose_diff_with_config(dir, config)
311
+ stat = git.get_compose_stat(dir)
312
+ snapshot = build_compose_snapshot(diff, stat)
313
+ pin_snapshot_worktree_state(snapshot, dir)
314
+ _save_debug_artifact(args, f"compose_round_{round}_snapshot.json", _snapshot_to_jsonable(snapshot))
315
+
316
+ token_counter = _create_token_counter(config)
317
+ observations = []
318
+ if _should_collect_compose_observations(snapshot, config, token_counter):
319
+ observations = await _observe_diff_files(
320
+ snapshot.diff,
321
+ str(getattr(config, "summary_model", getattr(config, "model", "")) or ""),
322
+ config,
323
+ token_counter,
324
+ )
325
+ if observations:
326
+ _save_debug_artifact(
327
+ args, f"compose_round_{round}_observations.json", [_observation_to_jsonable(item) for item in observations]
328
+ )
329
+
330
+ max_commits = int(getattr(args, "compose_max_commits", None) or 20)
331
+ model = str(getattr(config, "analysis_model", getattr(args, "model", "")) or "")
332
+ plan = _load_cached_plan(dir, snapshot, max_commits, model)
333
+ if plan is None:
334
+ plan = await plan_compose_snapshot(snapshot, config, args, max_commits=max_commits, observations=observations)
335
+ _save_cached_plan(dir, snapshot, max_commits, model, plan)
336
+ _save_debug_artifact(args, f"compose_round_{round}_executable_plan.json", _plan_to_jsonable(plan))
337
+
338
+ _print_executable_plan(snapshot, plan)
339
+
340
+ if bool(getattr(args, "compose_preview", False)):
341
+ preview_message = f"{style.icons.SUCCESS} Preview complete (use --compose without --compose-preview to execute)"
342
+ print(f"\n{style.success(preview_message)}")
343
+ return []
344
+ return await execute_compose(snapshot, plan, config, args, base_state)
345
+
346
+
347
+ async def plan_compose_snapshot(
348
+ snapshot: ComposeSnapshot,
349
+ config: Any,
350
+ args: Any | None = None,
351
+ *,
352
+ max_commits: int = 20,
353
+ observations: Sequence[Any] = (),
354
+ ) -> ComposeExecutablePlan:
355
+ """Build or request an executable compose plan for a pinned snapshot."""
356
+ intent_plan = await _analyze_compose_intent(
357
+ snapshot, observations, config, max_commits, getattr(args, "debug_output", None)
358
+ )
359
+ _save_debug_artifact(args, "compose_intent_plan.json", _intent_plan_to_jsonable(intent_plan))
360
+ return await _bind_compose_plan(snapshot, intent_plan, config, getattr(args, "debug_output", None))
361
+
362
+
363
+ async def execute_compose(
364
+ snapshot: ComposeSnapshot,
365
+ plan: ComposeExecutablePlan,
366
+ config: Any,
367
+ args: Any,
368
+ base_state: ComposeBaseState,
369
+ ) -> list[str]:
370
+ """Create compose commits from a temp index, then checked-update the real ref."""
371
+ if bool(getattr(args, "compose_preview", False)):
372
+ return []
373
+
374
+ dir = _arg_dir(args)
375
+ ordered_groups = [plan.groups[idx] for idx in plan.dependency_order]
376
+ group_patches = [create_executable_group_patch(snapshot, group) for group in ordered_groups]
377
+ prepared_messages = await _prepare_group_messages(snapshot, ordered_groups, group_patches, config, args)
378
+
379
+ with git.TempGitIndex(dir) as index:
380
+ git.read_tree_into_index(index.path, base_state.head_hash, dir)
381
+ parent_hash = base_state.head_hash
382
+ commit_hashes: list[str] = []
383
+ for position, group in enumerate(ordered_groups):
384
+ outcome = stage_executable_group_in_index(snapshot, group, dir, index.path)
385
+ staged_anything = outcome.result == StageResult.STAGED
386
+ for skipped in outcome.skipped:
387
+ file = snapshot.file_by_path(skipped.path)
388
+ if file is None:
389
+ continue
390
+ cumulative = _cumulative_file_hunk_ids(plan, position, snapshot, file.file_id)
391
+ force_stage_file_from_base_in_index(snapshot, file.file_id, cumulative, dir, index.path)
392
+ staged_anything = True
393
+ if not staged_anything:
394
+ continue
395
+ message = prepared_messages[position]
396
+ tree = git.write_index_tree(index.path, dir)
397
+ sign = bool(getattr(args, "sign", False) or getattr(config, "gpg_sign", False))
398
+ commit_hash = git.commit_tree(tree, [parent_hash], message, dir, sign=sign)
399
+ parent_hash = commit_hash
400
+ commit_hashes.append(commit_hash)
401
+ if bool(getattr(args, "compose_test_after_each", False)):
402
+ raise GitError("--compose-test-after-each is incompatible with isolated compose execution")
403
+
404
+ if not commit_hashes:
405
+ return []
406
+
407
+ git.update_ref_checked(base_state.head_ref, parent_hash, base_state.head_hash, dir)
408
+ current_index_tree = git.write_real_index_tree(dir)
409
+ if current_index_tree == base_state.index_tree:
410
+ git.reset_mixed_to(parent_hash, dir)
411
+ else:
412
+ paths = [file.path for file in snapshot.files]
413
+ git.reset_paths_to(parent_hash, paths, dir)
414
+ return commit_hashes
415
+
416
+
417
+ def _analyze_compose_intent_from_mapping(
418
+ raw: Any, snapshot: ComposeSnapshot, config: Any, max_commits: int
419
+ ) -> tuple[ComposeIntentGroup, ...]:
420
+ data = _object_mapping(raw)
421
+ raw_groups = data.get("groups", ())
422
+ groups = [_intent_group_from_mapping(item, idx) for idx, item in enumerate(raw_groups, start=1)]
423
+ planning_index = _build_planning_index(snapshot)
424
+ return _normalize_intent_plan(snapshot, planning_index, groups, config, max_commits)
425
+
426
+
427
+ async def _analyze_compose_intent(
428
+ snapshot: ComposeSnapshot,
429
+ observations: Sequence[Any],
430
+ config: Any,
431
+ max_commits: int,
432
+ debug_dir: str | os.PathLike[str] | None,
433
+ ) -> tuple[ComposeIntentGroup, ...]:
434
+ planning_index = _build_planning_index(snapshot)
435
+ try:
436
+ api = import_module("lgit.api")
437
+ templates = import_module("lgit.templates")
438
+ except ModuleNotFoundError:
439
+ return _fallback_intent_groups(snapshot, planning_index, max_commits, config)
440
+
441
+ schema = _build_intent_schema(config)
442
+ variant = "markdown" if bool(getattr(config, "markdown_output", True)) else "default"
443
+ types_description = api.format_types_description(config) if hasattr(api, "format_types_description") else None
444
+ parts = templates.render_compose_intent_prompt(
445
+ variant=variant,
446
+ max_commits=max_commits,
447
+ stat=_render_planning_stat(planning_index),
448
+ snapshot_summary=_render_planning_snapshot_summary(snapshot, observations, planning_index),
449
+ planning_targets=_render_planning_targets(planning_index, snapshot),
450
+ planning_notes=_render_planning_notes(planning_index),
451
+ split_bias=_render_split_bias(planning_index),
452
+ types_description=types_description,
453
+ )
454
+ try:
455
+ response = await api.run_oneshot(
456
+ config,
457
+ api.OneShotSpec(
458
+ operation="compose/intent",
459
+ model=str(getattr(config, "analysis_model", getattr(config, "model", "")) or ""),
460
+ prompt_family="compose-intent",
461
+ prompt_variant=variant,
462
+ system_prompt=parts.system,
463
+ user_prompt=parts.user,
464
+ tool_name="create_compose_intent_plan",
465
+ tool_description="Plan logical commit groups over the provided planning target IDs",
466
+ schema=schema,
467
+ progress_label="compose intent planner",
468
+ debug=api.OneShotDebug(debug_dir, None, "compose_intent") if debug_dir else None,
469
+ cacheable=True,
470
+ ),
471
+ )
472
+ output = response.output if hasattr(response, "output") else response
473
+ groups = _analyze_compose_intent_from_mapping(output, snapshot, config, max_commits)
474
+ except Exception as exc:
475
+ warnings.warn(
476
+ f"compose intent planner failed; falling back to deterministic plan: {exc}", RuntimeWarning, stacklevel=2
477
+ )
478
+ groups = _fallback_intent_groups(snapshot, planning_index, max_commits, config)
479
+ if _should_force_large_patch_fallback(snapshot, planning_index, groups, max_commits):
480
+ groups = _fallback_intent_groups(snapshot, planning_index, max_commits, config)
481
+ return groups
482
+
483
+
484
+ async def _bind_compose_plan(
485
+ snapshot: ComposeSnapshot,
486
+ intent_plan: Sequence[ComposeIntentGroup],
487
+ config: Any,
488
+ debug_dir: str | os.PathLike[str] | None,
489
+ ) -> ComposeExecutablePlan:
490
+ assigned_by_group, ambiguous_files = _auto_assign_hunks(snapshot, intent_plan)
491
+ unresolved: list[str] = []
492
+ if ambiguous_files:
493
+ for batch_idx, batch in enumerate(_chunk_ambiguous_files(ambiguous_files), start=1):
494
+ debug_name = "compose_bind" if len(ambiguous_files) == len(batch) else f"compose_bind_{batch_idx:03d}"
495
+ assignments = await _request_binding(snapshot, intent_plan, batch, config, debug_dir, debug_name)
496
+ evaluation = _evaluate_binding(
497
+ assignments, _ambiguous_hunk_context(batch), {group.group_id for group in intent_plan}, snapshot
498
+ )
499
+ for group_id, hunk_ids in evaluation.assigned.items():
500
+ assigned_by_group[group_id].update(hunk_ids)
501
+ unresolved.extend(evaluation.unresolved)
502
+ if unresolved:
503
+ group_rank = {
504
+ intent_plan[idx].group_id: position for position, idx in enumerate(compute_dependency_order(intent_plan))
505
+ }
506
+ repair_batches = _chunk_ambiguous_files(_filter_ambiguous_files(ambiguous_files, unresolved))
507
+ repair_unresolved: list[str] = []
508
+ for batch_idx, batch in enumerate(repair_batches, start=1):
509
+ debug_name = "compose_bind_repair" if len(repair_batches) == 1 else f"compose_bind_repair_{batch_idx:03d}"
510
+ assignments = await _request_binding(snapshot, intent_plan, batch, config, debug_dir, debug_name)
511
+ repair = _evaluate_binding(
512
+ assignments, _ambiguous_hunk_context(batch), {group.group_id for group in intent_plan}, snapshot
513
+ )
514
+ for group_id, hunk_ids in repair.assigned.items():
515
+ assigned_by_group[group_id].update(hunk_ids)
516
+ repair_unresolved.extend(repair.unresolved)
517
+ if repair_unresolved:
518
+ _assign_unresolved_hunks(repair_unresolved, assigned_by_group, ambiguous_files, group_rank)
519
+ plan = _finalize_executable_plan(snapshot, intent_plan, assigned_by_group)
520
+ _validate_executable_plan(snapshot, plan)
521
+ return plan
522
+
523
+
524
+ def _fallback_plan(snapshot: ComposeSnapshot, *, max_commits: int) -> ComposeExecutablePlan:
525
+ planning_index = _build_planning_index(snapshot)
526
+ intent = _fallback_intent_groups(snapshot, planning_index, max(1, max_commits), None)
527
+ assigned: dict[str, set[str]] = {
528
+ group.group_id: {
529
+ hunk_id
530
+ for file_id in group.file_ids
531
+ for file in [snapshot.file_by_id(file_id)]
532
+ if file
533
+ for hunk_id in file.hunk_ids
534
+ }
535
+ for group in intent
536
+ }
537
+ plan = _finalize_executable_plan(snapshot, intent, assigned)
538
+ _validate_executable_plan(snapshot, plan)
539
+ return plan
540
+
541
+
542
+ def _fallback_intent_groups(
543
+ snapshot: ComposeSnapshot,
544
+ planning_index: PlanningIndex,
545
+ max_commits: int,
546
+ config: Any,
547
+ ) -> tuple[ComposeIntentGroup, ...]:
548
+ del config
549
+ if planning_index.mode is PlanningMode.AREA:
550
+ bins = _fallback_area_bins(snapshot, planning_index, max(1, max_commits))
551
+ else:
552
+ bins = [tuple(file.file_id for file in bucket) for bucket in _bucket_files(snapshot, max(1, max_commits))]
553
+ groups: list[ComposeIntentGroup] = []
554
+ for idx, file_ids in enumerate(bins, start=1):
555
+ files = [file for file_id in file_ids for file in [snapshot.file_by_id(file_id)] if file is not None]
556
+ labels = [file.path for file in files]
557
+ groups.append(
558
+ ComposeIntentGroup(
559
+ group_id=f"G{idx:03d}",
560
+ commit_type=_fallback_commit_type_for_group(snapshot, labels, tuple(file_ids)),
561
+ scope=_fallback_scope_for_label(_common_path_prefix(labels)),
562
+ file_ids=tuple(file_ids),
563
+ rationale=_fallback_rationale_for_labels(labels),
564
+ dependencies=(),
565
+ )
566
+ )
567
+ return tuple(groups)
568
+
569
+
570
+ def _bucket_files(snapshot: ComposeSnapshot, max_commits: int) -> list[list[Any]]:
571
+ if not snapshot.files:
572
+ return []
573
+ if len(snapshot.files) <= max_commits:
574
+ return [[file] for file in snapshot.files]
575
+ buckets: list[list[Any]] = [[] for _ in range(max_commits)]
576
+ for idx, file in enumerate(snapshot.files):
577
+ buckets[idx % max_commits].append(file)
578
+ return [bucket for bucket in buckets if bucket]
579
+
580
+
581
+ def _fallback_area_bins(
582
+ snapshot: ComposeSnapshot, planning_index: PlanningIndex, max_commits: int
583
+ ) -> list[tuple[str, ...]]:
584
+ workstreams: dict[str, set[str]] = {}
585
+ weights: dict[str, int] = defaultdict(int)
586
+ for target in planning_index.targets:
587
+ key = _workstream_key_for_label(target.label)
588
+ workstreams.setdefault(key, set()).update(target.file_ids)
589
+ weights[key] += max(target.hunk_count, len(target.file_ids))
590
+ ordered = sorted(workstreams, key=lambda key: (-weights[key], key))
591
+ bins: list[tuple[list[str], int]] = [([], 0) for _ in range(max(1, min(max_commits, len(ordered) or 1)))]
592
+ for key in ordered:
593
+ idx = min(range(len(bins)), key=lambda i: (bins[i][1], len(bins[i][0])))
594
+ bins[idx][0].extend(workstreams[key])
595
+ bins[idx] = (bins[idx][0], bins[idx][1] + weights[key])
596
+ return [tuple(_ordered_file_ids(snapshot, set(file_ids))) for file_ids, _ in bins if file_ids]
597
+
598
+
599
+ def _guess_commit_type(files: Sequence[Any]) -> str:
600
+ paths = [file.path for file in files]
601
+ if any(_is_dependency_manifest(path) for path in paths):
602
+ return "build"
603
+ if all(path.startswith(("test/", "tests/")) or "test" in Path(path).stem.lower() for path in paths):
604
+ return "test"
605
+ if all(
606
+ path.lower().endswith((".md", ".rst", ".adoc")) or Path(path).name.lower().startswith("readme")
607
+ for path in paths
608
+ ):
609
+ return "docs"
610
+ return "chore"
611
+
612
+
613
+ def _guess_scope(files: Sequence[Any]) -> Scope | None:
614
+ first = files[0].path if files else ""
615
+ parts = [part for part in first.replace("\\", "/").split("/") if part]
616
+ raw = Path(parts[0]).stem if parts else None
617
+ return coerce_optional_scope(raw) if raw else None
618
+
619
+
620
+ def _is_dependency_manifest(path: str) -> bool:
621
+ name = Path(path).name
622
+ return name in _DEPENDENCY_MANIFESTS or Path(name).suffix.lower() in {".lock", ".lockb"}
623
+
624
+
625
+ def _compose_analysis_strategy(diff: str, config: Any, counter: Any) -> ComposeAnalysisStrategy:
626
+ if _should_use_map_reduce(diff, config, counter):
627
+ return ComposeAnalysisStrategy.MAP_REDUCE
628
+ diff_tokens = _count_tokens(counter, diff)
629
+ if len(diff) > int(getattr(config, "max_diff_length", 100_000)) or diff_tokens > int(
630
+ getattr(config, "max_diff_tokens", 25_000)
631
+ ):
632
+ return ComposeAnalysisStrategy.SMART_TRUNCATE
633
+ return ComposeAnalysisStrategy.DIRECT
634
+
635
+
636
+ def _compose_truncation_length(config: Any) -> int:
637
+ return max(
638
+ 1, min(int(getattr(config, "max_diff_length", 100_000)), int(getattr(config, "max_diff_tokens", 25_000)) * 4)
639
+ )
640
+
641
+
642
+ def _count_tokens(counter: Any, text: str) -> int:
643
+ count_sync = getattr(counter, "count_sync", None)
644
+ if callable(count_sync):
645
+ return int(count_sync(text))
646
+ count = getattr(counter, "count", None)
647
+ if callable(count):
648
+ return int(count(text))
649
+ return max(1, len(text) // 4)
650
+
651
+
652
+ def _create_token_counter(config: Any) -> Any:
653
+ try:
654
+ return import_module("lgit.tokens").create_token_counter(config)
655
+ except Exception as exc:
656
+ warnings.warn(f"token counter unavailable; using character-count fallback: {exc}", RuntimeWarning, stacklevel=2)
657
+ return SimpleNamespace(count_sync=lambda text: max(1, len(str(text)) // 4))
658
+
659
+
660
+ def _should_use_map_reduce(diff: str, config: Any, counter: Any | None = None) -> bool:
661
+ try:
662
+ return bool(import_module("lgit.map_reduce").should_use_map_reduce(diff, config, counter))
663
+ except Exception as exc:
664
+ warnings.warn(
665
+ f"map-reduce availability check failed; using direct analysis path: {exc}", RuntimeWarning, stacklevel=2
666
+ )
667
+ return False
668
+
669
+
670
+ async def _observe_diff_files(diff: str, model: str, config: Any, counter: Any | None = None) -> list[Any]:
671
+ return await import_module("lgit.map_reduce").observe_diff_files(diff, model, config, counter)
672
+
673
+
674
+ def _is_large_compose_snapshot(snapshot: ComposeSnapshot) -> bool:
675
+ return (
676
+ len(snapshot.files) > COMPOSE_SUMMARY_LARGE_FILE_THRESHOLD
677
+ or len(snapshot.hunks) > COMPOSE_SUMMARY_LARGE_HUNK_THRESHOLD
678
+ )
679
+
680
+
681
+ def _planning_mode_for_snapshot(snapshot: ComposeSnapshot) -> PlanningMode:
682
+ if _is_large_compose_snapshot(snapshot):
683
+ return PlanningMode.AREA
684
+ return PlanningMode.FILE
685
+
686
+
687
+ def _should_collect_compose_observations(snapshot: ComposeSnapshot, config: Any, counter: Any) -> bool:
688
+ return _planning_mode_for_snapshot(snapshot) is not PlanningMode.AREA and _should_use_map_reduce(
689
+ snapshot.diff, config, counter
690
+ )
691
+
692
+
693
+ def _snapshot_summary_budget(snapshot: ComposeSnapshot) -> SnapshotSummaryBudget:
694
+ if _is_large_compose_snapshot(snapshot):
695
+ return SnapshotSummaryBudget(1, 2)
696
+ if (
697
+ len(snapshot.files) > COMPOSE_SUMMARY_MEDIUM_FILE_THRESHOLD
698
+ or len(snapshot.hunks) > COMPOSE_SUMMARY_MEDIUM_HUNK_THRESHOLD
699
+ ):
700
+ return SnapshotSummaryBudget(2, 3)
701
+ return SnapshotSummaryBudget(MAX_OBSERVATIONS_PER_FILE, None)
702
+
703
+
704
+ def _sample_positions(count: int, max_samples: int) -> list[int]:
705
+ if count <= max_samples:
706
+ return list(range(count))
707
+ if max_samples <= 1:
708
+ return [0]
709
+ last = count - 1
710
+ out: list[int] = []
711
+ for slot in range(max_samples):
712
+ position = slot * last // (max_samples - 1)
713
+ if not out or out[-1] != position:
714
+ out.append(position)
715
+ return out
716
+
717
+
718
+ def _sampled_hunk_ids_for_summary(file: Any, budget: SnapshotSummaryBudget) -> list[str]:
719
+ if budget.max_hunks_per_file is None:
720
+ return list(file.hunk_ids)
721
+ return [file.hunk_ids[idx] for idx in _sample_positions(len(file.hunk_ids), budget.max_hunks_per_file)]
722
+
723
+
724
+ def _format_line_range(start: int, count: int) -> str:
725
+ if count == 0:
726
+ return "0"
727
+ if count == 1:
728
+ return str(start)
729
+ return f"{start}-{start + count - 1}"
730
+
731
+
732
+ def _render_snapshot_summary(snapshot: ComposeSnapshot, observations: Sequence[Any]) -> str:
733
+ budget = _snapshot_summary_budget(snapshot)
734
+ observations_by_file = {
735
+ str(getattr(item, "file", "")): list(getattr(item, "observations", ()))[: budget.max_observations_per_file]
736
+ for item in observations
737
+ }
738
+ out: list[str] = []
739
+ if budget.is_compacted:
740
+ out.append(
741
+ f"# snapshot compacted: all file IDs are preserved; showing up to {budget.max_hunks_per_file or 0} representative hunks and {budget.max_observations_per_file} observation(s) per file"
742
+ )
743
+ for file in snapshot.files:
744
+ out.append(f"- {file.file_id} {file.summary}")
745
+ for observation in observations_by_file.get(file.path, ()):
746
+ out.append(f" observation: {observation}")
747
+ rendered = _sampled_hunk_ids_for_summary(file, budget)
748
+ for hunk_id in rendered:
749
+ hunk = snapshot.hunk_by_id(hunk_id)
750
+ if hunk is None:
751
+ continue
752
+ if hunk.synthetic:
753
+ out.append(f" - {hunk.hunk_id} :: {hunk.snippet}")
754
+ else:
755
+ out.append(
756
+ f" - {hunk.hunk_id} old:{_format_line_range(hunk.old_start, hunk.old_count)} new:{_format_line_range(hunk.new_start, hunk.new_count)} :: {hunk.snippet}"
757
+ )
758
+ omitted = len(file.hunk_ids) - len(rendered)
759
+ if omitted > 0:
760
+ out.append(f" ... {omitted} more hunks omitted from {file.file_id}")
761
+ return "\n".join(out)
762
+
763
+
764
+ def _build_planning_index(snapshot: ComposeSnapshot) -> PlanningIndex:
765
+ mode = _planning_mode_for_snapshot(snapshot)
766
+ targets = tuple(
767
+ _build_file_planning_targets(snapshot) if mode is PlanningMode.FILE else _build_area_planning_targets(snapshot)
768
+ )
769
+ aliases: dict[str, str] = {}
770
+ for target in targets:
771
+ aliases[target.target_id] = target.target_id
772
+ aliases[target.target_id.upper()] = target.target_id
773
+ aliases[_normalize_file_reference(target.label)] = target.target_id
774
+ return PlanningIndex(mode=mode, targets=targets, aliases=aliases)
775
+
776
+
777
+ def _build_file_planning_targets(snapshot: ComposeSnapshot) -> list[PlanningTarget]:
778
+ return [
779
+ PlanningTarget(file.file_id, file.path, (file.file_id,), len(file.hunk_ids), file.additions, file.deletions)
780
+ for file in snapshot.files
781
+ ]
782
+
783
+
784
+ def _build_area_planning_targets(snapshot: ComposeSnapshot) -> list[PlanningTarget]:
785
+ all_file_ids = [file.file_id for file in snapshot.files]
786
+ buckets = _collect_planning_buckets(snapshot, all_file_ids, 0)
787
+ targets: list[PlanningTarget] = []
788
+ for idx, (label, file_ids) in enumerate(buckets, start=1):
789
+ files = [file for file_id in file_ids for file in [snapshot.file_by_id(file_id)] if file is not None]
790
+ targets.append(
791
+ PlanningTarget(
792
+ f"A{idx:03d}",
793
+ label,
794
+ tuple(file_ids),
795
+ sum(len(file.hunk_ids) for file in files),
796
+ sum(file.additions for file in files),
797
+ sum(file.deletions for file in files),
798
+ )
799
+ )
800
+ return targets
801
+
802
+
803
+ def _collect_planning_buckets(
804
+ snapshot: ComposeSnapshot, file_ids: Sequence[str], depth: int
805
+ ) -> list[tuple[str, tuple[str, ...]]]:
806
+ files = [file for file_id in file_ids for file in [snapshot.file_by_id(file_id)] if file is not None]
807
+ hunk_count = sum(len(file.hunk_ids) for file in files)
808
+ max_depth = max((_path_depth(file.path) for file in files), default=depth)
809
+ if (
810
+ (len(files) <= COMPOSE_AREA_TARGET_MAX_FILES and hunk_count <= COMPOSE_AREA_TARGET_MAX_HUNKS)
811
+ or depth >= COMPOSE_AREA_TARGET_MAX_DEPTH
812
+ or depth >= max_depth
813
+ ):
814
+ return [(_planning_bucket_label(snapshot, file_ids), tuple(file_ids))]
815
+ groups: dict[str, list[str]] = defaultdict(list)
816
+ for file in files:
817
+ groups[_prefix_at_depth(file.path, depth + 1)].append(file.file_id)
818
+ if len(groups) <= 1:
819
+ return _collect_planning_buckets(snapshot, file_ids, depth + 1)
820
+ out: list[tuple[str, tuple[str, ...]]] = []
821
+ for group_file_ids in groups.values():
822
+ out.extend(_collect_planning_buckets(snapshot, group_file_ids, depth + 1))
823
+ return out
824
+
825
+
826
+ def _path_depth(path: str) -> int:
827
+ return len(path.split("/"))
828
+
829
+
830
+ def _prefix_at_depth(path: str, depth: int) -> str:
831
+ return "/".join(path.split("/")[: max(0, min(depth, _path_depth(path)))])
832
+
833
+
834
+ def _planning_bucket_label(snapshot: ComposeSnapshot, file_ids: Sequence[str]) -> str:
835
+ paths = [file.path for file_id in file_ids for file in [snapshot.file_by_id(file_id)] if file is not None]
836
+ prefix = _common_path_prefix(paths)
837
+ return prefix or (paths[0] if paths else "misc")
838
+
839
+
840
+ def _common_path_prefix(paths: Sequence[str]) -> str:
841
+ if not paths:
842
+ return ""
843
+ prefix = paths[0].split("/")
844
+ for path in paths[1:]:
845
+ segments = path.split("/")
846
+ shared = 0
847
+ for left, right in zip(prefix, segments, strict=False):
848
+ if left != right:
849
+ break
850
+ shared += 1
851
+ prefix = prefix[:shared]
852
+ if not prefix:
853
+ break
854
+ return "/".join(prefix)
855
+
856
+
857
+ def _render_planning_stat(index: PlanningIndex) -> str:
858
+ lines = [
859
+ "# planning over individual file IDs"
860
+ if index.mode is PlanningMode.FILE
861
+ else f"# planning over {len(index.targets)} area IDs spanning {sum(len(target.file_ids) for target in index.targets)} files"
862
+ ]
863
+ for target in index.targets:
864
+ lines.append(
865
+ f"{target.target_id} {target.label} | {len(target.file_ids)} files | {target.hunk_count} hunks | +{target.additions}/-{target.deletions}"
866
+ )
867
+ return "\n".join(lines)
868
+
869
+
870
+ def _render_planning_snapshot_summary(
871
+ snapshot: ComposeSnapshot, observations: Sequence[Any], index: PlanningIndex
872
+ ) -> str:
873
+ if index.mode is PlanningMode.FILE:
874
+ return _render_snapshot_summary(snapshot, observations)
875
+ observations_by_file = {
876
+ str(getattr(item, "file", "")): list(getattr(item, "observations", ()))[:1] for item in observations
877
+ }
878
+ out = ["# snapshot compacted into path-based planning areas; use the area IDs below in `file_ids`"]
879
+ for target in index.targets:
880
+ out.append(
881
+ f"- {target.target_id} {target.label} ({len(target.file_ids)} files, {target.hunk_count} hunks, +{target.additions}/-{target.deletions})"
882
+ )
883
+ sample_files = [
884
+ snapshot.file_by_id(file_id).path
885
+ for file_id in _sample_file_ids_for_target(target)
886
+ if snapshot.file_by_id(file_id) is not None
887
+ ]
888
+ if sample_files:
889
+ out.append(f" files: {', '.join(sample_files)}")
890
+ omitted = len(target.file_ids) - len(sample_files)
891
+ if omitted > 0:
892
+ out.append(f" ... {omitted} more files omitted from {target.target_id}")
893
+ rendered_obs = 0
894
+ for file_id in target.file_ids:
895
+ file = snapshot.file_by_id(file_id)
896
+ if file is None:
897
+ continue
898
+ for observation in observations_by_file.get(file.path, ()):
899
+ out.append(f" observation: {observation}")
900
+ rendered_obs += 1
901
+ if rendered_obs >= 2:
902
+ break
903
+ if rendered_obs >= 2:
904
+ break
905
+ for hunk_id in _sample_hunk_ids_for_target(target, snapshot):
906
+ hunk = snapshot.hunk_by_id(hunk_id)
907
+ if hunk is None:
908
+ continue
909
+ if hunk.synthetic:
910
+ out.append(f" - {hunk.hunk_id} :: {hunk.snippet}")
911
+ else:
912
+ out.append(
913
+ f" - {hunk.hunk_id} old:{_format_line_range(hunk.old_start, hunk.old_count)} new:{_format_line_range(hunk.new_start, hunk.new_count)} :: {hunk.snippet}"
914
+ )
915
+ return "\n".join(out)
916
+
917
+
918
+ def _sample_file_ids_for_target(target: PlanningTarget) -> list[str]:
919
+ return [target.file_ids[idx] for idx in _sample_positions(len(target.file_ids), 4)]
920
+
921
+
922
+ def _sample_hunk_ids_for_target(target: PlanningTarget, snapshot: ComposeSnapshot) -> list[str]:
923
+ hunk_ids = [
924
+ hunk_id
925
+ for file_id in target.file_ids
926
+ for file in [snapshot.file_by_id(file_id)]
927
+ if file is not None
928
+ for hunk_id in file.hunk_ids
929
+ ]
930
+ return [hunk_ids[idx] for idx in _sample_positions(len(hunk_ids), 4)]
931
+
932
+
933
+ def _render_planning_targets(index: PlanningIndex, snapshot: ComposeSnapshot) -> str:
934
+ if index.mode is PlanningMode.FILE:
935
+ return f"File IDs only. Each target maps to exactly one file. Coverage: {len(snapshot.files)} files."
936
+ return f"Area IDs only. Each target may expand to multiple files by shared path prefix. Coverage: {len(index.targets)} areas spanning {len(snapshot.files)} files."
937
+
938
+
939
+ def _render_planning_notes(index: PlanningIndex) -> str:
940
+ if index.mode is PlanningMode.FILE:
941
+ return "Use only the provided file IDs and keep the grouping conservative."
942
+ return "This snapshot is large, so files were compacted into path-based planning areas. Split along independent subsystems or workstreams when the areas point at unrelated changes."
943
+
944
+
945
+ def _render_split_bias(index: PlanningIndex) -> str:
946
+ if index.mode is PlanningMode.FILE:
947
+ return "Prefer fewer groups when the split is uncertain."
948
+ return "Prefer splitting unrelated areas into separate groups. Only return one broad group if nearly every area clearly belongs to the same atomic change."
949
+
950
+
951
+ def _build_intent_schema(config: Any) -> dict[str, Any]:
952
+ type_enum = list(getattr(config, "types", {}) or {"chore": None})
953
+ return {
954
+ "type": "object",
955
+ "properties": {
956
+ "groups": {
957
+ "type": "array",
958
+ "items": {
959
+ "type": "object",
960
+ "properties": {
961
+ "group_id": {"type": "string", "description": "Stable identifier like G1, G2, G3"},
962
+ "file_ids": {
963
+ "type": "array",
964
+ "description": "Planning target IDs that belong to this logical commit. Use the exact IDs supplied in the prompt, even when they represent path-based areas instead of individual files. Never place group IDs or placeholder strings here. Repeat IDs across groups when a target is shared.",
965
+ "items": {"type": "string"},
966
+ },
967
+ "type": {
968
+ "type": "string",
969
+ "enum": type_enum,
970
+ "description": "Conventional commit type for this group",
971
+ },
972
+ "scope": {"type": "string", "description": "Optional scope (module/component). Omit if broad."},
973
+ "rationale": {"type": "string", "description": "Brief explanation of the logical change"},
974
+ "dependencies": {
975
+ "type": "array",
976
+ "description": "Group IDs this group depends on",
977
+ "items": {"type": "string"},
978
+ },
979
+ },
980
+ "required": ["group_id", "file_ids", "type", "rationale", "dependencies"],
981
+ "additionalProperties": False,
982
+ },
983
+ }
984
+ },
985
+ "required": ["groups"],
986
+ "additionalProperties": False,
987
+ }
988
+
989
+
990
+ def _build_binding_schema() -> dict[str, Any]:
991
+ return {
992
+ "type": "object",
993
+ "properties": {
994
+ "assignments": {
995
+ "type": "array",
996
+ "items": {
997
+ "type": "object",
998
+ "properties": {
999
+ "group_id": {"type": "string"},
1000
+ "hunk_ids": {"type": "array", "items": {"type": "string"}},
1001
+ },
1002
+ "required": ["group_id", "hunk_ids"],
1003
+ "additionalProperties": False,
1004
+ },
1005
+ }
1006
+ },
1007
+ "required": ["assignments"],
1008
+ "additionalProperties": False,
1009
+ }
1010
+
1011
+
1012
+ def _normalize_file_reference(raw_file_ref: str) -> str:
1013
+ value = raw_file_ref.strip().strip("`'\"").strip()
1014
+ for token in ("file", "path", "target"):
1015
+ if value.lower().startswith(token + ":"):
1016
+ value = value[len(token) + 1 :].strip()
1017
+ return value
1018
+
1019
+
1020
+ def _planning_text_tokens(text: str) -> list[str]:
1021
+ stop_words = {
1022
+ "and",
1023
+ "or",
1024
+ "the",
1025
+ "with",
1026
+ "from",
1027
+ "into",
1028
+ "after",
1029
+ "before",
1030
+ "over",
1031
+ "under",
1032
+ "plus",
1033
+ "across",
1034
+ "update",
1035
+ "updated",
1036
+ "refactor",
1037
+ "refactored",
1038
+ "changes",
1039
+ "change",
1040
+ "logical",
1041
+ "group",
1042
+ "groups",
1043
+ "commit",
1044
+ "commits",
1045
+ }
1046
+ tokens: list[str] = []
1047
+ current = []
1048
+ seen: set[str] = set()
1049
+ for char in text:
1050
+ if char.isascii() and char.isalnum():
1051
+ current.append(char.lower())
1052
+ else:
1053
+ if len(current) >= 3:
1054
+ token = "".join(current)
1055
+ if token not in stop_words and token not in seen:
1056
+ tokens.append(token)
1057
+ seen.add(token)
1058
+ current = []
1059
+ if len(current) >= 3:
1060
+ token = "".join(current)
1061
+ if token not in stop_words and token not in seen:
1062
+ tokens.append(token)
1063
+ return tokens
1064
+
1065
+
1066
+ def _extract_group_id_candidate(raw: str) -> str | None:
1067
+ normalized = _normalize_file_reference(raw)
1068
+ uppercase = normalized.upper().strip()
1069
+ if uppercase.startswith("G") and uppercase[1:].isdigit():
1070
+ return f"G{uppercase[1:]}"
1071
+ digits = "".join(ch for ch in uppercase if ch.isdigit())
1072
+ compact = "".join(ch for ch in uppercase if ch.isalnum())
1073
+ if compact.startswith("GROUP") and digits:
1074
+ return f"G{digits}"
1075
+ if compact.startswith("G") and digits:
1076
+ return f"G{digits}"
1077
+ return None
1078
+
1079
+
1080
+ def _intent_group_from_mapping(item: Any, idx: int) -> ComposeIntentGroup:
1081
+ data = _object_mapping(item)
1082
+ return ComposeIntentGroup(
1083
+ group_id=str(data.get("group_id") or data.get("id") or f"G{idx:03d}"),
1084
+ commit_type=CommitType.from_raw(data.get("type") or data.get("commit_type") or "chore"),
1085
+ scope=coerce_optional_scope(data.get("scope")),
1086
+ file_ids=tuple(str(value) for value in data.get("file_ids", ())),
1087
+ rationale=str(data.get("rationale") or "compose changes"),
1088
+ dependencies=tuple(str(value) for value in data.get("dependencies", ())),
1089
+ )
1090
+
1091
+
1092
+ def _normalize_dependency_reference(raw_dependency: str, known_group_ids: set[str]) -> str | None:
1093
+ normalized = _normalize_file_reference(raw_dependency)
1094
+ if not normalized:
1095
+ return None
1096
+ if normalized in known_group_ids:
1097
+ return normalized
1098
+ upper = normalized.upper()
1099
+ if upper in known_group_ids:
1100
+ return upper
1101
+ candidate = _extract_group_id_candidate(normalized)
1102
+ if candidate in known_group_ids:
1103
+ return candidate
1104
+ return None
1105
+
1106
+
1107
+ def _normalize_intent_plan(
1108
+ snapshot: ComposeSnapshot,
1109
+ planning_index: PlanningIndex,
1110
+ groups: Sequence[ComposeIntentGroup],
1111
+ config: Any,
1112
+ max_commits: int,
1113
+ ) -> tuple[ComposeIntentGroup, ...]:
1114
+ del config
1115
+ if not groups:
1116
+ raise ValidationFailure("Compose intent plan returned no groups", field="compose")
1117
+ known_target_ids = {target.target_id for target in planning_index.targets}
1118
+ covered_file_ids: set[str] = set()
1119
+ normalized_group_targets: list[list[str]] = []
1120
+ normalized_groups: list[ComposeIntentGroup] = []
1121
+ seen_group_ids: set[str] = set()
1122
+ for idx, group in enumerate(groups, start=1):
1123
+ group_id = group.group_id or f"G{idx:03d}"
1124
+ if group_id in seen_group_ids:
1125
+ group_id = f"{group_id}-{idx}"
1126
+ seen_group_ids.add(group_id)
1127
+ target_ids: list[str] = []
1128
+ seen_targets: set[str] = set()
1129
+ for raw_ref in group.file_ids:
1130
+ normalized_ref = _normalize_file_reference(raw_ref)
1131
+ target_id = (
1132
+ normalized_ref if normalized_ref in known_target_ids else planning_index.aliases.get(normalized_ref)
1133
+ )
1134
+ if target_id and target_id not in seen_targets:
1135
+ target_ids.append(target_id)
1136
+ seen_targets.add(target_id)
1137
+ if not target_ids:
1138
+ claimed_targets = {target_id for ids in normalized_group_targets for target_id in ids}
1139
+ target_ids = _seed_group_target(group, planning_index, claimed_targets)
1140
+ expanded = planning_index.expand_target_ids(target_ids)
1141
+ covered_file_ids.update(expanded)
1142
+ normalized_group_targets.append(target_ids)
1143
+ normalized_groups.append(
1144
+ ComposeIntentGroup(
1145
+ group_id, group.commit_type, group.scope, tuple(expanded), group.rationale, group.dependencies
1146
+ )
1147
+ )
1148
+ for file in snapshot.files:
1149
+ if file.file_id in covered_file_ids:
1150
+ continue
1151
+ best_idx = _best_group_for_missing_file(snapshot, normalized_groups, file)
1152
+ group = normalized_groups[best_idx]
1153
+ normalized_groups[best_idx] = ComposeIntentGroup(
1154
+ group.group_id,
1155
+ group.commit_type,
1156
+ group.scope,
1157
+ (*group.file_ids, file.file_id),
1158
+ group.rationale,
1159
+ group.dependencies,
1160
+ )
1161
+ covered_file_ids.add(file.file_id)
1162
+ max_group_count = max(1, max_commits)
1163
+ if len(normalized_groups) > max_group_count:
1164
+ kept = normalized_groups[:max_group_count]
1165
+ overflow = normalized_groups[max_group_count:]
1166
+ last = kept[-1]
1167
+ last_files = set(last.file_ids)
1168
+ overflow_file_ids = tuple(
1169
+ file_id for group in overflow for file_id in group.file_ids if file_id not in last_files
1170
+ )
1171
+ overflow_dependencies = tuple(
1172
+ dependency
1173
+ for group in overflow
1174
+ for dependency in group.dependencies
1175
+ if dependency not in last.dependencies and dependency != last.group_id
1176
+ )
1177
+ kept[-1] = ComposeIntentGroup(
1178
+ last.group_id,
1179
+ last.commit_type,
1180
+ last.scope,
1181
+ (*last.file_ids, *overflow_file_ids),
1182
+ last.rationale,
1183
+ (*last.dependencies, *overflow_dependencies),
1184
+ )
1185
+ normalized_groups = kept
1186
+ covered_file_ids = {file_id for group in normalized_groups for file_id in group.file_ids}
1187
+ known_group_ids = {group.group_id for group in normalized_groups}
1188
+ finalized: list[ComposeIntentGroup] = []
1189
+ for group in normalized_groups:
1190
+ deps: list[str] = []
1191
+ for raw_dependency in group.dependencies:
1192
+ dependency = _normalize_dependency_reference(raw_dependency, known_group_ids)
1193
+ if dependency and dependency != group.group_id and dependency not in deps:
1194
+ deps.append(dependency)
1195
+ finalized.append(
1196
+ ComposeIntentGroup(
1197
+ group.group_id, group.commit_type, group.scope, group.file_ids, group.rationale, tuple(deps)
1198
+ )
1199
+ )
1200
+ compute_dependency_order(finalized)
1201
+ return tuple(finalized)
1202
+
1203
+
1204
+ def _seed_group_target(group: ComposeIntentGroup, planning_index: PlanningIndex, claimed: set[str]) -> list[str]:
1205
+ if not planning_index.targets:
1206
+ return []
1207
+ best: tuple[int, int, str] | None = None
1208
+ for target in planning_index.targets:
1209
+ score = _planning_target_match_score(target, group)
1210
+ if target.target_id not in claimed:
1211
+ score += 60
1212
+ candidate = (score, -target.hunk_count, target.target_id)
1213
+ if best is None or candidate > best:
1214
+ best = candidate
1215
+ if best is None:
1216
+ return []
1217
+ _, _, target_id = best
1218
+ return [target_id]
1219
+
1220
+
1221
+ def _planning_target_match_score(target: PlanningTarget, group: ComposeIntentGroup) -> int:
1222
+ label = target.label.lower()
1223
+ workstream = _workstream_key_for_label(target.label).lower()
1224
+ score = 0
1225
+ score += min(target.hunk_count, 40)
1226
+ score += min(len(target.file_ids), 20)
1227
+ if group.scope and (str(group.scope) in label or str(group.scope) in workstream):
1228
+ score += 140
1229
+ for token in _planning_text_tokens(group.rationale):
1230
+ if token in label or token in workstream:
1231
+ score += 45
1232
+ type_name = str(group.commit_type)
1233
+ if type_name == "test" and ("test" in label or "spec" in label):
1234
+ score += 130
1235
+ if type_name == "docs" and ("docs" in label or label.endswith(".md")):
1236
+ score += 120
1237
+ if type_name in {"build", "chore"} and any(
1238
+ word in label for word in ("cargo", "package", "lock", "build", "config")
1239
+ ):
1240
+ score += 80
1241
+ return score
1242
+
1243
+
1244
+ def _best_group_for_missing_file(
1245
+ snapshot: ComposeSnapshot, groups: Sequence[ComposeIntentGroup], missing_file: Any
1246
+ ) -> int:
1247
+ best_idx = 0
1248
+ best_score = -(10**9)
1249
+ best_size = 10**9
1250
+ for idx, group in enumerate(groups):
1251
+ candidates = [snapshot.file_by_id(file_id) for file_id in group.file_ids]
1252
+ similarity = max(
1253
+ (_file_similarity_score(missing_file, file) for file in candidates if file is not None), default=0
1254
+ )
1255
+ score = similarity + _group_type_bonus(missing_file, group)
1256
+ size = len(group.file_ids)
1257
+ if score > best_score or score == best_score and size < best_size:
1258
+ best_idx = idx
1259
+ best_score = score
1260
+ best_size = size
1261
+ return best_idx
1262
+
1263
+
1264
+ def _file_similarity_score(missing_file: Any, candidate_file: Any) -> int:
1265
+ score = _common_path_prefix_depth(missing_file.path, candidate_file.path) * 25
1266
+ if Path(missing_file.path).parent == Path(candidate_file.path).parent:
1267
+ score += 40
1268
+ if Path(missing_file.path).suffix == Path(candidate_file.path).suffix:
1269
+ score += 18
1270
+ return score
1271
+
1272
+
1273
+ def _common_path_prefix_depth(left: str, right: str) -> int:
1274
+ depth = 0
1275
+ for left_part, right_part in zip(left.split("/"), right.split("/"), strict=False):
1276
+ if left_part != right_part:
1277
+ break
1278
+ depth += 1
1279
+ return depth
1280
+
1281
+
1282
+ def _group_type_bonus(file: Any, group: ComposeIntentGroup) -> int:
1283
+ category = _compose_file_category(file)
1284
+ type_name = str(group.commit_type)
1285
+ if category == "docs" and type_name == "docs":
1286
+ return 25
1287
+ if category == "test" and type_name == "test":
1288
+ return 25
1289
+ if category == "dependency" and type_name in {"build", "chore", "ci"}:
1290
+ return 18
1291
+ if category == "config" and type_name in {"build", "chore", "ci"}:
1292
+ return 12
1293
+ if category in {"prompt", "source"} and type_name in {"feat", "fix", "refactor", "perf"}:
1294
+ return 10
1295
+ return 0
1296
+
1297
+
1298
+ def _compose_file_category(file: Any) -> str:
1299
+ path = file.path.lower()
1300
+ name = Path(path).name
1301
+ ext = Path(path).suffix.lower().lstrip(".")
1302
+ if getattr(file, "is_binary", False):
1303
+ return "binary"
1304
+ if _is_dependency_manifest(file.path):
1305
+ return "dependency"
1306
+ if "prompt" in path or "system" in path:
1307
+ return "prompt"
1308
+ if ext == "md" or name in {"readme", "readme.md"}:
1309
+ return "docs"
1310
+ if "test" in path or name.endswith(("_test", ".test", ".spec")):
1311
+ return "test"
1312
+ if ext in {"toml", "yaml", "yml", "json", "ini", "cfg", "conf", "env"}:
1313
+ return "config"
1314
+ if ext in {
1315
+ "rs",
1316
+ "py",
1317
+ "js",
1318
+ "jsx",
1319
+ "ts",
1320
+ "tsx",
1321
+ "go",
1322
+ "java",
1323
+ "kt",
1324
+ "c",
1325
+ "cc",
1326
+ "cpp",
1327
+ "h",
1328
+ "hpp",
1329
+ "rb",
1330
+ "php",
1331
+ "swift",
1332
+ "scala",
1333
+ "sh",
1334
+ "bash",
1335
+ "zsh",
1336
+ "fish",
1337
+ "sql",
1338
+ }:
1339
+ return "source"
1340
+ return "other"
1341
+
1342
+
1343
+ def _should_force_large_patch_fallback(
1344
+ snapshot: ComposeSnapshot, planning_index: PlanningIndex, groups: Sequence[ComposeIntentGroup], max_commits: int
1345
+ ) -> bool:
1346
+ if max_commits <= 1 or planning_index.mode is not PlanningMode.AREA or not groups:
1347
+ return False
1348
+ if len(planning_index.targets) < COMPOSE_MONOLITH_FALLBACK_TARGET_THRESHOLD or not _is_monolithic_intent_plan(
1349
+ snapshot, groups
1350
+ ):
1351
+ return False
1352
+ workstream_count = len({_workstream_key_for_label(target.label) for target in planning_index.targets})
1353
+ return workstream_count >= COMPOSE_MONOLITH_FALLBACK_WORKSTREAM_THRESHOLD
1354
+
1355
+
1356
+ def _is_monolithic_intent_plan(snapshot: ComposeSnapshot, groups: Sequence[ComposeIntentGroup]) -> bool:
1357
+ largest = max((len(set(group.file_ids)) for group in groups), default=0)
1358
+ return len(groups) <= 2 and largest * 10 >= len(snapshot.files) * 9
1359
+
1360
+
1361
+ def _workstream_key_for_label(label: str) -> str:
1362
+ segments = [segment for segment in label.split("/") if segment]
1363
+ if not segments:
1364
+ return label
1365
+ first = segments[0]
1366
+ if first == ".github":
1367
+ return ".github"
1368
+ if first in {"apps", "packages", "crates", "services", "libs", "pass"} and len(segments) > 1:
1369
+ return f"{first}/{segments[1]}"
1370
+ return first
1371
+
1372
+
1373
+ def _fallback_scope_for_label(label: str) -> Scope | None:
1374
+ key = _workstream_key_for_label(label)
1375
+ candidate = key.split("/")[-1].replace("_", "-").replace(".", "-")
1376
+ return coerce_optional_scope(candidate)
1377
+
1378
+
1379
+ def _fallback_rationale_for_labels(labels: Sequence[str]) -> str:
1380
+ if not labels:
1381
+ return "compose changes"
1382
+ if len(labels) == 1:
1383
+ return f"Updated {labels[0]}"
1384
+ displays = labels[:3]
1385
+ suffix = "" if len(labels) <= 3 else f", and {len(labels) - 3} more"
1386
+ return f"Updated {', '.join(displays)}{suffix}"
1387
+
1388
+
1389
+ def _fallback_commit_type_for_group(
1390
+ snapshot: ComposeSnapshot, labels: Sequence[str], file_ids: Sequence[str]
1391
+ ) -> CommitType:
1392
+ if any(label == ".github" or label.startswith(".github/") for label in labels):
1393
+ return CommitType.from_raw("ci")
1394
+ files = [file for file_id in file_ids for file in [snapshot.file_by_id(file_id)] if file is not None]
1395
+ if files and all(_compose_file_category(file) == "docs" for file in files):
1396
+ return CommitType.from_raw("docs")
1397
+ if files and all(_compose_file_category(file) == "test" for file in files):
1398
+ return CommitType.from_raw("test")
1399
+ if files and all(_is_dependency_manifest(file.path) for file in files):
1400
+ return CommitType.from_raw("build")
1401
+ if files and all(_compose_file_category(file) in {"config", "dependency"} for file in files):
1402
+ return CommitType.from_raw("chore")
1403
+ return CommitType.from_raw("refactor")
1404
+
1405
+
1406
+ def _ordered_file_ids(snapshot: ComposeSnapshot, file_ids: set[str]) -> list[str]:
1407
+ return [file.file_id for file in snapshot.files if file.file_id in file_ids]
1408
+
1409
+
1410
+ def _auto_assign_hunks(
1411
+ snapshot: ComposeSnapshot, intent_plan: Sequence[ComposeIntentGroup]
1412
+ ) -> tuple[dict[str, set[str]], list[dict[str, Any]]]:
1413
+ groups_by_file: dict[str, list[str]] = defaultdict(list)
1414
+ for group in intent_plan:
1415
+ for file_id in group.file_ids:
1416
+ groups_by_file[file_id].append(group.group_id)
1417
+ assigned: dict[str, set[str]] = defaultdict(set)
1418
+ ambiguous: list[dict[str, Any]] = []
1419
+ for file in snapshot.files:
1420
+ candidates = groups_by_file.get(file.file_id)
1421
+ if not candidates:
1422
+ raise ValidationFailure(f"No compose group claimed file {file.file_id} ({file.path})", field="compose")
1423
+ if len(candidates) == 1:
1424
+ assigned[candidates[0]].update(file.hunk_ids)
1425
+ else:
1426
+ ambiguous.append(
1427
+ {
1428
+ "file_id": file.file_id,
1429
+ "path": file.path,
1430
+ "candidate_group_ids": tuple(candidates),
1431
+ "hunk_ids": tuple(file.hunk_ids),
1432
+ }
1433
+ )
1434
+ return assigned, ambiguous
1435
+
1436
+
1437
+ def _render_binding_groups(groups: Sequence[ComposeIntentGroup]) -> str:
1438
+ lines: list[str] = []
1439
+ for group in groups:
1440
+ scope = f"({group.scope})" if group.scope else ""
1441
+ lines.append(f"- {group.group_id}: {group.commit_type}{scope} :: {group.rationale}")
1442
+ return "\n".join(lines)
1443
+
1444
+
1445
+ def _render_binding_ambiguous_files(snapshot: ComposeSnapshot, ambiguous_files: Sequence[Mapping[str, Any]]) -> str:
1446
+ lines: list[str] = []
1447
+ for item in ambiguous_files:
1448
+ lines.append(f"- {item['file_id']} {item['path']} candidates: {', '.join(item['candidate_group_ids'])}")
1449
+ for hunk_id in item["hunk_ids"]:
1450
+ hunk = snapshot.hunk_by_id(hunk_id)
1451
+ if hunk is None:
1452
+ continue
1453
+ if hunk.synthetic:
1454
+ lines.append(f" - {hunk.hunk_id} :: {hunk.snippet}")
1455
+ else:
1456
+ lines.append(
1457
+ f" - {hunk.hunk_id} old:{_format_line_range(hunk.old_start, hunk.old_count)} new:{_format_line_range(hunk.new_start, hunk.new_count)} :: {hunk.snippet}"
1458
+ )
1459
+ return "\n".join(lines)
1460
+
1461
+
1462
+ async def _request_binding(
1463
+ snapshot: ComposeSnapshot,
1464
+ groups: Sequence[ComposeIntentGroup],
1465
+ ambiguous_files: Sequence[Mapping[str, Any]],
1466
+ config: Any,
1467
+ debug_dir: str | os.PathLike[str] | None,
1468
+ debug_name: str,
1469
+ ) -> list[Mapping[str, Any]]:
1470
+ if not ambiguous_files:
1471
+ return []
1472
+ try:
1473
+ api = import_module("lgit.api")
1474
+ templates = import_module("lgit.templates")
1475
+ except ModuleNotFoundError:
1476
+ return []
1477
+ variant = "markdown" if bool(getattr(config, "markdown_output", True)) else "default"
1478
+ parts = templates.render_compose_bind_prompt(
1479
+ variant=variant,
1480
+ groups=_render_binding_groups(groups),
1481
+ ambiguous_files=_render_binding_ambiguous_files(snapshot, ambiguous_files),
1482
+ )
1483
+ try:
1484
+ response = await api.run_oneshot(
1485
+ config,
1486
+ api.OneShotSpec(
1487
+ operation="compose/bind",
1488
+ model=str(getattr(config, "analysis_model", getattr(config, "model", "")) or ""),
1489
+ prompt_family="compose-bind",
1490
+ prompt_variant=variant,
1491
+ system_prompt=parts.system,
1492
+ user_prompt=parts.user,
1493
+ tool_name="bind_compose_hunks",
1494
+ tool_description="Assign hunk IDs to existing compose groups",
1495
+ schema=_build_binding_schema(),
1496
+ progress_label="compose hunk binder",
1497
+ debug=api.OneShotDebug(debug_dir, None, debug_name) if debug_dir else None,
1498
+ cacheable=True,
1499
+ ),
1500
+ )
1501
+ except Exception as exc:
1502
+ warnings.warn(f"compose hunk binder failed; using deterministic fallback: {exc}", RuntimeWarning, stacklevel=2)
1503
+ return []
1504
+ output = response.output if hasattr(response, "output") else response
1505
+ data = _object_mapping(output)
1506
+ assignments = data.get("assignments", ())
1507
+ return [_object_mapping(item) for item in assignments]
1508
+
1509
+
1510
+ def _ambiguous_hunk_context(ambiguous_files: Sequence[Mapping[str, Any]]) -> dict[str, tuple[str, ...]]:
1511
+ return {hunk_id: tuple(item["candidate_group_ids"]) for item in ambiguous_files for hunk_id in item["hunk_ids"]}
1512
+
1513
+
1514
+ def _evaluate_binding(
1515
+ assignments: Sequence[Mapping[str, Any]],
1516
+ hunk_context: Mapping[str, tuple[str, ...]],
1517
+ valid_group_ids: set[str],
1518
+ snapshot: ComposeSnapshot,
1519
+ ) -> SimpleNamespace:
1520
+ assigned_hunk_to_group: dict[str, str] = {}
1521
+ for assignment in assignments:
1522
+ group_id = str(assignment.get("group_id", ""))
1523
+ if group_id not in valid_group_ids:
1524
+ continue
1525
+ seen: set[str] = set()
1526
+ for raw_hunk_id in assignment.get("hunk_ids", ()):
1527
+ hunk_id = str(raw_hunk_id)
1528
+ if hunk_id in seen:
1529
+ continue
1530
+ seen.add(hunk_id)
1531
+ candidates = hunk_context.get(hunk_id)
1532
+ if not candidates or group_id not in candidates:
1533
+ continue
1534
+ if assigned_hunk_to_group.get(hunk_id) == group_id:
1535
+ continue
1536
+ if hunk_id in assigned_hunk_to_group:
1537
+ assigned_hunk_to_group.pop(hunk_id, None)
1538
+ else:
1539
+ assigned_hunk_to_group[hunk_id] = group_id
1540
+ assigned_by_group: dict[str, list[str]] = defaultdict(list)
1541
+ for hunk in snapshot.hunks:
1542
+ group_id = assigned_hunk_to_group.get(hunk.hunk_id)
1543
+ if group_id:
1544
+ assigned_by_group[group_id].append(hunk.hunk_id)
1545
+ unresolved = [
1546
+ hunk.hunk_id
1547
+ for hunk in snapshot.hunks
1548
+ if hunk.hunk_id in hunk_context and hunk.hunk_id not in assigned_hunk_to_group
1549
+ ]
1550
+ return SimpleNamespace(assigned=dict(assigned_by_group), unresolved=unresolved)
1551
+
1552
+
1553
+ def _filter_ambiguous_files(
1554
+ ambiguous_files: Sequence[Mapping[str, Any]], hunk_ids: Sequence[str]
1555
+ ) -> list[dict[str, Any]]:
1556
+ wanted = set(hunk_ids)
1557
+ out: list[dict[str, Any]] = []
1558
+ for item in ambiguous_files:
1559
+ matching = tuple(hunk_id for hunk_id in item["hunk_ids"] if hunk_id in wanted)
1560
+ if matching:
1561
+ out.append(
1562
+ {
1563
+ "file_id": item["file_id"],
1564
+ "path": item["path"],
1565
+ "candidate_group_ids": tuple(item["candidate_group_ids"]),
1566
+ "hunk_ids": matching,
1567
+ }
1568
+ )
1569
+ return out
1570
+
1571
+
1572
+ def _chunk_ambiguous_files(ambiguous_files: Sequence[Mapping[str, Any]]) -> list[list[Mapping[str, Any]]]:
1573
+ batches: list[list[Mapping[str, Any]]] = []
1574
+ current: list[Mapping[str, Any]] = []
1575
+ hunk_count = 0
1576
+ for item in ambiguous_files:
1577
+ item_hunks = len(item["hunk_ids"])
1578
+ should_split = current and (
1579
+ len(current) >= MAX_BIND_FILES_PER_REQUEST or hunk_count + item_hunks > MAX_BIND_HUNKS_PER_REQUEST
1580
+ )
1581
+ if should_split:
1582
+ batches.append(current)
1583
+ current = []
1584
+ hunk_count = 0
1585
+ current.append(item)
1586
+ hunk_count += item_hunks
1587
+ if current:
1588
+ batches.append(current)
1589
+ return batches
1590
+
1591
+
1592
+ def _assign_unresolved_hunks(
1593
+ unresolved_hunks: Sequence[str],
1594
+ assigned_by_group: dict[str, set[str]],
1595
+ ambiguous_files: Sequence[Mapping[str, Any]],
1596
+ group_rank: Mapping[str, int],
1597
+ ) -> None:
1598
+ context = _ambiguous_hunk_context(ambiguous_files)
1599
+ for hunk_id in unresolved_hunks:
1600
+ candidates = [candidate for candidate in context.get(hunk_id, ()) if candidate in group_rank]
1601
+ if not candidates:
1602
+ continue
1603
+ group_id = min(candidates, key=lambda item: group_rank.get(item, 10**9))
1604
+ assigned_by_group[group_id].add(hunk_id)
1605
+
1606
+
1607
+ def _derive_file_ids_for_hunks(snapshot: ComposeSnapshot, hunk_ids: Sequence[str]) -> list[str]:
1608
+ hunk_set = set(hunk_ids)
1609
+ return [file.file_id for file in snapshot.files if any(hunk_id in hunk_set for hunk_id in file.hunk_ids)]
1610
+
1611
+
1612
+ def _normalize_group_type(snapshot: ComposeSnapshot, file_ids: Sequence[str], original_type: CommitType) -> CommitType:
1613
+ dependency_only = bool(file_ids) and all(
1614
+ (file := snapshot.file_by_id(file_id)) is not None and _is_dependency_manifest(file.path)
1615
+ for file_id in file_ids
1616
+ )
1617
+ if dependency_only and str(original_type) in {"build", "chore", "ci"}:
1618
+ return CommitType.from_raw("build")
1619
+ return original_type
1620
+
1621
+
1622
+ def _build_redirects(
1623
+ intent_plan: Sequence[ComposeIntentGroup],
1624
+ executable_groups: Sequence[ComposeExecutableGroup],
1625
+ group_rank: Mapping[str, int],
1626
+ ) -> dict[str, str]:
1627
+ surviving = {group.group_id: group for group in executable_groups if group.hunk_ids}
1628
+ redirects: dict[str, str] = {}
1629
+ for group in intent_plan:
1630
+ if group.group_id in surviving:
1631
+ continue
1632
+ candidates = [
1633
+ candidate
1634
+ for candidate in executable_groups
1635
+ if candidate.group_id != group.group_id and any(file_id in group.file_ids for file_id in candidate.file_ids)
1636
+ ]
1637
+ if candidates:
1638
+ redirect = min(candidates, key=lambda candidate: group_rank.get(candidate.group_id, 10**9)).group_id
1639
+ redirects[group.group_id] = redirect
1640
+ return redirects
1641
+
1642
+
1643
+ def _resolve_redirect(group_id: str, redirects: Mapping[str, str]) -> str:
1644
+ current = group_id
1645
+ seen: set[str] = set()
1646
+ while current in redirects and current not in seen:
1647
+ seen.add(current)
1648
+ current = redirects[current]
1649
+ return current
1650
+
1651
+
1652
+ def _prune_empty_groups(
1653
+ groups: Sequence[ComposeExecutableGroup], redirects: Mapping[str, str]
1654
+ ) -> ComposeExecutablePlan:
1655
+ surviving_ids = {group.group_id for group in groups if group.hunk_ids}
1656
+ surviving: list[ComposeExecutableGroup] = []
1657
+ for group in groups:
1658
+ if not group.hunk_ids:
1659
+ continue
1660
+ deps: list[str] = []
1661
+ for dependency in group.dependencies:
1662
+ rewritten = _resolve_redirect(dependency, redirects)
1663
+ if rewritten != group.group_id and rewritten in surviving_ids and rewritten not in deps:
1664
+ deps.append(rewritten)
1665
+ surviving.append(
1666
+ ComposeExecutableGroup(
1667
+ group.group_id,
1668
+ group.commit_type,
1669
+ group.scope,
1670
+ group.file_ids,
1671
+ group.rationale,
1672
+ tuple(deps),
1673
+ group.hunk_ids,
1674
+ )
1675
+ )
1676
+ return ComposeExecutablePlan(tuple(surviving), compute_dependency_order(surviving))
1677
+
1678
+
1679
+ def _finalize_executable_plan(
1680
+ snapshot: ComposeSnapshot, intent_plan: Sequence[ComposeIntentGroup], assigned_by_group: Mapping[str, set[str]]
1681
+ ) -> ComposeExecutablePlan:
1682
+ order = compute_dependency_order(intent_plan)
1683
+ group_rank = {intent_plan[idx].group_id: position for position, idx in enumerate(order)}
1684
+ executable: list[ComposeExecutableGroup] = []
1685
+ for group in intent_plan:
1686
+ hunk_ids = [
1687
+ hunk.hunk_id for hunk in snapshot.hunks if hunk.hunk_id in assigned_by_group.get(group.group_id, set())
1688
+ ]
1689
+ file_ids = _derive_file_ids_for_hunks(snapshot, hunk_ids)
1690
+ executable.append(
1691
+ ComposeExecutableGroup(
1692
+ group_id=group.group_id,
1693
+ commit_type=_normalize_group_type(snapshot, file_ids, group.commit_type),
1694
+ scope=group.scope,
1695
+ file_ids=tuple(file_ids),
1696
+ rationale=group.rationale,
1697
+ dependencies=group.dependencies,
1698
+ hunk_ids=tuple(hunk_ids),
1699
+ )
1700
+ )
1701
+ redirects = _build_redirects(intent_plan, executable, group_rank)
1702
+ return _prune_empty_groups(executable, redirects)
1703
+
1704
+
1705
+ def _validate_executable_plan(snapshot: ComposeSnapshot, plan: ComposeExecutablePlan) -> None:
1706
+ if not plan.groups:
1707
+ raise ValidationFailure("Compose executable plan returned no groups", field="compose")
1708
+ known_files = {file.file_id for file in snapshot.files}
1709
+ known_hunks = {hunk.hunk_id for hunk in snapshot.hunks}
1710
+ coverage: dict[str, str] = {}
1711
+ for group in plan.groups:
1712
+ if not group.hunk_ids:
1713
+ raise ValidationFailure(f"Compose group {group.group_id} ended up empty after binding", field="compose")
1714
+ for file_id in group.file_ids:
1715
+ if file_id not in known_files:
1716
+ raise ValidationFailure(
1717
+ f"Compose group {group.group_id} references unknown file_id {file_id}", field="compose"
1718
+ )
1719
+ for hunk_id in group.hunk_ids:
1720
+ if hunk_id not in known_hunks:
1721
+ raise ValidationFailure(
1722
+ f"Compose group {group.group_id} references unknown hunk_id {hunk_id}", field="compose"
1723
+ )
1724
+ existing = coverage.get(hunk_id)
1725
+ if existing is not None:
1726
+ raise ValidationFailure(
1727
+ f"Hunk {hunk_id} was assigned to both {existing} and {group.group_id}", field="compose"
1728
+ )
1729
+ coverage[hunk_id] = group.group_id
1730
+ missing = [hunk.hunk_id for hunk in snapshot.hunks if hunk.hunk_id not in coverage]
1731
+ if missing:
1732
+ raise ValidationFailure(f"Compose plan left hunks unassigned: {', '.join(missing)}", field="compose")
1733
+ dependency_order = compute_dependency_order(plan.groups)
1734
+ if dependency_order != plan.dependency_order:
1735
+ raise ValidationFailure("Compose dependency order does not match recomputed order", field="compose")
1736
+
1737
+
1738
+ def _plan_from_mapping(raw: Any, snapshot: ComposeSnapshot) -> ComposeExecutablePlan:
1739
+ data = _object_mapping(raw)
1740
+ groups = tuple(_group_from_mapping(item, snapshot, idx) for idx, item in enumerate(data.get("groups", ()), start=1))
1741
+ order = tuple(data.get("dependency_order") or compute_dependency_order(groups))
1742
+ plan = ComposeExecutablePlan(groups=groups, dependency_order=order)
1743
+ _validate_executable_plan(snapshot, plan)
1744
+ return plan
1745
+
1746
+
1747
+ def _group_from_mapping(item: Any, snapshot: ComposeSnapshot, idx: int) -> ComposeExecutableGroup:
1748
+ data = _object_mapping(item)
1749
+ file_ids = tuple(str(value) for value in data.get("file_ids", ()))
1750
+ hunk_ids = tuple(str(value) for value in data.get("hunk_ids", ()))
1751
+ if not hunk_ids and file_ids:
1752
+ wanted = set(file_ids)
1753
+ hunk_ids = tuple(hunk_id for file in snapshot.files if file.file_id in wanted for hunk_id in file.hunk_ids)
1754
+ if not file_ids and hunk_ids:
1755
+ file_ids = tuple(_derive_file_ids_for_hunks(snapshot, hunk_ids))
1756
+ return ComposeExecutableGroup(
1757
+ group_id=str(data.get("group_id") or data.get("id") or f"G{idx:03d}"),
1758
+ commit_type=CommitType.from_raw(data.get("type") or data.get("commit_type") or "chore"),
1759
+ scope=coerce_optional_scope(data.get("scope")),
1760
+ file_ids=file_ids,
1761
+ rationale=str(data.get("rationale") or "compose changes"),
1762
+ dependencies=tuple(str(value) for value in data.get("dependencies", ())),
1763
+ hunk_ids=hunk_ids,
1764
+ )
1765
+
1766
+
1767
+ def _object_mapping(value: Any) -> Mapping[str, Any]:
1768
+ if isinstance(value, Mapping):
1769
+ return value
1770
+ if is_dataclass(value):
1771
+ return asdict(value)
1772
+ return vars(value)
1773
+
1774
+
1775
+ async def _prepare_group_messages(
1776
+ snapshot: ComposeSnapshot,
1777
+ groups: Sequence[ComposeExecutableGroup],
1778
+ group_patches: Sequence[Any],
1779
+ config: Any,
1780
+ args: Any,
1781
+ ) -> list[str]:
1782
+ semaphore = asyncio.Semaphore(min(COMPOSE_MESSAGE_PARALLELISM, max(len(groups), 1)))
1783
+ counter = _create_token_counter(config)
1784
+
1785
+ async def prepare(idx: int, group: ComposeExecutableGroup, patch: Any) -> str:
1786
+ async with semaphore:
1787
+ return await _generate_group_message(
1788
+ snapshot, group, patch.stat, patch.diff, config, args, counter, f"compose-{idx + 1}"
1789
+ )
1790
+
1791
+ return list(
1792
+ await asyncio.gather(
1793
+ *(prepare(idx, group, patch) for idx, (group, patch) in enumerate(zip(groups, group_patches, strict=True)))
1794
+ )
1795
+ )
1796
+
1797
+
1798
+ async def _generate_group_message(
1799
+ snapshot: ComposeSnapshot,
1800
+ group: ComposeExecutableGroup,
1801
+ stat: str,
1802
+ diff: str,
1803
+ config: Any,
1804
+ args: Any,
1805
+ counter: Any,
1806
+ debug_prefix: str,
1807
+ ) -> str:
1808
+ body, summary = await _message_parts_from_api(group, stat, diff, config, args, counter, debug_prefix)
1809
+ if summary is None:
1810
+ summary = _fallback_summary(group, snapshot)
1811
+ commit = SimpleNamespace(
1812
+ commit_type=group.commit_type,
1813
+ scope=group.scope,
1814
+ summary=CommitSummary.from_raw(summary, max_length=int(getattr(config, "summary_hard_limit", 128))),
1815
+ body=list(body),
1816
+ footers=[],
1817
+ )
1818
+ commit = post_process_commit_message(commit, config)
1819
+ report = validate_commit_message(commit, config, stat=stat)
1820
+ if report.errors:
1821
+ first = report.errors[0]
1822
+ raise ValidationFailure(first.message, field=first.field, value=first.value)
1823
+ message = format_commit_message(commit)
1824
+ if bool(getattr(args, "signoff", False) or getattr(config, "signoff", False)):
1825
+ message = git.append_signoff_trailer(message, _arg_dir(args))
1826
+ return message
1827
+
1828
+
1829
+ async def _message_parts_from_api(
1830
+ group: ComposeExecutableGroup,
1831
+ stat: str,
1832
+ diff: str,
1833
+ config: Any,
1834
+ args: Any,
1835
+ counter: Any,
1836
+ debug_prefix: str,
1837
+ ) -> tuple[list[str], str | None]:
1838
+ try:
1839
+ api = import_module("lgit.api")
1840
+ except ModuleNotFoundError:
1841
+ return [group.rationale], None
1842
+ analysis_fn = getattr(api, "generate_conventional_analysis", None)
1843
+ summary_fn = getattr(api, "generate_summary_from_analysis", None)
1844
+ if analysis_fn is None or summary_fn is None:
1845
+ return [group.rationale], None
1846
+
1847
+ strategy = _compose_analysis_strategy(diff, config, counter)
1848
+ if strategy is ComposeAnalysisStrategy.MAP_REDUCE:
1849
+ analysis = import_module("lgit.map_reduce").run_map_reduce(
1850
+ diff,
1851
+ stat,
1852
+ group.rationale,
1853
+ str(getattr(config, "analysis_model", getattr(config, "model", "")) or ""),
1854
+ config,
1855
+ counter,
1856
+ )
1857
+ else:
1858
+ analysis_diff = diff
1859
+ if strategy is ComposeAnalysisStrategy.SMART_TRUNCATE:
1860
+ analysis_diff = import_module("lgit.diffing").smart_truncate_diff(
1861
+ diff, _compose_truncation_length(config), config, counter
1862
+ )
1863
+ try:
1864
+ analysis = analysis_fn(
1865
+ config=config,
1866
+ stat=stat,
1867
+ diff=analysis_diff,
1868
+ scope_candidates=group.rationale,
1869
+ user_context=group.rationale,
1870
+ debug_output=getattr(args, "debug_output", None),
1871
+ debug_prefix=debug_prefix,
1872
+ )
1873
+ except TypeError:
1874
+ analysis = analysis_fn(
1875
+ config,
1876
+ stat,
1877
+ analysis_diff,
1878
+ group.rationale,
1879
+ user_context=group.rationale,
1880
+ debug_output=getattr(args, "debug_output", None),
1881
+ )
1882
+ analysis = await analysis if inspect.isawaitable(analysis) else analysis
1883
+ body = _analysis_body(analysis) or [group.rationale]
1884
+ if not isinstance(analysis, ConventionalAnalysis):
1885
+ analysis = ConventionalAnalysis(
1886
+ commit_type=group.commit_type,
1887
+ scope=group.scope,
1888
+ summary=None,
1889
+ details=tuple(AnalysisDetail(text=item) for item in body),
1890
+ issue_refs=(),
1891
+ )
1892
+ try:
1893
+ summary = summary_fn(
1894
+ config=config,
1895
+ analysis=analysis,
1896
+ stat=stat,
1897
+ user_context=group.rationale,
1898
+ debug_output=getattr(args, "debug_output", None),
1899
+ debug_prefix=debug_prefix,
1900
+ )
1901
+ except TypeError:
1902
+ summary = summary_fn(
1903
+ config,
1904
+ analysis,
1905
+ stat=stat,
1906
+ user_context=group.rationale,
1907
+ debug_output=getattr(args, "debug_output", None),
1908
+ )
1909
+ return body, str(getattr(summary, "value", summary)) if summary is not None else None
1910
+
1911
+
1912
+ def _analysis_body(analysis: Any) -> list[str]:
1913
+ if analysis is None:
1914
+ return []
1915
+ body_texts = getattr(analysis, "body_texts", None)
1916
+ if callable(body_texts):
1917
+ return [str(value) for value in body_texts()]
1918
+ details = getattr(analysis, "details", None)
1919
+ if details:
1920
+ return [str(getattr(detail, "text", detail)) for detail in details]
1921
+ if isinstance(analysis, Mapping):
1922
+ details = analysis.get("details") or analysis.get("body") or ()
1923
+ return [str(getattr(detail, "text", detail)) for detail in details]
1924
+ return []
1925
+
1926
+
1927
+ def _fallback_summary(group: ComposeExecutableGroup, snapshot: ComposeSnapshot) -> str:
1928
+ files = [
1929
+ snapshot.file_by_id(file_id).path for file_id in group.file_ids if snapshot.file_by_id(file_id) is not None
1930
+ ]
1931
+ target = files[0] if len(files) == 1 else (str(group.scope) if group.scope else "compose changes")
1932
+ verb = "updated"
1933
+ if str(group.commit_type) == "docs":
1934
+ verb = "documented"
1935
+ elif str(group.commit_type) == "test":
1936
+ verb = "tested"
1937
+ return f"{verb} {target}"[:128]
1938
+
1939
+
1940
+ def _cumulative_file_hunk_ids(
1941
+ plan: ComposeExecutablePlan, position: int, snapshot: ComposeSnapshot, file_id: str
1942
+ ) -> list[str]:
1943
+ hunk_ids: list[str] = []
1944
+ for group_idx in plan.dependency_order[: position + 1]:
1945
+ group = plan.groups[group_idx]
1946
+ for hunk_id in group.hunk_ids:
1947
+ hunk = snapshot.hunk_by_id(hunk_id)
1948
+ if hunk is not None and hunk.file_id == file_id:
1949
+ hunk_ids.append(hunk_id)
1950
+ return hunk_ids
1951
+
1952
+
1953
+ def _arg_dir(args: Any) -> str | os.PathLike[str]:
1954
+ return getattr(args, "dir", ".")
1955
+
1956
+
1957
+ def _cache_file(dir: str | os.PathLike[str], key: str) -> Path:
1958
+ cache_dir = git.get_git_dir(dir) / "llm-git"
1959
+ cache_dir.mkdir(parents=True, exist_ok=True)
1960
+ return cache_dir / f"compose-plan-{key}.json"
1961
+
1962
+
1963
+ def _snapshot_cache_key(snapshot: ComposeSnapshot, max_commits: int, model: str) -> str:
1964
+ payload = json.dumps(
1965
+ {
1966
+ "schema": COMPOSE_PLAN_SCHEMA_VERSION,
1967
+ "model": model,
1968
+ "max_commits": max_commits,
1969
+ "files": [(file.file_id, file.path, file.hunk_ids) for file in snapshot.files],
1970
+ "hunks": [(hunk.hunk_id, hunk.semantic_key) for hunk in snapshot.hunks],
1971
+ "diff": snapshot.diff,
1972
+ },
1973
+ sort_keys=True,
1974
+ ).encode()
1975
+ try:
1976
+ from blake3 import blake3
1977
+
1978
+ return blake3(payload).hexdigest()
1979
+ except Exception:
1980
+ import hashlib
1981
+
1982
+ return hashlib.sha256(payload).hexdigest()
1983
+
1984
+
1985
+ def _load_cached_plan(
1986
+ dir: str | os.PathLike[str], snapshot: ComposeSnapshot, max_commits: int, model: str
1987
+ ) -> ComposeExecutablePlan | None:
1988
+ key = _snapshot_cache_key(snapshot, max_commits, model)
1989
+ path = _cache_file(dir, key)
1990
+ if not path.exists():
1991
+ return None
1992
+ try:
1993
+ data = json.loads(path.read_text(encoding="utf-8"))
1994
+ except OSError, json.JSONDecodeError:
1995
+ return None
1996
+ if data.get("schema_version") != COMPOSE_PLAN_SCHEMA_VERSION or data.get("cache_key") != key:
1997
+ return None
1998
+ try:
1999
+ return _plan_from_mapping(data.get("plan", {}), snapshot)
2000
+ except Exception:
2001
+ try:
2002
+ path.unlink()
2003
+ except OSError:
2004
+ pass
2005
+ return None
2006
+
2007
+
2008
+ def _save_cached_plan(
2009
+ dir: str | os.PathLike[str], snapshot: ComposeSnapshot, max_commits: int, model: str, plan: ComposeExecutablePlan
2010
+ ) -> None:
2011
+ key = _snapshot_cache_key(snapshot, max_commits, model)
2012
+ path = _cache_file(dir, key)
2013
+ tmp = path.with_suffix(path.suffix + ".tmp")
2014
+ tmp.write_text(
2015
+ json.dumps(
2016
+ {"schema_version": COMPOSE_PLAN_SCHEMA_VERSION, "cache_key": key, "plan": _plan_to_jsonable(plan)},
2017
+ indent=2,
2018
+ sort_keys=True,
2019
+ ),
2020
+ encoding="utf-8",
2021
+ )
2022
+ tmp.replace(path)
2023
+
2024
+
2025
+ def _save_debug_artifact(args: Any, filename: str, value: Any) -> None:
2026
+ if args is None:
2027
+ return
2028
+ debug_dir = getattr(args, "debug_output", None)
2029
+ if not debug_dir:
2030
+ return
2031
+ path = Path(debug_dir)
2032
+ path.mkdir(parents=True, exist_ok=True)
2033
+ (path / filename).write_text(json.dumps(value, indent=2, sort_keys=True), encoding="utf-8")
2034
+
2035
+
2036
+ def _plan_to_jsonable(plan: ComposeExecutablePlan) -> dict[str, Any]:
2037
+ return {
2038
+ "groups": [
2039
+ {
2040
+ "group_id": group.group_id,
2041
+ "type": str(group.commit_type),
2042
+ "scope": str(group.scope) if group.scope else None,
2043
+ "file_ids": list(group.file_ids),
2044
+ "rationale": group.rationale,
2045
+ "dependencies": list(group.dependencies),
2046
+ "hunk_ids": list(group.hunk_ids),
2047
+ }
2048
+ for group in plan.groups
2049
+ ],
2050
+ "dependency_order": list(plan.dependency_order),
2051
+ }
2052
+
2053
+
2054
+ def _intent_plan_to_jsonable(plan: Sequence[ComposeIntentGroup]) -> dict[str, Any]:
2055
+ return {
2056
+ "groups": [
2057
+ {
2058
+ "group_id": group.group_id,
2059
+ "type": str(group.commit_type),
2060
+ "scope": str(group.scope) if group.scope else None,
2061
+ "file_ids": list(group.file_ids),
2062
+ "rationale": group.rationale,
2063
+ "dependencies": list(group.dependencies),
2064
+ }
2065
+ for group in plan
2066
+ ],
2067
+ "dependency_order": list(compute_dependency_order(plan)),
2068
+ }
2069
+
2070
+
2071
+ def _snapshot_to_jsonable(snapshot: ComposeSnapshot) -> dict[str, Any]:
2072
+ return {
2073
+ "diff": snapshot.diff,
2074
+ "stat": snapshot.stat,
2075
+ "files": [asdict(file) for file in snapshot.files],
2076
+ "hunks": [asdict(hunk) for hunk in snapshot.hunks],
2077
+ "pins": {
2078
+ path: {"kind": str(pin.kind), "mode": pin.mode, "oid": pin.oid} for path, pin in snapshot.pins.items()
2079
+ },
2080
+ }
2081
+
2082
+
2083
+ def _observation_to_jsonable(observation: Any) -> dict[str, Any]:
2084
+ if is_dataclass(observation):
2085
+ return asdict(observation)
2086
+ if isinstance(observation, Mapping):
2087
+ return dict(observation)
2088
+ return {
2089
+ "file": getattr(observation, "file", ""),
2090
+ "observations": list(getattr(observation, "observations", ())),
2091
+ "additions": getattr(observation, "additions", 0),
2092
+ "deletions": getattr(observation, "deletions", 0),
2093
+ }
2094
+
2095
+
2096
+ __all__ = [
2097
+ "ComposeBaseState",
2098
+ "ComposeExecutableGroup",
2099
+ "ComposeExecutablePlan",
2100
+ "build_compose_snapshot",
2101
+ "capture_compose_base_state",
2102
+ "compute_dependency_order",
2103
+ "create_executable_group_patch",
2104
+ "execute_compose",
2105
+ "pin_snapshot_worktree_state",
2106
+ "plan_compose_snapshot",
2107
+ "run_compose_mode",
2108
+ "run_compose_round",
2109
+ "stage_executable_group_in_index",
2110
+ ]