repodoctor-cli 1.0.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.
repodoctor/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """RepoDoctor package"""
repodoctor/__main__.py ADDED
@@ -0,0 +1,370 @@
1
+ import sys
2
+ import os
3
+ import time
4
+ import re
5
+ import concurrent.futures
6
+ import collections
7
+ import io
8
+ import contextlib
9
+
10
+ # Force utf-8 output to avoid cp1252 encoding errors on Windows
11
+ if sys.stdout.encoding != 'utf-8':
12
+ try:
13
+ sys.stdout.reconfigure(encoding='utf-8')
14
+ except Exception:
15
+ pass
16
+
17
+ from .cli import parse_args
18
+ from .scanner import scan_repository
19
+ from .languages import detect_languages
20
+ from .metrics import analyze_metrics
21
+ from .todos import scan_todos
22
+ from .security import scan_security
23
+ from .duplicates import scan_duplicates
24
+ from .structure import check_project_structure
25
+ from .git import get_git_info
26
+ from .scoring import calculate_score
27
+ from .report import print_terminal_report, get_json_report, generate_html_report
28
+ from .baseline import compare_baseline
29
+ from .models import ReportData
30
+ from .spinner import Spinner
31
+
32
+ def process_single_repo(root_path, args, idx, custom_ignores, use_parallel, show_animation, start_time):
33
+ repo_start_time = time.time()
34
+ # Determine if we should suppress live spinner output (if scanning multiple repos)
35
+ silent = len(args.path) > 1
36
+
37
+ # 1. Scan files
38
+ files = scan_repository(
39
+ root_path,
40
+ custom_ignores,
41
+ parallel=use_parallel,
42
+ show_animation=show_animation and not silent,
43
+ )
44
+
45
+ # 2. Run analysis phases
46
+ with Spinner(f"Detecting languages ({root_path})", colour=show_animation, silent=silent):
47
+ detect_languages(files)
48
+
49
+ with Spinner(f"Analysing metrics ({root_path})", colour=show_animation, silent=silent):
50
+ analyze_metrics(files)
51
+
52
+ with Spinner(f"Scanning TODOs ({root_path})", colour=show_animation, silent=silent):
53
+ todos = scan_todos(files)
54
+
55
+ with Spinner(f"Scanning security patterns ({root_path})", colour=show_animation, silent=silent):
56
+ security = scan_security(files)
57
+
58
+ with Spinner(f"Detecting duplicates ({root_path})", colour=show_animation, silent=silent):
59
+ duplicates = scan_duplicates(files, args.duplicate_lines)
60
+
61
+ with Spinner(f"Checking project structure ({root_path})", colour=show_animation, silent=silent):
62
+ structure = check_project_structure(root_path)
63
+
64
+ with Spinner(f"Reading Git info ({root_path})", colour=show_animation, silent=silent):
65
+ git_info = get_git_info(root_path)
66
+
67
+ repo_name = os.path.basename(os.path.abspath(root_path)) or "Unknown"
68
+
69
+ # AI & Advanced analytics (computed just in time)
70
+ all_words = []
71
+ for f in files:
72
+ try:
73
+ with open(f.path, 'r', encoding='utf-8', errors='ignore') as file_handle:
74
+ content = file_handle.read()
75
+ f._words = re.findall(r'\b[a-zA-Z_]{3,}\b', content)
76
+ all_words.extend(f._words)
77
+ except Exception:
78
+ f._words = []
79
+
80
+ positive_words = {"awesome", "great", "excellent", "amazing", "good", "perfect", "wow", "love", "thanks", "beautiful", "brilliant", "clean", "elegant", "smart"}
81
+ negative_words = {"fuck", "shit", "crap", "bitch", "damn", "hate", "ugly", "stupid", "terrible", "awful", "horrible", "mess", "hack", "fixme", "gross", "disgusting", "wtf"}
82
+
83
+ pos_count = sum(1 for f in files for w in getattr(f, "_words", []) if w.lower() in positive_words)
84
+ neg_count = sum(1 for f in files for w in getattr(f, "_words", []) if w.lower() in negative_words)
85
+
86
+ if pos_count == 0 and neg_count == 0:
87
+ mood_str = "Neutral 😐 (0 positive, 0 negative words)"
88
+ elif pos_count > neg_count * 2:
89
+ mood_str = f"Highly Motivated 🚀 ({pos_count} positive, {neg_count} negative words)"
90
+ elif neg_count > pos_count * 2:
91
+ mood_str = f"Severely Frustrated 😡 ({pos_count} positive, {neg_count} negative words)"
92
+ else:
93
+ mood_str = f"Balanced ⚖️ ({pos_count} positive, {neg_count} negative words)"
94
+
95
+ clone_str = "No major clones detected 👏"
96
+ if len(files) > 1:
97
+ try:
98
+ import difflib
99
+ texts = [(f, " ".join(getattr(f, "_words", []))) for f in files if len(getattr(f, "_words", [])) > 50]
100
+ if len(texts) > 1:
101
+ texts.sort(key=lambda x: len(x[1]), reverse=True)
102
+ top_files = texts[:10]
103
+ best_ratio = 0
104
+ best_pair = None
105
+ for i in range(len(top_files)):
106
+ for j in range(i+1, len(top_files)):
107
+ ratio = difflib.SequenceMatcher(None, top_files[i][1], top_files[j][1]).quick_ratio()
108
+ if ratio > best_ratio:
109
+ best_ratio = ratio
110
+ best_pair = (top_files[i][0].path, top_files[j][0].path)
111
+ if best_ratio > 0.8:
112
+ clone_str = f"{best_pair[0]} & {best_pair[1]} ({int(best_ratio*100)}% identical)"
113
+ except Exception:
114
+ pass
115
+
116
+ stop_words = {"the", "and", "but", "for", "with", "was", "were", "been", "being", "have", "has", "had", "will", "would", "shall", "should", "can", "could", "may", "might", "must", "then", "else", "while", "def", "class", "return", "import", "from", "print", "self", "None", "True", "False"}
117
+ filtered_words = [w for w in all_words if len(w) > 3 and w.lower() not in stop_words]
118
+ top_words = collections.Counter(filtered_words).most_common(5)
119
+
120
+ data = ReportData(
121
+ path=os.path.abspath(root_path),
122
+ name=repo_name,
123
+ files=files,
124
+ todos=todos,
125
+ security=security,
126
+ duplicates=duplicates,
127
+ structure=structure,
128
+ git=git_info,
129
+ score=None
130
+ )
131
+
132
+ data.mood = mood_str
133
+ data.clone_exposer = clone_str
134
+ data.top_words = top_words
135
+
136
+ score = calculate_score(data)
137
+ data.score = score
138
+
139
+ local_exit_code = 0
140
+ if score and score.score < getattr(args, "fail_under", 0):
141
+ local_exit_code = 1
142
+
143
+ deltas = None
144
+ if getattr(args, "baseline", None) and os.path.exists(args.baseline):
145
+ try:
146
+ import json
147
+ with open(args.baseline, "r") as bf:
148
+ base_data = json.load(bf)
149
+ if "score" in base_data and data.score:
150
+ deltas = {"score": data.score.score - base_data["score"]}
151
+ except Exception:
152
+ pass
153
+
154
+ # Generate badge SVG
155
+ badge_svg = None
156
+ badge_path = None
157
+ if getattr(args, "badge", None):
158
+ color = "#4c1" if score.score >= 90 else ("#dfb317" if score.score >= 70 else "#e05d44")
159
+ badge_svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="140" height="20">
160
+ <linearGradient id="b" x2="0" y2="100%">
161
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
162
+ <stop offset="1" stop-opacity=".1"/>
163
+ </linearGradient>
164
+ <mask id="a">
165
+ <rect width="140" height="20" rx="3" fill="#fff"/>
166
+ </mask>
167
+ <g mask="url(#a)">
168
+ <path fill="#555" d="M0 0h80v20H0z"/>
169
+ <path fill="{color}" d="M80 0h60v20H0z"/>
170
+ <path fill="url(#b)" d="M0 0h140v20H0z"/>
171
+ </g>
172
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
173
+ <text x="40" y="15" fill="#010101" fill-opacity=".3">RepoDoctor</text>
174
+ <text x="40" y="14">RepoDoctor</text>
175
+ <text x="109" y="15" fill="#010101" fill-opacity=".3">{score.score}/100</text>
176
+ <text x="109" y="14">{score.score}/100</text>
177
+ </g>
178
+ </svg>'''
179
+ badge_path = args.badge
180
+ if len(args.path) > 1:
181
+ base, ext = os.path.splitext(badge_path)
182
+ badge_path = f"{base}_{idx+1}{ext}"
183
+
184
+ # Capture terminal report
185
+ terminal_report = ""
186
+ repo_duration = time.time() - repo_start_time
187
+ if not args.json:
188
+ f_buf = io.StringIO()
189
+ with contextlib.redirect_stdout(f_buf):
190
+ use_color = not args.no_color and sys.stdout.isatty()
191
+ print_terminal_report(data, use_color, args.large_file_lines, deltas, repo_duration, getattr(args, 'tree', False))
192
+ terminal_report = f_buf.getvalue()
193
+
194
+ # Generate JSON
195
+ json_report = None
196
+ if args.json:
197
+ from .report import get_json_report
198
+ import json
199
+ json_report = json.loads(get_json_report(data, args.large_file_lines))
200
+
201
+ # Generate HTML
202
+ html_report = None
203
+ if args.html:
204
+ html_report = generate_html_report(data, args.large_file_lines)
205
+
206
+ # Generate LLM Export
207
+ llm_report = None
208
+ if args.export_prompt:
209
+ prompt_chunk = f"=== REPOSITORY: {repo_name} ===\n\n"
210
+ for file_info in files:
211
+ prompt_chunk += f"--- {file_info.path} ---\n"
212
+ try:
213
+ with open(file_info.path, "r", encoding="utf-8", errors="ignore") as src:
214
+ prompt_chunk += src.read() + "\n\n"
215
+ except Exception:
216
+ prompt_chunk += "[Error reading file contents]\n\n"
217
+ llm_report = prompt_chunk
218
+
219
+ return {
220
+ "idx": idx,
221
+ "repo_name": repo_name,
222
+ "exit_code": local_exit_code,
223
+ "badge_svg": badge_svg,
224
+ "badge_path": badge_path,
225
+ "terminal_report": terminal_report,
226
+ "json_report": json_report,
227
+ "html_report": html_report,
228
+ "llm_report": llm_report,
229
+ "duration": repo_duration,
230
+ }
231
+
232
+ def main():
233
+ start_time = time.time()
234
+ args = parse_args()
235
+
236
+ # 1. Print Banner & Greeting
237
+ use_color = not args.no_color and sys.stdout.isatty()
238
+ def c(text, code):
239
+ return f"\033[{code}m{text}\033[0m" if use_color else text
240
+
241
+ if not args.json:
242
+ print()
243
+ print(c("╔════════════════════════════════════════════════════════════╗", "94;1"))
244
+ print(c("║ ", "94;1"), end="")
245
+ for char in "REPO DOCTOR":
246
+ print(c(char, "96;1"), end="")
247
+ sys.stdout.flush()
248
+ time.sleep(0.05)
249
+ print(c(" ║", "94;1"))
250
+ print(c("╚════════════════════════════════════════════════════════════╝", "94;1"))
251
+ print(c("Welcome to RepoDoctor! 🩺", "92;1"))
252
+ print(c("Initializing zero-dependency static analysis engine...", "90;1"))
253
+ print()
254
+ time.sleep(0.5)
255
+
256
+ root_paths = args.path
257
+ if not root_paths:
258
+ root_paths = ['.']
259
+
260
+ # Validate all directories first
261
+ for rp in root_paths:
262
+ if not os.path.isdir(rp):
263
+ print(f"Error: {rp} is not a directory.")
264
+ sys.exit(2)
265
+
266
+ custom_ignores = args.ignore.split(",") if args.ignore else []
267
+ show_animation = not getattr(args, "no_animation", False)
268
+ use_parallel = getattr(args, "parallel", False)
269
+
270
+ html_outputs = []
271
+ json_outputs = []
272
+ llm_outputs = []
273
+ exit_code = 0
274
+
275
+ # If scanning multiple repositories, notify the user we are processing them in parallel
276
+ if len(root_paths) > 1 and not args.json:
277
+ print(c(f"Starting parallel analysis on {len(root_paths)} repositories...", "96"))
278
+ print()
279
+
280
+ # Use ThreadPoolExecutor to run analyses in parallel
281
+ analysis_start_time = time.time()
282
+ max_workers = min(len(root_paths), (os.cpu_count() or 1) + 4)
283
+ results = []
284
+
285
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
286
+ futures = {
287
+ executor.submit(
288
+ process_single_repo, rp, args, idx, custom_ignores, use_parallel, show_animation, start_time
289
+ ): rp
290
+ for idx, rp in enumerate(root_paths)
291
+ }
292
+ for future in concurrent.futures.as_completed(futures):
293
+ rp = futures[future]
294
+ try:
295
+ res = future.result()
296
+ results.append(res)
297
+ if len(root_paths) > 1 and not args.json:
298
+ print(c(f"✔ Completed analysis of {res['repo_name']}", "92"))
299
+ except Exception as e:
300
+ print(f"Error analyzing {rp}: {e}", file=sys.stderr)
301
+ exit_code = max(exit_code, 1)
302
+
303
+ if len(root_paths) > 1 and not args.json:
304
+ print()
305
+ print(c("All analyses completed. Generating reports...", "90"))
306
+ print()
307
+
308
+ # Sort results by their original path order to keep output deterministic
309
+ results.sort(key=lambda x: x["idx"])
310
+
311
+ for res in results:
312
+ # Update exit code
313
+ exit_code = max(exit_code, res["exit_code"])
314
+
315
+ # Write Badge
316
+ if res["badge_path"] and res["badge_svg"]:
317
+ try:
318
+ with open(res["badge_path"], "w", encoding="utf-8") as bf:
319
+ bf.write(res["badge_svg"])
320
+ except Exception:
321
+ pass
322
+
323
+ # Print Terminal Report
324
+ if not args.json and res["terminal_report"]:
325
+ print(res["terminal_report"])
326
+
327
+ # Accumulate reports
328
+ if res["json_report"] is not None:
329
+ json_outputs.append(res["json_report"])
330
+ if res["html_report"] is not None:
331
+ html_outputs.append(res["html_report"])
332
+ if res["llm_report"] is not None:
333
+ llm_outputs.append(res["llm_report"])
334
+
335
+ if args.json and json_outputs:
336
+ import json
337
+ if len(json_outputs) == 1:
338
+ print(json.dumps(json_outputs[0], indent=2))
339
+ else:
340
+ print(json.dumps(json_outputs, indent=2))
341
+
342
+ if args.html and html_outputs:
343
+ try:
344
+ with open(args.html, "w", encoding="utf-8") as f:
345
+ f.write("\n<hr>\n<br><br>\n".join(html_outputs))
346
+ print(f"HTML report successfully written to {args.html}")
347
+ except Exception as e:
348
+ print(f"Failed to write HTML report: {e}")
349
+ sys.exit(3)
350
+
351
+ if args.export_prompt and llm_outputs:
352
+ try:
353
+ with open(args.export_prompt, "w", encoding="utf-8") as f:
354
+ f.write("\n\n".join(llm_outputs))
355
+ print(f"LLM prompt successfully exported to {args.export_prompt}")
356
+ except Exception as e:
357
+ print(f"Failed to export LLM prompt: {e}")
358
+ sys.exit(3)
359
+
360
+ if len(root_paths) > 1 and not args.json:
361
+ total_analysis_time = time.time() - analysis_start_time
362
+ print(c("────────────────────────────────────────────────────────────", "90"))
363
+ print(c(f"⚡ Concurrently analyzed {len(root_paths)} repositories in {total_analysis_time:.2f}s", "96;1"))
364
+ print(c("────────────────────────────────────────────────────────────", "90"))
365
+ print()
366
+
367
+ sys.exit(exit_code)
368
+
369
+ if __name__ == "__main__":
370
+ main()
repodoctor/baseline.py ADDED
@@ -0,0 +1,42 @@
1
+ import json
2
+ import os
3
+ from typing import Dict, Optional
4
+ from .models import ReportData
5
+
6
+ def compare_baseline(current_data: ReportData, baseline_path: str) -> Optional[Dict[str, int]]:
7
+ if not os.path.exists(baseline_path):
8
+ return None
9
+
10
+ try:
11
+ with open(baseline_path, 'r', encoding='utf-8') as f:
12
+ baseline = json.load(f)
13
+
14
+ deltas = {}
15
+
16
+ # Current values
17
+ c_score = current_data.score.score if current_data.score else 0
18
+ c_files = len(current_data.files)
19
+ c_lines = sum(f.lines for f in current_data.files)
20
+ c_todos = len(current_data.todos)
21
+ c_dups = len(current_data.duplicates)
22
+ c_secrets = len(current_data.security)
23
+
24
+ # Baseline values
25
+ b_score = baseline.get("summary", {}).get("health_score", 0)
26
+ b_files = baseline.get("summary", {}).get("files", 0)
27
+ b_lines = baseline.get("summary", {}).get("lines", 0)
28
+ b_todos = baseline.get("maintainability", {}).get("todos", 0)
29
+ b_dups = baseline.get("maintainability", {}).get("duplicates", 0)
30
+ b_secrets = baseline.get("security", {}).get("potential_secrets", 0)
31
+
32
+ deltas["score"] = c_score - (b_score or 0)
33
+ deltas["files"] = c_files - b_files
34
+ deltas["lines"] = c_lines - b_lines
35
+ deltas["todos"] = c_todos - b_todos
36
+ deltas["duplicates"] = c_dups - b_dups
37
+ deltas["secrets"] = c_secrets - b_secrets
38
+
39
+ return deltas
40
+
41
+ except Exception:
42
+ return None
repodoctor/cli.py ADDED
@@ -0,0 +1,47 @@
1
+ import argparse
2
+ import sys
3
+
4
+ def build_parser() -> argparse.ArgumentParser:
5
+ parser = argparse.ArgumentParser(
6
+ prog="repodoctor",
7
+ description="RepoDoctor diagnoses a codebase for maintainability, security, duplication, project-structure and Git issues using only the language standard library.",
8
+ formatter_class=argparse.RawDescriptionHelpFormatter
9
+ )
10
+
11
+ parser.add_argument(
12
+ "path",
13
+ help="Path to the repository to analyze",
14
+ default=["."],
15
+ nargs="*"
16
+ )
17
+ parser.add_argument("--json", action="store_true", help="Output valid machine-readable JSON")
18
+ parser.add_argument("--html", type=str, help="Output a self-contained HTML report to the specified file", default="")
19
+ parser.add_argument("--baseline", type=str, help="Path to a previous JSON report to compare against", default="")
20
+ parser.add_argument("--no-color", action="store_true", help="Disable ANSI color output")
21
+ parser.add_argument("--badge", type=str, metavar="FILE", help="Generate an SVG health badge", default="")
22
+ parser.add_argument("--tree", action="store_true", help="Print an ASCII project tree")
23
+ parser.add_argument("--export-prompt", type=str, metavar="FILE", help="Export the codebase into a single text file for LLM prompting", default="")
24
+ parser.add_argument("--ignore", type=str, help="Comma-separated list of custom directories to ignore", default="")
25
+ parser.add_argument("--large-file-lines", type=int, help="Threshold for large file lines", default=500)
26
+ parser.add_argument("--duplicate-lines", type=int, help="Minimum lines for duplicate detection", default=8)
27
+ parser.add_argument("--security", action="store_true", help="Focus only on security analysis")
28
+ parser.add_argument("--todos", action="store_true", help="Focus only on TODO/FIXME analysis")
29
+ parser.add_argument("--git", action="store_true", help="Include Git analysis")
30
+ parser.add_argument("--verbose", action="store_true", help="Enable verbose logging")
31
+ parser.add_argument(
32
+ "--parallel", "-j",
33
+ action="store_true",
34
+ help="Enable parallel file scanning using concurrent.futures.ThreadPoolExecutor (faster on large repos)"
35
+ )
36
+ parser.add_argument(
37
+ "--no-animation",
38
+ action="store_true",
39
+ help="Disable live CLI spinner / progress bar animation"
40
+ )
41
+ parser.add_argument("--version", action="version", version="%(prog)s 1.0.0")
42
+
43
+ return parser
44
+
45
+ def parse_args(args=None):
46
+ parser = build_parser()
47
+ return parser.parse_args(args)
@@ -0,0 +1,69 @@
1
+ import hashlib
2
+ from typing import List, Dict, Tuple
3
+ from collections import defaultdict
4
+ from .models import FileInfo, DuplicateBlock
5
+
6
+ def normalize_line(line: str) -> str:
7
+ """Strip whitespace and ignore if it's too short to be useful code."""
8
+ return line.strip()
9
+
10
+ def scan_duplicates(files: List[FileInfo], min_lines: int = 8) -> List[DuplicateBlock]:
11
+ block_hashes = defaultdict(list)
12
+ duplicates = []
13
+
14
+ for f in files:
15
+ if f.is_binary:
16
+ continue
17
+
18
+ try:
19
+ with open(f.path, 'r', encoding='utf-8', errors='ignore') as file:
20
+ lines = file.readlines()
21
+ except Exception:
22
+ continue
23
+
24
+ valid_lines = []
25
+ for idx, line in enumerate(lines):
26
+ norm = normalize_line(line)
27
+ # basic ignore for very short lines, blank lines, or common comments
28
+ if not norm or len(norm) < 4 or norm.startswith('#') or norm.startswith('//'):
29
+ continue
30
+ valid_lines.append((idx + 1, norm))
31
+
32
+ if len(valid_lines) < min_lines:
33
+ continue
34
+
35
+ # Create rolling window of hashes
36
+ for i in range(len(valid_lines) - min_lines + 1):
37
+ window = valid_lines[i:i + min_lines]
38
+ start_line = window[0][0]
39
+ end_line = window[-1][0]
40
+
41
+ # Create block text
42
+ block_text = "".join(x[1] for x in window)
43
+ h = hashlib.sha256(block_text.encode('utf-8')).hexdigest()
44
+
45
+ block_hashes[h].append((f.relative_path, start_line, end_line))
46
+
47
+ # Find duplicates
48
+ # Since rolling windows produce overlapping duplicates, we should just report them simply.
49
+ # A true robust algorithm would merge overlapping blocks, but for MVP we just report unique combinations.
50
+ reported_combinations = set()
51
+
52
+ for h, occurrences in block_hashes.items():
53
+ if len(occurrences) > 1:
54
+ paths = [occ[0] for occ in occurrences]
55
+
56
+ # Simple deduplication of reports (e.g. if we have 9 duplicated lines, it will create two 8-line blocks)
57
+ # We just take the first start_line and end_line for simplicity in MVP.
58
+ combo_key = tuple(sorted(paths))
59
+ if combo_key not in reported_combinations:
60
+ # Approximate the lines
61
+ # The format is just showing that these files share duplicate code blocks.
62
+ duplicates.append(DuplicateBlock(
63
+ filepaths=paths,
64
+ lines=(occurrences[0][1], occurrences[0][2]),
65
+ similarity="exact"
66
+ ))
67
+ reported_combinations.add(combo_key)
68
+
69
+ return duplicates
repodoctor/git.py ADDED
@@ -0,0 +1,67 @@
1
+ import subprocess
2
+ import os
3
+ from .models import GitInfo
4
+
5
+ def run_git(cmd: list, cwd: str) -> str:
6
+ try:
7
+ result = subprocess.run(
8
+ ["git"] + cmd,
9
+ cwd=cwd,
10
+ stdout=subprocess.PIPE,
11
+ stderr=subprocess.DEVNULL,
12
+ text=True,
13
+ check=True
14
+ )
15
+ return result.stdout.strip()
16
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
17
+ return ""
18
+
19
+ def get_git_info(root_path: str) -> GitInfo:
20
+ root = os.path.abspath(root_path)
21
+
22
+ is_git_repo = run_git(["rev-parse", "--is-inside-work-tree"], root)
23
+ if is_git_repo != "true":
24
+ return GitInfo(available=False)
25
+
26
+ branch = run_git(["branch", "--show-current"], root)
27
+ if not branch:
28
+ branch = "detached"
29
+
30
+ commits_str = run_git(["rev-list", "--count", "HEAD"], root)
31
+ commits = int(commits_str) if commits_str.isdigit() else 0
32
+
33
+ status_str = run_git(["status", "--porcelain"], root)
34
+ uncommitted = len(status_str.splitlines()) if status_str else 0
35
+
36
+ top_contributor = ""
37
+ try:
38
+ result = subprocess.run(["git", "shortlog", "-sn", "HEAD"], cwd=root, capture_output=True, text=True, check=True)
39
+ lines = result.stdout.splitlines()
40
+ if lines and lines[0]:
41
+ parts = lines[0].strip().split('\t', 1)
42
+ if len(parts) == 2:
43
+ top_contributor = f"{parts[1].strip()} ({parts[0].strip()} commits)"
44
+ except Exception:
45
+ pass
46
+
47
+ hotspot = ""
48
+ try:
49
+ result = subprocess.run(["git", "log", "--name-only", "--pretty=format:"], cwd=root, capture_output=True, text=True, check=True)
50
+ files = [f for f in result.stdout.split('\n') if f.strip()]
51
+ if files:
52
+ from collections import Counter
53
+ c = Counter(files)
54
+ most_common = c.most_common(1)
55
+ if most_common:
56
+ hotspot = f"{most_common[0][0]} ({most_common[0][1]} edits)"
57
+ except Exception:
58
+ pass
59
+
60
+ return GitInfo(
61
+ available=True,
62
+ branch=branch,
63
+ uncommitted_changes=uncommitted,
64
+ commits=commits,
65
+ top_contributor=top_contributor,
66
+ hotspot=hotspot
67
+ )
@@ -0,0 +1,33 @@
1
+ from .models import FileInfo
2
+ from typing import List
3
+
4
+ EXTENSION_MAP = {
5
+ ".py": "Python",
6
+ ".js": "JavaScript",
7
+ ".ts": "TypeScript",
8
+ ".java": "Java",
9
+ ".go": "Go",
10
+ ".rs": "Rust",
11
+ ".c": "C",
12
+ ".h": "C/C++",
13
+ ".cpp": "C++",
14
+ ".hpp": "C++",
15
+ ".cs": "C#",
16
+ ".kt": "Kotlin",
17
+ ".rb": "Ruby",
18
+ ".php": "PHP",
19
+ ".html": "HTML",
20
+ ".css": "CSS",
21
+ ".json": "JSON",
22
+ ".yaml": "YAML",
23
+ ".yml": "YAML",
24
+ ".md": "Markdown",
25
+ ".sh": "Shell",
26
+ ".sql": "SQL"
27
+ }
28
+
29
+ def detect_languages(files: List[FileInfo]) -> None:
30
+ for f in files:
31
+ if f.is_binary:
32
+ continue
33
+ f.language = EXTENSION_MAP.get(f.extension, "Unknown")