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/__init__.py +3 -0
- gitview/backends/__init__.py +18 -0
- gitview/backends/anthropic_backend.py +66 -0
- gitview/backends/base.py +60 -0
- gitview/backends/ollama_backend.py +96 -0
- gitview/backends/openai_backend.py +71 -0
- gitview/backends/router.py +164 -0
- gitview/chunker.py +367 -0
- gitview/cli.py +332 -0
- gitview/extractor.py +423 -0
- gitview/storyteller.py +352 -0
- gitview/summarizer.py +270 -0
- gitview/writer.py +271 -0
- gitview-0.1.0.dist-info/METADATA +468 -0
- gitview-0.1.0.dist-info/RECORD +19 -0
- gitview-0.1.0.dist-info/WHEEL +5 -0
- gitview-0.1.0.dist-info/entry_points.txt +2 -0
- gitview-0.1.0.dist-info/licenses/LICENSE +21 -0
- gitview-0.1.0.dist-info/top_level.txt +1 -0
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
|