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.
- {gitview-0.1.0/gitview.egg-info → gitview-0.1.2}/PKG-INFO +1 -1
- {gitview-0.1.0 → gitview-0.1.2}/gitview/__init__.py +1 -1
- gitview-0.1.2/gitview/cli.py +665 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/extractor.py +75 -7
- {gitview-0.1.0 → gitview-0.1.2}/gitview/writer.py +47 -7
- {gitview-0.1.0 → gitview-0.1.2/gitview.egg-info}/PKG-INFO +1 -1
- {gitview-0.1.0 → gitview-0.1.2}/pyproject.toml +1 -1
- {gitview-0.1.0 → gitview-0.1.2}/setup.py +1 -1
- gitview-0.1.0/gitview/cli.py +0 -332
- {gitview-0.1.0 → gitview-0.1.2}/INSTALL.md +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/LICENSE +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/MANIFEST.in +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/README.md +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/bin/gitview +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/examples/basic_usage.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/__init__.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/anthropic_backend.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/base.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/ollama_backend.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/openai_backend.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/backends/router.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/chunker.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/storyteller.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview/summarizer.py +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/SOURCES.txt +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/dependency_links.txt +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/entry_points.txt +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/not-zip-safe +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/requires.txt +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/gitview.egg-info/top_level.txt +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/requirements.txt +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/setup.cfg +0 -0
- {gitview-0.1.0 → gitview-0.1.2}/verify_installation.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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("
|
|
114
|
+
events.append("Large Deletion")
|
|
94
115
|
if phase.has_large_addition:
|
|
95
|
-
events.append("
|
|
116
|
+
events.append("Large Addition")
|
|
96
117
|
if phase.has_refactor:
|
|
97
|
-
events.append("
|
|
118
|
+
events.append("Refactoring")
|
|
98
119
|
if phase.readme_changed:
|
|
99
|
-
events.append("
|
|
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
|
-
|
|
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,
|
|
@@ -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.
|
|
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",
|
gitview-0.1.0/gitview/cli.py
DELETED
|
@@ -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
|
|
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
|