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/chunker.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Chunk git history into meaningful epochs/phases."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
from dataclasses import dataclass, asdict
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from .extractor import CommitRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Phase:
|
|
14
|
+
"""Represents a phase/epoch in repository history."""
|
|
15
|
+
|
|
16
|
+
phase_number: int
|
|
17
|
+
start_date: str
|
|
18
|
+
end_date: str
|
|
19
|
+
commit_count: int
|
|
20
|
+
commits: List[CommitRecord]
|
|
21
|
+
|
|
22
|
+
# Phase characteristics
|
|
23
|
+
loc_start: int
|
|
24
|
+
loc_end: int
|
|
25
|
+
loc_delta: int
|
|
26
|
+
loc_delta_percent: float
|
|
27
|
+
|
|
28
|
+
total_insertions: int
|
|
29
|
+
total_deletions: int
|
|
30
|
+
|
|
31
|
+
# Language evolution
|
|
32
|
+
languages_start: Dict[str, int]
|
|
33
|
+
languages_end: Dict[str, int]
|
|
34
|
+
|
|
35
|
+
# Major events
|
|
36
|
+
has_large_deletion: bool
|
|
37
|
+
has_large_addition: bool
|
|
38
|
+
has_refactor: bool
|
|
39
|
+
readme_changed: bool
|
|
40
|
+
|
|
41
|
+
# Authors
|
|
42
|
+
authors: List[str]
|
|
43
|
+
primary_author: str
|
|
44
|
+
|
|
45
|
+
# Summary (will be filled by LLM later)
|
|
46
|
+
summary: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
49
|
+
"""Convert to dictionary for JSON serialization."""
|
|
50
|
+
data = asdict(self)
|
|
51
|
+
# Convert CommitRecord objects to dicts
|
|
52
|
+
data['commits'] = [c.to_dict() for c in self.commits]
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'Phase':
|
|
57
|
+
"""Create Phase from dictionary."""
|
|
58
|
+
commits = [CommitRecord(**c) for c in data.pop('commits')]
|
|
59
|
+
return cls(commits=commits, **data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class HistoryChunker:
|
|
63
|
+
"""Chunk repository history into meaningful phases."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, strategy: str = "adaptive"):
|
|
66
|
+
"""
|
|
67
|
+
Initialize chunker with strategy.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
strategy: 'fixed', 'time', or 'adaptive'
|
|
71
|
+
"""
|
|
72
|
+
self.strategy = strategy
|
|
73
|
+
|
|
74
|
+
def chunk(self, records: List[CommitRecord], **kwargs) -> List[Phase]:
|
|
75
|
+
"""
|
|
76
|
+
Chunk commit records into phases.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
records: List of CommitRecord objects (chronologically sorted)
|
|
80
|
+
**kwargs: Strategy-specific parameters
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of Phase objects
|
|
84
|
+
"""
|
|
85
|
+
if self.strategy == "fixed":
|
|
86
|
+
return self._chunk_fixed(records, kwargs.get('chunk_size', 50))
|
|
87
|
+
elif self.strategy == "time":
|
|
88
|
+
return self._chunk_time(records, kwargs.get('period', 'quarter'))
|
|
89
|
+
elif self.strategy == "adaptive":
|
|
90
|
+
return self._chunk_adaptive(records, kwargs)
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError(f"Unknown strategy: {self.strategy}")
|
|
93
|
+
|
|
94
|
+
def _chunk_fixed(self, records: List[CommitRecord], chunk_size: int) -> List[Phase]:
|
|
95
|
+
"""Split into fixed-size chunks."""
|
|
96
|
+
phases = []
|
|
97
|
+
|
|
98
|
+
for i in range(0, len(records), chunk_size):
|
|
99
|
+
chunk = records[i:i + chunk_size]
|
|
100
|
+
phase = self._create_phase(len(phases) + 1, chunk)
|
|
101
|
+
phases.append(phase)
|
|
102
|
+
|
|
103
|
+
return phases
|
|
104
|
+
|
|
105
|
+
def _chunk_time(self, records: List[CommitRecord], period: str) -> List[Phase]:
|
|
106
|
+
"""
|
|
107
|
+
Split by time period.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
period: 'week', 'month', 'quarter', or 'year'
|
|
111
|
+
"""
|
|
112
|
+
from dateutil.relativedelta import relativedelta
|
|
113
|
+
|
|
114
|
+
if not records:
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
phases = []
|
|
118
|
+
current_chunk = []
|
|
119
|
+
|
|
120
|
+
# Parse period
|
|
121
|
+
delta_map = {
|
|
122
|
+
'week': relativedelta(weeks=1),
|
|
123
|
+
'month': relativedelta(months=1),
|
|
124
|
+
'quarter': relativedelta(months=3),
|
|
125
|
+
'year': relativedelta(years=1),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if period not in delta_map:
|
|
129
|
+
raise ValueError(f"Unknown period: {period}")
|
|
130
|
+
|
|
131
|
+
delta = delta_map[period]
|
|
132
|
+
|
|
133
|
+
# Start from first commit
|
|
134
|
+
current_start = datetime.fromisoformat(records[0].timestamp)
|
|
135
|
+
current_end = current_start + delta
|
|
136
|
+
|
|
137
|
+
for record in records:
|
|
138
|
+
record_time = datetime.fromisoformat(record.timestamp)
|
|
139
|
+
|
|
140
|
+
if record_time >= current_end:
|
|
141
|
+
# Start new phase
|
|
142
|
+
if current_chunk:
|
|
143
|
+
phase = self._create_phase(len(phases) + 1, current_chunk)
|
|
144
|
+
phases.append(phase)
|
|
145
|
+
|
|
146
|
+
current_chunk = [record]
|
|
147
|
+
current_start = current_end
|
|
148
|
+
current_end = current_start + delta
|
|
149
|
+
else:
|
|
150
|
+
current_chunk.append(record)
|
|
151
|
+
|
|
152
|
+
# Add final chunk
|
|
153
|
+
if current_chunk:
|
|
154
|
+
phase = self._create_phase(len(phases) + 1, current_chunk)
|
|
155
|
+
phases.append(phase)
|
|
156
|
+
|
|
157
|
+
return phases
|
|
158
|
+
|
|
159
|
+
def _chunk_adaptive(self, records: List[CommitRecord], config: Dict[str, Any]) -> List[Phase]:
|
|
160
|
+
"""
|
|
161
|
+
Adaptive chunking based on significant changes.
|
|
162
|
+
|
|
163
|
+
Splits when:
|
|
164
|
+
- LOC changes by more than threshold (default 30%)
|
|
165
|
+
- Large deletions/additions detected
|
|
166
|
+
- Language mix changes significantly
|
|
167
|
+
- README is rewritten
|
|
168
|
+
- Comment density shifts significantly
|
|
169
|
+
- Refactoring detected
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
config: Configuration dict with thresholds
|
|
173
|
+
"""
|
|
174
|
+
# Default thresholds
|
|
175
|
+
loc_threshold = config.get('loc_threshold', 0.3)
|
|
176
|
+
min_chunk_size = config.get('min_chunk_size', 5)
|
|
177
|
+
max_chunk_size = config.get('max_chunk_size', 100)
|
|
178
|
+
readme_change_split = config.get('readme_change_split', True)
|
|
179
|
+
refactor_split = config.get('refactor_split', True)
|
|
180
|
+
|
|
181
|
+
if not records:
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
phases = []
|
|
185
|
+
current_chunk = [records[0]]
|
|
186
|
+
chunk_start_loc = records[0].loc_total
|
|
187
|
+
|
|
188
|
+
for i, record in enumerate(records[1:], 1):
|
|
189
|
+
should_split = False
|
|
190
|
+
|
|
191
|
+
# Check LOC change
|
|
192
|
+
if chunk_start_loc > 0:
|
|
193
|
+
loc_change = abs(record.loc_total - chunk_start_loc) / chunk_start_loc
|
|
194
|
+
if loc_change > loc_threshold:
|
|
195
|
+
should_split = True
|
|
196
|
+
|
|
197
|
+
# Check large deletions/additions
|
|
198
|
+
if record.is_large_deletion or record.is_large_addition:
|
|
199
|
+
should_split = True
|
|
200
|
+
|
|
201
|
+
# Check README rewrite
|
|
202
|
+
if readme_change_split and record.readme_exists:
|
|
203
|
+
if current_chunk:
|
|
204
|
+
last_readme_size = current_chunk[-1].readme_size
|
|
205
|
+
if last_readme_size > 0:
|
|
206
|
+
readme_change = abs(record.readme_size - last_readme_size) / last_readme_size
|
|
207
|
+
if readme_change > 0.5: # README changed by >50%
|
|
208
|
+
should_split = True
|
|
209
|
+
|
|
210
|
+
# Check refactoring
|
|
211
|
+
if refactor_split and record.is_refactor:
|
|
212
|
+
should_split = True
|
|
213
|
+
|
|
214
|
+
# Enforce min/max chunk sizes
|
|
215
|
+
if len(current_chunk) < min_chunk_size:
|
|
216
|
+
should_split = False
|
|
217
|
+
elif len(current_chunk) >= max_chunk_size:
|
|
218
|
+
should_split = True
|
|
219
|
+
|
|
220
|
+
if should_split:
|
|
221
|
+
# Create phase from current chunk
|
|
222
|
+
phase = self._create_phase(len(phases) + 1, current_chunk)
|
|
223
|
+
phases.append(phase)
|
|
224
|
+
|
|
225
|
+
# Start new chunk
|
|
226
|
+
current_chunk = [record]
|
|
227
|
+
chunk_start_loc = record.loc_total
|
|
228
|
+
else:
|
|
229
|
+
current_chunk.append(record)
|
|
230
|
+
|
|
231
|
+
# Add final chunk
|
|
232
|
+
if current_chunk:
|
|
233
|
+
phase = self._create_phase(len(phases) + 1, current_chunk)
|
|
234
|
+
phases.append(phase)
|
|
235
|
+
|
|
236
|
+
return phases
|
|
237
|
+
|
|
238
|
+
def _create_phase(self, phase_number: int, commits: List[CommitRecord]) -> Phase:
|
|
239
|
+
"""Create a Phase object from a list of commits."""
|
|
240
|
+
if not commits:
|
|
241
|
+
raise ValueError("Cannot create phase from empty commit list")
|
|
242
|
+
|
|
243
|
+
# Basic info
|
|
244
|
+
start_date = commits[0].timestamp
|
|
245
|
+
end_date = commits[-1].timestamp
|
|
246
|
+
commit_count = len(commits)
|
|
247
|
+
|
|
248
|
+
# LOC metrics
|
|
249
|
+
loc_start = commits[0].loc_total
|
|
250
|
+
loc_end = commits[-1].loc_total
|
|
251
|
+
loc_delta = loc_end - loc_start
|
|
252
|
+
loc_delta_percent = (loc_delta / loc_start * 100) if loc_start > 0 else 0
|
|
253
|
+
|
|
254
|
+
# Insertions/deletions
|
|
255
|
+
total_insertions = sum(c.insertions for c in commits)
|
|
256
|
+
total_deletions = sum(c.deletions for c in commits)
|
|
257
|
+
|
|
258
|
+
# Language breakdown
|
|
259
|
+
languages_start = commits[0].language_breakdown
|
|
260
|
+
languages_end = commits[-1].language_breakdown
|
|
261
|
+
|
|
262
|
+
# Major events
|
|
263
|
+
has_large_deletion = any(c.is_large_deletion for c in commits)
|
|
264
|
+
has_large_addition = any(c.is_large_addition for c in commits)
|
|
265
|
+
has_refactor = any(c.is_refactor for c in commits)
|
|
266
|
+
|
|
267
|
+
# README changes
|
|
268
|
+
readme_sizes = [c.readme_size for c in commits if c.readme_exists]
|
|
269
|
+
readme_changed = len(readme_sizes) > 1 and max(readme_sizes) - min(readme_sizes) > 100
|
|
270
|
+
|
|
271
|
+
# Authors
|
|
272
|
+
authors = list(set(c.author for c in commits))
|
|
273
|
+
author_counts = {}
|
|
274
|
+
for c in commits:
|
|
275
|
+
author_counts[c.author] = author_counts.get(c.author, 0) + 1
|
|
276
|
+
primary_author = max(author_counts.items(), key=lambda x: x[1])[0]
|
|
277
|
+
|
|
278
|
+
return Phase(
|
|
279
|
+
phase_number=phase_number,
|
|
280
|
+
start_date=start_date,
|
|
281
|
+
end_date=end_date,
|
|
282
|
+
commit_count=commit_count,
|
|
283
|
+
commits=commits,
|
|
284
|
+
loc_start=loc_start,
|
|
285
|
+
loc_end=loc_end,
|
|
286
|
+
loc_delta=loc_delta,
|
|
287
|
+
loc_delta_percent=loc_delta_percent,
|
|
288
|
+
total_insertions=total_insertions,
|
|
289
|
+
total_deletions=total_deletions,
|
|
290
|
+
languages_start=languages_start,
|
|
291
|
+
languages_end=languages_end,
|
|
292
|
+
has_large_deletion=has_large_deletion,
|
|
293
|
+
has_large_addition=has_large_addition,
|
|
294
|
+
has_refactor=has_refactor,
|
|
295
|
+
readme_changed=readme_changed,
|
|
296
|
+
authors=authors,
|
|
297
|
+
primary_author=primary_author,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def save_phases(self, phases: List[Phase], output_dir: str):
|
|
301
|
+
"""Save phases to JSON files."""
|
|
302
|
+
output_path = Path(output_dir)
|
|
303
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
|
|
305
|
+
# Save each phase separately
|
|
306
|
+
for phase in phases:
|
|
307
|
+
phase_file = output_path / f"phase_{phase.phase_number:02d}.json"
|
|
308
|
+
with open(phase_file, 'w') as f:
|
|
309
|
+
json.dump(phase.to_dict(), f, indent=2)
|
|
310
|
+
|
|
311
|
+
# Save phase index
|
|
312
|
+
index = {
|
|
313
|
+
'total_phases': len(phases),
|
|
314
|
+
'phases': [
|
|
315
|
+
{
|
|
316
|
+
'phase_number': p.phase_number,
|
|
317
|
+
'start_date': p.start_date,
|
|
318
|
+
'end_date': p.end_date,
|
|
319
|
+
'commit_count': p.commit_count,
|
|
320
|
+
'loc_delta': p.loc_delta,
|
|
321
|
+
}
|
|
322
|
+
for p in phases
|
|
323
|
+
]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
index_file = output_path / "phase_index.json"
|
|
327
|
+
with open(index_file, 'w') as f:
|
|
328
|
+
json.dump(index, f, indent=2)
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def load_phases(input_dir: str) -> List[Phase]:
|
|
332
|
+
"""Load phases from JSON files."""
|
|
333
|
+
input_path = Path(input_dir)
|
|
334
|
+
phases = []
|
|
335
|
+
|
|
336
|
+
# Find all phase files
|
|
337
|
+
phase_files = sorted(input_path.glob("phase_*.json"))
|
|
338
|
+
|
|
339
|
+
for phase_file in phase_files:
|
|
340
|
+
with open(phase_file, 'r') as f:
|
|
341
|
+
data = json.load(f)
|
|
342
|
+
phase = Phase.from_dict(data)
|
|
343
|
+
phases.append(phase)
|
|
344
|
+
|
|
345
|
+
return phases
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def chunk_history(records: List[CommitRecord],
|
|
349
|
+
strategy: str = "adaptive",
|
|
350
|
+
output_dir: str = "output/phases",
|
|
351
|
+
**kwargs) -> List[Phase]:
|
|
352
|
+
"""
|
|
353
|
+
Chunk commit history into phases.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
records: List of CommitRecord objects
|
|
357
|
+
strategy: 'fixed', 'time', or 'adaptive'
|
|
358
|
+
output_dir: Directory to save phase files
|
|
359
|
+
**kwargs: Strategy-specific parameters
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
List of Phase objects
|
|
363
|
+
"""
|
|
364
|
+
chunker = HistoryChunker(strategy)
|
|
365
|
+
phases = chunker.chunk(records, **kwargs)
|
|
366
|
+
chunker.save_phases(phases, output_dir)
|
|
367
|
+
return phases
|
gitview/cli.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Command-line interface for GitView."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from .extractor import GitHistoryExtractor
|
|
13
|
+
from .chunker import HistoryChunker
|
|
14
|
+
from .summarizer import PhaseSummarizer
|
|
15
|
+
from .storyteller import StoryTeller
|
|
16
|
+
from .writer import OutputWriter
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group()
|
|
22
|
+
@click.version_option(version="0.1.0")
|
|
23
|
+
def cli():
|
|
24
|
+
"""GitView - Git history analyzer with LLM-powered narrative generation.
|
|
25
|
+
|
|
26
|
+
Extract, analyze, and generate compelling narratives from your git repository's history.
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@cli.command()
|
|
32
|
+
@click.option('--repo', '-r', default=".", help="Path to git repository")
|
|
33
|
+
@click.option('--output', '-o', default="output", help="Output directory")
|
|
34
|
+
@click.option('--strategy', '-s', type=click.Choice(['fixed', 'time', 'adaptive']),
|
|
35
|
+
default='adaptive', help="Chunking strategy")
|
|
36
|
+
@click.option('--chunk-size', type=int, default=50,
|
|
37
|
+
help="Chunk size for fixed strategy")
|
|
38
|
+
@click.option('--max-commits', type=int, help="Maximum commits to analyze")
|
|
39
|
+
@click.option('--branch', default='HEAD', help="Branch to analyze")
|
|
40
|
+
@click.option('--backend', '-b', type=click.Choice(['anthropic', 'openai', 'ollama']),
|
|
41
|
+
help="LLM backend (auto-detected from environment if not specified)")
|
|
42
|
+
@click.option('--model', '-m', help="Model identifier (uses backend defaults if not specified)")
|
|
43
|
+
@click.option('--api-key', help="API key for the backend (defaults to env var)")
|
|
44
|
+
@click.option('--ollama-url', default='http://localhost:11434', help="Ollama API URL")
|
|
45
|
+
@click.option('--repo-name', help="Repository name for output")
|
|
46
|
+
@click.option('--skip-llm', is_flag=True, help="Skip LLM summarization (extract and chunk only)")
|
|
47
|
+
def analyze(repo, output, strategy, chunk_size, max_commits, branch, backend,
|
|
48
|
+
model, api_key, ollama_url, repo_name, skip_llm):
|
|
49
|
+
"""Analyze git repository and generate narrative history.
|
|
50
|
+
|
|
51
|
+
This is the main command that runs the full pipeline:
|
|
52
|
+
1. Extract git history
|
|
53
|
+
2. Chunk into meaningful phases
|
|
54
|
+
3. Summarize each phase with LLM
|
|
55
|
+
4. Generate global narrative
|
|
56
|
+
5. Write output files
|
|
57
|
+
"""
|
|
58
|
+
console.print("\n[bold blue]GitView - Repository History Analyzer[/bold blue]\n")
|
|
59
|
+
|
|
60
|
+
# Validate repository
|
|
61
|
+
repo_path = Path(repo).resolve()
|
|
62
|
+
if not (repo_path / '.git').exists():
|
|
63
|
+
console.print(f"[red]Error: {repo_path} is not a git repository[/red]")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
# Get repo name if not provided
|
|
67
|
+
if not repo_name:
|
|
68
|
+
repo_name = repo_path.name
|
|
69
|
+
|
|
70
|
+
console.print(f"[cyan]Repository:[/cyan] {repo_path}")
|
|
71
|
+
console.print(f"[cyan]Output:[/cyan] {output}")
|
|
72
|
+
console.print(f"[cyan]Strategy:[/cyan] {strategy}")
|
|
73
|
+
|
|
74
|
+
if not skip_llm:
|
|
75
|
+
# Determine backend for display
|
|
76
|
+
from .backends import LLMRouter
|
|
77
|
+
router = LLMRouter(backend=backend, model=model, api_key=api_key, ollama_url=ollama_url)
|
|
78
|
+
console.print(f"[cyan]Backend:[/cyan] {router.backend_type.value}")
|
|
79
|
+
console.print(f"[cyan]Model:[/cyan] {router.model}\n")
|
|
80
|
+
else:
|
|
81
|
+
console.print("[yellow]Skipping LLM summarization[/yellow]\n")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
# Step 1: Extract git history
|
|
85
|
+
console.print("[bold]Step 1: Extracting git history...[/bold]")
|
|
86
|
+
extractor = GitHistoryExtractor(str(repo_path))
|
|
87
|
+
|
|
88
|
+
with Progress(
|
|
89
|
+
SpinnerColumn(),
|
|
90
|
+
TextColumn("[progress.description]{task.description}"),
|
|
91
|
+
console=console
|
|
92
|
+
) as progress:
|
|
93
|
+
task = progress.add_task("Extracting commits...", total=None)
|
|
94
|
+
records = extractor.extract_history(max_commits=max_commits, branch=branch)
|
|
95
|
+
progress.update(task, completed=True)
|
|
96
|
+
|
|
97
|
+
console.print(f"[green]✓ Extracted {len(records)} commits[/green]\n")
|
|
98
|
+
|
|
99
|
+
# Save raw history
|
|
100
|
+
history_file = Path(output) / "repo_history.jsonl"
|
|
101
|
+
extractor.save_to_jsonl(records, str(history_file))
|
|
102
|
+
|
|
103
|
+
# Step 2: Chunk into phases
|
|
104
|
+
console.print("[bold]Step 2: Chunking into phases...[/bold]")
|
|
105
|
+
chunker = HistoryChunker(strategy)
|
|
106
|
+
|
|
107
|
+
kwargs = {}
|
|
108
|
+
if strategy == 'fixed':
|
|
109
|
+
kwargs['chunk_size'] = chunk_size
|
|
110
|
+
|
|
111
|
+
phases = chunker.chunk(records, **kwargs)
|
|
112
|
+
console.print(f"[green]✓ Created {len(phases)} phases[/green]\n")
|
|
113
|
+
|
|
114
|
+
# Display phase overview
|
|
115
|
+
_display_phase_overview(phases)
|
|
116
|
+
|
|
117
|
+
# Save phases
|
|
118
|
+
phases_dir = Path(output) / "phases"
|
|
119
|
+
chunker.save_phases(phases, str(phases_dir))
|
|
120
|
+
|
|
121
|
+
if skip_llm:
|
|
122
|
+
console.print("\n[yellow]Skipping LLM summarization. Writing basic timeline...[/yellow]")
|
|
123
|
+
timeline_file = Path(output) / "timeline.md"
|
|
124
|
+
OutputWriter.write_simple_timeline(phases, str(timeline_file))
|
|
125
|
+
console.print(f"[green]✓ Wrote timeline to {timeline_file}[/green]\n")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# Step 3: Summarize phases with LLM
|
|
129
|
+
console.print("[bold]Step 3: Summarizing phases with LLM...[/bold]")
|
|
130
|
+
summarizer = PhaseSummarizer(
|
|
131
|
+
backend=backend,
|
|
132
|
+
model=model,
|
|
133
|
+
api_key=api_key,
|
|
134
|
+
ollama_url=ollama_url
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
with Progress(
|
|
138
|
+
SpinnerColumn(),
|
|
139
|
+
TextColumn("[progress.description]{task.description}"),
|
|
140
|
+
console=console
|
|
141
|
+
) as progress:
|
|
142
|
+
task = progress.add_task("Summarizing phases...", total=len(phases))
|
|
143
|
+
|
|
144
|
+
previous_summaries = []
|
|
145
|
+
for i, phase in enumerate(phases):
|
|
146
|
+
progress.update(task, description=f"Summarizing phase {i+1}/{len(phases)}...")
|
|
147
|
+
|
|
148
|
+
context = summarizer._build_context(previous_summaries)
|
|
149
|
+
summary = summarizer.summarize_phase(phase, context)
|
|
150
|
+
phase.summary = summary
|
|
151
|
+
|
|
152
|
+
previous_summaries.append({
|
|
153
|
+
'phase_number': phase.phase_number,
|
|
154
|
+
'summary': summary,
|
|
155
|
+
'loc_delta': phase.loc_delta,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
summarizer._save_phase_with_summary(phase, str(phases_dir))
|
|
159
|
+
progress.update(task, advance=1)
|
|
160
|
+
|
|
161
|
+
console.print(f"[green]✓ Summarized all phases[/green]\n")
|
|
162
|
+
|
|
163
|
+
# Step 4: Generate global story
|
|
164
|
+
console.print("[bold]Step 4: Generating global narrative...[/bold]")
|
|
165
|
+
storyteller = StoryTeller(
|
|
166
|
+
backend=backend,
|
|
167
|
+
model=model,
|
|
168
|
+
api_key=api_key,
|
|
169
|
+
ollama_url=ollama_url
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
with Progress(
|
|
173
|
+
SpinnerColumn(),
|
|
174
|
+
TextColumn("[progress.description]{task.description}"),
|
|
175
|
+
console=console
|
|
176
|
+
) as progress:
|
|
177
|
+
task = progress.add_task("Generating story...", total=None)
|
|
178
|
+
stories = storyteller.generate_global_story(phases, repo_name)
|
|
179
|
+
progress.update(task, completed=True)
|
|
180
|
+
|
|
181
|
+
console.print(f"[green]✓ Generated global narrative[/green]\n")
|
|
182
|
+
|
|
183
|
+
# Step 5: Write output
|
|
184
|
+
console.print("[bold]Step 5: Writing output files...[/bold]")
|
|
185
|
+
output_path = Path(output)
|
|
186
|
+
|
|
187
|
+
# Write markdown report
|
|
188
|
+
markdown_path = output_path / "history_story.md"
|
|
189
|
+
OutputWriter.write_markdown(stories, phases, str(markdown_path), repo_name)
|
|
190
|
+
console.print(f"[green]✓ Wrote {markdown_path}[/green]")
|
|
191
|
+
|
|
192
|
+
# Write JSON data
|
|
193
|
+
json_path = output_path / "history_data.json"
|
|
194
|
+
OutputWriter.write_json(stories, phases, str(json_path))
|
|
195
|
+
console.print(f"[green]✓ Wrote {json_path}[/green]")
|
|
196
|
+
|
|
197
|
+
# Write timeline
|
|
198
|
+
timeline_path = output_path / "timeline.md"
|
|
199
|
+
OutputWriter.write_simple_timeline(phases, str(timeline_path))
|
|
200
|
+
console.print(f"[green]✓ Wrote {timeline_path}[/green]\n")
|
|
201
|
+
|
|
202
|
+
# Success summary
|
|
203
|
+
console.print("[bold green]✓ Analysis complete![/bold green]\n")
|
|
204
|
+
console.print(f"📊 Analyzed {len(records)} commits across {len(phases)} phases")
|
|
205
|
+
console.print(f"📝 Output written to: {output_path.resolve()}\n")
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
console.print(f"\n[red]Error: {e}[/red]")
|
|
209
|
+
import traceback
|
|
210
|
+
traceback.print_exc()
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@cli.command()
|
|
215
|
+
@click.option('--repo', '-r', default=".", help="Path to git repository")
|
|
216
|
+
@click.option('--output', '-o', default="output/repo_history.jsonl",
|
|
217
|
+
help="Output JSONL file")
|
|
218
|
+
@click.option('--max-commits', type=int, help="Maximum commits to extract")
|
|
219
|
+
@click.option('--branch', default='HEAD', help="Branch to extract from")
|
|
220
|
+
def extract(repo, output, max_commits, branch):
|
|
221
|
+
"""Extract git history to JSONL file.
|
|
222
|
+
|
|
223
|
+
This command only extracts the git history without chunking or summarization.
|
|
224
|
+
"""
|
|
225
|
+
console.print("\n[bold blue]Extracting Git History[/bold blue]\n")
|
|
226
|
+
|
|
227
|
+
repo_path = Path(repo).resolve()
|
|
228
|
+
if not (repo_path / '.git').exists():
|
|
229
|
+
console.print(f"[red]Error: {repo_path} is not a git repository[/red]")
|
|
230
|
+
sys.exit(1)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
extractor = GitHistoryExtractor(str(repo_path))
|
|
234
|
+
|
|
235
|
+
with Progress(
|
|
236
|
+
SpinnerColumn(),
|
|
237
|
+
TextColumn("[progress.description]{task.description}"),
|
|
238
|
+
console=console
|
|
239
|
+
) as progress:
|
|
240
|
+
task = progress.add_task("Extracting commits...", total=None)
|
|
241
|
+
records = extractor.extract_history(max_commits=max_commits, branch=branch)
|
|
242
|
+
progress.update(task, completed=True)
|
|
243
|
+
|
|
244
|
+
extractor.save_to_jsonl(records, output)
|
|
245
|
+
|
|
246
|
+
console.print(f"\n[green]✓ Extracted {len(records)} commits to {output}[/green]\n")
|
|
247
|
+
|
|
248
|
+
except Exception as e:
|
|
249
|
+
console.print(f"\n[red]Error: {e}[/red]")
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@cli.command()
|
|
254
|
+
@click.argument('history_file', type=click.Path(exists=True))
|
|
255
|
+
@click.option('--output', '-o', default="output/phases", help="Output directory for phases")
|
|
256
|
+
@click.option('--strategy', '-s', type=click.Choice(['fixed', 'time', 'adaptive']),
|
|
257
|
+
default='adaptive', help="Chunking strategy")
|
|
258
|
+
@click.option('--chunk-size', type=int, default=50, help="Chunk size for fixed strategy")
|
|
259
|
+
def chunk(history_file, output, strategy, chunk_size):
|
|
260
|
+
"""Chunk extracted history into phases.
|
|
261
|
+
|
|
262
|
+
Takes a JSONL file from the extract command and chunks it into phases.
|
|
263
|
+
"""
|
|
264
|
+
console.print("\n[bold blue]Chunking History into Phases[/bold blue]\n")
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
# Load history
|
|
268
|
+
from .extractor import GitHistoryExtractor
|
|
269
|
+
records = GitHistoryExtractor.load_from_jsonl(history_file)
|
|
270
|
+
|
|
271
|
+
console.print(f"[cyan]Loaded {len(records)} commits[/cyan]")
|
|
272
|
+
console.print(f"[cyan]Strategy: {strategy}[/cyan]\n")
|
|
273
|
+
|
|
274
|
+
# Chunk
|
|
275
|
+
chunker = HistoryChunker(strategy)
|
|
276
|
+
kwargs = {}
|
|
277
|
+
if strategy == 'fixed':
|
|
278
|
+
kwargs['chunk_size'] = chunk_size
|
|
279
|
+
|
|
280
|
+
phases = chunker.chunk(records, **kwargs)
|
|
281
|
+
|
|
282
|
+
console.print(f"[green]✓ Created {len(phases)} phases[/green]\n")
|
|
283
|
+
_display_phase_overview(phases)
|
|
284
|
+
|
|
285
|
+
# Save
|
|
286
|
+
chunker.save_phases(phases, output)
|
|
287
|
+
console.print(f"\n[green]✓ Saved phases to {output}[/green]\n")
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
console.print(f"\n[red]Error: {e}[/red]")
|
|
291
|
+
sys.exit(1)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _display_phase_overview(phases):
|
|
295
|
+
"""Display phase overview table."""
|
|
296
|
+
table = Table(title="Phase Overview")
|
|
297
|
+
|
|
298
|
+
table.add_column("Phase", style="cyan", justify="right")
|
|
299
|
+
table.add_column("Period", style="magenta")
|
|
300
|
+
table.add_column("Commits", justify="right")
|
|
301
|
+
table.add_column("LOC Δ", justify="right")
|
|
302
|
+
table.add_column("Events", style="yellow")
|
|
303
|
+
|
|
304
|
+
for phase in phases:
|
|
305
|
+
events = []
|
|
306
|
+
if phase.has_large_deletion:
|
|
307
|
+
events.append("🗑️")
|
|
308
|
+
if phase.has_large_addition:
|
|
309
|
+
events.append("➕")
|
|
310
|
+
if phase.has_refactor:
|
|
311
|
+
events.append("♻️")
|
|
312
|
+
if phase.readme_changed:
|
|
313
|
+
events.append("📝")
|
|
314
|
+
|
|
315
|
+
table.add_row(
|
|
316
|
+
str(phase.phase_number),
|
|
317
|
+
f"{phase.start_date[:10]} to {phase.end_date[:10]}",
|
|
318
|
+
str(phase.commit_count),
|
|
319
|
+
f"{phase.loc_delta:+,d}",
|
|
320
|
+
" ".join(events)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
console.print(table)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main():
|
|
327
|
+
"""Main entry point."""
|
|
328
|
+
cli()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
if __name__ == '__main__':
|
|
332
|
+
main()
|