lgit-cli 3.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lgit/__init__.py +75 -0
- lgit/__main__.py +8 -0
- lgit/analysis.py +326 -0
- lgit/api.py +1077 -0
- lgit/cache.py +338 -0
- lgit/changelog.py +523 -0
- lgit/cli.py +1104 -0
- lgit/compose.py +2110 -0
- lgit/config.py +437 -0
- lgit/diffing.py +384 -0
- lgit/errors.py +137 -0
- lgit/git.py +852 -0
- lgit/map_reduce.py +508 -0
- lgit/markdown_output.py +709 -0
- lgit/models.py +924 -0
- lgit/normalization.py +411 -0
- lgit/patch.py +784 -0
- lgit/profile.py +426 -0
- lgit/py.typed +0 -0
- lgit/repo.py +287 -0
- lgit/resources/__init__.py +1 -0
- lgit/resources/commit_types.json +242 -0
- lgit/resources/prompts/analysis/default.md +237 -0
- lgit/resources/prompts/analysis/markdown.md +112 -0
- lgit/resources/prompts/changelog/default.md +89 -0
- lgit/resources/prompts/changelog/markdown.md +60 -0
- lgit/resources/prompts/compose-bind/default.md +40 -0
- lgit/resources/prompts/compose-bind/markdown.md +41 -0
- lgit/resources/prompts/compose-intent/default.md +63 -0
- lgit/resources/prompts/compose-intent/markdown.md +59 -0
- lgit/resources/prompts/fast/default.md +46 -0
- lgit/resources/prompts/fast/markdown.md +51 -0
- lgit/resources/prompts/map/default.md +67 -0
- lgit/resources/prompts/map/markdown.md +63 -0
- lgit/resources/prompts/reduce/default.md +81 -0
- lgit/resources/prompts/reduce/markdown.md +68 -0
- lgit/resources/prompts/summary/default.md +74 -0
- lgit/resources/prompts/summary/markdown.md +77 -0
- lgit/resources/validation_data.json +1 -0
- lgit/rewrite.py +392 -0
- lgit/style.py +295 -0
- lgit/templates.py +385 -0
- lgit/testing/__init__.py +62 -0
- lgit/testing/compare.py +57 -0
- lgit/testing/fixture.py +386 -0
- lgit/testing/report.py +201 -0
- lgit/testing/runner.py +256 -0
- lgit/tokens.py +90 -0
- lgit/validation.py +545 -0
- lgit_cli-3.7.0.dist-info/METADATA +288 -0
- lgit_cli-3.7.0.dist-info/RECORD +54 -0
- lgit_cli-3.7.0.dist-info/WHEEL +4 -0
- lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
- lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
lgit/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
|
+
]
|