gitview 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.
gitview/extractor.py ADDED
@@ -0,0 +1,423 @@
1
+ """Extract git history with detailed metadata."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional, Any
7
+ from datetime import datetime
8
+ from dataclasses import dataclass, asdict
9
+
10
+ import git
11
+ from git import Repo
12
+
13
+
14
+ @dataclass
15
+ class CommitRecord:
16
+ """Represents a single commit with extracted metadata."""
17
+
18
+ commit_hash: str
19
+ short_hash: str
20
+ timestamp: str
21
+ author: str
22
+ author_email: str
23
+ commit_message: str
24
+ commit_subject: str
25
+ commit_body: str
26
+ parent_hashes: List[str]
27
+
28
+ # Code metrics
29
+ loc_added: int
30
+ loc_deleted: int
31
+ loc_total: int
32
+ files_changed: int
33
+
34
+ # Language breakdown
35
+ language_breakdown: Dict[str, int]
36
+
37
+ # README state
38
+ readme_exists: bool
39
+ readme_size: int
40
+ readme_excerpt: Optional[str]
41
+
42
+ # Comment analysis
43
+ comment_samples: List[str]
44
+ comment_density: float
45
+
46
+ # Diff stats
47
+ insertions: int
48
+ deletions: int
49
+ files_stats: Dict[str, Dict[str, int]]
50
+
51
+ # Large changes detection
52
+ is_large_deletion: bool
53
+ is_large_addition: bool
54
+ is_refactor: bool
55
+
56
+ def to_dict(self) -> Dict[str, Any]:
57
+ """Convert to dictionary for JSON serialization."""
58
+ return asdict(self)
59
+
60
+
61
+ class GitHistoryExtractor:
62
+ """Extract detailed git history from a repository."""
63
+
64
+ def __init__(self, repo_path: str = "."):
65
+ """Initialize with repository path."""
66
+ self.repo_path = Path(repo_path)
67
+ self.repo = Repo(repo_path)
68
+
69
+ # Language extensions mapping
70
+ self.language_extensions = {
71
+ '.py': 'Python',
72
+ '.js': 'JavaScript',
73
+ '.ts': 'TypeScript',
74
+ '.jsx': 'JSX',
75
+ '.tsx': 'TSX',
76
+ '.java': 'Java',
77
+ '.cpp': 'C++',
78
+ '.c': 'C',
79
+ '.h': 'C/C++ Header',
80
+ '.go': 'Go',
81
+ '.rs': 'Rust',
82
+ '.rb': 'Ruby',
83
+ '.php': 'PHP',
84
+ '.swift': 'Swift',
85
+ '.kt': 'Kotlin',
86
+ '.scala': 'Scala',
87
+ '.sh': 'Shell',
88
+ '.bash': 'Bash',
89
+ '.md': 'Markdown',
90
+ '.html': 'HTML',
91
+ '.css': 'CSS',
92
+ '.sql': 'SQL',
93
+ '.yaml': 'YAML',
94
+ '.yml': 'YAML',
95
+ '.json': 'JSON',
96
+ '.xml': 'XML',
97
+ }
98
+
99
+ # Comment patterns for different languages
100
+ self.comment_patterns = {
101
+ 'Python': [r'#.*', r'"""[\s\S]*?"""', r"'''[\s\S]*?'''"],
102
+ 'JavaScript': [r'//.*', r'/\*[\s\S]*?\*/'],
103
+ 'TypeScript': [r'//.*', r'/\*[\s\S]*?\*/'],
104
+ 'Java': [r'//.*', r'/\*[\s\S]*?\*/'],
105
+ 'C++': [r'//.*', r'/\*[\s\S]*?\*/'],
106
+ 'C': [r'/\*[\s\S]*?\*/'],
107
+ 'Go': [r'//.*', r'/\*[\s\S]*?\*/'],
108
+ 'Rust': [r'//.*', r'/\*[\s\S]*?\*/'],
109
+ 'Ruby': [r'#.*'],
110
+ 'Shell': [r'#.*'],
111
+ }
112
+
113
+ def extract_history(self, max_commits: Optional[int] = None,
114
+ branch: str = "HEAD") -> List[CommitRecord]:
115
+ """Extract full git history with metadata.
116
+
117
+ Args:
118
+ max_commits: Maximum number of commits to extract (None for all)
119
+ branch: Branch to extract from (default: HEAD)
120
+
121
+ Returns:
122
+ List of CommitRecord objects, sorted chronologically (oldest first)
123
+ """
124
+ commits = []
125
+ commit_iterator = self.repo.iter_commits(branch, max_count=max_commits)
126
+
127
+ for commit in commit_iterator:
128
+ try:
129
+ record = self._extract_commit_record(commit)
130
+ commits.append(record)
131
+ except Exception as e:
132
+ print(f"Warning: Failed to extract commit {commit.hexsha[:8]}: {e}")
133
+ continue
134
+
135
+ # Sort chronologically (oldest first)
136
+ commits.reverse()
137
+
138
+ # Calculate cumulative LOC
139
+ total_loc = 0
140
+ for record in commits:
141
+ total_loc += (record.loc_added - record.loc_deleted)
142
+ record.loc_total = max(0, total_loc)
143
+
144
+ return commits
145
+
146
+ def _extract_commit_record(self, commit: git.Commit) -> CommitRecord:
147
+ """Extract detailed metadata from a single commit."""
148
+
149
+ # Basic commit info
150
+ commit_hash = commit.hexsha
151
+ short_hash = commit.hexsha[:8]
152
+ timestamp = datetime.fromtimestamp(commit.committed_date).isoformat()
153
+ author = commit.author.name
154
+ author_email = commit.author.email
155
+
156
+ # Parse commit message
157
+ message_lines = commit.message.strip().split('\n')
158
+ subject = message_lines[0] if message_lines else ""
159
+ body = '\n'.join(message_lines[1:]).strip() if len(message_lines) > 1 else ""
160
+
161
+ # Parent commits
162
+ parent_hashes = [p.hexsha for p in commit.parents]
163
+
164
+ # Get diff stats
165
+ stats = self._get_diff_stats(commit)
166
+
167
+ # Language breakdown
168
+ language_breakdown = self._get_language_breakdown(commit)
169
+
170
+ # README analysis
171
+ readme_info = self._get_readme_info(commit)
172
+
173
+ # Comment analysis
174
+ comment_info = self._get_comment_info(commit)
175
+
176
+ # Detect large changes
177
+ is_large_deletion = stats['deletions'] > 1000
178
+ is_large_addition = stats['insertions'] > 1000
179
+ is_refactor = self._detect_refactor(commit, stats)
180
+
181
+ return CommitRecord(
182
+ commit_hash=commit_hash,
183
+ short_hash=short_hash,
184
+ timestamp=timestamp,
185
+ author=author,
186
+ author_email=author_email,
187
+ commit_message=commit.message.strip(),
188
+ commit_subject=subject,
189
+ commit_body=body,
190
+ parent_hashes=parent_hashes,
191
+ loc_added=stats['insertions'],
192
+ loc_deleted=stats['deletions'],
193
+ loc_total=0, # Will be calculated later
194
+ files_changed=stats['files_changed'],
195
+ language_breakdown=language_breakdown,
196
+ readme_exists=readme_info['exists'],
197
+ readme_size=readme_info['size'],
198
+ readme_excerpt=readme_info['excerpt'],
199
+ comment_samples=comment_info['samples'],
200
+ comment_density=comment_info['density'],
201
+ insertions=stats['insertions'],
202
+ deletions=stats['deletions'],
203
+ files_stats=stats['files'],
204
+ is_large_deletion=is_large_deletion,
205
+ is_large_addition=is_large_addition,
206
+ is_refactor=is_refactor,
207
+ )
208
+
209
+ def _get_diff_stats(self, commit: git.Commit) -> Dict[str, Any]:
210
+ """Get diff statistics for a commit."""
211
+ stats = {
212
+ 'insertions': 0,
213
+ 'deletions': 0,
214
+ 'files_changed': 0,
215
+ 'files': {}
216
+ }
217
+
218
+ if not commit.parents:
219
+ # Initial commit
220
+ try:
221
+ diff_index = commit.diff(git.NULL_TREE)
222
+ except:
223
+ return stats
224
+ else:
225
+ diff_index = commit.parents[0].diff(commit)
226
+
227
+ for diff in diff_index:
228
+ try:
229
+ # Skip binary files
230
+ if diff.a_blob and diff.a_blob.mime_type.startswith('image/'):
231
+ continue
232
+ if diff.b_blob and diff.b_blob.mime_type.startswith('image/'):
233
+ continue
234
+
235
+ file_path = diff.b_path or diff.a_path
236
+ if not file_path:
237
+ continue
238
+
239
+ # Get line changes
240
+ if diff.diff:
241
+ diff_text = diff.diff.decode('utf-8', errors='ignore')
242
+ insertions = len([l for l in diff_text.split('\n') if l.startswith('+')])
243
+ deletions = len([l for l in diff_text.split('\n') if l.startswith('-')])
244
+
245
+ stats['insertions'] += insertions
246
+ stats['deletions'] += deletions
247
+ stats['files'][file_path] = {
248
+ 'insertions': insertions,
249
+ 'deletions': deletions
250
+ }
251
+
252
+ stats['files_changed'] += 1
253
+
254
+ except Exception as e:
255
+ continue
256
+
257
+ return stats
258
+
259
+ def _get_language_breakdown(self, commit: git.Commit) -> Dict[str, int]:
260
+ """Get language breakdown for files in commit."""
261
+ breakdown = {}
262
+
263
+ try:
264
+ for item in commit.tree.traverse():
265
+ if item.type == 'blob':
266
+ ext = Path(item.path).suffix.lower()
267
+ language = self.language_extensions.get(ext, 'Other')
268
+ breakdown[language] = breakdown.get(language, 0) + 1
269
+ except:
270
+ pass
271
+
272
+ return breakdown
273
+
274
+ def _get_readme_info(self, commit: git.Commit) -> Dict[str, Any]:
275
+ """Extract README information at this commit."""
276
+ info = {
277
+ 'exists': False,
278
+ 'size': 0,
279
+ 'excerpt': None
280
+ }
281
+
282
+ try:
283
+ readme_names = ['README.md', 'README', 'README.txt', 'README.rst', 'readme.md']
284
+
285
+ for item in commit.tree.traverse():
286
+ if item.type == 'blob' and item.name in readme_names:
287
+ info['exists'] = True
288
+ info['size'] = item.size
289
+
290
+ # Get excerpt (first 200 chars)
291
+ try:
292
+ content = item.data_stream.read().decode('utf-8', errors='ignore')
293
+ info['excerpt'] = content[:200].strip()
294
+ except:
295
+ pass
296
+
297
+ break
298
+ except:
299
+ pass
300
+
301
+ return info
302
+
303
+ def _get_comment_info(self, commit: git.Commit) -> Dict[str, Any]:
304
+ """Extract comment samples and density from code files."""
305
+ info = {
306
+ 'samples': [],
307
+ 'density': 0.0
308
+ }
309
+
310
+ total_lines = 0
311
+ comment_lines = 0
312
+
313
+ try:
314
+ if not commit.parents:
315
+ diff_index = commit.diff(git.NULL_TREE)
316
+ else:
317
+ diff_index = commit.parents[0].diff(commit)
318
+
319
+ for diff in diff_index:
320
+ if not diff.b_blob:
321
+ continue
322
+
323
+ file_path = diff.b_path
324
+ if not file_path:
325
+ continue
326
+
327
+ ext = Path(file_path).suffix.lower()
328
+ language = self.language_extensions.get(ext)
329
+
330
+ if language not in self.comment_patterns:
331
+ continue
332
+
333
+ try:
334
+ content = diff.b_blob.data_stream.read().decode('utf-8', errors='ignore')
335
+ lines = content.split('\n')
336
+ total_lines += len(lines)
337
+
338
+ # Find comments
339
+ for pattern in self.comment_patterns[language]:
340
+ for match in re.finditer(pattern, content, re.MULTILINE):
341
+ comment_text = match.group(0).strip()
342
+ if len(comment_text) > 10: # Skip very short comments
343
+ comment_lines += len(comment_text.split('\n'))
344
+ if len(info['samples']) < 5:
345
+ info['samples'].append(comment_text[:100])
346
+
347
+ except:
348
+ continue
349
+
350
+ except:
351
+ pass
352
+
353
+ # Calculate density
354
+ if total_lines > 0:
355
+ info['density'] = comment_lines / total_lines
356
+
357
+ return info
358
+
359
+ def _detect_refactor(self, commit: git.Commit, stats: Dict[str, Any]) -> bool:
360
+ """Detect if this is likely a refactoring commit."""
361
+ # Heuristics for refactoring:
362
+ # 1. Similar insertions and deletions
363
+ # 2. Message contains refactor keywords
364
+ # 3. Multiple files changed
365
+
366
+ insertions = stats['insertions']
367
+ deletions = stats['deletions']
368
+
369
+ if insertions == 0 or deletions == 0:
370
+ return False
371
+
372
+ ratio = min(insertions, deletions) / max(insertions, deletions)
373
+
374
+ refactor_keywords = ['refactor', 'rename', 'reorganize', 'restructure', 'cleanup', 'rewrite']
375
+ message_lower = commit.message.lower()
376
+ has_keyword = any(keyword in message_lower for keyword in refactor_keywords)
377
+
378
+ return ratio > 0.7 and (has_keyword or stats['files_changed'] > 3)
379
+
380
+ def save_to_jsonl(self, records: List[CommitRecord], output_path: str):
381
+ """Save commit records to JSONL file."""
382
+ output_file = Path(output_path)
383
+ output_file.parent.mkdir(parents=True, exist_ok=True)
384
+
385
+ with open(output_file, 'w') as f:
386
+ for record in records:
387
+ json.dump(record.to_dict(), f)
388
+ f.write('\n')
389
+
390
+ @staticmethod
391
+ def load_from_jsonl(input_path: str) -> List[CommitRecord]:
392
+ """Load commit records from JSONL file."""
393
+ records = []
394
+
395
+ with open(input_path, 'r') as f:
396
+ for line in f:
397
+ data = json.loads(line)
398
+ # Convert dict back to CommitRecord
399
+ records.append(CommitRecord(**data))
400
+
401
+ return records
402
+
403
+
404
+ def extract_git_history(repo_path: str = ".",
405
+ output_path: str = "output/repo_history.jsonl",
406
+ max_commits: Optional[int] = None,
407
+ branch: str = "HEAD") -> List[CommitRecord]:
408
+ """
409
+ Extract git history and save to JSONL.
410
+
411
+ Args:
412
+ repo_path: Path to git repository
413
+ output_path: Path to output JSONL file
414
+ max_commits: Maximum number of commits to extract (None for all)
415
+ branch: Branch to extract from
416
+
417
+ Returns:
418
+ List of CommitRecord objects
419
+ """
420
+ extractor = GitHistoryExtractor(repo_path)
421
+ records = extractor.extract_history(max_commits=max_commits, branch=branch)
422
+ extractor.save_to_jsonl(records, output_path)
423
+ return records