agent-trace-cli 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.
Files changed (42) hide show
  1. agent_trace/__init__.py +3 -0
  2. agent_trace/blame.py +471 -0
  3. agent_trace/blame_git.py +133 -0
  4. agent_trace/blame_meta.py +167 -0
  5. agent_trace/cli.py +2680 -0
  6. agent_trace/commit_link.py +226 -0
  7. agent_trace/config.py +137 -0
  8. agent_trace/context.py +385 -0
  9. agent_trace/conversations.py +358 -0
  10. agent_trace/git_notes.py +491 -0
  11. agent_trace/hooks/__init__.py +163 -0
  12. agent_trace/hooks/base.py +233 -0
  13. agent_trace/hooks/claude.py +483 -0
  14. agent_trace/hooks/codex.py +232 -0
  15. agent_trace/hooks/cursor.py +278 -0
  16. agent_trace/hooks/git.py +144 -0
  17. agent_trace/ledger.py +955 -0
  18. agent_trace/models.py +618 -0
  19. agent_trace/record.py +417 -0
  20. agent_trace/registry.py +230 -0
  21. agent_trace/remote.py +673 -0
  22. agent_trace/rewrite.py +176 -0
  23. agent_trace/rules.py +209 -0
  24. agent_trace/schemas/commit-link.schema.json +34 -0
  25. agent_trace/schemas/git-note.schema.json +88 -0
  26. agent_trace/schemas/ledger.schema.json +97 -0
  27. agent_trace/schemas/remotes.schema.json +30 -0
  28. agent_trace/schemas/sync-state.schema.json +49 -0
  29. agent_trace/schemas/trace-record.schema.json +130 -0
  30. agent_trace/session.py +136 -0
  31. agent_trace/storage.py +229 -0
  32. agent_trace/summary.py +465 -0
  33. agent_trace/summary_presets.py +188 -0
  34. agent_trace/sync.py +1182 -0
  35. agent_trace/telemetry.py +142 -0
  36. agent_trace/trace.py +460 -0
  37. agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
  38. agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
  39. agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
  40. agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """agent-trace — CLI tool for tracing AI-generated code changes."""
2
+
3
+ __version__ = "0.1.0"
agent_trace/blame.py ADDED
@@ -0,0 +1,471 @@
1
+ """
2
+ CLI blame command — show AI attribution for file lines.
3
+
4
+ Schema 2.0: attribution is binary. A line is either AI (with a matching
5
+ ledger segment backed by hash + content evidence) or NO_ATTRIBUTION
6
+ (everything else). No MIXED, HUMAN, or UNKNOWN labels — we never claim
7
+ "this was a human"; we only ever assert "this matches an AI trace".
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ from typing import Any
16
+
17
+ from .blame_git import _git, _git_blame_porcelain, _group_into_segments, _parse_blame_porcelain
18
+ from .blame_meta import (
19
+ _extract_trace_meta,
20
+ _load_conversation_preview,
21
+ _load_local_traces,
22
+ )
23
+ from .git_notes import ledger_from_note_for_blame, read_note
24
+ from .ledger import load_local_ledgers
25
+ from .storage import resolve_project_id
26
+ from .summary import latest_summary_by_id
27
+
28
+
29
+ def _merge_ledgers_from_git_notes(
30
+ git_root: str,
31
+ segments: list[dict[str, Any]],
32
+ ledgers: dict[str, dict[str, Any]],
33
+ ) -> dict[str, dict[str, Any]]:
34
+ """Fill in ledgers from ``refs/notes/agent-trace`` when missing locally."""
35
+ merged = dict(ledgers)
36
+ missing = {seg.get("commit_sha") for seg in segments if seg.get("commit_sha") not in merged}
37
+ for sha in missing:
38
+ if not sha:
39
+ continue
40
+ note = read_note(sha, git_root)
41
+ if not note:
42
+ continue
43
+ mini = ledger_from_note_for_blame(note)
44
+ if mini:
45
+ merged[sha] = mini
46
+ return merged
47
+
48
+
49
+ def _attribution_type_label(attr_type: str) -> str:
50
+ if attr_type == "ai":
51
+ return "AI"
52
+ return "No attribution"
53
+
54
+
55
+ def _ledger_kind(attr_type: str) -> str:
56
+ if attr_type == "ai":
57
+ return "AI"
58
+ return "NO_ATTRIBUTION"
59
+
60
+
61
+ def _ranges_overlap(
62
+ attr_start: int, attr_end: int,
63
+ seg_start: int, seg_end: int,
64
+ ) -> bool:
65
+ return attr_start <= seg_end and attr_end >= seg_start
66
+
67
+
68
+ def _attribute_from_ledger(
69
+ blame_segments: list[dict[str, Any]],
70
+ ledgers: dict[str, dict[str, Any]],
71
+ file_path: str,
72
+ traces: list[dict[str, Any]] | None = None,
73
+ summary_by_id: dict[str, str] | None = None,
74
+ project_id: str | None = None,
75
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
76
+ attributed: list[dict[str, Any]] = []
77
+ remaining: list[dict[str, Any]] = []
78
+
79
+ trace_by_id: dict[str, dict[str, Any]] = {}
80
+ if traces:
81
+ for t in traces:
82
+ tid = t.get("id")
83
+ if tid:
84
+ trace_by_id[tid] = t
85
+
86
+ for seg in blame_segments:
87
+ commit_sha = seg["commit_sha"]
88
+ ledger = ledgers.get(commit_sha)
89
+
90
+ if ledger and file_path in ledger.get("files", {}):
91
+ file_ledger = ledger["files"][file_path]
92
+ line_attrs = file_ledger.get("line_attributions", [])
93
+
94
+ orig_start = seg.get("orig_start_line", seg["start_line"])
95
+ orig_end = seg.get("orig_end_line", seg["end_line"])
96
+ offset = seg["start_line"] - orig_start
97
+
98
+ overlapping: list[tuple[int, int, dict[str, Any]]] = []
99
+ for la in sorted(line_attrs, key=lambda x: x.get("start_line", 0)):
100
+ la_start = la.get("start_line", 0)
101
+ la_end = la.get("end_line", 0)
102
+ if _ranges_overlap(la_start, la_end, orig_start, orig_end):
103
+ clamped_start = max(la_start, orig_start)
104
+ clamped_end = min(la_end, orig_end)
105
+ overlapping.append((clamped_start, clamped_end, la))
106
+
107
+ if overlapping:
108
+ for clamped_orig_start, clamped_orig_end, la in overlapping:
109
+ final_start = clamped_orig_start + offset
110
+ final_end = clamped_orig_end + offset
111
+ attr_type = la.get("type", "ai")
112
+ # Schema 2.0 only allows "ai" segments; anything else is
113
+ # invalid data and is skipped (treated as NO_ATTRIBUTION).
114
+ if attr_type != "ai":
115
+ continue
116
+ kind = _ledger_kind(attr_type)
117
+ trace_id = la.get("trace_id")
118
+ trace_rec = trace_by_id.get(trace_id) if trace_id else None
119
+ label = _attribution_type_label(attr_type)
120
+ meta = (
121
+ _extract_trace_meta(trace_rec, file_path, (final_start + final_end) // 2)
122
+ if trace_rec
123
+ else {}
124
+ )
125
+ conv_id = la.get("conversation_id") or meta.get("conversation_id")
126
+ conv_sha = meta.get("content_sha256")
127
+ conv_summary = (summary_by_id or {}).get(conv_id) if conv_id else None
128
+ conv_preview = (
129
+ _load_conversation_preview(project_id, conv_sha)
130
+ if not conv_summary
131
+ else None
132
+ )
133
+
134
+ attributed.append({
135
+ "start_line": final_start,
136
+ "end_line": final_end,
137
+ "kind": kind,
138
+ "attribution_label": label,
139
+ "trace_id": trace_id,
140
+ "timestamp": trace_rec.get("timestamp") if trace_rec else None,
141
+ "model_id": la.get("model_id") or meta.get("model_id"),
142
+ "contributor_type": attr_type,
143
+ "tool": trace_rec.get("tool") if trace_rec else None,
144
+ "conversation_id": conv_id,
145
+ "conversation_summary": conv_summary,
146
+ "conversation_preview": conv_preview,
147
+ "matched_range": {
148
+ "start_line": la.get("start_line"),
149
+ "end_line": la.get("end_line"),
150
+ },
151
+ "evidence": la.get("evidence"),
152
+ "commit_sha": commit_sha,
153
+ "signals": ["ledger"],
154
+ "source": "ledger",
155
+ })
156
+
157
+ covered_orig_start = overlapping[0][0]
158
+ covered_orig_end = overlapping[-1][1]
159
+ if orig_start < covered_orig_start:
160
+ gap_seg = dict(seg)
161
+ gap_seg["start_line"] = seg["start_line"]
162
+ gap_seg["end_line"] = covered_orig_start + offset - 1
163
+ gap_seg["orig_start_line"] = orig_start
164
+ gap_seg["orig_end_line"] = covered_orig_start - 1
165
+ n_lines = covered_orig_start - orig_start
166
+ gap_seg["content_lines"] = seg["content_lines"][:n_lines]
167
+ remaining.append(gap_seg)
168
+ if covered_orig_end < orig_end:
169
+ gap_seg = dict(seg)
170
+ n_before = covered_orig_end - orig_start + 1
171
+ gap_seg["start_line"] = covered_orig_end + offset + 1
172
+ gap_seg["end_line"] = seg["end_line"]
173
+ gap_seg["orig_start_line"] = covered_orig_end + 1
174
+ gap_seg["orig_end_line"] = orig_end
175
+ gap_seg["content_lines"] = seg["content_lines"][n_before:]
176
+ remaining.append(gap_seg)
177
+ continue
178
+
179
+ remaining.append(seg)
180
+
181
+ return attributed, remaining
182
+
183
+
184
+ def _no_attribution_entry(seg: dict[str, Any]) -> dict[str, Any]:
185
+ return {
186
+ "start_line": seg["start_line"],
187
+ "end_line": seg["end_line"],
188
+ "kind": "NO_ATTRIBUTION",
189
+ "attribution_label": "No attribution",
190
+ "trace_id": None,
191
+ "timestamp": None,
192
+ "model_id": None,
193
+ "contributor_type": None,
194
+ "tool": None,
195
+ "conversation_id": None,
196
+ "conversation_summary": None,
197
+ "conversation_preview": None,
198
+ "matched_range": None,
199
+ "commit_sha": seg["commit_sha"],
200
+ "signals": [],
201
+ "source": None,
202
+ }
203
+
204
+
205
+ def _attribute_deterministic(
206
+ blame_segments: list[dict[str, Any]],
207
+ file_path: str,
208
+ ledgers: dict[str, dict[str, Any]],
209
+ traces: list[dict[str, Any]],
210
+ summary_by_id: dict[str, str] | None = None,
211
+ project_id: str | None = None,
212
+ ) -> list[dict[str, Any]]:
213
+ ledger_results: list[dict[str, Any]] = []
214
+ remaining = blame_segments
215
+ if ledgers:
216
+ ledger_results, remaining = _attribute_from_ledger(
217
+ blame_segments, ledgers, file_path, traces=traces,
218
+ summary_by_id=summary_by_id, project_id=project_id,
219
+ )
220
+
221
+ gap_results = [_no_attribution_entry(seg) for seg in remaining]
222
+
223
+ all_results = ledger_results + gap_results
224
+ all_results.sort(key=lambda a: (a.get("start_line", 0), a.get("end_line", 0)))
225
+ return all_results
226
+
227
+
228
+ def _merge_attributions(attributions: list[dict[str, Any]]) -> list[dict[str, Any]]:
229
+ if not attributions:
230
+ return []
231
+ merged: list[dict[str, Any]] = []
232
+ for entry in attributions:
233
+ if merged:
234
+ prev = merged[-1]
235
+ if (
236
+ prev["end_line"] + 1 >= entry["start_line"]
237
+ and prev.get("kind") == entry.get("kind")
238
+ and prev.get("trace_id") == entry.get("trace_id")
239
+ and prev.get("source") == entry.get("source")
240
+ ):
241
+ prev["end_line"] = entry["end_line"]
242
+ continue
243
+ merged.append(dict(entry))
244
+ return merged
245
+
246
+
247
+ _BOLD = "\033[1m"
248
+ _DIM = "\033[2m"
249
+ _GREEN = "\033[32m"
250
+ _RESET = "\033[0m"
251
+
252
+
253
+ def _format_line_range(start: int, end: int) -> str:
254
+ if start == end:
255
+ return f"L{start}"
256
+ return f"L{start}-{end}"
257
+
258
+
259
+ def _format_terminal(file_path: str, attributions: list[dict[str, Any]]) -> str:
260
+ lines: list[str] = []
261
+ lines.append("")
262
+ lines.append(f" {_BOLD}{file_path}{_RESET}")
263
+ lines.append("")
264
+
265
+ for attr in attributions:
266
+ start = attr.get("start_line", 0)
267
+ end = attr.get("end_line", 0)
268
+ lr = _format_line_range(start, end)
269
+ kind = attr.get("kind", "NO_ATTRIBUTION")
270
+
271
+ if kind == "AI":
272
+ tag = f"{_GREEN}[AI]{_RESET}"
273
+ else:
274
+ tag = f"{_DIM}[NO ATTRIBUTION]{_RESET}"
275
+
276
+ model_id = attr.get("model_id") or ""
277
+ tool = attr.get("tool")
278
+ tool_name = ""
279
+ if isinstance(tool, dict):
280
+ tool_name = tool.get("name", "")
281
+ elif isinstance(tool, str):
282
+ tool_name = tool
283
+
284
+ model_tool = model_id
285
+ if tool_name:
286
+ model_tool = f"{model_id} via {tool_name}" if model_id else tool_name
287
+
288
+ if kind == "NO_ATTRIBUTION":
289
+ lines.append(f" {lr:<12}{tag}")
290
+ continue
291
+
292
+ lines.append(f" {lr:<12}{tag} {model_tool}")
293
+
294
+ if model_id:
295
+ lines.append(f" {_DIM}model: {model_id}{_RESET}")
296
+
297
+ conv_summary = attr.get("conversation_summary") or ""
298
+ conv_preview = attr.get("conversation_preview") or ""
299
+ conv_id = attr.get("conversation_id") or ""
300
+ if conv_summary:
301
+ lines.append(f" summary: {conv_summary}")
302
+ if conv_id:
303
+ lines.append(f" {_DIM}conversation: {conv_id[:16]}{_RESET}")
304
+ elif conv_preview:
305
+ preview_line = conv_preview.replace("\n", " ").strip()
306
+ if len(preview_line) > 120:
307
+ preview_line = preview_line[:120] + "..."
308
+ lines.append(f" conversation: \"{preview_line}\"")
309
+ elif conv_id:
310
+ lines.append(f" conversation: {conv_id[:16]}")
311
+
312
+ trace_id = attr.get("trace_id") or ""
313
+ commit_sha = attr.get("commit_sha") or ""
314
+ detail_parts = []
315
+ if trace_id:
316
+ detail_parts.append(f"trace: {trace_id}")
317
+ if commit_sha:
318
+ detail_parts.append(f"commit: {commit_sha[:12]}")
319
+ if detail_parts:
320
+ lines.append(f" {_DIM}{' | '.join(detail_parts)}{_RESET}")
321
+
322
+ lines.append("")
323
+ return "\n".join(lines)
324
+
325
+
326
+ def _format_json(file_path: str, attributions: list[dict[str, Any]]) -> str:
327
+ clean: list[dict[str, Any]] = []
328
+ for attr in attributions:
329
+ entry: dict[str, Any] = {
330
+ "start_line": attr.get("start_line"),
331
+ "end_line": attr.get("end_line"),
332
+ "kind": attr.get("kind"),
333
+ }
334
+ if attr.get("trace_id"):
335
+ entry["trace_id"] = attr["trace_id"]
336
+ model_id = attr.get("model_id")
337
+ if model_id:
338
+ entry["model_id"] = model_id
339
+ contributor_type = attr.get("contributor_type")
340
+ if contributor_type:
341
+ entry["contributor_type"] = contributor_type
342
+ tool = attr.get("tool")
343
+ if isinstance(tool, dict):
344
+ entry["tool"] = {"name": tool.get("name", ""), "version": tool.get("version", "")}
345
+ elif isinstance(tool, str):
346
+ entry["tool"] = {"name": tool, "version": ""}
347
+ if attr.get("timestamp"):
348
+ entry["timestamp"] = attr["timestamp"]
349
+ if attr.get("commit_sha"):
350
+ entry["commit_sha"] = attr["commit_sha"]
351
+ if attr.get("conversation_id"):
352
+ entry["conversation_id"] = attr["conversation_id"]
353
+ if attr.get("conversation_summary"):
354
+ entry["conversation_summary"] = attr["conversation_summary"]
355
+ if attr.get("conversation_preview"):
356
+ entry["conversation_preview"] = attr["conversation_preview"]
357
+ if attr.get("signals"):
358
+ entry["signals"] = attr["signals"]
359
+ if attr.get("source"):
360
+ entry["source"] = attr["source"]
361
+ if attr.get("attribution_label"):
362
+ entry["attribution_label"] = attr["attribution_label"]
363
+ if attr.get("evidence"):
364
+ entry["evidence"] = attr["evidence"]
365
+ clean.append(entry)
366
+
367
+ return json.dumps({"file": file_path, "attributions": clean}, indent=2)
368
+
369
+
370
+ def _filter_no_attribution(
371
+ attributions: list[dict[str, Any]],
372
+ show_no_attribution: bool,
373
+ ) -> list[dict[str, Any]]:
374
+ if show_no_attribution:
375
+ return attributions
376
+ return [a for a in attributions if a.get("kind") != "NO_ATTRIBUTION"]
377
+
378
+
379
+ def _has_no_attribution(attributions: list[dict[str, Any]]) -> bool:
380
+ return any(a.get("kind") == "NO_ATTRIBUTION" for a in attributions)
381
+
382
+
383
+ def blame_file(
384
+ file_path: str,
385
+ *,
386
+ line: int | None = None,
387
+ start_line: int | None = None,
388
+ end_line: int | None = None,
389
+ show_no_attribution: bool = False,
390
+ require_attribution: bool = False,
391
+ json_output: bool = False,
392
+ project_dir: str | None = None,
393
+ ) -> str | None:
394
+ cwd = project_dir if project_dir else os.getcwd()
395
+ abs_path = (
396
+ os.path.abspath(file_path)
397
+ if os.path.isabs(file_path)
398
+ else os.path.abspath(os.path.join(cwd, file_path))
399
+ )
400
+
401
+ if not os.path.isfile(abs_path):
402
+ if json_output:
403
+ return None
404
+ print(f"agent-trace blame: file not found: {file_path}", file=sys.stderr)
405
+ sys.exit(1)
406
+
407
+ file_dir = os.path.dirname(abs_path) or cwd
408
+ git_root = _git("rev-parse", "--show-toplevel", cwd=file_dir)
409
+ if git_root is None:
410
+ if json_output:
411
+ return None
412
+ print("agent-trace blame: not a git repository", file=sys.stderr)
413
+ sys.exit(1)
414
+
415
+ try:
416
+ rel_path = os.path.relpath(abs_path, git_root)
417
+ except ValueError:
418
+ rel_path = file_path
419
+
420
+ if line is not None:
421
+ start_line = line
422
+ end_line = line
423
+
424
+ raw = _git_blame_porcelain(
425
+ rel_path,
426
+ start_line=start_line,
427
+ end_line=end_line,
428
+ cwd=git_root,
429
+ )
430
+ if raw is None:
431
+ if json_output:
432
+ return None
433
+ print(f"agent-trace blame: git blame failed for {file_path}", file=sys.stderr)
434
+ sys.exit(1)
435
+
436
+ records = _parse_blame_porcelain(raw)
437
+ if not records:
438
+ if json_output:
439
+ return None
440
+ print(f"agent-trace blame: no blame data for {file_path}", file=sys.stderr)
441
+ sys.exit(1)
442
+
443
+ segments = _group_into_segments(records)
444
+
445
+ traces = _load_local_traces(git_root)
446
+ ledgers = load_local_ledgers(git_root)
447
+ ledgers = _merge_ledgers_from_git_notes(git_root, segments, ledgers)
448
+
449
+ pid = resolve_project_id(git_root, create=False)
450
+ summary_by_id = latest_summary_by_id(pid) if pid else {}
451
+
452
+ raw_attrs = _attribute_deterministic(
453
+ segments, rel_path, ledgers, traces,
454
+ summary_by_id=summary_by_id, project_id=pid,
455
+ )
456
+ attributions = _merge_attributions(raw_attrs)
457
+
458
+ if require_attribution and _has_no_attribution(attributions):
459
+ print(
460
+ "agent-trace blame: no AI attribution for one or more lines "
461
+ "(ledger missing, or lines were not written by a tracked AI tool).",
462
+ file=sys.stderr,
463
+ )
464
+ sys.exit(1)
465
+
466
+ out = _filter_no_attribution(attributions, show_no_attribution)
467
+
468
+ if json_output:
469
+ return _format_json(rel_path, out)
470
+ print(_format_terminal(rel_path, out))
471
+ return None
@@ -0,0 +1,133 @@
1
+ """Git blame --porcelain parsing and segment grouping (internal)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from typing import Any
7
+
8
+
9
+ def _git(*args: str, cwd: str | None = None) -> str | None:
10
+ try:
11
+ result = subprocess.run(
12
+ ["git", *args],
13
+ capture_output=True, text=True, cwd=cwd, timeout=30,
14
+ )
15
+ if result.returncode == 0:
16
+ return result.stdout.strip()
17
+ except Exception:
18
+ pass
19
+ return None
20
+
21
+
22
+ def _git_blame_porcelain(
23
+ file_path: str,
24
+ *,
25
+ start_line: int | None = None,
26
+ end_line: int | None = None,
27
+ cwd: str | None = None,
28
+ ) -> str | None:
29
+ args = ["blame", "--porcelain"]
30
+ if start_line is not None and end_line is not None:
31
+ args.extend(["-L", f"{start_line},{end_line}"])
32
+ elif start_line is not None:
33
+ args.extend(["-L", f"{start_line},{start_line}"])
34
+ args.append(file_path)
35
+ return _git(*args, cwd=cwd)
36
+
37
+
38
+ def _parse_blame_porcelain(raw: str) -> list[dict[str, Any]]:
39
+ lines = raw.split("\n")
40
+ records: list[dict[str, Any]] = []
41
+ commit_info: dict[str, dict[str, Any]] = {}
42
+ i = 0
43
+ while i < len(lines):
44
+ line = lines[i]
45
+ if not line:
46
+ i += 1
47
+ continue
48
+ parts = line.split()
49
+ if len(parts) < 3:
50
+ i += 1
51
+ continue
52
+ sha = parts[0]
53
+ if len(sha) != 40 or not all(c in "0123456789abcdef" for c in sha):
54
+ i += 1
55
+ continue
56
+ orig_line = int(parts[1])
57
+ final_line = int(parts[2])
58
+ i += 1
59
+ if sha not in commit_info:
60
+ info: dict[str, Any] = {}
61
+ while i < len(lines):
62
+ hline = lines[i]
63
+ if hline.startswith("\t"):
64
+ break
65
+ if hline.startswith("author "):
66
+ info["author"] = hline[7:]
67
+ elif hline.startswith("author-time "):
68
+ try:
69
+ info["author_time"] = int(hline[12:])
70
+ except ValueError:
71
+ pass
72
+ elif hline.startswith("summary "):
73
+ info["summary"] = hline[8:]
74
+ elif hline.startswith("filename "):
75
+ info["filename"] = hline[9:]
76
+ i += 1
77
+ commit_info[sha] = info
78
+ else:
79
+ while i < len(lines) and not lines[i].startswith("\t"):
80
+ hline = lines[i]
81
+ if hline.startswith("filename "):
82
+ commit_info[sha]["filename"] = hline[9:]
83
+ i += 1
84
+ content = ""
85
+ if i < len(lines) and lines[i].startswith("\t"):
86
+ content = lines[i][1:]
87
+ i += 1
88
+ info = commit_info.get(sha, {})
89
+ records.append({
90
+ "commit_sha": sha,
91
+ "orig_line": orig_line,
92
+ "final_line": final_line,
93
+ "content": content,
94
+ "author": info.get("author", ""),
95
+ "author_time": info.get("author_time"),
96
+ "summary": info.get("summary", ""),
97
+ "filename": info.get("filename", ""),
98
+ })
99
+ return records
100
+
101
+
102
+ def _group_into_segments(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
103
+ if not records:
104
+ return []
105
+ segments: list[dict[str, Any]] = []
106
+ current: dict[str, Any] | None = None
107
+ for rec in records:
108
+ if (
109
+ current is not None
110
+ and current["commit_sha"] == rec["commit_sha"]
111
+ and current["end_line"] + 1 == rec["final_line"]
112
+ ):
113
+ current["end_line"] = rec["final_line"]
114
+ current["orig_end_line"] = rec["orig_line"]
115
+ current["content_lines"].append(rec["content"])
116
+ else:
117
+ if current is not None:
118
+ segments.append(current)
119
+ current = {
120
+ "commit_sha": rec["commit_sha"],
121
+ "start_line": rec["final_line"],
122
+ "end_line": rec["final_line"],
123
+ "orig_start_line": rec["orig_line"],
124
+ "orig_end_line": rec["orig_line"],
125
+ "content_lines": [rec["content"]],
126
+ "author": rec.get("author", ""),
127
+ "author_time": rec.get("author_time"),
128
+ "summary": rec.get("summary", ""),
129
+ "filename": rec.get("filename", ""),
130
+ }
131
+ if current is not None:
132
+ segments.append(current)
133
+ return segments