cicada-mcp 0.1.4__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.
Potentially problematic release.
This version of cicada-mcp might be problematic. Click here for more details.
- cicada/__init__.py +30 -0
- cicada/clean.py +297 -0
- cicada/command_logger.py +293 -0
- cicada/dead_code_analyzer.py +282 -0
- cicada/extractors/__init__.py +36 -0
- cicada/extractors/base.py +66 -0
- cicada/extractors/call.py +176 -0
- cicada/extractors/dependency.py +361 -0
- cicada/extractors/doc.py +179 -0
- cicada/extractors/function.py +246 -0
- cicada/extractors/module.py +123 -0
- cicada/extractors/spec.py +151 -0
- cicada/find_dead_code.py +270 -0
- cicada/formatter.py +918 -0
- cicada/git_helper.py +646 -0
- cicada/indexer.py +629 -0
- cicada/install.py +724 -0
- cicada/keyword_extractor.py +364 -0
- cicada/keyword_search.py +553 -0
- cicada/lightweight_keyword_extractor.py +298 -0
- cicada/mcp_server.py +1559 -0
- cicada/mcp_tools.py +291 -0
- cicada/parser.py +124 -0
- cicada/pr_finder.py +435 -0
- cicada/pr_indexer/__init__.py +20 -0
- cicada/pr_indexer/cli.py +62 -0
- cicada/pr_indexer/github_api_client.py +431 -0
- cicada/pr_indexer/indexer.py +297 -0
- cicada/pr_indexer/line_mapper.py +209 -0
- cicada/pr_indexer/pr_index_builder.py +253 -0
- cicada/setup.py +339 -0
- cicada/utils/__init__.py +52 -0
- cicada/utils/call_site_formatter.py +95 -0
- cicada/utils/function_grouper.py +57 -0
- cicada/utils/hash_utils.py +173 -0
- cicada/utils/index_utils.py +290 -0
- cicada/utils/path_utils.py +240 -0
- cicada/utils/signature_builder.py +106 -0
- cicada/utils/storage.py +111 -0
- cicada/utils/subprocess_runner.py +182 -0
- cicada/utils/text_utils.py +90 -0
- cicada/version_check.py +116 -0
- cicada_mcp-0.1.4.dist-info/METADATA +619 -0
- cicada_mcp-0.1.4.dist-info/RECORD +48 -0
- cicada_mcp-0.1.4.dist-info/WHEEL +5 -0
- cicada_mcp-0.1.4.dist-info/entry_points.txt +8 -0
- cicada_mcp-0.1.4.dist-info/licenses/LICENSE +21 -0
- cicada_mcp-0.1.4.dist-info/top_level.txt +1 -0
cicada/git_helper.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git integration - extract commit history and file changes
|
|
3
|
+
|
|
4
|
+
This module provides access to git commit history using GitPython.
|
|
5
|
+
It complements pr_finder.py (which provides PR attribution) by
|
|
6
|
+
offering comprehensive commit history for files and functions.
|
|
7
|
+
|
|
8
|
+
Author: Cursor(Auto)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import git
|
|
12
|
+
import subprocess
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import List, Dict, Optional
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitHelper:
|
|
19
|
+
"""Helper class for extracting git commit history"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, repo_path: str):
|
|
22
|
+
"""
|
|
23
|
+
Initialize GitHelper with a repository path
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
repo_path: Path to git repository
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
git.InvalidGitRepositoryError: If path is not a git repository
|
|
30
|
+
"""
|
|
31
|
+
self.repo = git.Repo(repo_path)
|
|
32
|
+
self.repo_path = Path(repo_path)
|
|
33
|
+
|
|
34
|
+
def get_file_history(self, file_path: str, max_commits: int = 10) -> List[Dict]:
|
|
35
|
+
"""
|
|
36
|
+
Get commit history for a specific file
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
file_path: Relative path to file from repo root
|
|
40
|
+
max_commits: Maximum number of commits to return
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
List of commit information dictionaries with keys:
|
|
44
|
+
- sha: Short commit SHA (8 chars)
|
|
45
|
+
- full_sha: Full commit SHA
|
|
46
|
+
- author: Author name
|
|
47
|
+
- author_email: Author email
|
|
48
|
+
- date: Commit date in ISO format
|
|
49
|
+
- message: Full commit message
|
|
50
|
+
- summary: First line of commit message
|
|
51
|
+
"""
|
|
52
|
+
commits = []
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Get commits that touched this file
|
|
56
|
+
for commit in self.repo.iter_commits(
|
|
57
|
+
paths=file_path, max_count=max_commits
|
|
58
|
+
):
|
|
59
|
+
commits.append(
|
|
60
|
+
{
|
|
61
|
+
"sha": commit.hexsha[:8], # Short SHA
|
|
62
|
+
"full_sha": commit.hexsha,
|
|
63
|
+
"author": str(commit.author),
|
|
64
|
+
"author_email": commit.author.email,
|
|
65
|
+
"date": commit.committed_datetime.isoformat(),
|
|
66
|
+
"message": commit.message.strip(),
|
|
67
|
+
"summary": commit.summary,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
print(f"Error getting history for {file_path}: {e}")
|
|
72
|
+
|
|
73
|
+
return commits
|
|
74
|
+
|
|
75
|
+
def get_function_history_heuristic(
|
|
76
|
+
self,
|
|
77
|
+
file_path: str,
|
|
78
|
+
function_name: str,
|
|
79
|
+
_line_number: int,
|
|
80
|
+
max_commits: int = 5,
|
|
81
|
+
) -> List[Dict]:
|
|
82
|
+
"""
|
|
83
|
+
Get commit history for a specific function using heuristics.
|
|
84
|
+
|
|
85
|
+
This is a heuristic-based approach that returns commits that:
|
|
86
|
+
1. Modified the file near the function's location, OR
|
|
87
|
+
2. Mention the function name in the commit message
|
|
88
|
+
|
|
89
|
+
A more sophisticated version would use git blame to track
|
|
90
|
+
exact line changes over time.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
file_path: Relative path to file
|
|
94
|
+
function_name: Name of the function
|
|
95
|
+
line_number: Line number where function is defined
|
|
96
|
+
max_commits: Maximum commits to return
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
List of relevant commits with 'relevance' field:
|
|
100
|
+
- 'mentioned': Function name in commit message
|
|
101
|
+
- 'file_change': Recent change to the file
|
|
102
|
+
"""
|
|
103
|
+
# Get file history with more commits than requested
|
|
104
|
+
file_commits = self.get_file_history(file_path, max_commits * 2)
|
|
105
|
+
|
|
106
|
+
# Filter for commits mentioning the function or likely relevant
|
|
107
|
+
relevant_commits = []
|
|
108
|
+
for commit in file_commits:
|
|
109
|
+
# Include if function name in commit message
|
|
110
|
+
if function_name.lower() in commit["message"].lower():
|
|
111
|
+
commit["relevance"] = "mentioned"
|
|
112
|
+
relevant_commits.append(commit)
|
|
113
|
+
# Or if it's a recent commit to the file
|
|
114
|
+
elif len(relevant_commits) < max_commits:
|
|
115
|
+
commit["relevance"] = "file_change"
|
|
116
|
+
relevant_commits.append(commit)
|
|
117
|
+
|
|
118
|
+
if len(relevant_commits) >= max_commits:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
return relevant_commits
|
|
122
|
+
|
|
123
|
+
def get_function_history_precise(
|
|
124
|
+
self,
|
|
125
|
+
file_path: str,
|
|
126
|
+
start_line: Optional[int] = None,
|
|
127
|
+
end_line: Optional[int] = None,
|
|
128
|
+
function_name: Optional[str] = None,
|
|
129
|
+
max_commits: int = 5,
|
|
130
|
+
) -> List[Dict]:
|
|
131
|
+
"""
|
|
132
|
+
Get precise commit history for a function using git log -L.
|
|
133
|
+
|
|
134
|
+
This method uses git's native function tracking (when function_name is provided)
|
|
135
|
+
or line tracking (when start_line/end_line are provided) to find commits that
|
|
136
|
+
actually modified the function, even as it moves within the file.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
file_path: Relative path to file from repo root
|
|
140
|
+
start_line: Starting line number (optional, for line-based tracking)
|
|
141
|
+
end_line: Ending line number (optional, for line-based tracking)
|
|
142
|
+
function_name: Function name to track (e.g., "create_user")
|
|
143
|
+
max_commits: Maximum number of commits to return
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
List of commit information dictionaries with keys:
|
|
147
|
+
- sha: Short commit SHA (8 chars)
|
|
148
|
+
- full_sha: Full commit SHA
|
|
149
|
+
- author: Author name
|
|
150
|
+
- author_email: Author email
|
|
151
|
+
- date: Commit date in ISO format
|
|
152
|
+
- message: Full commit message
|
|
153
|
+
- summary: First line of commit message
|
|
154
|
+
|
|
155
|
+
Note:
|
|
156
|
+
- Provide either function_name OR (start_line, end_line)
|
|
157
|
+
- If function_name is provided and fails, falls back to line-based tracking
|
|
158
|
+
- Requires .gitattributes with "*.ex diff=elixir" for function tracking
|
|
159
|
+
"""
|
|
160
|
+
commits = []
|
|
161
|
+
import subprocess
|
|
162
|
+
|
|
163
|
+
# Determine tracking mode
|
|
164
|
+
use_function_tracking = function_name is not None
|
|
165
|
+
use_line_tracking = start_line is not None and end_line is not None
|
|
166
|
+
|
|
167
|
+
if not use_function_tracking and not use_line_tracking:
|
|
168
|
+
print(f"Error: Must provide either function_name or (start_line, end_line)")
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# Build git log -L command
|
|
173
|
+
if use_function_tracking:
|
|
174
|
+
# Try function-based tracking first: git log -L :funcname:file
|
|
175
|
+
line_spec = f":{function_name}:{file_path}"
|
|
176
|
+
else:
|
|
177
|
+
# Use line-based tracking: git log -L start,end:file
|
|
178
|
+
line_spec = f"{start_line},{end_line}:{file_path}"
|
|
179
|
+
|
|
180
|
+
cmd = [
|
|
181
|
+
"git",
|
|
182
|
+
"log",
|
|
183
|
+
f"-L{line_spec}",
|
|
184
|
+
f"--max-count={max_commits}",
|
|
185
|
+
"--format=%H|%an|%ae|%aI|%s",
|
|
186
|
+
"--no-patch", # Don't show diffs, just commits
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
# Run command in repo directory
|
|
190
|
+
result = subprocess.run(
|
|
191
|
+
cmd, cwd=str(self.repo_path), capture_output=True, text=True, check=True
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Parse output
|
|
195
|
+
for line in result.stdout.strip().split("\n"):
|
|
196
|
+
if not line or line.startswith("diff"):
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
parts = line.split("|")
|
|
200
|
+
if len(parts) >= 5:
|
|
201
|
+
full_sha = parts[0]
|
|
202
|
+
commits.append(
|
|
203
|
+
{
|
|
204
|
+
"sha": full_sha[:8],
|
|
205
|
+
"full_sha": full_sha,
|
|
206
|
+
"author": parts[1],
|
|
207
|
+
"author_email": parts[2],
|
|
208
|
+
"date": parts[3],
|
|
209
|
+
"summary": parts[4],
|
|
210
|
+
"message": parts[4], # Summary for now, can enhance later
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
except subprocess.CalledProcessError as e:
|
|
215
|
+
# git log -L failed
|
|
216
|
+
error_msg = e.stderr if e.stderr else str(e)
|
|
217
|
+
|
|
218
|
+
# If function tracking failed and we have line numbers, try fallback
|
|
219
|
+
if use_function_tracking and start_line and end_line:
|
|
220
|
+
print(
|
|
221
|
+
f"Function tracking failed for {function_name}, falling back to line tracking"
|
|
222
|
+
)
|
|
223
|
+
return self.get_function_history_precise(
|
|
224
|
+
file_path,
|
|
225
|
+
start_line=start_line,
|
|
226
|
+
end_line=end_line,
|
|
227
|
+
max_commits=max_commits,
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
print(f"Warning: git log -L failed for {file_path}: {error_msg}")
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
print(f"Error getting precise history for {file_path}: {e}")
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
return commits
|
|
238
|
+
|
|
239
|
+
def get_function_evolution(
|
|
240
|
+
self,
|
|
241
|
+
file_path: str,
|
|
242
|
+
start_line: Optional[int] = None,
|
|
243
|
+
end_line: Optional[int] = None,
|
|
244
|
+
function_name: Optional[str] = None,
|
|
245
|
+
) -> Optional[Dict]:
|
|
246
|
+
"""
|
|
247
|
+
Get evolution metadata for a function (creation, last modification, change count).
|
|
248
|
+
|
|
249
|
+
Uses git log -L to track the complete history of a function and
|
|
250
|
+
extract key lifecycle information.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
file_path: Relative path to file from repo root
|
|
254
|
+
start_line: Starting line number (optional, for line-based tracking)
|
|
255
|
+
end_line: Ending line number (optional, for line-based tracking)
|
|
256
|
+
function_name: Function name to track (e.g., "create_user")
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Dictionary with evolution information:
|
|
260
|
+
- created_at: {sha, date, author, message} for first commit
|
|
261
|
+
- last_modified: {sha, date, author, message} for most recent commit
|
|
262
|
+
- total_modifications: Total number of commits that touched this function
|
|
263
|
+
- modification_frequency: Average commits per month (if > 1 month of history)
|
|
264
|
+
Returns None if no history found or on error.
|
|
265
|
+
|
|
266
|
+
Note:
|
|
267
|
+
- Provide either function_name OR (start_line, end_line)
|
|
268
|
+
"""
|
|
269
|
+
try:
|
|
270
|
+
# Get all commits that touched this function (no limit)
|
|
271
|
+
commits = self.get_function_history_precise(
|
|
272
|
+
file_path,
|
|
273
|
+
start_line=start_line,
|
|
274
|
+
end_line=end_line,
|
|
275
|
+
function_name=function_name,
|
|
276
|
+
max_commits=1000, # Get all commits
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if not commits:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
# Calculate evolution metadata
|
|
283
|
+
created_at = commits[-1] # Oldest commit (last in list)
|
|
284
|
+
last_modified = commits[0] # Most recent commit (first in list)
|
|
285
|
+
total_modifications = len(commits)
|
|
286
|
+
|
|
287
|
+
# Calculate modification frequency (commits per month)
|
|
288
|
+
modification_frequency = None
|
|
289
|
+
if len(commits) > 1:
|
|
290
|
+
try:
|
|
291
|
+
from datetime import datetime
|
|
292
|
+
|
|
293
|
+
first_date = datetime.fromisoformat(created_at["date"])
|
|
294
|
+
last_date = datetime.fromisoformat(last_modified["date"])
|
|
295
|
+
days_between = (last_date - first_date).days
|
|
296
|
+
|
|
297
|
+
if days_between > 0:
|
|
298
|
+
months = days_between / 30.0
|
|
299
|
+
modification_frequency = (
|
|
300
|
+
total_modifications / months
|
|
301
|
+
if months > 0
|
|
302
|
+
else total_modifications
|
|
303
|
+
)
|
|
304
|
+
except Exception:
|
|
305
|
+
# If date parsing fails, skip frequency calculation
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"created_at": {
|
|
310
|
+
"sha": created_at["sha"],
|
|
311
|
+
"full_sha": created_at["full_sha"],
|
|
312
|
+
"date": created_at["date"],
|
|
313
|
+
"author": created_at["author"],
|
|
314
|
+
"author_email": created_at["author_email"],
|
|
315
|
+
"message": created_at["summary"],
|
|
316
|
+
},
|
|
317
|
+
"last_modified": {
|
|
318
|
+
"sha": last_modified["sha"],
|
|
319
|
+
"full_sha": last_modified["full_sha"],
|
|
320
|
+
"date": last_modified["date"],
|
|
321
|
+
"author": last_modified["author"],
|
|
322
|
+
"author_email": last_modified["author_email"],
|
|
323
|
+
"message": last_modified["summary"],
|
|
324
|
+
},
|
|
325
|
+
"total_modifications": total_modifications,
|
|
326
|
+
"modification_frequency": modification_frequency,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
except Exception as e:
|
|
330
|
+
print(f"Error getting function evolution for {file_path}: {e}")
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
def get_function_history(
|
|
334
|
+
self, file_path: str, start_line: int, end_line: int
|
|
335
|
+
) -> List[Dict]:
|
|
336
|
+
"""
|
|
337
|
+
Get line-by-line authorship for a function using git blame.
|
|
338
|
+
|
|
339
|
+
Shows who wrote each line of code, with consecutive lines by the
|
|
340
|
+
same author grouped together for easier reading.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
file_path: Relative path to file from repo root
|
|
344
|
+
start_line: Starting line number of the function
|
|
345
|
+
end_line: Ending line number of the function
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
List of blame groups, each containing:
|
|
349
|
+
- author: Author name
|
|
350
|
+
- author_email: Author email
|
|
351
|
+
- sha: Commit SHA (short)
|
|
352
|
+
- full_sha: Full commit SHA
|
|
353
|
+
- date: Commit date
|
|
354
|
+
- line_start: First line number in this group
|
|
355
|
+
- line_end: Last line number in this group
|
|
356
|
+
- line_count: Number of consecutive lines by this author
|
|
357
|
+
- lines: List of {number, content} for each line
|
|
358
|
+
"""
|
|
359
|
+
blame_groups = []
|
|
360
|
+
import subprocess
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
# Use git blame with line range
|
|
364
|
+
cmd = [
|
|
365
|
+
"git",
|
|
366
|
+
"blame",
|
|
367
|
+
f"-L{start_line},{end_line}",
|
|
368
|
+
"--porcelain", # Machine-readable format
|
|
369
|
+
file_path,
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
result = subprocess.run(
|
|
373
|
+
cmd, cwd=str(self.repo_path), capture_output=True, text=True, check=True
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Parse porcelain format
|
|
377
|
+
lines_data = []
|
|
378
|
+
current_commit = {}
|
|
379
|
+
|
|
380
|
+
for line in result.stdout.split("\n"):
|
|
381
|
+
if not line:
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
# Commit SHA line (40 char hex)
|
|
385
|
+
if len(line) >= 40 and line[0:40].isalnum():
|
|
386
|
+
parts = line.split()
|
|
387
|
+
if len(parts) >= 3:
|
|
388
|
+
current_commit = {
|
|
389
|
+
"sha": parts[0][:8],
|
|
390
|
+
"full_sha": parts[0],
|
|
391
|
+
"line_number": int(parts[2]),
|
|
392
|
+
}
|
|
393
|
+
# Author name
|
|
394
|
+
elif line.startswith("author "):
|
|
395
|
+
current_commit["author"] = line[7:]
|
|
396
|
+
# Author email
|
|
397
|
+
elif line.startswith("author-mail "):
|
|
398
|
+
email = line[12:].strip("<>")
|
|
399
|
+
current_commit["author_email"] = email
|
|
400
|
+
# Author time
|
|
401
|
+
elif line.startswith("author-time "):
|
|
402
|
+
try:
|
|
403
|
+
timestamp = int(line[12:])
|
|
404
|
+
current_commit["date"] = datetime.fromtimestamp(
|
|
405
|
+
timestamp
|
|
406
|
+
).isoformat()
|
|
407
|
+
except:
|
|
408
|
+
current_commit["date"] = line[12:]
|
|
409
|
+
# Actual code line (starts with tab)
|
|
410
|
+
elif line.startswith("\t"):
|
|
411
|
+
code_line = line[1:] # Remove leading tab
|
|
412
|
+
line_info = {**current_commit, "content": code_line}
|
|
413
|
+
lines_data.append(line_info)
|
|
414
|
+
|
|
415
|
+
# Group consecutive lines by same author and commit
|
|
416
|
+
if lines_data:
|
|
417
|
+
current_group = {
|
|
418
|
+
"author": lines_data[0]["author"],
|
|
419
|
+
"author_email": lines_data[0]["author_email"],
|
|
420
|
+
"sha": lines_data[0]["sha"],
|
|
421
|
+
"full_sha": lines_data[0]["full_sha"],
|
|
422
|
+
"date": lines_data[0]["date"],
|
|
423
|
+
"line_start": lines_data[0]["line_number"],
|
|
424
|
+
"line_end": lines_data[0]["line_number"],
|
|
425
|
+
"lines": [
|
|
426
|
+
{
|
|
427
|
+
"number": lines_data[0]["line_number"],
|
|
428
|
+
"content": lines_data[0]["content"],
|
|
429
|
+
}
|
|
430
|
+
],
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for line_info in lines_data[1:]:
|
|
434
|
+
# Same author and commit? Extend current group
|
|
435
|
+
if (
|
|
436
|
+
line_info["author"] == current_group["author"]
|
|
437
|
+
and line_info["sha"] == current_group["sha"]
|
|
438
|
+
):
|
|
439
|
+
current_group["line_end"] = line_info["line_number"]
|
|
440
|
+
_ = current_group["lines"].append(
|
|
441
|
+
{
|
|
442
|
+
"number": line_info["line_number"],
|
|
443
|
+
"content": line_info["content"],
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
# Different author/commit - save current group and start new one
|
|
448
|
+
current_group["line_count"] = len(current_group["lines"])
|
|
449
|
+
blame_groups.append(current_group)
|
|
450
|
+
current_group = {
|
|
451
|
+
"author": line_info["author"],
|
|
452
|
+
"author_email": line_info["author_email"],
|
|
453
|
+
"sha": line_info["sha"],
|
|
454
|
+
"full_sha": line_info["full_sha"],
|
|
455
|
+
"date": line_info["date"],
|
|
456
|
+
"line_start": line_info["line_number"],
|
|
457
|
+
"line_end": line_info["line_number"],
|
|
458
|
+
"lines": [
|
|
459
|
+
{
|
|
460
|
+
"number": line_info["line_number"],
|
|
461
|
+
"content": line_info["content"],
|
|
462
|
+
}
|
|
463
|
+
],
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# Add the last group
|
|
467
|
+
current_group["line_count"] = len(current_group["lines"])
|
|
468
|
+
blame_groups.append(current_group)
|
|
469
|
+
|
|
470
|
+
except subprocess.CalledProcessError as e:
|
|
471
|
+
error_msg = e.stderr if e.stderr else str(e)
|
|
472
|
+
print(
|
|
473
|
+
f"Warning: git blame failed for {file_path}:{start_line}-{end_line}: {error_msg}"
|
|
474
|
+
)
|
|
475
|
+
return []
|
|
476
|
+
except Exception as e:
|
|
477
|
+
print(f"Error getting blame for {file_path}: {e}")
|
|
478
|
+
return []
|
|
479
|
+
|
|
480
|
+
return blame_groups
|
|
481
|
+
|
|
482
|
+
def get_recent_commits(self, max_count: int = 20) -> List[Dict]:
|
|
483
|
+
"""
|
|
484
|
+
Get recent commits in the repository
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
max_count: Maximum number of commits to return
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
List of recent commits with summary information
|
|
491
|
+
"""
|
|
492
|
+
commits = []
|
|
493
|
+
|
|
494
|
+
for commit in self.repo.iter_commits(max_count=max_count):
|
|
495
|
+
# Try to get stats, but handle errors for initial/incomplete commits
|
|
496
|
+
try:
|
|
497
|
+
files_changed = len(commit.stats.files)
|
|
498
|
+
except Exception:
|
|
499
|
+
# Can't get stats (e.g., initial commit, shallow clone)
|
|
500
|
+
files_changed = 0
|
|
501
|
+
|
|
502
|
+
commits.append(
|
|
503
|
+
{
|
|
504
|
+
"sha": commit.hexsha[:8],
|
|
505
|
+
"full_sha": commit.hexsha,
|
|
506
|
+
"author": str(commit.author),
|
|
507
|
+
"date": commit.committed_datetime.isoformat(),
|
|
508
|
+
"message": commit.summary,
|
|
509
|
+
"files_changed": files_changed,
|
|
510
|
+
}
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
return commits
|
|
514
|
+
|
|
515
|
+
def get_commit_details(self, commit_sha: str) -> Optional[Dict]:
|
|
516
|
+
"""
|
|
517
|
+
Get detailed information about a specific commit
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
commit_sha: Commit SHA (can be short or full)
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Detailed commit information or None if not found:
|
|
524
|
+
- sha: Short SHA
|
|
525
|
+
- full_sha: Full SHA
|
|
526
|
+
- author: Author name
|
|
527
|
+
- author_email: Author email
|
|
528
|
+
- date: Commit date
|
|
529
|
+
- message: Full commit message
|
|
530
|
+
- files_changed: List of files modified
|
|
531
|
+
- insertions: Number of lines inserted
|
|
532
|
+
- deletions: Number of lines deleted
|
|
533
|
+
"""
|
|
534
|
+
try:
|
|
535
|
+
commit = self.repo.commit(commit_sha)
|
|
536
|
+
|
|
537
|
+
# Try to get stats, but handle errors for initial/incomplete commits
|
|
538
|
+
try:
|
|
539
|
+
files_changed = list(commit.stats.files.keys())
|
|
540
|
+
insertions = commit.stats.total["insertions"]
|
|
541
|
+
deletions = commit.stats.total["deletions"]
|
|
542
|
+
except Exception:
|
|
543
|
+
# Can't get stats (e.g., initial commit, shallow clone)
|
|
544
|
+
files_changed = []
|
|
545
|
+
insertions = 0
|
|
546
|
+
deletions = 0
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
"sha": commit.hexsha[:8],
|
|
550
|
+
"full_sha": commit.hexsha,
|
|
551
|
+
"author": str(commit.author),
|
|
552
|
+
"author_email": commit.author.email,
|
|
553
|
+
"date": commit.committed_datetime.isoformat(),
|
|
554
|
+
"message": commit.message.strip(),
|
|
555
|
+
"files_changed": files_changed,
|
|
556
|
+
"insertions": insertions,
|
|
557
|
+
"deletions": deletions,
|
|
558
|
+
}
|
|
559
|
+
except Exception as e:
|
|
560
|
+
print(f"Error getting commit {commit_sha}: {e}")
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
def search_commits(self, query: str, max_results: int = 10) -> List[Dict]:
|
|
564
|
+
"""
|
|
565
|
+
Search commit messages for a query string
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
query: Search term to find in commit messages
|
|
569
|
+
max_results: Maximum results to return
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
List of matching commits
|
|
573
|
+
"""
|
|
574
|
+
results = []
|
|
575
|
+
query_lower = query.lower()
|
|
576
|
+
|
|
577
|
+
# Search through the last 500 commits
|
|
578
|
+
for commit in self.repo.iter_commits(max_count=500):
|
|
579
|
+
if query_lower in str(commit.message).lower():
|
|
580
|
+
results.append(
|
|
581
|
+
{
|
|
582
|
+
"sha": commit.hexsha[:8],
|
|
583
|
+
"full_sha": commit.hexsha,
|
|
584
|
+
"author": str(commit.author),
|
|
585
|
+
"date": commit.committed_datetime.isoformat(),
|
|
586
|
+
"message": commit.summary,
|
|
587
|
+
}
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
if len(results) >= max_results:
|
|
591
|
+
break
|
|
592
|
+
|
|
593
|
+
return results
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def main():
|
|
597
|
+
"""Test git helper functions"""
|
|
598
|
+
import sys
|
|
599
|
+
|
|
600
|
+
if len(sys.argv) < 2:
|
|
601
|
+
print("Usage: python -m cicada.git_helper /path/to/repo")
|
|
602
|
+
print("\nExample:")
|
|
603
|
+
print(" python -m cicada.git_helper .")
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
repo_path = sys.argv[1]
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
helper = GitHelper(repo_path)
|
|
610
|
+
|
|
611
|
+
print("=" * 60)
|
|
612
|
+
print("Git Helper Test")
|
|
613
|
+
print("=" * 60)
|
|
614
|
+
|
|
615
|
+
print("\nš Recent commits (last 5):")
|
|
616
|
+
for commit in helper.get_recent_commits(5):
|
|
617
|
+
print(f" {commit['sha']} - {commit['message']}")
|
|
618
|
+
print(f" by {commit['author']} ({commit['files_changed']} files)")
|
|
619
|
+
|
|
620
|
+
print("\nš Searching for 'README' in commits:")
|
|
621
|
+
for commit in helper.search_commits("README", 3):
|
|
622
|
+
print(f" {commit['sha']} - {commit['message']}")
|
|
623
|
+
|
|
624
|
+
# Try to get history for a known file
|
|
625
|
+
print("\nš Testing file history:")
|
|
626
|
+
test_files = ["README.md", "pyproject.toml", "cicada/mcp_server.py"]
|
|
627
|
+
for test_file in test_files:
|
|
628
|
+
history = helper.get_file_history(test_file, max_commits=3)
|
|
629
|
+
if history:
|
|
630
|
+
print(f"\n {test_file} (last 3 commits):")
|
|
631
|
+
for commit in history:
|
|
632
|
+
print(f" {commit['sha']} - {commit['summary']}")
|
|
633
|
+
break
|
|
634
|
+
|
|
635
|
+
print("\nā
Git helper is working correctly!")
|
|
636
|
+
|
|
637
|
+
except git.InvalidGitRepositoryError:
|
|
638
|
+
print(f"ā Error: {repo_path} is not a git repository")
|
|
639
|
+
sys.exit(1)
|
|
640
|
+
except Exception as e:
|
|
641
|
+
print(f"ā Error: {e}")
|
|
642
|
+
sys.exit(1)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
if __name__ == "__main__":
|
|
646
|
+
main()
|