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/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()