descry-codegraph 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.
descry/git_history.py ADDED
@@ -0,0 +1,1631 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Git History Analysis Module
4
+
5
+ Provides symbol-level git history analysis by combining git operations
6
+ with descry's structural knowledge. Enables churn hotspot detection,
7
+ symbol evolution tracking, and change impact analysis.
8
+
9
+ Usage:
10
+ from git_history import GitHistoryAnalyzer, GitError
11
+ analyzer = GitHistoryAnalyzer("/path/to/repo")
12
+ churn = analyzer.get_churn(time_range="last 30 days", limit=20)
13
+ """
14
+
15
+ import logging
16
+ import re
17
+ import subprocess
18
+ from collections import defaultdict
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from descry._env import safe_env
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class GitError(Exception):
28
+ """Raised for git-specific errors (not installed, not a repo, etc.)."""
29
+
30
+ pass
31
+
32
+
33
+ # --- Git argument validators (A.1) ---
34
+ # Accepts: HEAD, HEAD^, HEAD^^, HEAD~N, HEAD^{tree}, @, @{-1},
35
+ # branch@{upstream}, master@{yesterday}, branch names, sha prefixes.
36
+ # Rejects: leading '-', '--' anywhere, empty peel, shell metacharacters.
37
+ _REF_REGEX = re.compile(
38
+ r"^(?:HEAD|@)(?:[~^]\d*)*(?:\^\{[a-z]+\})?(?:@\{(?:-\d+|[^}\-]+)\})?$"
39
+ r"|^[A-Za-z0-9][A-Za-z0-9._\-/]*(?:@\{(?:-\d+|[^}\-]+)\})?$"
40
+ )
41
+ _SYMBOL_REGEX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
42
+
43
+
44
+ def _validate_git_ref(ref: str) -> str:
45
+ """Validate a single git ref. Raises GitError on invalid input."""
46
+ if not ref or "--" in ref or not _REF_REGEX.match(ref):
47
+ raise GitError(f"Invalid git ref: {ref!r}")
48
+ return ref
49
+
50
+
51
+ def _validate_git_range(range_str: str) -> str:
52
+ """Validate a git range (ref, ref..ref, or ref...ref).
53
+
54
+ Raises GitError on invalid input.
55
+ """
56
+ if not range_str or "--" in range_str:
57
+ raise GitError(f"Invalid git range: {range_str!r}")
58
+ # Try triple-dot first, then double-dot, then single ref
59
+ for sep in ("...", ".."):
60
+ if sep in range_str:
61
+ parts = range_str.split(sep)
62
+ if len(parts) != 2:
63
+ raise GitError(f"Invalid git range: {range_str!r}")
64
+ _validate_git_ref(parts[0])
65
+ _validate_git_ref(parts[1])
66
+ return range_str
67
+ _validate_git_ref(range_str)
68
+ return range_str
69
+
70
+
71
+ def _validate_git_symbol(name: str) -> str:
72
+ """Validate a symbol name for `git log -L:<symbol>:<file>`."""
73
+ if not name or not _SYMBOL_REGEX.match(name):
74
+ raise GitError(f"Invalid git symbol: {name!r}")
75
+ return name
76
+
77
+
78
+ def _validate_git_file(path: str) -> str:
79
+ """Validate a file path for git (-L, pathspec).
80
+
81
+ Rejects: absolute paths, '..' traversal, leading '-', pathspec magic
82
+ prefix ':', shell metacharacters.
83
+ """
84
+ if not path:
85
+ raise GitError("Empty git file path")
86
+ if path.startswith("-") or path.startswith(":") or path.startswith("/"):
87
+ raise GitError(f"Invalid git file: {path!r}")
88
+ if ".." in path.split("/"):
89
+ raise GitError(f"Invalid git file: {path!r}")
90
+ if any(c in path for c in "\x00;|&`$\n\r\t"):
91
+ raise GitError(f"Invalid git file: {path!r}")
92
+ return path
93
+
94
+
95
+ def _validate_pathspec(spec: str) -> str:
96
+ """Validate a pathspec entry (no magic prefixes)."""
97
+ if not spec:
98
+ raise GitError("Empty pathspec")
99
+ if spec.startswith(":") or spec.startswith("-"):
100
+ raise GitError(f"Invalid pathspec (magic prefix): {spec!r}")
101
+ if ".." in spec.split("/"):
102
+ raise GitError(f"Invalid pathspec: {spec!r}")
103
+ return spec
104
+
105
+
106
+ DEFAULT_CHURN_EXCLUSIONS = [
107
+ ".descry_cache/",
108
+ ".beads/",
109
+ "Cargo.lock",
110
+ "package-lock.json",
111
+ "yarn.lock",
112
+ "pnpm-lock.yaml",
113
+ ]
114
+
115
+ CODE_EXTENSIONS = {
116
+ ".rs",
117
+ ".py",
118
+ ".ts",
119
+ ".tsx",
120
+ ".js",
121
+ ".jsx",
122
+ ".svelte",
123
+ ".go",
124
+ ".java",
125
+ ".css",
126
+ ".scss",
127
+ ".html",
128
+ }
129
+
130
+
131
+ class GitHistoryAnalyzer:
132
+ """Analyzes git history at the symbol level using descry metadata."""
133
+
134
+ def __init__(
135
+ self,
136
+ project_root: str,
137
+ graph_querier=None,
138
+ churn_exclusions=None,
139
+ code_extensions=None,
140
+ git_timeout=30,
141
+ ):
142
+ """Initialize the analyzer.
143
+
144
+ Args:
145
+ project_root: Path to the git repository root.
146
+ graph_querier: Optional GraphQuerier instance for symbol resolution.
147
+ churn_exclusions: List of path patterns to exclude from churn analysis.
148
+ Defaults to DEFAULT_CHURN_EXCLUSIONS.
149
+ code_extensions: Set of file extensions considered as code.
150
+ Defaults to CODE_EXTENSIONS.
151
+ git_timeout: Default timeout in seconds for git commands.
152
+ """
153
+ self.project_root = Path(project_root).resolve()
154
+ self.querier = graph_querier
155
+ self._verified = False
156
+ self.churn_exclusions = (
157
+ churn_exclusions
158
+ if churn_exclusions is not None
159
+ else DEFAULT_CHURN_EXCLUSIONS
160
+ )
161
+ self.code_extensions = (
162
+ code_extensions if code_extensions is not None else CODE_EXTENSIONS
163
+ )
164
+ self.default_timeout = git_timeout
165
+
166
+ def _run_git(self, args: list[str], timeout: int | None = None) -> str:
167
+ """Run a git command and return stdout.
168
+
169
+ Args:
170
+ args: Git command arguments (without 'git' prefix).
171
+ timeout: Timeout in seconds. Defaults to self.default_timeout.
172
+
173
+ Returns:
174
+ Command stdout as string.
175
+
176
+ Raises:
177
+ GitError: If git command fails or times out.
178
+ """
179
+ if timeout is None:
180
+ timeout = self.default_timeout
181
+ cmd = ["git"] + args
182
+ try:
183
+ result = subprocess.run(
184
+ cmd,
185
+ capture_output=True,
186
+ text=True,
187
+ cwd=str(self.project_root),
188
+ timeout=timeout,
189
+ env=safe_env(),
190
+ )
191
+ if result.returncode != 0:
192
+ stderr = result.stderr.strip()
193
+ raise GitError(f"git {' '.join(args[:3])}... failed: {stderr}")
194
+ return result.stdout
195
+ except FileNotFoundError:
196
+ raise GitError("git not found. Install git to use history tools.")
197
+ except subprocess.TimeoutExpired:
198
+ raise GitError(
199
+ f"Git timed out after {timeout}s. Try a narrower time/path range."
200
+ )
201
+
202
+ def _verify_git(self) -> None:
203
+ """Verify we're in a git repo. Cached after first check."""
204
+ if self._verified:
205
+ return
206
+ try:
207
+ output = self._run_git(["rev-parse", "--is-inside-work-tree"])
208
+ if output.strip() != "true":
209
+ raise GitError("Not inside a git repository.")
210
+ except GitError:
211
+ raise GitError("Not inside a git repository.")
212
+ self._verified = True
213
+
214
+ def _is_shallow(self) -> bool:
215
+ """Check if the repo is a shallow clone."""
216
+ return (self.project_root / ".git" / "shallow").exists()
217
+
218
+ def _parse_time_range(self, time_range: Optional[str]) -> list[str]:
219
+ """Convert human-readable time range to git flags.
220
+
221
+ Supported formats:
222
+ - "last 30 days" / "last 2 weeks" -> ["--since=30 days ago"]
223
+ - "since v1.0" / "since abc1234" -> ["v1.0..HEAD"]
224
+ - None -> [] (no filter)
225
+
226
+ Args:
227
+ time_range: Human-readable time range string.
228
+
229
+ Returns:
230
+ List of git CLI flags.
231
+ """
232
+ if not time_range:
233
+ return []
234
+
235
+ time_range = time_range.strip()
236
+
237
+ # "last N days/weeks/months"
238
+ match = re.match(
239
+ r"last\s+(\d+)\s+(days?|weeks?|months?)", time_range, re.IGNORECASE
240
+ )
241
+ if match:
242
+ count = match.group(1)
243
+ unit = match.group(2)
244
+ return [f"--since={count} {unit} ago"]
245
+
246
+ # "since <ref>"
247
+ match = re.match(r"since\s+(.+)", time_range, re.IGNORECASE)
248
+ if match:
249
+ ref = match.group(1).strip()
250
+ _validate_git_ref(ref)
251
+ return [f"{ref}..HEAD"]
252
+
253
+ # Treat as a direct git --since value. Limit to characters git would
254
+ # plausibly accept as a date expression so we don't pass crafted
255
+ # user input straight through as a single argv token.
256
+ if not re.match(r"^[A-Za-z0-9 :,.\-/+]{1,64}$", time_range):
257
+ raise GitError(f"Invalid time_range: {time_range!r}")
258
+ return [f"--since={time_range}"]
259
+
260
+ def _build_file_line_map(self, file_path: str) -> dict[int, str]:
261
+ """Build a mapping from line numbers to symbol node IDs for a file.
262
+
263
+ Uses the graph querier to find all functions/methods in the file
264
+ and maps each line to its containing symbol.
265
+
266
+ Args:
267
+ file_path: Relative file path (e.g., "backend/src/config.rs").
268
+
269
+ Returns:
270
+ Dict mapping line_number -> node_id.
271
+ """
272
+ if not self.querier:
273
+ return {}
274
+
275
+ file_id = f"FILE:{file_path}"
276
+ nodes = self.querier.data.get("nodes", [])
277
+
278
+ # Collect function/method spans in this file
279
+ spans = []
280
+ for node in nodes:
281
+ if node["id"].startswith(file_id + "::"):
282
+ if node["type"] in ("Function", "Method", "Class"):
283
+ start = node["metadata"].get("lineno", 0)
284
+ end = node["metadata"].get("end_lineno", start)
285
+ if start > 0:
286
+ span_size = end - start
287
+ spans.append((span_size, start, end, node["id"]))
288
+
289
+ # Sort by span size descending so outer spans are written first and inner spans overwrite them
290
+ spans.sort(key=lambda x: -x[0])
291
+
292
+ line_map = {}
293
+ for _, start, end, node_id in spans:
294
+ for ln in range(start, end + 1):
295
+ line_map[ln] = node_id
296
+
297
+ return line_map
298
+
299
+ def _parse_diff_hunks(self, diff_output: str) -> list[dict]:
300
+ """Parse unified diff output into structured hunk data.
301
+
302
+ Args:
303
+ diff_output: Raw git diff output.
304
+
305
+ Returns:
306
+ List of dicts with keys: file, old_start, old_count,
307
+ new_start, new_count, header, lines.
308
+ """
309
+ hunks = []
310
+ current_file = None
311
+ current_hunk = None
312
+
313
+ for line in diff_output.split("\n"):
314
+ # Track file changes
315
+ if line.startswith("diff --git"):
316
+ current_file = None
317
+ current_hunk = None
318
+ elif line.startswith("+++ b/"):
319
+ current_file = line[6:]
320
+ elif line.startswith("--- /dev/null"):
321
+ # New file
322
+ pass
323
+ elif line.startswith("@@ "):
324
+ # Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
325
+ match = re.match(
326
+ r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)", line
327
+ )
328
+ if match and current_file:
329
+ current_hunk = {
330
+ "file": current_file,
331
+ "old_start": int(match.group(1)),
332
+ "old_count": int(match.group(2) or 1),
333
+ "new_start": int(match.group(3)),
334
+ "new_count": int(match.group(4) or 1),
335
+ "header": match.group(5).strip(),
336
+ "lines": [],
337
+ }
338
+ hunks.append(current_hunk)
339
+ elif current_hunk is not None:
340
+ if line.startswith("+") or line.startswith("-") or line.startswith(" "):
341
+ current_hunk["lines"].append(line)
342
+
343
+ return hunks
344
+
345
+ def _get_node_display(self, node_id: str) -> tuple[str, str, str]:
346
+ """Get display info for a node ID.
347
+
348
+ Returns:
349
+ Tuple of (type_badge, display_name, location).
350
+ """
351
+ if not self.querier:
352
+ # Extract basic info from node ID
353
+ parts = node_id.replace("FILE:", "").split("::")
354
+ file_path = parts[0]
355
+ name = parts[-1] if len(parts) > 1 else file_path
356
+ return "[Fun]", name, file_path
357
+
358
+ node = self.querier.get_node_info(node_id)
359
+ if not node:
360
+ parts = node_id.replace("FILE:", "").split("::")
361
+ return "[???]", parts[-1] if len(parts) > 1 else node_id, ""
362
+
363
+ meta = node.get("metadata", {})
364
+ node_type = node.get("type", "?")[:3]
365
+ name = meta.get("name", "unknown")
366
+ parent = meta.get("parent_name", "")
367
+ display_name = f"{parent}.{name}" if parent else name
368
+
369
+ # Build location
370
+ file_path = (
371
+ node_id.split("::")[0].replace("FILE:", "")
372
+ if node_id.startswith("FILE:")
373
+ else ""
374
+ )
375
+ lineno = meta.get("lineno")
376
+ location = f"{file_path}:{lineno}" if lineno else file_path
377
+
378
+ return f"[{node_type}]", display_name, location
379
+
380
+ # --- Main analysis methods ---
381
+
382
+ def get_churn(
383
+ self,
384
+ time_range: Optional[str] = None,
385
+ path_filter: Optional[str] = None,
386
+ limit: int = 20,
387
+ mode: str = "symbols",
388
+ exclude_generated: bool = True,
389
+ ) -> str:
390
+ """Analyze code churn to find hotspots.
391
+
392
+ Args:
393
+ time_range: Time range filter (e.g., "last 30 days").
394
+ path_filter: Path filter (e.g., "backend/", "*.rs").
395
+ limit: Maximum results to return.
396
+ mode: "symbols" (default), "files", or "co-change".
397
+
398
+ Returns:
399
+ Formatted analysis string.
400
+ """
401
+ self._verify_git()
402
+
403
+ if path_filter:
404
+ _validate_pathspec(path_filter)
405
+
406
+ # Build git log command for file-level commit counts
407
+ git_args = [
408
+ "log",
409
+ "--format=%H",
410
+ "--name-only",
411
+ "--no-merges",
412
+ ]
413
+ git_args.extend(self._parse_time_range(time_range))
414
+ if path_filter:
415
+ git_args.extend(["--", path_filter])
416
+ elif exclude_generated:
417
+ # Validate each exclusion entry before ':!' prefixing
418
+ exclusions = []
419
+ for p in self.churn_exclusions:
420
+ _validate_pathspec(p)
421
+ exclusions.append(f":!{p}")
422
+ git_args.extend(["--", "."] + exclusions)
423
+
424
+ output = self._run_git(git_args, timeout=60)
425
+
426
+ if not output.strip():
427
+ return "No commits found for the given time range."
428
+
429
+ # Parse file-level commit counts
430
+ file_commits: dict[str, set[str]] = defaultdict(set)
431
+ current_commit = None
432
+ for line in output.strip().split("\n"):
433
+ line = line.strip()
434
+ if not line:
435
+ continue
436
+ if len(line) == 40 and all(c in "0123456789abcdef" for c in line):
437
+ current_commit = line
438
+ elif current_commit:
439
+ file_commits[line].add(current_commit)
440
+
441
+ if not file_commits:
442
+ return "No file changes found for the given time range."
443
+
444
+ # FILE MODE: Just return file-level results
445
+ if mode == "files":
446
+ sorted_files = sorted(
447
+ file_commits.items(), key=lambda x: len(x[1]), reverse=True
448
+ )[:limit]
449
+
450
+ lines = []
451
+ range_note = f" ({time_range})" if time_range else ""
452
+ lines.append(
453
+ f"### Top {len(sorted_files)} Most Changed Files{range_note}\n"
454
+ )
455
+
456
+ for rank, (file_path, commits) in enumerate(sorted_files, 1):
457
+ lines.append(f"{rank:>3}. {file_path} ({len(commits)} commits)")
458
+
459
+ return "\n".join(lines)
460
+
461
+ # SYMBOL MODE: Attribute changes to symbols
462
+ if mode in ("symbols", "co-change"):
463
+ # Sort files by commit count and take top 50 for symbol attribution
464
+ sorted_files = sorted(
465
+ file_commits.items(), key=lambda x: len(x[1]), reverse=True
466
+ )[:50]
467
+
468
+ symbol_commits: dict[str, set[str]] = defaultdict(set)
469
+ symbol_lines: dict[str, tuple[int, int]] = {} # node_id -> (added, removed)
470
+
471
+ for file_path, commits in sorted_files:
472
+ # Skip commits touching too many files (bulk refactors)
473
+ file_line_map = self._build_file_line_map(file_path)
474
+ if not file_line_map:
475
+ # No graph data for this file, count at file level
476
+ symbol_commits[f"FILE:{file_path}"].update(commits)
477
+ continue
478
+
479
+ # Get per-commit diffs for this file
480
+ for commit_hash in commits:
481
+ try:
482
+ diff_output = self._run_git(
483
+ [
484
+ "diff",
485
+ f"{commit_hash}~1..{commit_hash}",
486
+ "--",
487
+ file_path,
488
+ ],
489
+ timeout=10,
490
+ )
491
+ except GitError:
492
+ # First commit or other issue - skip
493
+ continue
494
+
495
+ hunks = self._parse_diff_hunks(diff_output)
496
+ touched_symbols = set()
497
+
498
+ for hunk in hunks:
499
+ # Map changed lines to symbols
500
+ line = hunk["new_start"]
501
+ for diff_line in hunk["lines"]:
502
+ if diff_line.startswith("+"):
503
+ symbol = file_line_map.get(line)
504
+ if symbol:
505
+ touched_symbols.add(symbol)
506
+ old_add, old_rem = symbol_lines.get(symbol, (0, 0))
507
+ symbol_lines[symbol] = (old_add + 1, old_rem)
508
+ line += 1
509
+ elif diff_line.startswith("-"):
510
+ # NOTE: Removed lines use new-side line numbers for symbol lookup,
511
+ # which is approximate when insertions shift line positions.
512
+ # Commit-count attribution (the primary ranking metric) is unaffected.
513
+ symbol = file_line_map.get(line)
514
+ if symbol:
515
+ touched_symbols.add(symbol)
516
+ old_add, old_rem = symbol_lines.get(symbol, (0, 0))
517
+ symbol_lines[symbol] = (old_add, old_rem + 1)
518
+ # Removed lines don't advance new-side line counter
519
+ else:
520
+ line += 1
521
+
522
+ for sym in touched_symbols:
523
+ symbol_commits[sym].add(commit_hash)
524
+
525
+ if not symbol_commits:
526
+ return (
527
+ "No symbol-level changes detected. Graph may not cover these files."
528
+ )
529
+
530
+ # Separate graph-resolved symbols (FILE:path::Symbol) from
531
+ # file-level fallbacks (FILE:path with no ::).
532
+ graph_resolved = {}
533
+ file_fallbacks = {}
534
+ for k, v in symbol_commits.items():
535
+ is_file_fallback = k.startswith("FILE:") and "::" not in k
536
+ if is_file_fallback:
537
+ ext = Path(k.replace("FILE:", "")).suffix.lower()
538
+ if ext not in self.code_extensions:
539
+ continue # Drop non-code FILE entries
540
+ file_fallbacks[k] = v
541
+ else:
542
+ graph_resolved[k] = v
543
+ # Prefer graph-resolved symbols; fall back to file-level only if none
544
+ symbol_commits = graph_resolved if graph_resolved else file_fallbacks
545
+
546
+ if mode == "co-change":
547
+ return self._format_co_change(symbol_commits, limit, file_commits)
548
+
549
+ # Sort by commit count
550
+ sorted_symbols = sorted(
551
+ symbol_commits.items(), key=lambda x: len(x[1]), reverse=True
552
+ )[:limit]
553
+
554
+ lines = []
555
+ range_note = f" ({time_range})" if time_range else ""
556
+ path_note = f" in {path_filter}" if path_filter else ""
557
+ lines.append(
558
+ f"### Top {len(sorted_symbols)} Most Changed Symbols{range_note}{path_note}\n"
559
+ )
560
+
561
+ for rank, (node_id, commits) in enumerate(sorted_symbols, 1):
562
+ type_badge, name, location = self._get_node_display(node_id)
563
+ added, removed = symbol_lines.get(node_id, (0, 0))
564
+ line_stats = f", +{added}/-{removed}" if added or removed else ""
565
+ lines.append(
566
+ f"{rank:>3}. {type_badge} {name} | {location} "
567
+ f"({len(commits)} commits{line_stats})"
568
+ )
569
+
570
+ return "\n".join(lines)
571
+
572
+ return f"Unknown mode '{mode}'. Use 'symbols', 'files', or 'co-change'."
573
+
574
+ def get_churn_structured(
575
+ self,
576
+ time_range: Optional[str] = None,
577
+ path_filter: Optional[str] = None,
578
+ limit: int = 20,
579
+ mode: str = "symbols",
580
+ exclude_generated: bool = True,
581
+ ) -> dict:
582
+ """Analyze code churn, returning structured data for UI rendering.
583
+
584
+ Returns dict with keys: mode, time_range, path_filter, and one of:
585
+ - symbols: list of {id, type, name, file, line, commits, added, removed}
586
+ - files: list of {file, commits}
587
+ - pairs: list of {a_id, a_name, a_file, b_id, b_name, b_file, shared_commits}
588
+ """
589
+ self._verify_git()
590
+
591
+ if path_filter:
592
+ _validate_pathspec(path_filter)
593
+
594
+ git_args = ["log", "--format=%H", "--name-only", "--no-merges"]
595
+ git_args.extend(self._parse_time_range(time_range))
596
+ if path_filter:
597
+ git_args.extend(["--", path_filter])
598
+ elif exclude_generated:
599
+ exclusions = []
600
+ for p in self.churn_exclusions:
601
+ _validate_pathspec(p)
602
+ exclusions.append(f":!{p}")
603
+ git_args.extend(["--", "."] + exclusions)
604
+
605
+ output = self._run_git(git_args, timeout=60)
606
+
607
+ if not output.strip():
608
+ return {"error": "No commits found for the given time range."}
609
+
610
+ file_commits: dict[str, set[str]] = defaultdict(set)
611
+ current_commit = None
612
+ for line in output.strip().split("\n"):
613
+ line = line.strip()
614
+ if not line:
615
+ continue
616
+ if len(line) == 40 and all(c in "0123456789abcdef" for c in line):
617
+ current_commit = line
618
+ elif current_commit:
619
+ file_commits[line].add(current_commit)
620
+
621
+ if not file_commits:
622
+ return {"error": "No file changes found for the given time range."}
623
+
624
+ base = {
625
+ "mode": mode,
626
+ "time_range": time_range or "",
627
+ "path_filter": path_filter or "",
628
+ }
629
+
630
+ # FILE MODE
631
+ if mode == "files":
632
+ sorted_files = sorted(
633
+ file_commits.items(), key=lambda x: len(x[1]), reverse=True
634
+ )[:limit]
635
+ base["files"] = [
636
+ {"file": fp, "commits": len(commits)} for fp, commits in sorted_files
637
+ ]
638
+ return base
639
+
640
+ # SYMBOL / CO-CHANGE: attribute changes to symbols
641
+ sorted_files = sorted(
642
+ file_commits.items(), key=lambda x: len(x[1]), reverse=True
643
+ )[:50]
644
+
645
+ symbol_commits: dict[str, set[str]] = defaultdict(set)
646
+ symbol_lines: dict[str, tuple[int, int]] = {}
647
+
648
+ for file_path, commits in sorted_files:
649
+ file_line_map = self._build_file_line_map(file_path)
650
+ if not file_line_map:
651
+ symbol_commits[f"FILE:{file_path}"].update(commits)
652
+ continue
653
+
654
+ for commit_hash in commits:
655
+ try:
656
+ diff_output = self._run_git(
657
+ ["diff", f"{commit_hash}~1..{commit_hash}", "--", file_path],
658
+ timeout=10,
659
+ )
660
+ except GitError:
661
+ continue
662
+
663
+ hunks = self._parse_diff_hunks(diff_output)
664
+ touched_symbols = set()
665
+
666
+ for hunk in hunks:
667
+ line = hunk["new_start"]
668
+ for diff_line in hunk["lines"]:
669
+ if diff_line.startswith("+"):
670
+ symbol = file_line_map.get(line)
671
+ if symbol:
672
+ touched_symbols.add(symbol)
673
+ old_add, old_rem = symbol_lines.get(symbol, (0, 0))
674
+ symbol_lines[symbol] = (old_add + 1, old_rem)
675
+ line += 1
676
+ elif diff_line.startswith("-"):
677
+ symbol = file_line_map.get(line)
678
+ if symbol:
679
+ touched_symbols.add(symbol)
680
+ old_add, old_rem = symbol_lines.get(symbol, (0, 0))
681
+ symbol_lines[symbol] = (old_add, old_rem + 1)
682
+ else:
683
+ line += 1
684
+
685
+ for sym in touched_symbols:
686
+ symbol_commits[sym].add(commit_hash)
687
+
688
+ if not symbol_commits:
689
+ return {
690
+ "error": "No symbol-level changes detected. Graph may not cover these files."
691
+ }
692
+
693
+ # Filter to graph-resolved symbols
694
+ graph_resolved = {}
695
+ file_fallbacks = {}
696
+ for k, v in symbol_commits.items():
697
+ is_file_fallback = k.startswith("FILE:") and "::" not in k
698
+ if is_file_fallback:
699
+ ext = Path(k.replace("FILE:", "")).suffix.lower()
700
+ if ext not in self.code_extensions:
701
+ continue
702
+ file_fallbacks[k] = v
703
+ else:
704
+ graph_resolved[k] = v
705
+ symbol_commits = graph_resolved if graph_resolved else file_fallbacks
706
+
707
+ # CO-CHANGE MODE
708
+ if mode == "co-change":
709
+ return self._structured_co_change(symbol_commits, limit, file_commits, base)
710
+
711
+ # SYMBOLS MODE
712
+ sorted_symbols = sorted(
713
+ symbol_commits.items(), key=lambda x: len(x[1]), reverse=True
714
+ )[:limit]
715
+
716
+ symbols = []
717
+ for node_id, commits in sorted_symbols:
718
+ type_badge, name, location = self._get_node_display(node_id)
719
+ file_path = ""
720
+ line = None
721
+ if ":" in location:
722
+ file_path, line_str = location.rsplit(":", 1)
723
+ try:
724
+ line = int(line_str)
725
+ except ValueError:
726
+ file_path = location
727
+ else:
728
+ file_path = location
729
+ added, removed = symbol_lines.get(node_id, (0, 0))
730
+ symbols.append(
731
+ {
732
+ "id": node_id,
733
+ "type": type_badge.strip("[]"),
734
+ "name": name,
735
+ "file": file_path,
736
+ "line": line,
737
+ "commits": len(commits),
738
+ "added": added,
739
+ "removed": removed,
740
+ }
741
+ )
742
+
743
+ base["symbols"] = symbols
744
+ return base
745
+
746
+ def _structured_co_change(
747
+ self,
748
+ symbol_commits: dict[str, set[str]],
749
+ limit: int,
750
+ file_commits: dict[str, set[str]],
751
+ base: dict,
752
+ ) -> dict:
753
+ """Build structured co-change data."""
754
+ graph_symbols = {k: v for k, v in symbol_commits.items() if "::" in k}
755
+
756
+ pairs = []
757
+ if graph_symbols:
758
+ commit_symbols: dict[str, set[str]] = defaultdict(set)
759
+ for sym, commits in graph_symbols.items():
760
+ for c in commits:
761
+ commit_symbols[c].add(sym)
762
+
763
+ pair_counts: dict[tuple[str, str], int] = defaultdict(int)
764
+ for symbols in commit_symbols.values():
765
+ sym_list = sorted(symbols)
766
+ for i in range(len(sym_list)):
767
+ for j in range(i + 1, len(sym_list)):
768
+ pair_counts[(sym_list[i], sym_list[j])] += 1
769
+
770
+ frequent_pairs = [
771
+ (pair, count) for pair, count in pair_counts.items() if count >= 2
772
+ ]
773
+ frequent_pairs.sort(key=lambda x: x[1], reverse=True)
774
+
775
+ for (sym_a, sym_b), count in frequent_pairs[:limit]:
776
+ _, name_a, loc_a = self._get_node_display(sym_a)
777
+ _, name_b, loc_b = self._get_node_display(sym_b)
778
+ pairs.append(
779
+ {
780
+ "a_id": sym_a,
781
+ "a_name": name_a,
782
+ "a_file": loc_a,
783
+ "b_id": sym_b,
784
+ "b_name": name_b,
785
+ "b_file": loc_b,
786
+ "shared_commits": count,
787
+ }
788
+ )
789
+
790
+ if not pairs and file_commits:
791
+ # Fall back to file-level co-change
792
+ code_files = {
793
+ f: commits
794
+ for f, commits in file_commits.items()
795
+ if Path(f).suffix.lower() in self.code_extensions
796
+ }
797
+ if code_files:
798
+ commit_files: dict[str, set[str]] = defaultdict(set)
799
+ for fp, commits in code_files.items():
800
+ for c in commits:
801
+ commit_files[c].add(fp)
802
+
803
+ pair_counts: dict[tuple[str, str], int] = defaultdict(int)
804
+ for files in commit_files.values():
805
+ file_list = sorted(files)
806
+ for i in range(len(file_list)):
807
+ for j in range(i + 1, len(file_list)):
808
+ pair_counts[(file_list[i], file_list[j])] += 1
809
+
810
+ frequent_pairs = [
811
+ (pair, count) for pair, count in pair_counts.items() if count >= 2
812
+ ]
813
+ frequent_pairs.sort(key=lambda x: x[1], reverse=True)
814
+
815
+ for (file_a, file_b), count in frequent_pairs[:limit]:
816
+ pairs.append(
817
+ {
818
+ "a_id": "",
819
+ "a_name": file_a,
820
+ "a_file": file_a,
821
+ "b_id": "",
822
+ "b_name": file_b,
823
+ "b_file": file_b,
824
+ "shared_commits": count,
825
+ }
826
+ )
827
+ base["file_level"] = True
828
+
829
+ base["pairs"] = pairs
830
+ return base
831
+
832
+ def _format_co_change(
833
+ self,
834
+ symbol_commits: dict[str, set[str]],
835
+ limit: int,
836
+ file_commits: Optional[dict[str, set[str]]] = None,
837
+ ) -> str:
838
+ """Format co-change analysis showing symbol pairs that change together.
839
+
840
+ Args:
841
+ symbol_commits: Mapping of symbol -> set of commit hashes.
842
+ limit: Max pairs to show.
843
+ file_commits: Optional file-level commit data for fallback.
844
+
845
+ Returns:
846
+ Formatted co-change analysis.
847
+ """
848
+ # Exclude file-level fallback entries (FILE:path with no ::) for meaningful co-change
849
+ graph_symbols = {k: v for k, v in symbol_commits.items() if "::" in k}
850
+
851
+ frequent_pairs = []
852
+ if graph_symbols:
853
+ # Build commit -> symbols map
854
+ commit_symbols: dict[str, set[str]] = defaultdict(set)
855
+ for sym, commits in graph_symbols.items():
856
+ for c in commits:
857
+ commit_symbols[c].add(sym)
858
+
859
+ # Count co-occurrences
860
+ pair_counts: dict[tuple[str, str], int] = defaultdict(int)
861
+ for symbols in commit_symbols.values():
862
+ sym_list = sorted(symbols)
863
+ for i in range(len(sym_list)):
864
+ for j in range(i + 1, len(sym_list)):
865
+ pair_counts[(sym_list[i], sym_list[j])] += 1
866
+
867
+ # Filter to pairs with >1 co-occurrence
868
+ frequent_pairs = [
869
+ (pair, count) for pair, count in pair_counts.items() if count >= 2
870
+ ]
871
+ frequent_pairs.sort(key=lambda x: x[1], reverse=True)
872
+ frequent_pairs = frequent_pairs[:limit]
873
+
874
+ if frequent_pairs:
875
+ lines = [f"### Top {len(frequent_pairs)} Co-Change Pairs\n"]
876
+ lines.append("Symbols that frequently change in the same commit:\n")
877
+
878
+ for rank, ((sym_a, sym_b), count) in enumerate(frequent_pairs, 1):
879
+ _, name_a, loc_a = self._get_node_display(sym_a)
880
+ _, name_b, loc_b = self._get_node_display(sym_b)
881
+ loc_suffix_a = f" ({loc_a})" if loc_a else ""
882
+ loc_suffix_b = f" ({loc_b})" if loc_b else ""
883
+ lines.append(
884
+ f"{rank:>3}. {name_a}{loc_suffix_a} <-> "
885
+ f"{name_b}{loc_suffix_b} ({count} shared commits)"
886
+ )
887
+
888
+ return "\n".join(lines)
889
+
890
+ # Fall back to file-level co-change when symbol data is sparse
891
+ if file_commits:
892
+ return self._format_file_co_change(file_commits, limit)
893
+
894
+ return "No co-change patterns found (need pairs with 2+ shared commits)."
895
+
896
+ def _format_file_co_change(
897
+ self, file_commits: dict[str, set[str]], limit: int
898
+ ) -> str:
899
+ """Format co-change analysis at the file level (fallback).
900
+
901
+ Args:
902
+ file_commits: Mapping of file path -> set of commit hashes.
903
+ limit: Max pairs to show.
904
+
905
+ Returns:
906
+ Formatted file-level co-change analysis.
907
+ """
908
+ # Filter to code files only
909
+ code_files = {
910
+ f: commits
911
+ for f, commits in file_commits.items()
912
+ if Path(f).suffix.lower() in self.code_extensions
913
+ }
914
+ if not code_files:
915
+ return "No co-change patterns found in code files."
916
+
917
+ # Build commit -> files map
918
+ commit_files: dict[str, set[str]] = defaultdict(set)
919
+ for fp, commits in code_files.items():
920
+ for c in commits:
921
+ commit_files[c].add(fp)
922
+
923
+ # Count co-occurrences
924
+ pair_counts: dict[tuple[str, str], int] = defaultdict(int)
925
+ for files in commit_files.values():
926
+ file_list = sorted(files)
927
+ for i in range(len(file_list)):
928
+ for j in range(i + 1, len(file_list)):
929
+ pair_counts[(file_list[i], file_list[j])] += 1
930
+
931
+ frequent_pairs = [
932
+ (pair, count) for pair, count in pair_counts.items() if count >= 2
933
+ ]
934
+ frequent_pairs.sort(key=lambda x: x[1], reverse=True)
935
+ frequent_pairs = frequent_pairs[:limit]
936
+
937
+ if not frequent_pairs:
938
+ return "No co-change patterns found (need pairs with 2+ shared commits)."
939
+
940
+ lines = [f"### Top {len(frequent_pairs)} Co-Change Pairs (file-level)\n"]
941
+ lines.append(
942
+ "*Symbol-level data was too sparse; showing file-level co-changes:*\n"
943
+ )
944
+
945
+ for rank, ((file_a, file_b), count) in enumerate(frequent_pairs, 1):
946
+ lines.append(f"{rank:>3}. {file_a} <-> {file_b} ({count} shared commits)")
947
+
948
+ return "\n".join(lines)
949
+
950
+ def get_evolution(
951
+ self,
952
+ name: str,
953
+ time_range: Optional[str] = None,
954
+ limit: int = 10,
955
+ show_diff: bool = False,
956
+ crate: Optional[str] = None,
957
+ ) -> str:
958
+ """Track the evolution of a specific symbol over time.
959
+
960
+ Args:
961
+ name: Symbol name (fuzzy-matched via graph querier).
962
+ time_range: Time range filter.
963
+ limit: Maximum commits to show.
964
+ show_diff: Include actual diff hunks.
965
+
966
+ Returns:
967
+ Formatted evolution timeline.
968
+ """
969
+ self._verify_git()
970
+
971
+ # Resolve symbol
972
+ file_path = None
973
+ symbol_name = name
974
+ resolved_node_id = None
975
+ match_count = 0
976
+
977
+ if self.querier:
978
+ matches = self.querier.find_nodes_by_name(name)
979
+ # Prefer functions/methods
980
+ func_matches = [m for m in matches if m["type"] in ("Function", "Method")]
981
+ if func_matches:
982
+ matches = func_matches
983
+
984
+ if not matches:
985
+ matches = self.querier.find_nodes_by_name(name, fuzzy=True)
986
+ func_matches = [
987
+ m for m in matches if m["type"] in ("Function", "Method")
988
+ ]
989
+ if func_matches:
990
+ matches = func_matches
991
+
992
+ if matches:
993
+ all_matches = list(matches)
994
+ match_count = len(all_matches)
995
+
996
+ # Filter by crate if specified
997
+ if crate:
998
+ crate_filtered = [
999
+ m for m in matches if crate.lower() in m["id"].lower()
1000
+ ]
1001
+ if crate_filtered:
1002
+ matches = crate_filtered
1003
+
1004
+ # Rank by in_degree (callers), non-test preferred
1005
+ if len(matches) > 1:
1006
+
1007
+ def rank_match(m):
1008
+ in_degree = m.get("metadata", {}).get("in_degree", 0)
1009
+ is_test = "test" in m["id"].lower()
1010
+ return (-in_degree, is_test)
1011
+
1012
+ matches.sort(key=rank_match)
1013
+
1014
+ best = matches[0]
1015
+ resolved_node_id = best["id"]
1016
+ symbol_name = best["metadata"].get("name", name)
1017
+ # Extract file path from node ID
1018
+ if best["id"].startswith("FILE:"):
1019
+ file_path = best["id"].split("::")[0].replace("FILE:", "")
1020
+
1021
+ if not file_path:
1022
+ return (
1023
+ f"Could not resolve symbol '{name}'. "
1024
+ "Try descry_search to find the exact name."
1025
+ )
1026
+
1027
+ # Try git log -L :name:file (git's native function tracking)
1028
+ # This works for top-level functions but fails for methods/nested fns
1029
+ # Note: '--' separator does NOT protect -L (it's a flag with inline value);
1030
+ # symbol and file must be strictly validated before interpolation.
1031
+ _validate_git_symbol(symbol_name)
1032
+ _validate_git_file(file_path)
1033
+ git_args = [
1034
+ "log",
1035
+ f"-L:{symbol_name}:{file_path}",
1036
+ "--format=%H %ad %an%n%s",
1037
+ "--date=short",
1038
+ "--no-merges",
1039
+ ]
1040
+ git_args.extend(self._parse_time_range(time_range))
1041
+
1042
+ try:
1043
+ output = self._run_git(git_args, timeout=30)
1044
+ except GitError:
1045
+ output = None
1046
+
1047
+ # Fallback: try line-range based -L using descry metadata
1048
+ if not output and resolved_node_id and self.querier:
1049
+ node_info = self.querier.get_node_info(resolved_node_id)
1050
+ if node_info:
1051
+ meta = node_info.get("metadata", {})
1052
+ start_line = meta.get("lineno")
1053
+ end_line = meta.get("end_lineno")
1054
+ if start_line and end_line:
1055
+ # file_path already validated above; line numbers from
1056
+ # metadata are trusted ints.
1057
+ _validate_git_file(file_path)
1058
+ git_args = [
1059
+ "log",
1060
+ f"-L{int(start_line)},{int(end_line)}:{file_path}",
1061
+ "--format=%H %ad %an%n%s",
1062
+ "--date=short",
1063
+ "--no-merges",
1064
+ ]
1065
+ git_args.extend(self._parse_time_range(time_range))
1066
+ try:
1067
+ output = self._run_git(git_args, timeout=30)
1068
+ except GitError:
1069
+ output = None
1070
+
1071
+ # Build disambiguation note if multiple matches
1072
+ disambig_note = ""
1073
+ if match_count > 1:
1074
+ _, _, location = (
1075
+ self._get_node_display(resolved_node_id)
1076
+ if resolved_node_id
1077
+ else ("", "", file_path)
1078
+ )
1079
+ disambig_note = (
1080
+ f"\n*{match_count} symbols named `{name}` — "
1081
+ f"showing: {location}. Use `crate` to disambiguate.*"
1082
+ )
1083
+
1084
+ if output and output.strip():
1085
+ result = self._format_evolution_from_log_l(
1086
+ output, symbol_name, file_path, resolved_node_id, limit, show_diff
1087
+ )
1088
+ return result + disambig_note
1089
+
1090
+ # Fallback: simple file log filtered by symbol
1091
+ git_args = ["log", "--format=%H %ad %an%n%s", "--date=short", "--no-merges"]
1092
+ git_args.extend(self._parse_time_range(time_range))
1093
+ git_args.extend(["--", file_path])
1094
+
1095
+ try:
1096
+ output = self._run_git(git_args, timeout=30)
1097
+ except GitError as e:
1098
+ return f"Error getting history: {e}"
1099
+
1100
+ if not output.strip():
1101
+ return f"No commits found for '{symbol_name}' in {file_path}."
1102
+
1103
+ result = self._format_evolution_fallback(
1104
+ output, symbol_name, file_path, resolved_node_id, limit, show_diff
1105
+ )
1106
+ return result + disambig_note
1107
+
1108
+ def _format_evolution_from_log_l(
1109
+ self,
1110
+ output: str,
1111
+ symbol_name: str,
1112
+ file_path: str,
1113
+ node_id: Optional[str],
1114
+ limit: int,
1115
+ show_diff: bool,
1116
+ ) -> str:
1117
+ """Format evolution from git log -L output.
1118
+
1119
+ The output format is: commit_hash date author\nsubject\ndiff...
1120
+ """
1121
+ commits = []
1122
+ current_commit = None
1123
+ diff_lines = []
1124
+ authors = set()
1125
+
1126
+ for line in output.split("\n"):
1127
+ # New commit line: hash date author
1128
+ match = re.match(r"^([0-9a-f]{40})\s+(\S+)\s+(.+)$", line)
1129
+ if match:
1130
+ if current_commit:
1131
+ current_commit["diff"] = "\n".join(diff_lines)
1132
+ commits.append(current_commit)
1133
+ diff_lines = []
1134
+
1135
+ current_commit = {
1136
+ "hash": match.group(1)[:8],
1137
+ "date": match.group(2),
1138
+ "author": match.group(3).strip(),
1139
+ "subject": "",
1140
+ "diff": "",
1141
+ "added": 0,
1142
+ "removed": 0,
1143
+ }
1144
+ authors.add(match.group(3).strip())
1145
+ elif current_commit and not current_commit["subject"]:
1146
+ current_commit["subject"] = line.strip()
1147
+ elif current_commit:
1148
+ diff_lines.append(line)
1149
+ if line.startswith("+") and not line.startswith("+++"):
1150
+ current_commit["added"] += 1
1151
+ elif line.startswith("-") and not line.startswith("---"):
1152
+ current_commit["removed"] += 1
1153
+
1154
+ if current_commit:
1155
+ current_commit["diff"] = "\n".join(diff_lines)
1156
+ commits.append(current_commit)
1157
+
1158
+ commits = commits[:limit]
1159
+
1160
+ if not commits:
1161
+ return f"No evolution data found for '{symbol_name}'."
1162
+
1163
+ # Format output
1164
+ location = file_path
1165
+ if node_id:
1166
+ _, _, loc = self._get_node_display(node_id)
1167
+ if loc:
1168
+ location = loc
1169
+
1170
+ # Calculate date span
1171
+ dates = [c["date"] for c in commits]
1172
+ date_span = f"{dates[-1]} to {dates[0]}" if len(dates) > 1 else dates[0]
1173
+
1174
+ lines = [f"### Evolution of `{symbol_name}` ({location})\n"]
1175
+ lines.append(
1176
+ f"{len(commits)} commit(s) | {len(authors)} author(s) | {date_span}\n"
1177
+ )
1178
+
1179
+ shallow = (
1180
+ " (shallow clone - history may be incomplete)" if self._is_shallow() else ""
1181
+ )
1182
+ if shallow:
1183
+ lines.append(f"*Warning: {shallow}*\n")
1184
+
1185
+ for i, commit in enumerate(commits, 1):
1186
+ lines.append(
1187
+ f"{i}. **{commit['hash']}** ({commit['date']}) {commit['author']}"
1188
+ )
1189
+ lines.append(f" {commit['subject']}")
1190
+ if commit["added"] or commit["removed"]:
1191
+ lines.append(f" +{commit['added']}/-{commit['removed']} lines")
1192
+
1193
+ if show_diff and commit["diff"].strip():
1194
+ # Show condensed diff - indent content for markdown list nesting
1195
+ diff_text = commit["diff"].strip()
1196
+ diff_lines_raw = diff_text.split("\n")[:30]
1197
+ # Indent each line by 3 spaces so marked.js keeps it inside the list item
1198
+ indented = "\n".join(" " + dl for dl in diff_lines_raw)
1199
+ if len(diff_text.split("\n")) > 30:
1200
+ indented += "\n ... (truncated)"
1201
+ lines.append(f" ```diff\n{indented}\n ```")
1202
+ lines.append("")
1203
+
1204
+ return "\n".join(lines)
1205
+
1206
+ def _format_evolution_fallback(
1207
+ self,
1208
+ output: str,
1209
+ symbol_name: str,
1210
+ file_path: str,
1211
+ node_id: Optional[str],
1212
+ limit: int,
1213
+ show_diff: bool,
1214
+ ) -> str:
1215
+ """Format evolution using simple file-level log (fallback mode)."""
1216
+ commits = []
1217
+ authors = set()
1218
+
1219
+ for line in output.strip().split("\n"):
1220
+ match = re.match(r"^([0-9a-f]{40})\s+(\S+)\s+(.+)$", line)
1221
+ if match:
1222
+ commits.append(
1223
+ {
1224
+ "hash": match.group(1)[:8],
1225
+ "date": match.group(2),
1226
+ "author": match.group(3).strip(),
1227
+ "subject": "",
1228
+ }
1229
+ )
1230
+ authors.add(match.group(3).strip())
1231
+ elif commits and not commits[-1]["subject"]:
1232
+ commits[-1]["subject"] = line.strip()
1233
+
1234
+ commits = commits[:limit]
1235
+
1236
+ if not commits:
1237
+ return f"No commits found for '{symbol_name}' in {file_path}."
1238
+
1239
+ location = file_path
1240
+ if node_id:
1241
+ _, _, loc = self._get_node_display(node_id)
1242
+ if loc:
1243
+ location = loc
1244
+
1245
+ lines = [f"### Evolution of `{symbol_name}` ({location})\n"]
1246
+ note = "*Note: Using file-level history (git log -L failed for this symbol)*"
1247
+ if show_diff:
1248
+ note += "\n*Diffs were requested but are not available in file-level fallback mode.*"
1249
+ lines.append(note + "\n")
1250
+ lines.append(f"{len(commits)} commit(s) | {len(authors)} author(s)\n")
1251
+
1252
+ for i, commit in enumerate(commits, 1):
1253
+ lines.append(
1254
+ f"{i}. **{commit['hash']}** ({commit['date']}) {commit['author']}"
1255
+ )
1256
+ lines.append(f" {commit['subject']}")
1257
+ lines.append("")
1258
+
1259
+ return "\n".join(lines)
1260
+
1261
+ def get_changes(
1262
+ self,
1263
+ commit_range: Optional[str] = None,
1264
+ time_range: Optional[str] = None,
1265
+ path_filter: Optional[str] = None,
1266
+ show_callers: bool = True,
1267
+ limit: int = 50,
1268
+ ) -> str:
1269
+ """Analyze change impact for a commit range.
1270
+
1271
+ Args:
1272
+ commit_range: Git commit range (e.g., "HEAD~5..HEAD").
1273
+ time_range: Time range (e.g., "last 7 days"). Used if commit_range not given.
1274
+ path_filter: Path filter (e.g., "backend/").
1275
+ show_callers: Include callers of changed symbols.
1276
+ limit: Maximum symbols to show.
1277
+
1278
+ Returns:
1279
+ Formatted impact analysis.
1280
+ """
1281
+ self._verify_git()
1282
+
1283
+ # Determine effective commit range. User-supplied commit_range is
1284
+ # validated; internally-built ranges use trusted sha output from
1285
+ # `git log --format=%H`.
1286
+ if commit_range:
1287
+ try:
1288
+ _validate_git_range(commit_range)
1289
+ except GitError as e:
1290
+ return f"Invalid commit range '{commit_range}': {e}"
1291
+ effective_range = commit_range
1292
+ if not effective_range:
1293
+ if time_range:
1294
+ # Get commits in time range and build a range
1295
+ time_flags = self._parse_time_range(time_range)
1296
+ git_args = ["log", "--format=%H", "--no-merges"] + time_flags
1297
+ output = self._run_git(git_args, timeout=30)
1298
+ commit_hashes = [
1299
+ h.strip() for h in output.strip().split("\n") if h.strip()
1300
+ ]
1301
+ if not commit_hashes:
1302
+ return "No commits found for the given time range."
1303
+ # oldest..newest
1304
+ effective_range = f"{commit_hashes[-1]}~1..{commit_hashes[0]}"
1305
+ else:
1306
+ effective_range = "HEAD~1..HEAD"
1307
+
1308
+ if path_filter:
1309
+ try:
1310
+ _validate_pathspec(path_filter)
1311
+ except GitError as e:
1312
+ return f"Invalid path filter '{path_filter}': {e}"
1313
+
1314
+ # Get changed files with stats
1315
+ diff_stat_args = ["diff", "--numstat", effective_range]
1316
+ if path_filter:
1317
+ diff_stat_args.extend(["--", path_filter])
1318
+
1319
+ try:
1320
+ numstat_output = self._run_git(diff_stat_args, timeout=30)
1321
+ except GitError as e:
1322
+ return f"Invalid commit range '{effective_range}': {e}"
1323
+
1324
+ if not numstat_output.strip():
1325
+ return f"No changes found in {effective_range}."
1326
+
1327
+ # Parse numstat output
1328
+ changed_files: list[dict] = []
1329
+ for line in numstat_output.strip().split("\n"):
1330
+ parts = line.split("\t")
1331
+ if len(parts) == 3:
1332
+ added = int(parts[0]) if parts[0] != "-" else 0
1333
+ removed = int(parts[1]) if parts[1] != "-" else 0
1334
+ changed_files.append(
1335
+ {
1336
+ "file": parts[2],
1337
+ "added": added,
1338
+ "removed": removed,
1339
+ }
1340
+ )
1341
+
1342
+ # Count commits in range
1343
+ try:
1344
+ commit_count_output = self._run_git(
1345
+ ["rev-list", "--count", effective_range], timeout=10
1346
+ )
1347
+ commit_count = int(commit_count_output.strip())
1348
+ except (GitError, ValueError):
1349
+ commit_count = 0
1350
+
1351
+ # Get full diff for symbol attribution
1352
+ diff_args = ["diff", effective_range]
1353
+ if path_filter:
1354
+ diff_args.extend(["--", path_filter])
1355
+
1356
+ try:
1357
+ diff_output = self._run_git(diff_args, timeout=60)
1358
+ except GitError:
1359
+ diff_output = ""
1360
+
1361
+ hunks = self._parse_diff_hunks(diff_output) if diff_output else []
1362
+
1363
+ # Attribute changes to symbols
1364
+ modified_symbols: dict[str, dict] = {} # node_id -> {added, removed}
1365
+
1366
+ # Group hunks by file
1367
+ file_hunks: dict[str, list[dict]] = defaultdict(list)
1368
+ for hunk in hunks:
1369
+ file_hunks[hunk["file"]].append(hunk)
1370
+
1371
+ for file_path, fhunks in file_hunks.items():
1372
+ line_map = self._build_file_line_map(file_path)
1373
+ if not line_map:
1374
+ continue
1375
+
1376
+ for hunk in fhunks:
1377
+ line = hunk["new_start"]
1378
+ for diff_line in hunk["lines"]:
1379
+ if diff_line.startswith("+"):
1380
+ symbol = line_map.get(line)
1381
+ if symbol:
1382
+ if symbol not in modified_symbols:
1383
+ modified_symbols[symbol] = {"added": 0, "removed": 0}
1384
+ modified_symbols[symbol]["added"] += 1
1385
+ line += 1
1386
+ elif diff_line.startswith("-"):
1387
+ # NOTE: For removed lines, symbol attribution uses new-side line numbers
1388
+ # (approximate — old-side would require building line map from pre-change state).
1389
+ # The +N/-M stats may be slightly inaccurate but commit-level attribution is correct.
1390
+ symbol = line_map.get(line)
1391
+ if symbol:
1392
+ if symbol not in modified_symbols:
1393
+ modified_symbols[symbol] = {"added": 0, "removed": 0}
1394
+ modified_symbols[symbol]["removed"] += 1
1395
+ else:
1396
+ line += 1
1397
+
1398
+ # Format output
1399
+ lines = []
1400
+ range_display = commit_range or time_range or "HEAD~1..HEAD"
1401
+ count_note = (
1402
+ f" ({commit_count} commit{'s' if commit_count != 1 else ''})"
1403
+ if commit_count
1404
+ else ""
1405
+ )
1406
+ lines.append(f"### Change Impact: {range_display}{count_note}\n")
1407
+ lines.append(
1408
+ f"Scope: {len(changed_files)} file(s) | "
1409
+ f"{len(modified_symbols)} symbol(s) affected\n"
1410
+ )
1411
+
1412
+ if not modified_symbols:
1413
+ # Show file-level summary if no symbol attribution possible
1414
+ lines.append(
1415
+ "*No symbol-level attribution available (graph may not cover these files).*\n"
1416
+ )
1417
+ lines.append("#### Changed Files")
1418
+ for f in changed_files[:limit]:
1419
+ lines.append(f" - {f['file']} (+{f['added']}/-{f['removed']})")
1420
+ return "\n".join(lines)
1421
+
1422
+ # Sort modified symbols by total change magnitude
1423
+ sorted_modified = sorted(
1424
+ modified_symbols.items(),
1425
+ key=lambda x: x[1]["added"] + x[1]["removed"],
1426
+ reverse=True,
1427
+ )[:limit]
1428
+
1429
+ lines.append(f"#### Modified ({len(sorted_modified)})")
1430
+ for rank, (node_id, stats) in enumerate(sorted_modified, 1):
1431
+ type_badge, name, location = self._get_node_display(node_id)
1432
+ lines.append(
1433
+ f"{rank:>3}. {type_badge} {name} | {location} "
1434
+ f"(+{stats['added']}/-{stats['removed']})"
1435
+ )
1436
+
1437
+ # Show callers if requested
1438
+ if show_callers and self.querier:
1439
+ sym_name = name.split(".")[-1] # Get base name
1440
+ callers = self.querier.get_callers(sym_name)
1441
+ if callers:
1442
+ caller_names = []
1443
+ for caller_id in callers[:5]:
1444
+ c_node = self.querier.get_node_info(caller_id)
1445
+ if c_node:
1446
+ caller_names.append(c_node["metadata"].get("name", "?"))
1447
+ else:
1448
+ # Extract name from node ID
1449
+ parts = caller_id.split("::")
1450
+ caller_names.append(parts[-1] if parts else "?")
1451
+
1452
+ more = f", +{len(callers) - 5} more" if len(callers) > 5 else ""
1453
+ lines.append(f" Callers: {', '.join(caller_names)}{more}")
1454
+
1455
+ return "\n".join(lines)
1456
+
1457
+ def get_changes_structured(
1458
+ self,
1459
+ commit_range: Optional[str] = None,
1460
+ time_range: Optional[str] = None,
1461
+ path_filter: Optional[str] = None,
1462
+ show_callers: bool = True,
1463
+ limit: int = 50,
1464
+ ) -> dict:
1465
+ """Analyze change impact, returning structured data for UI rendering.
1466
+
1467
+ Returns dict with keys: range, commits, files_count, symbols_count,
1468
+ symbols (list), changed_files (list when no symbol attribution).
1469
+ """
1470
+ self._verify_git()
1471
+
1472
+ if commit_range:
1473
+ try:
1474
+ _validate_git_range(commit_range)
1475
+ except GitError as e:
1476
+ return {"error": f"Invalid commit range '{commit_range}': {e}"}
1477
+ effective_range = commit_range
1478
+ if not effective_range:
1479
+ if time_range:
1480
+ time_flags = self._parse_time_range(time_range)
1481
+ git_args = ["log", "--format=%H", "--no-merges"] + time_flags
1482
+ output = self._run_git(git_args, timeout=30)
1483
+ commit_hashes = [
1484
+ h.strip() for h in output.strip().split("\n") if h.strip()
1485
+ ]
1486
+ if not commit_hashes:
1487
+ return {"error": "No commits found for the given time range."}
1488
+ effective_range = f"{commit_hashes[-1]}~1..{commit_hashes[0]}"
1489
+ else:
1490
+ effective_range = "HEAD~1..HEAD"
1491
+
1492
+ if path_filter:
1493
+ try:
1494
+ _validate_pathspec(path_filter)
1495
+ except GitError as e:
1496
+ return {"error": f"Invalid path filter '{path_filter}': {e}"}
1497
+
1498
+ diff_stat_args = ["diff", "--numstat", effective_range]
1499
+ if path_filter:
1500
+ diff_stat_args.extend(["--", path_filter])
1501
+
1502
+ try:
1503
+ numstat_output = self._run_git(diff_stat_args, timeout=30)
1504
+ except GitError as e:
1505
+ return {"error": f"Invalid commit range '{effective_range}': {e}"}
1506
+
1507
+ if not numstat_output.strip():
1508
+ return {"error": f"No changes found in {effective_range}."}
1509
+
1510
+ changed_files: list[dict] = []
1511
+ for line in numstat_output.strip().split("\n"):
1512
+ parts = line.split("\t")
1513
+ if len(parts) == 3:
1514
+ added = int(parts[0]) if parts[0] != "-" else 0
1515
+ removed = int(parts[1]) if parts[1] != "-" else 0
1516
+ changed_files.append(
1517
+ {"file": parts[2], "added": added, "removed": removed}
1518
+ )
1519
+
1520
+ try:
1521
+ commit_count_output = self._run_git(
1522
+ ["rev-list", "--count", effective_range], timeout=10
1523
+ )
1524
+ commit_count = int(commit_count_output.strip())
1525
+ except (GitError, ValueError):
1526
+ commit_count = 0
1527
+
1528
+ diff_args = ["diff", effective_range]
1529
+ if path_filter:
1530
+ diff_args.extend(["--", path_filter])
1531
+
1532
+ try:
1533
+ diff_output = self._run_git(diff_args, timeout=60)
1534
+ except GitError:
1535
+ diff_output = ""
1536
+
1537
+ hunks = self._parse_diff_hunks(diff_output) if diff_output else []
1538
+
1539
+ modified_symbols: dict[str, dict] = {}
1540
+ file_hunks: dict[str, list[dict]] = defaultdict(list)
1541
+ for hunk in hunks:
1542
+ file_hunks[hunk["file"]].append(hunk)
1543
+
1544
+ for file_path, fhunks in file_hunks.items():
1545
+ line_map = self._build_file_line_map(file_path)
1546
+ if not line_map:
1547
+ continue
1548
+ for hunk in fhunks:
1549
+ line = hunk["new_start"]
1550
+ for diff_line in hunk["lines"]:
1551
+ if diff_line.startswith("+"):
1552
+ symbol = line_map.get(line)
1553
+ if symbol:
1554
+ if symbol not in modified_symbols:
1555
+ modified_symbols[symbol] = {"added": 0, "removed": 0}
1556
+ modified_symbols[symbol]["added"] += 1
1557
+ line += 1
1558
+ elif diff_line.startswith("-"):
1559
+ symbol = line_map.get(line)
1560
+ if symbol:
1561
+ if symbol not in modified_symbols:
1562
+ modified_symbols[symbol] = {"added": 0, "removed": 0}
1563
+ modified_symbols[symbol]["removed"] += 1
1564
+ else:
1565
+ line += 1
1566
+
1567
+ range_display = commit_range or time_range or "HEAD~1..HEAD"
1568
+
1569
+ result = {
1570
+ "range": range_display,
1571
+ "commits": commit_count,
1572
+ "files_count": len(changed_files),
1573
+ "symbols_count": len(modified_symbols),
1574
+ }
1575
+
1576
+ if not modified_symbols:
1577
+ result["changed_files"] = changed_files[:limit]
1578
+ return result
1579
+
1580
+ sorted_modified = sorted(
1581
+ modified_symbols.items(),
1582
+ key=lambda x: x[1]["added"] + x[1]["removed"],
1583
+ reverse=True,
1584
+ )[:limit]
1585
+
1586
+ symbols = []
1587
+ for node_id, stats in sorted_modified:
1588
+ type_badge, name, location = self._get_node_display(node_id)
1589
+ file_path = ""
1590
+ line = None
1591
+ if ":" in location:
1592
+ file_path, line_str = location.rsplit(":", 1)
1593
+ try:
1594
+ line = int(line_str)
1595
+ except ValueError:
1596
+ file_path = location
1597
+ else:
1598
+ file_path = location
1599
+
1600
+ entry = {
1601
+ "id": node_id,
1602
+ "type": type_badge.strip("[]"),
1603
+ "name": name,
1604
+ "file": file_path,
1605
+ "line": line,
1606
+ "added": stats["added"],
1607
+ "removed": stats["removed"],
1608
+ }
1609
+
1610
+ if show_callers and self.querier:
1611
+ sym_name = name.split(".")[-1]
1612
+ caller_ids = self.querier.get_callers(sym_name)
1613
+ if caller_ids:
1614
+ callers = []
1615
+ for caller_id in caller_ids[:5]:
1616
+ c_node = self.querier.get_node_info(caller_id)
1617
+ c_name = (
1618
+ c_node["metadata"].get("name", "?")
1619
+ if c_node
1620
+ else caller_id.split("::")[-1]
1621
+ if "::" in caller_id
1622
+ else "?"
1623
+ )
1624
+ callers.append({"id": caller_id, "name": c_name})
1625
+ entry["callers"] = callers
1626
+ entry["callers_total"] = len(caller_ids)
1627
+
1628
+ symbols.append(entry)
1629
+
1630
+ result["symbols"] = symbols
1631
+ return result