gitview 0.1.0__tar.gz → 0.1.2__tar.gz

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.
Files changed (33) hide show
  1. {gitview-0.1.0/gitview.egg-info → gitview-0.1.2}/PKG-INFO +1 -1
  2. {gitview-0.1.0 → gitview-0.1.2}/gitview/__init__.py +1 -1
  3. gitview-0.1.2/gitview/cli.py +665 -0
  4. {gitview-0.1.0 → gitview-0.1.2}/gitview/extractor.py +75 -7
  5. {gitview-0.1.0 → gitview-0.1.2}/gitview/writer.py +47 -7
  6. {gitview-0.1.0 → gitview-0.1.2/gitview.egg-info}/PKG-INFO +1 -1
  7. {gitview-0.1.0 → gitview-0.1.2}/pyproject.toml +1 -1
  8. {gitview-0.1.0 → gitview-0.1.2}/setup.py +1 -1
  9. gitview-0.1.0/gitview/cli.py +0 -332
  10. {gitview-0.1.0 → gitview-0.1.2}/INSTALL.md +0 -0
  11. {gitview-0.1.0 → gitview-0.1.2}/LICENSE +0 -0
  12. {gitview-0.1.0 → gitview-0.1.2}/MANIFEST.in +0 -0
  13. {gitview-0.1.0 → gitview-0.1.2}/README.md +0 -0
  14. {gitview-0.1.0 → gitview-0.1.2}/bin/gitview +0 -0
  15. {gitview-0.1.0 → gitview-0.1.2}/examples/basic_usage.py +0 -0
  16. {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/__init__.py +0 -0
  17. {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/anthropic_backend.py +0 -0
  18. {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/base.py +0 -0
  19. {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/ollama_backend.py +0 -0
  20. {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/openai_backend.py +0 -0
  21. {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/router.py +0 -0
  22. {gitview-0.1.0 → gitview-0.1.2}/gitview/chunker.py +0 -0
  23. {gitview-0.1.0 → gitview-0.1.2}/gitview/storyteller.py +0 -0
  24. {gitview-0.1.0 → gitview-0.1.2}/gitview/summarizer.py +0 -0
  25. {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/SOURCES.txt +0 -0
  26. {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/dependency_links.txt +0 -0
  27. {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/entry_points.txt +0 -0
  28. {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/not-zip-safe +0 -0
  29. {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/requires.txt +0 -0
  30. {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/top_level.txt +0 -0
  31. {gitview-0.1.0 → gitview-0.1.2}/requirements.txt +0 -0
  32. {gitview-0.1.0 → gitview-0.1.2}/setup.cfg +0 -0
  33. {gitview-0.1.0 → gitview-0.1.2}/verify_installation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitview
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Git history analyzer with LLM-powered narrative generation
5
5
  Home-page: https://github.com/carstenbund/gitview
6
6
  Author: GitView Contributors
@@ -1,3 +1,3 @@
1
1
  """GitView - Git history analyzer with LLM-powered narrative generation."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -0,0 +1,665 @@
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
+ \b
27
+ Extract, chunk, and use LLMs to generate compelling narratives from your
28
+ git repository's history.
29
+
30
+ \b
31
+ Quick Start:
32
+ # Using Anthropic Claude (default)
33
+ export ANTHROPIC_API_KEY="your-key"
34
+ gitview analyze
35
+
36
+ # Using OpenAI GPT
37
+ export OPENAI_API_KEY="your-key"
38
+ gitview analyze --backend openai
39
+
40
+ # Using local Ollama (no API key needed)
41
+ gitview analyze --backend ollama --model llama3
42
+
43
+ \b
44
+ See 'gitview analyze --help' for detailed LLM configuration options.
45
+ """
46
+ pass
47
+
48
+
49
+ ANALYZE_HELP = """Analyze git repository and generate narrative history.
50
+
51
+ \b
52
+ This command runs the full pipeline:
53
+ 1. Extract git history with detailed metadata
54
+ 2. Chunk commits into meaningful phases/epochs
55
+ 3. Summarize each phase using LLM
56
+ 4. Generate global narrative stories
57
+ 5. Write markdown reports and JSON data
58
+
59
+ \b
60
+ LLM BACKEND CONFIGURATION:
61
+
62
+ GitView supports three LLM backends:
63
+
64
+ \b
65
+ 1. Anthropic Claude (default, requires API key):
66
+ export ANTHROPIC_API_KEY="your-key"
67
+ gitview analyze
68
+
69
+ Or: gitview analyze --backend anthropic --api-key "your-key"
70
+
71
+ Default model: claude-sonnet-4-5-20250929
72
+ Other models: claude-3-opus-20240229, claude-3-haiku-20240307
73
+
74
+ \b
75
+ 2. OpenAI GPT (requires API key):
76
+ export OPENAI_API_KEY="your-key"
77
+ gitview analyze --backend openai
78
+
79
+ Default model: gpt-4
80
+ Other models: gpt-4-turbo-preview, gpt-3.5-turbo
81
+
82
+ \b
83
+ 3. Ollama (local, FREE, no API key needed):
84
+ # Start Ollama server first: ollama serve
85
+ # Pull a model: ollama pull llama3
86
+ gitview analyze --backend ollama --model llama3
87
+
88
+ Popular models: llama3, mistral, codellama, mixtral
89
+ Default URL: http://localhost:11434
90
+
91
+ \b
92
+ Backend auto-detection:
93
+ If no --backend is specified, GitView checks environment variables:
94
+ - If ANTHROPIC_API_KEY is set → uses Anthropic
95
+ - If OPENAI_API_KEY is set → uses OpenAI
96
+ - Otherwise → uses Ollama (local)
97
+
98
+ \b
99
+ EXAMPLES:
100
+
101
+ # Analyze current directory with Claude (auto-detected)
102
+ export ANTHROPIC_API_KEY="sk-ant-..."
103
+ gitview analyze
104
+
105
+ # Use OpenAI GPT-4 with custom model
106
+ gitview analyze --backend openai --model gpt-4-turbo-preview
107
+
108
+ # Use local Ollama (no API costs!)
109
+ gitview analyze --backend ollama --model llama3
110
+
111
+ # Analyze specific repository
112
+ gitview analyze --repo /path/to/repo --output ./analysis
113
+
114
+ # Quick analysis without LLM (just extract and chunk)
115
+ gitview analyze --skip-llm
116
+
117
+ # Analyze last 100 commits only
118
+ gitview analyze --max-commits 100
119
+
120
+ # Adaptive chunking (default, splits on significant changes)
121
+ gitview analyze --strategy adaptive
122
+
123
+ # Fixed-size chunks (50 commits per phase)
124
+ gitview analyze --strategy fixed --chunk-size 50
125
+
126
+ # Use custom Ollama server
127
+ gitview analyze --backend ollama --ollama-url http://192.168.1.100:11434
128
+
129
+ \b
130
+ INCREMENTAL ANALYSIS (Cost-Efficient Ongoing Monitoring):
131
+
132
+ For managers analyzing multiple projects on an ongoing basis, incremental
133
+ analysis dramatically reduces costs by reusing previous LLM summaries.
134
+
135
+ # Initial full analysis
136
+ gitview analyze --output reports/myproject
137
+
138
+ # Later: incremental update (only analyzes new commits)
139
+ gitview analyze --output reports/myproject --incremental
140
+
141
+ # Manual incremental from specific commit
142
+ gitview analyze --since-commit abc123def
143
+
144
+ # Incremental from date
145
+ gitview analyze --since-date 2025-11-01
146
+
147
+ How it works:
148
+ - Detects previous analysis in output directory
149
+ - Extracts only commits since last run
150
+ - Reuses existing phase summaries (no LLM calls!)
151
+ - Only summarizes new/modified phases
152
+ - Updates JSON with new metadata
153
+
154
+ Benefits:
155
+ - Massive API cost savings for ongoing monitoring
156
+ - Much faster analysis (only processes new commits)
157
+ - Perfect for CI/CD integration or periodic reviews
158
+ """
159
+
160
+
161
+ @cli.command(help=ANALYZE_HELP)
162
+ @click.option('--repo', '-r', default=".",
163
+ help="Path to git repository (default: current directory)")
164
+ @click.option('--output', '-o', default="output",
165
+ help="Output directory for reports and data")
166
+ @click.option('--strategy', '-s', type=click.Choice(['fixed', 'time', 'adaptive']),
167
+ default='adaptive',
168
+ help="Chunking strategy: 'adaptive' (default, splits on significant changes), "
169
+ "'fixed' (N commits per phase), 'time' (by time period)")
170
+ @click.option('--chunk-size', type=int, default=50,
171
+ help="Commits per chunk when using 'fixed' strategy")
172
+ @click.option('--max-commits', type=int,
173
+ help="Maximum commits to analyze (default: all commits)")
174
+ @click.option('--branch', default='HEAD',
175
+ help="Branch to analyze (default: HEAD/current branch)")
176
+ @click.option('--backend', '-b', type=click.Choice(['anthropic', 'openai', 'ollama']),
177
+ help="LLM backend: 'anthropic' (Claude), 'openai' (GPT), 'ollama' (local). "
178
+ "Auto-detected from env vars if not specified.")
179
+ @click.option('--model', '-m',
180
+ help="Model identifier. Defaults: claude-sonnet-4-5-20250929 (Anthropic), "
181
+ "gpt-4 (OpenAI), llama3 (Ollama)")
182
+ @click.option('--api-key',
183
+ help="API key for Anthropic/OpenAI. Defaults to ANTHROPIC_API_KEY or "
184
+ "OPENAI_API_KEY environment variable")
185
+ @click.option('--ollama-url', default='http://localhost:11434',
186
+ help="Ollama server URL (only for --backend ollama)")
187
+ @click.option('--repo-name',
188
+ help="Repository name for output (default: directory name)")
189
+ @click.option('--skip-llm', is_flag=True,
190
+ help="Skip LLM summarization - only extract and chunk history "
191
+ "(useful for quick analysis without API costs)")
192
+ @click.option('--incremental', is_flag=True,
193
+ help="Incremental analysis: only process commits since last run. "
194
+ "Automatically detects previous analysis in output directory")
195
+ @click.option('--since-commit',
196
+ help="Extract commits since this commit hash (for manual incremental analysis)")
197
+ @click.option('--since-date',
198
+ help="Extract commits since this date (ISO format: YYYY-MM-DD)")
199
+ def analyze(repo, output, strategy, chunk_size, max_commits, branch, backend,
200
+ model, api_key, ollama_url, repo_name, skip_llm, incremental,
201
+ since_commit, since_date):
202
+ """Analyze git repository and generate narrative history.
203
+
204
+ This is the main command that runs the full pipeline:
205
+ 1. Extract git history
206
+ 2. Chunk into meaningful phases
207
+ 3. Summarize each phase with LLM
208
+ 4. Generate global narrative
209
+ 5. Write output files
210
+ """
211
+ console.print("\n[bold blue]GitView - Repository History Analyzer[/bold blue]\n")
212
+
213
+ # Validate repository
214
+ repo_path = Path(repo).resolve()
215
+ if not (repo_path / '.git').exists():
216
+ console.print(f"[red]Error: {repo_path} is not a git repository[/red]")
217
+ sys.exit(1)
218
+
219
+ # Get repo name if not provided
220
+ if not repo_name:
221
+ repo_name = repo_path.name
222
+
223
+ console.print(f"[cyan]Repository:[/cyan] {repo_path}")
224
+ console.print(f"[cyan]Output:[/cyan] {output}")
225
+ console.print(f"[cyan]Strategy:[/cyan] {strategy}")
226
+
227
+ if not skip_llm:
228
+ # Determine backend for display
229
+ from .backends import LLMRouter
230
+ router = LLMRouter(backend=backend, model=model, api_key=api_key, ollama_url=ollama_url)
231
+ console.print(f"[cyan]Backend:[/cyan] {router.backend_type.value}")
232
+ console.print(f"[cyan]Model:[/cyan] {router.model}\n")
233
+ else:
234
+ console.print("[yellow]Skipping LLM summarization[/yellow]\n")
235
+
236
+ try:
237
+ # Check for incremental analysis
238
+ previous_analysis = None
239
+ existing_phases = []
240
+ starting_loc = 0
241
+
242
+ if incremental or since_commit or since_date:
243
+ # Load previous analysis
244
+ previous_analysis = OutputWriter.load_previous_analysis(output)
245
+
246
+ if incremental and not previous_analysis:
247
+ console.print("[yellow]Warning: --incremental specified but no previous analysis found.[/yellow]")
248
+ console.print("[yellow]Running full analysis instead...[/yellow]\n")
249
+ incremental = False
250
+ elif previous_analysis:
251
+ metadata = previous_analysis.get('metadata', {})
252
+ last_hash = metadata.get('last_commit_hash')
253
+ last_date = metadata.get('last_commit_date')
254
+
255
+ if incremental:
256
+ since_commit = last_hash
257
+ console.print(f"[cyan]Incremental mode:[/cyan] Analyzing commits since {last_hash[:8]}")
258
+ console.print(f"[cyan]Last analysis:[/cyan] {metadata.get('generated_at', 'unknown')}\n")
259
+
260
+ # Load existing phases
261
+ from .chunker import Phase
262
+ existing_phases = [Phase.from_dict(p) for p in previous_analysis.get('phases', [])]
263
+
264
+ if existing_phases and existing_phases[-1].commits:
265
+ starting_loc = existing_phases[-1].commits[-1].loc_total
266
+
267
+ # Step 1: Extract git history
268
+ console.print("[bold]Step 1: Extracting git history...[/bold]")
269
+ extractor = GitHistoryExtractor(str(repo_path))
270
+
271
+ with Progress(
272
+ SpinnerColumn(),
273
+ TextColumn("[progress.description]{task.description}"),
274
+ console=console
275
+ ) as progress:
276
+ task = progress.add_task("Extracting commits...", total=None)
277
+
278
+ # Use incremental extraction if requested
279
+ if since_commit or since_date:
280
+ records = extractor.extract_incremental(
281
+ since_commit=since_commit,
282
+ since_date=since_date,
283
+ branch=branch
284
+ )
285
+ # Adjust LOC to continue from previous analysis
286
+ if starting_loc > 0:
287
+ extractor._calculate_cumulative_loc(records, starting_loc)
288
+ else:
289
+ records = extractor.extract_history(max_commits=max_commits, branch=branch)
290
+
291
+ progress.update(task, completed=True)
292
+
293
+ if since_commit or since_date:
294
+ console.print(f"[green]Extracted {len(records)} new commits[/green]\n")
295
+
296
+ # Exit early if no new commits
297
+ if len(records) == 0:
298
+ console.print("[yellow]No new commits found since last analysis.[/yellow]")
299
+ console.print("[green]Repository is up to date![/green]\n")
300
+ return
301
+ else:
302
+ console.print(f"[green]Extracted {len(records)} commits[/green]\n")
303
+
304
+ # Save raw history
305
+ history_file = Path(output) / "repo_history.jsonl"
306
+ extractor.save_to_jsonl(records, str(history_file))
307
+
308
+ # Step 2: Chunk into phases
309
+ console.print("[bold]Step 2: Chunking into phases...[/bold]")
310
+ chunker = HistoryChunker(strategy)
311
+
312
+ kwargs = {}
313
+ if strategy == 'fixed':
314
+ kwargs['chunk_size'] = chunk_size
315
+
316
+ # Handle incremental phase management
317
+ if existing_phases and len(records) > 0:
318
+ # Incremental mode: merge new commits with existing phases
319
+ merge_threshold = 10 # commits - merge if fewer, create new phase if more
320
+
321
+ if len(records) < merge_threshold:
322
+ # Append new commits to last phase
323
+ console.print(f"[yellow]Merging {len(records)} new commits into last phase...[/yellow]")
324
+ last_phase = existing_phases[-1]
325
+ last_phase.commits.extend(records)
326
+
327
+ # Recalculate phase stats
328
+ from .chunker import Phase
329
+ last_phase.commit_count = len(last_phase.commits)
330
+ last_phase.end_date = records[-1].timestamp
331
+ last_phase.total_insertions = sum(c.insertions for c in last_phase.commits)
332
+ last_phase.total_deletions = sum(c.deletions for c in last_phase.commits)
333
+ last_phase.loc_end = records[-1].loc_total
334
+ last_phase.loc_delta = last_phase.loc_end - last_phase.loc_start
335
+ if last_phase.loc_start > 0:
336
+ last_phase.loc_delta_percent = (last_phase.loc_delta / last_phase.loc_start) * 100
337
+
338
+ # Clear summary so it will be regenerated
339
+ last_phase.summary = None
340
+
341
+ phases = existing_phases
342
+ console.print(f"[green]Updated last phase (now {last_phase.commit_count} commits)[/green]\n")
343
+ else:
344
+ # Create new phases for new commits
345
+ new_phases = chunker.chunk(records, **kwargs)
346
+
347
+ # Renumber new phases to continue from existing
348
+ for phase in new_phases:
349
+ phase.phase_number = len(existing_phases) + phase.phase_number
350
+
351
+ phases = existing_phases + new_phases
352
+ console.print(f"[green]Created {len(new_phases)} new phases (total: {len(phases)})[/green]\n")
353
+ else:
354
+ # Full analysis: chunk normally
355
+ phases = chunker.chunk(records, **kwargs)
356
+ console.print(f"[green]Created {len(phases)} phases[/green]\n")
357
+
358
+ # Display phase overview
359
+ _display_phase_overview(phases)
360
+
361
+ # Save phases
362
+ phases_dir = Path(output) / "phases"
363
+ chunker.save_phases(phases, str(phases_dir))
364
+
365
+ if skip_llm:
366
+ console.print("\n[yellow]Skipping LLM summarization. Writing basic timeline...[/yellow]")
367
+ timeline_file = Path(output) / "timeline.md"
368
+ OutputWriter.write_simple_timeline(phases, str(timeline_file))
369
+ console.print(f"[green]Wrote timeline to {timeline_file}[/green]\n")
370
+ return
371
+
372
+ # Step 3: Summarize phases with LLM
373
+ console.print("[bold]Step 3: Summarizing phases with LLM...[/bold]")
374
+ summarizer = PhaseSummarizer(
375
+ backend=backend,
376
+ model=model,
377
+ api_key=api_key,
378
+ ollama_url=ollama_url
379
+ )
380
+
381
+ # Identify phases that need summarization (no summary)
382
+ phases_to_summarize = [p for p in phases if p.summary is None]
383
+
384
+ if previous_analysis and len(phases_to_summarize) < len(phases):
385
+ console.print(f"[cyan]Incremental mode: {len(phases_to_summarize)} phases need summarization "
386
+ f"({len(phases) - len(phases_to_summarize)} already summarized)[/cyan]")
387
+
388
+ with Progress(
389
+ SpinnerColumn(),
390
+ TextColumn("[progress.description]{task.description}"),
391
+ console=console
392
+ ) as progress:
393
+ task = progress.add_task("Summarizing phases...", total=len(phases_to_summarize))
394
+
395
+ # Build previous summaries from all phases (including existing ones)
396
+ previous_summaries = []
397
+ for i, phase in enumerate(phases):
398
+ progress.update(task, description=f"Processing phase {i+1}/{len(phases)}...")
399
+
400
+ if phase.summary is None:
401
+ # Need to summarize this phase
402
+ context = summarizer._build_context(previous_summaries)
403
+ summary = summarizer.summarize_phase(phase, context)
404
+ phase.summary = summary
405
+ progress.update(task, advance=1)
406
+
407
+ previous_summaries.append({
408
+ 'phase_number': phase.phase_number,
409
+ 'summary': phase.summary,
410
+ 'loc_delta': phase.loc_delta,
411
+ })
412
+
413
+ summarizer._save_phase_with_summary(phase, str(phases_dir))
414
+
415
+ if len(phases_to_summarize) > 0:
416
+ console.print(f"[green]Summarized {len(phases_to_summarize)} phase(s)[/green]\n")
417
+ else:
418
+ console.print(f"[green]All phases already summarized[/green]\n")
419
+
420
+ # Step 4: Generate global story
421
+ console.print("[bold]Step 4: Generating global narrative...[/bold]")
422
+ storyteller = StoryTeller(
423
+ backend=backend,
424
+ model=model,
425
+ api_key=api_key,
426
+ ollama_url=ollama_url
427
+ )
428
+
429
+ with Progress(
430
+ SpinnerColumn(),
431
+ TextColumn("[progress.description]{task.description}"),
432
+ console=console
433
+ ) as progress:
434
+ task = progress.add_task("Generating story...", total=None)
435
+ stories = storyteller.generate_global_story(phases, repo_name)
436
+ progress.update(task, completed=True)
437
+
438
+ console.print(f"[green]Generated global narrative[/green]\n")
439
+
440
+ # Step 5: Write output
441
+ console.print("[bold]Step 5: Writing output files...[/bold]")
442
+ output_path = Path(output)
443
+
444
+ # Write markdown report
445
+ markdown_path = output_path / "history_story.md"
446
+ OutputWriter.write_markdown(stories, phases, str(markdown_path), repo_name)
447
+ console.print(f"[green]Wrote {markdown_path}[/green]")
448
+
449
+ # Write JSON data with metadata for incremental analysis
450
+ json_path = output_path / "history_data.json"
451
+ OutputWriter.write_json(stories, phases, str(json_path), repo_path=str(repo_path))
452
+ console.print(f"[green]Wrote {json_path}[/green]")
453
+
454
+ # Write timeline
455
+ timeline_path = output_path / "timeline.md"
456
+ OutputWriter.write_simple_timeline(phases, str(timeline_path))
457
+ console.print(f"[green]Wrote {timeline_path}[/green]\n")
458
+
459
+ # Success summary
460
+ console.print("[bold green]Analysis complete![/bold green]\n")
461
+ console.print(f"Analyzed {len(records)} commits across {len(phases)} phases")
462
+ console.print(f"Output written to: {output_path.resolve()}\n")
463
+
464
+ except Exception as e:
465
+ console.print(f"\n[red]Error: {e}[/red]")
466
+ import traceback
467
+ traceback.print_exc()
468
+ sys.exit(1)
469
+
470
+
471
+ EXTRACT_HELP = """Extract git history to JSONL file (no LLM needed).
472
+
473
+ \b
474
+ This command extracts detailed metadata from git commits without using an LLM.
475
+ Useful for:
476
+ - Quick history extraction
477
+ - Pre-processing for later analysis
478
+ - Exploring repository metrics
479
+
480
+ \b
481
+ Extracted data includes:
482
+ - Commit metadata (hash, author, date, message)
483
+ - Lines of code changes (insertions/deletions)
484
+ - Language breakdown per commit
485
+ - README evolution
486
+ - Comment density analysis
487
+ - Detection of large changes and refactors
488
+
489
+ \b
490
+ EXAMPLES:
491
+
492
+ # Extract full history to default location
493
+ gitview extract
494
+
495
+ # Extract to custom file
496
+ gitview extract --output my_history.jsonl
497
+
498
+ # Extract only last 100 commits
499
+ gitview extract --max-commits 100
500
+
501
+ # Extract from specific branch
502
+ gitview extract --branch develop
503
+
504
+ # Extract from different repository
505
+ gitview extract --repo /path/to/repo --output repo_data.jsonl
506
+ """
507
+
508
+
509
+ @cli.command(help=EXTRACT_HELP)
510
+ @click.option('--repo', '-r', default=".",
511
+ help="Path to git repository (default: current directory)")
512
+ @click.option('--output', '-o', default="output/repo_history.jsonl",
513
+ help="Output JSONL file path")
514
+ @click.option('--max-commits', type=int,
515
+ help="Maximum commits to extract (default: all commits)")
516
+ @click.option('--branch', default='HEAD',
517
+ help="Branch to extract from (default: HEAD/current branch)")
518
+ def extract(repo, output, max_commits, branch):
519
+ console.print("\n[bold blue]Extracting Git History[/bold blue]\n")
520
+
521
+ repo_path = Path(repo).resolve()
522
+ if not (repo_path / '.git').exists():
523
+ console.print(f"[red]Error: {repo_path} is not a git repository[/red]")
524
+ sys.exit(1)
525
+
526
+ try:
527
+ extractor = GitHistoryExtractor(str(repo_path))
528
+
529
+ with Progress(
530
+ SpinnerColumn(),
531
+ TextColumn("[progress.description]{task.description}"),
532
+ console=console
533
+ ) as progress:
534
+ task = progress.add_task("Extracting commits...", total=None)
535
+ records = extractor.extract_history(max_commits=max_commits, branch=branch)
536
+ progress.update(task, completed=True)
537
+
538
+ extractor.save_to_jsonl(records, output)
539
+
540
+ console.print(f"\n[green]Extracted {len(records)} commits to {output}[/green]\n")
541
+
542
+ except Exception as e:
543
+ console.print(f"\n[red]Error: {e}[/red]")
544
+ sys.exit(1)
545
+
546
+
547
+ CHUNK_HELP = """Chunk extracted history into meaningful phases (no LLM needed).
548
+
549
+ \b
550
+ Takes a JSONL file from 'gitview extract' and splits it into phases/epochs
551
+ based on the chosen strategy. No LLM or API key required.
552
+
553
+ \b
554
+ CHUNKING STRATEGIES:
555
+
556
+ 1. Adaptive (recommended):
557
+ Automatically splits when significant changes occur:
558
+ - LOC changes by >30%
559
+ - Large deletions or additions (>1000 lines)
560
+ - README rewrites
561
+ - Major refactorings
562
+
563
+ 2. Fixed:
564
+ Split into fixed-size chunks (e.g., 50 commits per phase)
565
+
566
+ 3. Time:
567
+ Split by time periods (week, month, quarter, year)
568
+
569
+ \b
570
+ EXAMPLES:
571
+
572
+ # Chunk with adaptive strategy (recommended)
573
+ gitview chunk repo_history.jsonl
574
+
575
+ # Chunk with fixed size (25 commits per phase)
576
+ gitview chunk repo_history.jsonl --strategy fixed --chunk-size 25
577
+
578
+ # Save phases to custom directory
579
+ gitview chunk repo_history.jsonl --output ./my_phases
580
+
581
+ # First extract, then chunk separately
582
+ gitview extract --output data.jsonl
583
+ gitview chunk data.jsonl --output phases/
584
+ """
585
+
586
+
587
+ @cli.command(help=CHUNK_HELP)
588
+ @click.argument('history_file', type=click.Path(exists=True))
589
+ @click.option('--output', '-o', default="output/phases",
590
+ help="Output directory for phase JSON files")
591
+ @click.option('--strategy', '-s', type=click.Choice(['fixed', 'time', 'adaptive']),
592
+ default='adaptive',
593
+ help="Chunking strategy: 'adaptive' (default), 'fixed', 'time'")
594
+ @click.option('--chunk-size', type=int, default=50,
595
+ help="Commits per chunk when using 'fixed' strategy")
596
+ def chunk(history_file, output, strategy, chunk_size):
597
+ console.print("\n[bold blue]Chunking History into Phases[/bold blue]\n")
598
+
599
+ try:
600
+ # Load history
601
+ from .extractor import GitHistoryExtractor
602
+ records = GitHistoryExtractor.load_from_jsonl(history_file)
603
+
604
+ console.print(f"[cyan]Loaded {len(records)} commits[/cyan]")
605
+ console.print(f"[cyan]Strategy: {strategy}[/cyan]\n")
606
+
607
+ # Chunk
608
+ chunker = HistoryChunker(strategy)
609
+ kwargs = {}
610
+ if strategy == 'fixed':
611
+ kwargs['chunk_size'] = chunk_size
612
+
613
+ phases = chunker.chunk(records, **kwargs)
614
+
615
+ console.print(f"[green]Created {len(phases)} phases[/green]\n")
616
+ _display_phase_overview(phases)
617
+
618
+ # Save
619
+ chunker.save_phases(phases, output)
620
+ console.print(f"\n[green]Saved phases to {output}[/green]\n")
621
+
622
+ except Exception as e:
623
+ console.print(f"\n[red]Error: {e}[/red]")
624
+ sys.exit(1)
625
+
626
+
627
+ def _display_phase_overview(phases):
628
+ """Display phase overview table."""
629
+ table = Table(title="Phase Overview")
630
+
631
+ table.add_column("Phase", style="cyan", justify="right")
632
+ table.add_column("Period", style="magenta")
633
+ table.add_column("Commits", justify="right")
634
+ table.add_column("LOC Δ", justify="right")
635
+ table.add_column("Events", style="yellow")
636
+
637
+ for phase in phases:
638
+ events = []
639
+ if phase.has_large_deletion:
640
+ events.append("x")
641
+ if phase.has_large_addition:
642
+ events.append("+")
643
+ if phase.has_refactor:
644
+ events.append(">>")
645
+ if phase.readme_changed:
646
+ events.append(">")
647
+
648
+ table.add_row(
649
+ str(phase.phase_number),
650
+ f"{phase.start_date[:10]} to {phase.end_date[:10]}",
651
+ str(phase.commit_count),
652
+ f"{phase.loc_delta:+,d}",
653
+ " ".join(events)
654
+ )
655
+
656
+ console.print(table)
657
+
658
+
659
+ def main():
660
+ """Main entry point."""
661
+ cli()
662
+
663
+
664
+ if __name__ == '__main__':
665
+ main()
@@ -136,7 +136,74 @@ class GitHistoryExtractor:
136
136
  commits.reverse()
137
137
 
138
138
  # Calculate cumulative LOC
139
- total_loc = 0
139
+ return self._calculate_cumulative_loc(commits)
140
+
141
+ def extract_incremental(self, since_commit: str = None, since_date: str = None,
142
+ branch: str = "HEAD") -> List[CommitRecord]:
143
+ """Extract only new commits since a specific commit or date.
144
+
145
+ Args:
146
+ since_commit: Commit hash to start from (exclusive)
147
+ since_date: ISO date string to start from (exclusive)
148
+ branch: Branch to extract from (default: HEAD)
149
+
150
+ Returns:
151
+ List of CommitRecord objects for new commits, sorted chronologically (oldest first)
152
+ """
153
+ commits = []
154
+
155
+ # Build revision range
156
+ if since_commit:
157
+ # Extract commits from since_commit..HEAD (exclusive of since_commit)
158
+ revision = f"{since_commit}..{branch}"
159
+ elif since_date:
160
+ # Extract commits after the given date
161
+ commit_iterator = self.repo.iter_commits(
162
+ branch,
163
+ since=since_date
164
+ )
165
+ for commit in commit_iterator:
166
+ try:
167
+ record = self._extract_commit_record(commit)
168
+ commits.append(record)
169
+ except Exception as e:
170
+ print(f"Warning: Failed to extract commit {commit.hexsha[:8]}: {e}")
171
+ continue
172
+
173
+ # Sort chronologically and calculate LOC
174
+ commits.reverse()
175
+ return self._calculate_cumulative_loc(commits)
176
+ else:
177
+ raise ValueError("Must provide either since_commit or since_date")
178
+
179
+ # Extract commits in the range
180
+ commit_iterator = self.repo.iter_commits(revision)
181
+
182
+ for commit in commit_iterator:
183
+ try:
184
+ record = self._extract_commit_record(commit)
185
+ commits.append(record)
186
+ except Exception as e:
187
+ print(f"Warning: Failed to extract commit {commit.hexsha[:8]}: {e}")
188
+ continue
189
+
190
+ # Sort chronologically (oldest first)
191
+ commits.reverse()
192
+
193
+ return commits
194
+
195
+ def _calculate_cumulative_loc(self, commits: List[CommitRecord],
196
+ starting_loc: int = 0) -> List[CommitRecord]:
197
+ """Calculate cumulative LOC for a list of commits.
198
+
199
+ Args:
200
+ commits: List of CommitRecord objects
201
+ starting_loc: Starting LOC count (for incremental analysis)
202
+
203
+ Returns:
204
+ Same list with loc_total updated
205
+ """
206
+ total_loc = starting_loc
140
207
  for record in commits:
141
208
  total_loc += (record.loc_added - record.loc_deleted)
142
209
  record.loc_total = max(0, total_loc)
@@ -218,11 +285,11 @@ class GitHistoryExtractor:
218
285
  if not commit.parents:
219
286
  # Initial commit
220
287
  try:
221
- diff_index = commit.diff(git.NULL_TREE)
288
+ diff_index = commit.diff(git.NULL_TREE, create_patch=True)
222
289
  except:
223
290
  return stats
224
291
  else:
225
- diff_index = commit.parents[0].diff(commit)
292
+ diff_index = commit.parents[0].diff(commit, create_patch=True)
226
293
 
227
294
  for diff in diff_index:
228
295
  try:
@@ -239,8 +306,9 @@ class GitHistoryExtractor:
239
306
  # Get line changes
240
307
  if diff.diff:
241
308
  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('-')])
309
+ # Exclude diff headers (---, +++) from line counts
310
+ insertions = len([l for l in diff_text.split('\n') if l.startswith('+') and not l.startswith('+++')])
311
+ deletions = len([l for l in diff_text.split('\n') if l.startswith('-') and not l.startswith('---')])
244
312
 
245
313
  stats['insertions'] += insertions
246
314
  stats['deletions'] += deletions
@@ -312,9 +380,9 @@ class GitHistoryExtractor:
312
380
 
313
381
  try:
314
382
  if not commit.parents:
315
- diff_index = commit.diff(git.NULL_TREE)
383
+ diff_index = commit.diff(git.NULL_TREE, create_patch=True)
316
384
  else:
317
- diff_index = commit.parents[0].diff(commit)
385
+ diff_index = commit.parents[0].diff(commit, create_patch=True)
318
386
 
319
387
  for diff in diff_index:
320
388
  if not diff.b_blob:
@@ -11,6 +11,27 @@ from .chunker import Phase
11
11
  class OutputWriter:
12
12
  """Write git history stories to various formats."""
13
13
 
14
+ @staticmethod
15
+ def load_previous_analysis(output_path: str) -> Dict[str, Any]:
16
+ """
17
+ Load previous analysis from JSON file.
18
+
19
+ Args:
20
+ output_path: Path to output directory containing history_data.json
21
+
22
+ Returns:
23
+ Dict with previous analysis data, or None if not found
24
+ """
25
+ json_file = Path(output_path) / "history_data.json"
26
+ if not json_file.exists():
27
+ return None
28
+
29
+ try:
30
+ with open(json_file, 'r') as f:
31
+ return json.load(f)
32
+ except (json.JSONDecodeError, IOError):
33
+ return None
34
+
14
35
  @staticmethod
15
36
  def write_markdown(stories: Dict[str, str], phases: List[Phase],
16
37
  output_path: str, repo_name: str = "Repository"):
@@ -90,13 +111,13 @@ class OutputWriter:
90
111
  # Events
91
112
  events = []
92
113
  if phase.has_large_deletion:
93
- events.append("🗑️ Large Deletion")
114
+ events.append("Large Deletion")
94
115
  if phase.has_large_addition:
95
- events.append("Large Addition")
116
+ events.append("Large Addition")
96
117
  if phase.has_refactor:
97
- events.append("♻️ Refactoring")
118
+ events.append("Refactoring")
98
119
  if phase.readme_changed:
99
- events.append("📝 README Changed")
120
+ events.append("README Changed")
100
121
 
101
122
  if events:
102
123
  f.write(f"**Events:** {' | '.join(events)}\n\n")
@@ -164,20 +185,39 @@ class OutputWriter:
164
185
  f.write("\n")
165
186
 
166
187
  @staticmethod
167
- def write_json(stories: Dict[str, str], phases: List[Phase], output_path: str):
188
+ def write_json(stories: Dict[str, str], phases: List[Phase], output_path: str,
189
+ repo_path: str = None):
168
190
  """
169
- Write complete data to JSON file.
191
+ Write complete data to JSON file with metadata for incremental analysis.
170
192
 
171
193
  Args:
172
194
  stories: Dict of story sections
173
195
  phases: List of Phase objects
174
196
  output_path: Path to output JSON file
197
+ repo_path: Path to git repository (for metadata)
175
198
  """
176
199
  output_file = Path(output_path)
177
200
  output_file.parent.mkdir(parents=True, exist_ok=True)
178
201
 
179
- data = {
202
+ # Calculate metadata from phases
203
+ metadata = {
180
204
  'generated_at': datetime.now().isoformat(),
205
+ 'total_commits_analyzed': sum(p.commit_count for p in phases),
206
+ }
207
+
208
+ # Add last commit info if phases exist
209
+ if phases:
210
+ last_phase = phases[-1]
211
+ if last_phase.commits:
212
+ last_commit = last_phase.commits[-1]
213
+ metadata['last_commit_hash'] = last_commit.commit_hash
214
+ metadata['last_commit_date'] = last_commit.timestamp
215
+
216
+ if repo_path:
217
+ metadata['repository_path'] = str(Path(repo_path).resolve())
218
+
219
+ data = {
220
+ 'metadata': metadata,
181
221
  'total_phases': len(phases),
182
222
  'total_commits': sum(p.commit_count for p in phases),
183
223
  'stories': stories,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitview
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Git history analyzer with LLM-powered narrative generation
5
5
  Home-page: https://github.com/carstenbund/gitview
6
6
  Author: GitView Contributors
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitview"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Git history analyzer with LLM-powered narrative generation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -13,7 +13,7 @@ with open("requirements.txt", "r", encoding="utf-8") as fh:
13
13
 
14
14
  setup(
15
15
  name="gitview",
16
- version="0.1.0",
16
+ version="0.1.1",
17
17
  author="GitView Contributors",
18
18
  author_email="", # Add if publishing
19
19
  description="Git history analyzer with LLM-powered narrative generation",
@@ -1,332 +0,0 @@
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()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes