diamond-dev 0.1.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.
@@ -0,0 +1,345 @@
1
+ """Deterministic comparison bundle generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from loguru import logger
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+ from diamond_dev.executor import CommandRunner
15
+ from diamond_dev.git_ops import GitOperations
16
+ from diamond_dev.workflow import ImplementationBranch, RunContext
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class _DiffBudget:
21
+ remaining_bytes: int
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class _ChangedFile:
26
+ status: str
27
+ display_path: str
28
+ diff_path: str
29
+
30
+
31
+ def write_comparison_bundle(
32
+ *,
33
+ context: RunContext,
34
+ runner: CommandRunner,
35
+ git: GitOperations,
36
+ ) -> RunContext:
37
+ """Write the deterministic comparison bundle and return updated context."""
38
+ diff_budget = _DiffBudget(
39
+ remaining_bytes=context.config.comparison.max_total_diff_bytes,
40
+ )
41
+ active_context = context
42
+ lines = [
43
+ "# Diamond Dev comparison bundle",
44
+ "",
45
+ f"- Plan: {context.plan.file_name}",
46
+ f"- Base branch: {context.implementation.base_branch}",
47
+ f"- Diff byte budget: {context.config.comparison.max_total_diff_bytes}",
48
+ f"- Per-file diff byte cap: {context.config.comparison.max_file_diff_bytes}",
49
+ "",
50
+ ]
51
+ for branch in context.implementation.branches:
52
+ branch_lines, tests_ran = _branch_section(
53
+ context=context,
54
+ runner=runner,
55
+ git=git,
56
+ branch=branch,
57
+ diff_budget=diff_budget,
58
+ )
59
+ lines.extend(branch_lines)
60
+ if tests_ran:
61
+ active_context = git.record_dirty_files(
62
+ active_context,
63
+ f"{branch.agent_name} comparison tests",
64
+ branch.repo_dir,
65
+ branch.branch,
66
+ log_prefix=f"{branch.log_prefix}-comparison-tests",
67
+ )
68
+
69
+ bundle_markdown = "\n".join(lines).rstrip()
70
+ context.comparison_bundle_file.write_text(f"{bundle_markdown}\n", encoding="utf-8")
71
+ logger.info("Wrote comparison bundle: {}", context.comparison_bundle_file)
72
+ return active_context
73
+
74
+
75
+ def _branch_section(
76
+ *,
77
+ context: RunContext,
78
+ runner: CommandRunner,
79
+ git: GitOperations,
80
+ branch: ImplementationBranch,
81
+ diff_budget: _DiffBudget,
82
+ ) -> tuple[list[str], bool]:
83
+ head_revision = git.revision(
84
+ branch.repo_dir,
85
+ branch.branch,
86
+ log_name=f"{branch.log_prefix}-comparison-head-revision",
87
+ )
88
+ ahead_behind = git.branch_ahead_behind(
89
+ branch.repo_dir,
90
+ branch=branch.branch,
91
+ base_branch=context.implementation.base_branch,
92
+ log_name=f"{branch.log_prefix}-comparison-ahead-behind",
93
+ )
94
+ changed_files = _changed_files(
95
+ git.run(
96
+ branch.repo_dir,
97
+ "diff",
98
+ "--name-status",
99
+ f"origin/{context.implementation.base_branch}...{branch.branch}",
100
+ log_name=f"{branch.log_prefix}-comparison-name-status",
101
+ ).output,
102
+ )
103
+ lines = [
104
+ f"## {branch.agent_name}",
105
+ "",
106
+ f"- Branch: {branch.branch}",
107
+ f"- Repository: {branch.repo_dir}",
108
+ f"- Head SHA: {head_revision}",
109
+ (
110
+ "- Ahead/behind base: "
111
+ f"ahead={ahead_behind.ahead}, behind={ahead_behind.behind}"
112
+ ),
113
+ f"- Changed files: {len(changed_files)}",
114
+ *_change_stat_lines(changed_files),
115
+ "",
116
+ "### Changed file list",
117
+ "",
118
+ *_changed_file_list_lines(
119
+ changed_files,
120
+ context.config.comparison.max_file_diff_bytes,
121
+ ),
122
+ "",
123
+ "### Tests",
124
+ "",
125
+ ]
126
+ test_lines, tests_ran = _test_lines(
127
+ context=context,
128
+ runner=runner,
129
+ branch=branch,
130
+ )
131
+ lines.extend(test_lines)
132
+ lines.extend(("", "### Capped diffs", ""))
133
+ lines.extend(
134
+ _diff_lines(
135
+ context=context,
136
+ git=git,
137
+ branch=branch,
138
+ changed_files=changed_files,
139
+ diff_budget=diff_budget,
140
+ ),
141
+ )
142
+ lines.append("")
143
+ return lines, tests_ran
144
+
145
+
146
+ def _changed_files(name_status_output: str) -> tuple[_ChangedFile, ...]:
147
+ files: list[_ChangedFile] = []
148
+ for line in name_status_output.splitlines():
149
+ if not line.strip():
150
+ continue
151
+ parts = line.split("\t")
152
+ status = parts[0]
153
+ if len(parts) == 1:
154
+ display_path = parts[0]
155
+ diff_path = parts[0]
156
+ elif status.startswith(("R", "C")) and len(parts) >= 3:
157
+ display_path = f"{parts[-2]} -> {parts[-1]}"
158
+ diff_path = parts[-1]
159
+ else:
160
+ display_path = parts[-1]
161
+ diff_path = parts[-1]
162
+ files.append(
163
+ _ChangedFile(
164
+ status=status,
165
+ display_path=display_path,
166
+ diff_path=diff_path,
167
+ ),
168
+ )
169
+ return tuple(files)
170
+
171
+
172
+ def _change_stat_lines(changed_files: Sequence[_ChangedFile]) -> list[str]:
173
+ stats = {
174
+ "added": 0,
175
+ "modified": 0,
176
+ "deleted": 0,
177
+ "renamed": 0,
178
+ "copied": 0,
179
+ "other": 0,
180
+ }
181
+ for changed_file in changed_files:
182
+ match changed_file.status[:1]:
183
+ case "A":
184
+ stats["added"] += 1
185
+ case "M":
186
+ stats["modified"] += 1
187
+ case "D":
188
+ stats["deleted"] += 1
189
+ case "R":
190
+ stats["renamed"] += 1
191
+ case "C":
192
+ stats["copied"] += 1
193
+ case _:
194
+ stats["other"] += 1
195
+ summary = ", ".join(f"{key}={value}" for key, value in stats.items() if value)
196
+ return [f"- Change stats: {summary or 'none'}"]
197
+
198
+
199
+ def _changed_file_list_lines(
200
+ changed_files: Sequence[_ChangedFile],
201
+ byte_budget: int,
202
+ ) -> list[str]:
203
+ if not changed_files:
204
+ return ["- No changed files."]
205
+
206
+ included, omitted = _capped_lines(
207
+ (
208
+ f"- {changed_file.status}: {changed_file.display_path}"
209
+ for changed_file in changed_files
210
+ ),
211
+ max_bytes=byte_budget,
212
+ )
213
+ if not included:
214
+ included = ["- All changed files omitted due to byte budget."]
215
+ if omitted:
216
+ included.extend(("", "Omitted changed files:", *omitted))
217
+ return included
218
+
219
+
220
+ def _test_lines(
221
+ *,
222
+ context: RunContext,
223
+ runner: CommandRunner,
224
+ branch: ImplementationBranch,
225
+ ) -> tuple[list[str], bool]:
226
+ if not context.config.comparison.test_commands:
227
+ return ["- tests: not_run"], False
228
+
229
+ lines: list[str] = []
230
+ for index, command_text in enumerate(
231
+ context.config.comparison.test_commands,
232
+ start=1,
233
+ ):
234
+ log_name = f"{branch.log_prefix}-comparison-test-{index}"
235
+ result = runner.run(
236
+ ("sh", "-lc", command_text),
237
+ cwd=branch.repo_dir,
238
+ log_name=log_name,
239
+ check=False,
240
+ )
241
+ status = "passed" if result.returncode == 0 else "failed"
242
+ clipped_output, omitted_bytes = _clip_bytes(
243
+ result.output,
244
+ context.config.comparison.max_test_output_bytes,
245
+ )
246
+ lines.extend(
247
+ (
248
+ f"- Command {index}: `{command_text}`",
249
+ f" - Status: {status} (exit {result.returncode})",
250
+ f" - Log: {result.log_path}",
251
+ ),
252
+ )
253
+ if clipped_output:
254
+ lines.extend((" - Output:", " ```text"))
255
+ lines.extend(f" {line}" for line in clipped_output.splitlines())
256
+ lines.append(" ```")
257
+ if omitted_bytes:
258
+ lines.append(f" - Omitted output bytes: {omitted_bytes}")
259
+ return lines, True
260
+
261
+
262
+ def _diff_lines(
263
+ *,
264
+ context: RunContext,
265
+ git: GitOperations,
266
+ branch: ImplementationBranch,
267
+ changed_files: Sequence[_ChangedFile],
268
+ diff_budget: _DiffBudget,
269
+ ) -> list[str]:
270
+ lines: list[str] = []
271
+ omitted: list[str] = []
272
+ for index, changed_file in enumerate(changed_files, start=1):
273
+ if diff_budget.remaining_bytes <= 0:
274
+ omitted.append(
275
+ f"- {changed_file.display_path}: total diff budget exhausted",
276
+ )
277
+ continue
278
+ result = git.run(
279
+ branch.repo_dir,
280
+ "diff",
281
+ "--no-color",
282
+ f"origin/{context.implementation.base_branch}...{branch.branch}",
283
+ "--",
284
+ changed_file.diff_path,
285
+ log_name=f"{branch.log_prefix}-comparison-diff-{index}",
286
+ )
287
+ diff_text, file_omitted = _clip_bytes(
288
+ result.output,
289
+ context.config.comparison.max_file_diff_bytes,
290
+ )
291
+ diff_text, total_omitted = _clip_bytes(diff_text, diff_budget.remaining_bytes)
292
+ diff_budget.remaining_bytes -= len(diff_text.encode("utf-8"))
293
+ if not diff_text.strip():
294
+ continue
295
+ lines.extend(
296
+ (
297
+ f"#### {changed_file.display_path}",
298
+ "",
299
+ "```diff",
300
+ diff_text.rstrip(),
301
+ "```",
302
+ "",
303
+ ),
304
+ )
305
+ if file_omitted:
306
+ omitted.append(
307
+ f"- {changed_file.display_path}: omitted {file_omitted} bytes "
308
+ "by per-file cap",
309
+ )
310
+ if total_omitted:
311
+ omitted.append(
312
+ f"- {changed_file.display_path}: omitted {total_omitted} bytes "
313
+ "by total diff cap",
314
+ )
315
+ if not lines:
316
+ lines.append("- No diff content included.")
317
+ if omitted:
318
+ lines.extend(("### Omitted diff files", "", *omitted))
319
+ return lines
320
+
321
+
322
+ def _capped_lines(
323
+ lines: Iterable[str],
324
+ *,
325
+ max_bytes: int,
326
+ ) -> tuple[list[str], list[str]]:
327
+ included: list[str] = []
328
+ omitted: list[str] = []
329
+ used_bytes = 0
330
+ for line in lines:
331
+ line_bytes = len(f"{line}\n".encode("utf-8")) # noqa: UP012
332
+ if used_bytes + line_bytes > max_bytes:
333
+ omitted.append(line)
334
+ continue
335
+ included.append(line)
336
+ used_bytes += line_bytes
337
+ return included, omitted
338
+
339
+
340
+ def _clip_bytes(text: str, max_bytes: int) -> tuple[str, int]:
341
+ encoded = text.encode("utf-8")
342
+ if len(encoded) <= max_bytes:
343
+ return text, 0
344
+ clipped = encoded[:max_bytes].decode("utf-8", errors="ignore")
345
+ return clipped, len(encoded) - max_bytes