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/__init__.py +4 -0
- descry/_env.py +30 -0
- descry/_graph.py +36 -0
- descry/ast_grep.py +382 -0
- descry/cli.py +457 -0
- descry/cross_lang.py +404 -0
- descry/embeddings.py +593 -0
- descry/generate.py +3579 -0
- descry/git_history.py +1631 -0
- descry/handlers.py +1907 -0
- descry/mcp_server.py +355 -0
- descry/query.py +2345 -0
- descry/scip/__init__.py +0 -0
- descry/scip/cache.py +1047 -0
- descry/scip/parser.py +531 -0
- descry/scip/pb2.py +92 -0
- descry/scip/support.py +174 -0
- descry/tree_sitter_parser.py +356 -0
- descry/web/__init__.py +0 -0
- descry/web/server.py +1457 -0
- descry/web/web/index.html +2444 -0
- descry/web/web/style.css +1791 -0
- descry_codegraph-0.1.0.dist-info/METADATA +345 -0
- descry_codegraph-0.1.0.dist-info/RECORD +27 -0
- descry_codegraph-0.1.0.dist-info/WHEEL +4 -0
- descry_codegraph-0.1.0.dist-info/entry_points.txt +5 -0
- descry_codegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|