#!/usr/bin/env python3 """ git-story: tells the story of how your codebase evolved Usage: python git_story.py [path-to-repo] """ import subprocess import sys import os from collections import defaultdict from datetime import datetime, timezone, timedelta from pathlib import Path import argparse import itertools from rich.console import Console from rich.table import Table from rich.panel import Panel from rich.text import Text from rich import box from rich.rule import Rule from rich.tree import Tree from rich.padding import Padding from rich.columns import Columns from rich.bar import Bar console = Console(force_terminal=True, highlight=False) # Force UTF-8 output on Windows if sys.platform == "win32": import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") # ── Git data extraction ──────────────────────────────────────────────────────── def run_git(args, cwd): result = subprocess.run( ["git"] + args, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace" ) if result.returncode != 0: return "" return result.stdout def parse_log(repo_path): """Returns list of commit dicts with files changed.""" separator = "|||GIT_STORY_SEP|||" fmt = f"%H{separator}%ai{separator}%an{separator}%s" raw = run_git( ["log", "--numstat", f"--format={fmt}", "--no-merges"], cwd=repo_path ) commits = [] current = None for line in raw.splitlines(): if separator in line: parts = line.split(separator) if len(parts) == 4: current = { "hash": parts[0].strip(), "date": parts[1].strip(), "author": parts[2].strip(), "message": parts[3].strip(), "files": [], } commits.append(current) elif line.strip() == "": continue elif current is not None: parts = line.split("\t") if len(parts) == 3: added, removed, filepath = parts if filepath and not filepath.startswith("{"): current["files"].append(filepath.strip()) return commits def parse_date(date_str): """Parse git date string to datetime.""" try: # Format: 2024-01-15 10:30:00 +0200 dt = datetime.fromisoformat(date_str) return dt.astimezone(timezone.utc).replace(tzinfo=None) except Exception: return datetime.now() # ── Analysis ─────────────────────────────────────────────────────────────────── def analyze(commits): now = datetime.utcnow() file_commits = defaultdict(list) # file -> [commit] file_authors = defaultdict(set) # file -> {authors} co_changes = defaultdict(int) # (fileA, fileB) -> count dir_commits = defaultdict(set) # dir -> {commit hashes} dir_authors = defaultdict(set) # dir -> {authors} author_commits = defaultdict(int) # author -> count timeline = [] # (date, num_files_changed) for commit in commits: date = parse_date(commit["date"]) files = [f for f in commit["files"] if f] author = commit["author"] author_commits[author] += 1 timeline.append((date, len(files))) for f in files: file_commits[f].append(commit) file_authors[f].add(author) d = str(Path(f).parent) dir_commits[d].add(commit["hash"]) dir_authors[d].add(author) # Co-change: every pair of files in this commit if len(files) <= 20: # skip massive commits for a, b in itertools.combinations(sorted(files), 2): co_changes[(a, b)] += 1 # File stats file_stats = {} for f, fcommits in file_commits.items(): dates = [parse_date(c["date"]) for c in fcommits] last = max(dates) file_stats[f] = { "commits": len(fcommits), "authors": len(file_authors[f]), "last_touched": last, "days_since": (now - last).days, } # Directory stats dir_stats = {} for d, hashes in dir_commits.items(): # Gather file stats for this dir dir_files = [f for f in file_stats if str(Path(f).parent) == d] if not dir_files: continue total_commits = len(hashes) authors = dir_authors[d] last_touched = max(file_stats[f]["last_touched"] for f in dir_files) dir_stats[d] = { "commits": total_commits, "authors": len(authors), "files": len(dir_files), "last_touched": last_touched, "days_since": (now - last_touched).days, } # Coupling pairs: normalize by the more-changed file coupling = [] for (a, b), count in co_changes.items(): if count < 2: continue total_a = file_stats.get(a, {}).get("commits", 1) total_b = file_stats.get(b, {}).get("commits", 1) min_total = min(total_a, total_b) if min_total == 0: continue pct = count / min_total coupling.append((a, b, count, pct)) coupling.sort(key=lambda x: -x[3]) return { "file_stats": file_stats, "dir_stats": dir_stats, "coupling": coupling, "author_commits": dict(author_commits), "timeline": sorted(timeline, key=lambda x: x[0]), "total_commits": len(commits), "total_files": len(file_stats), } # ── Rendering ────────────────────────────────────────────────────────────────── HEAT_COLORS = ["dim white", "green", "yellow", "orange1", "red", "bold red"] def heat_color(value, max_value): if max_value == 0: return HEAT_COLORS[0] idx = int((value / max_value) * (len(HEAT_COLORS) - 1)) return HEAT_COLORS[min(idx, len(HEAT_COLORS) - 1)] def days_label(days): if days < 30: return f"[green]{days}d ago[/green]" elif days < 180: return f"[yellow]{days}d ago[/yellow]" elif days < 365: return f"[orange1]{days}d ago[/orange1]" else: years = days / 365 return f"[red]{years:.1f}y ago[/red]" def render_header(repo_path, data, commits): repo_name = Path(repo_path).resolve().name total = data["total_commits"] files = data["total_files"] authors = len(data["author_commits"]) if commits: oldest = parse_date(commits[-1]["date"]) newest = parse_date(commits[0]["date"]) span = (newest - oldest).days span_str = f"{span // 365}y {(span % 365) // 30}m" if span > 60 else f"{span}d" else: span_str = "?" console.print() console.print(Panel( f"[bold cyan]{repo_name}[/bold cyan] · " f"[white]{total:,} commits[/white] · " f"[white]{files:,} files[/white] · " f"[white]{authors} authors[/white] · " f"[white]{span_str} of history[/white]", title="[bold]git-story[/bold]", border_style="cyan", expand=False )) def render_hot_zones(dir_stats, top_n=12): console.print(Rule("[bold red]>> HOT ZONES[/bold red] — directories with most churn")) sorted_dirs = sorted(dir_stats.items(), key=lambda x: -x[1]["commits"])[:top_n] if not sorted_dirs: return max_commits = sorted_dirs[0][1]["commits"] table = Table(box=box.SIMPLE_HEAD, show_header=True, header_style="bold") table.add_column("Directory", style="cyan", no_wrap=True) table.add_column("Commits", justify="right") table.add_column("Authors", justify="right") table.add_column("Files", justify="right") table.add_column("Last touched") table.add_column("Activity", min_width=20) for d, s in sorted_dirs: color = heat_color(s["commits"], max_commits) bar_width = max(1, int((s["commits"] / max_commits) * 20)) bar = f"[{color}]{'█' * bar_width}[/{color}]" label = "." if d == "." else d table.add_row( f"[{color}]{label}[/{color}]", f"[{color}]{s['commits']}[/{color}]", str(s["authors"]), str(s["files"]), days_label(s["days_since"]), bar, ) console.print(table) def render_cold_zones(file_stats, top_n=10): console.print(Rule("[bold blue]-- COLD ZONES[/bold blue] — files untouched the longest")) candidates = [(f, s) for f, s in file_stats.items() if s["commits"] > 0] sorted_files = sorted(candidates, key=lambda x: -x[1]["days_since"])[:top_n] if not sorted_files: return table = Table(box=box.SIMPLE_HEAD, show_header=True, header_style="bold") table.add_column("File", style="dim cyan", no_wrap=True) table.add_column("Last touched") table.add_column("Total commits", justify="right") table.add_column("Authors", justify="right") for f, s in sorted_files: table.add_row( f, days_label(s["days_since"]), str(s["commits"]), str(s["authors"]), ) console.print(table) def render_coupling(coupling, top_n=10): console.print(Rule("[bold yellow]<> COUPLING[/bold yellow] — files that change together")) shown = coupling[:top_n] if not shown: console.print(" [dim]Not enough data for coupling analysis[/dim]\n") return table = Table(box=box.SIMPLE_HEAD, show_header=True, header_style="bold") table.add_column("File A", style="cyan", no_wrap=True) table.add_column("File B", style="cyan", no_wrap=True) table.add_column("Co-changes", justify="right") table.add_column("Coupling", justify="right") for a, b, count, pct in shown: color = "red" if pct > 0.7 else "yellow" if pct > 0.4 else "green" table.add_row( _truncate(a, 40), _truncate(b, 40), str(count), f"[{color}]{int(pct*100)}%[/{color}]", ) console.print(table) def render_authors(author_commits, top_n=8): console.print(Rule("[bold magenta]** AUTHORS[/bold magenta]")) sorted_authors = sorted(author_commits.items(), key=lambda x: -x[1])[:top_n] if not sorted_authors: return max_commits = sorted_authors[0][1] total = sum(author_commits.values()) table = Table(box=box.SIMPLE_HEAD, show_header=True, header_style="bold") table.add_column("Author") table.add_column("Commits", justify="right") table.add_column("%", justify="right") table.add_column("", min_width=20) for author, count in sorted_authors: pct = count / total bar_width = max(1, int(pct * 20)) table.add_row( author, str(count), f"{pct:.0%}", f"[magenta]{'█' * bar_width}[/magenta]", ) console.print(table) def render_silos(commits, file_stats, top_n=10): """Files touched by only one author — real bus-factor risk.""" console.print(Rule("[bold red]!! SILOS[/bold red] — files only one person has ever touched")) # Build file -> set of authors from commits file_authors = defaultdict(set) for commit in commits: author = commit["author"] for f in commit["files"]: file_authors[f].add(author) silos = [] for f, authors in file_authors.items(): if len(authors) == 1: sole = next(iter(authors)) stats = file_stats.get(f, {}) silos.append({ "file": f, "owner": sole, "commits": stats.get("commits", 1), "days_since": stats.get("days_since", 0), }) if not silos: console.print(" [dim green]No silos found — knowledge is well distributed.[/dim green]\n") return # Group by owner so you see each person's "private empire" by_owner = defaultdict(list) for s in silos: by_owner[s["owner"]].append(s) # Sort owners by number of silo files descending sorted_owners = sorted(by_owner.items(), key=lambda x: -len(x[1])) total_silo_files = len(silos) total_files = len(file_stats) pct = total_silo_files / total_files if total_files else 0 color = "red" if pct > 0.3 else "yellow" if pct > 0.15 else "green" console.print() console.print(Padding( f"[{color}]{total_silo_files} of {total_files} files ({pct:.0%}) are single-owner.[/{color}]" + (" [red]High risk.[/red]" if pct > 0.3 else ""), (0, 2) )) console.print() shown = 0 for owner, files in sorted_owners: if shown >= top_n: break # sort by most commits (most "important" silos first) files_sorted = sorted(files, key=lambda x: -x["commits"]) label_count = f"[dim]({len(files)} file{'s' if len(files) > 1 else ''})[/dim]" console.print(Padding(f"[bold magenta]{owner}[/bold magenta] {label_count}", (0, 2))) for entry in files_sorted[:5]: age = days_label(entry["days_since"]) console.print(Padding( f" [dim cyan]{_truncate(entry['file'], 60)}[/dim cyan]" f" [dim]{entry['commits']} commits last touched {age}[/dim]", (0, 2) )) if len(files_sorted) > 5: console.print(Padding(f" [dim]... and {len(files_sorted)-5} more[/dim]", (0, 2))) console.print() shown += 1 if len(sorted_owners) > top_n: console.print(Padding(f"[dim] ... and {len(sorted_owners) - top_n} more owners with silos[/dim]", (0, 2))) console.print() def render_timeline(timeline): console.print(Rule("[bold green]~~ TIMELINE[/bold green]")) if not timeline: return # Bucket by month buckets = defaultdict(int) for date, _ in timeline: key = date.strftime("%Y-%m") buckets[key] += 1 sorted_buckets = sorted(buckets.items()) if len(sorted_buckets) < 2: return # Show last 24 months max sorted_buckets = sorted_buckets[-24:] max_val = max(v for _, v in sorted_buckets) HEIGHT = 6 rows = [] for row_idx in range(HEIGHT, 0, -1): row = "" threshold = (row_idx / HEIGHT) * max_val for _, val in sorted_buckets: if val >= threshold: row += "█" else: row += " " rows.append(row) console.print() for r in rows: console.print(f" [green]{r}[/green]") # X-axis labels (every 3 months) label_row = "" for i, (key, _) in enumerate(sorted_buckets): if i % 3 == 0: label_row += key[2:] # "24-01" else: label_row += " " console.print(f" [dim]{label_row}[/dim]") console.print() def render_insights(data, commits): """Highlight a few key insights as a narrative.""" console.print(Rule("[bold white]!! STORY[/bold white]")) file_stats = data["file_stats"] dir_stats = data["dir_stats"] coupling = data["coupling"] author_commits = data["author_commits"] lines = [] # Most active directory if dir_stats: top_dir = max(dir_stats.items(), key=lambda x: x[1]["commits"]) d, s = top_dir label = "." if d == "." else d lines.append( f"[red]->[/red] [bold]{label}[/bold] is the heart of this repo " f"({s['commits']} commits, {s['authors']} authors)." ) # Oldest untouched file if file_stats: oldest = max(file_stats.items(), key=lambda x: x[1]["days_since"]) f, s = oldest if s["days_since"] > 180: lines.append( f"[blue]->[/blue] [bold]{f}[/bold] hasn't been touched in " f"{days_label(s['days_since'])} — possible dead code." ) # Strongest coupling if coupling: a, b, count, pct = coupling[0] if pct > 0.5: lines.append( f"[yellow]->[/yellow] [bold]{_truncate(a,35)}[/bold] and " f"[bold]{_truncate(b,35)}[/bold] change together " f"{pct:.0%} of the time — they might want to be one module." ) # Bus factor if author_commits: total = sum(author_commits.values()) top_author, top_count = max(author_commits.items(), key=lambda x: x[1]) pct = top_count / total if pct > 0.5: lines.append( f"[magenta]->[/magenta] [bold]{top_author}[/bold] wrote " f"{pct:.0%} of all commits — bus factor risk." ) # Repo age if commits: oldest_commit = parse_date(commits[-1]["date"]) age_days = (datetime.utcnow() - oldest_commit).days if age_days > 365: lines.append( f"[cyan]->[/cyan] This repo is [bold]{age_days // 365} year(s) old[/bold] " f"— it has a story worth reading." ) console.print() for line in lines: console.print(Padding(line, (0, 2))) console.print() def _truncate(s, n): return s if len(s) <= n else "..." + s[-(n-1):] # ── Mood analysis ────────────────────────────────────────────────────────────── MOOD_CATEGORIES = { "BUILDING": { "keywords": ["add", "implement", "feature", "new", "create", "introduce", "support", "enable", "allow", "initial", "init"], "color": "green", "symbol": "+", }, "FIXING": { "keywords": ["fix", "bug", "patch", "revert", "error", "broken", "wrong", "fail", "crash", "issue", "correct", "resolve"], "color": "red", "symbol": "x", }, "CLEANUP": { "keywords": ["refactor", "clean", "remove", "delete", "rename", "simplify", "reorganize", "move", "restructure", "extract", "split"], "color": "cyan", "symbol": "~", }, "MAINTENANCE": { "keywords": ["update", "bump", "upgrade", "dependency", "version", "release", "changelog", "readme", "docs", "document", "typo"], "color": "yellow", "symbol": "o", }, } def classify_commit(message): msg = message.lower() scores = {cat: 0 for cat in MOOD_CATEGORIES} for cat, cfg in MOOD_CATEGORIES.items(): for kw in cfg["keywords"]: if kw in msg: scores[cat] += 1 best = max(scores, key=lambda c: scores[c]) return best if scores[best] > 0 else None def analyze_mood(commits, buckets=8): """Divide commits into time buckets and classify mood per bucket.""" if not commits: return [] dated = [(parse_date(c["date"]), c["message"]) for c in commits] dated.sort(key=lambda x: x[0]) oldest, newest = dated[0][0], dated[-1][0] total_span = (newest - oldest).total_seconds() if total_span == 0: return [] bucket_size = total_span / buckets result = [] for i in range(buckets): t_start = oldest + timedelta(seconds=i * bucket_size) t_end = oldest + timedelta(seconds=(i + 1) * bucket_size) in_bucket = [msg for dt, msg in dated if t_start <= dt < t_end] counts = {cat: 0 for cat in MOOD_CATEGORIES} unclassified = 0 for msg in in_bucket: cat = classify_commit(msg) if cat: counts[cat] += 1 else: unclassified += 1 total = sum(counts.values()) + unclassified result.append({ "label": t_start.strftime("%y-%m"), "counts": counts, "total": total, "unclassified": unclassified, }) return result def render_mood(commits): console.print(Rule("[bold white]~~ MOOD[/bold white] — what kind of work dominates over time")) buckets = analyze_mood(commits, buckets=12) if not buckets: console.print(" [dim]Not enough data[/dim]\n") return # Overall mood summary totals = {cat: 0 for cat in MOOD_CATEGORIES} grand_total = 0 for b in buckets: for cat, n in b["counts"].items(): totals[cat] += n grand_total += b["total"] classified = sum(totals.values()) if classified == 0: console.print(" [dim]Could not classify commits[/dim]\n") return # Current mood = last 2 buckets recent_counts = {cat: 0 for cat in MOOD_CATEGORIES} for b in buckets[-2:]: for cat, n in b["counts"].items(): recent_counts[cat] += n current_mood = max(recent_counts, key=lambda c: recent_counts[c]) cfg = MOOD_CATEGORIES[current_mood] console.print() console.print(Padding( f"Current mode: [{cfg['color']}][bold]{current_mood}[/bold][/{cfg['color']}] " + " ".join( f"[{MOOD_CATEGORIES[c]['color']}]{c}: {totals[c]}[/{MOOD_CATEGORIES[c]['color']}]" for c in MOOD_CATEGORIES ), (0, 2) )) console.print() # Stacked bar chart over time HEIGHT = 5 cat_order = list(MOOD_CATEGORIES.keys()) # Build rows: for each height level, for each bucket, what symbol to show console.print(Padding("[dim] " + " ".join(b["label"] for b in buckets) + "[/dim]", (0, 2))) for row_idx in range(HEIGHT, 0, -1): row_parts = [] for b in buckets: total = b["total"] or 1 # Stack categories threshold = (row_idx / HEIGHT) * total cumulative = 0 symbol = " " color = "dim" for cat in cat_order: cumulative += b["counts"].get(cat, 0) if cumulative >= threshold: symbol = MOOD_CATEGORIES[cat]["symbol"] color = MOOD_CATEGORIES[cat]["color"] break row_parts.append(f"[{color}]{symbol}[/{color}] ") console.print(Padding(" " + " ".join(row_parts), (0, 2))) # Legend legend = " ".join( f"[{cfg['color']}]{cfg['symbol']}[/{cfg['color']}]={cat[:5]}" for cat, cfg in MOOD_CATEGORIES.items() ) console.print(Padding(f"[dim] {legend}[/dim]", (0, 2))) console.print() # Narrative narrative = _mood_narrative(totals, classified, current_mood, buckets) console.print(Padding(f"[italic]{narrative}[/italic]", (0, 2))) console.print() def _mood_narrative(totals, classified, current_mood, buckets): dominant = max(totals, key=lambda c: totals[c]) dominant_pct = totals[dominant] / classified if classified else 0 # Trend: is mood shifting? early = {cat: 0 for cat in MOOD_CATEGORIES} late = {cat: 0 for cat in MOOD_CATEGORIES} mid = len(buckets) // 2 for b in buckets[:mid]: for cat, n in b["counts"].items(): early[cat] += n for b in buckets[mid:]: for cat, n in b["counts"].items(): late[cat] += n early_mood = max(early, key=lambda c: early[c]) if sum(early.values()) else dominant late_mood = max(late, key=lambda c: late[c]) if sum(late.values()) else dominant mood_descriptions = { "BUILDING": "in active feature development", "FIXING": "in firefighting mode", "CLEANUP": "in a consolidation phase", "MAINTENANCE": "in maintenance/housekeeping mode", } parts = [] parts.append(f"This project is currently {mood_descriptions.get(current_mood, current_mood.lower())}.") if early_mood != late_mood: parts.append( f"It shifted from {early_mood.lower()} to {late_mood.lower()} over time." ) elif dominant_pct > 0.5: parts.append(f"{dominant.capitalize()} work has dominated {dominant_pct:.0%} of all commits.") fix_pct = totals["FIXING"] / classified if classified else 0 if fix_pct > 0.35: parts.append("High fix rate — this codebase has seen a lot of bugs.") elif fix_pct < 0.1 and totals["BUILDING"] > totals["FIXING"]: parts.append("Low bug rate relative to new features — healthy signal.") return " ".join(parts) # ── Drift analysis ───────────────────────────────────────────────────────────── def analyze_drift(commits, window_months=6): """Compare recent window vs prior window for each directory.""" now = datetime.utcnow() cutoff_recent = now - timedelta(days=window_months * 30) cutoff_prior = now - timedelta(days=window_months * 60) recent = defaultdict(int) prior = defaultdict(int) for commit in commits: date = parse_date(commit["date"]) for f in commit["files"]: d = str(Path(f).parent) if date >= cutoff_recent: recent[d] += 1 elif date >= cutoff_prior: prior[d] += 1 all_dirs = set(recent) | set(prior) drift = [] for d in all_dirs: r = recent.get(d, 0) p = prior.get(d, 0) if r == 0 and p == 0: continue if p == 0: pct = float("inf") else: pct = (r - p) / p drift.append({ "dir": d, "recent": r, "prior": p, "pct": pct, "delta": r - p, }) drift.sort(key=lambda x: -(x["pct"] if x["pct"] != float("inf") else 9999)) return drift, window_months def drift_label(pct, recent, prior): if prior == 0 and recent > 0: return "[bold green]NEW[/bold green]", "brand new activity" if recent == 0 and prior > 0: return "[bold red]DEAD[/bold red]", "abandoned" if pct >= 2.0: return "[green]^^[/green]", "surging" if pct >= 0.5: return "[green]^[/green]", "gaining" if pct >= -0.15: return "[white]~[/white]", "stable" if pct >= -0.5: return "[yellow]v[/yellow]", "slowing" if pct >= -0.8: return "[orange1]vv[/orange1]", "fading" return "[red]vvv[/red]", "dying" def render_drift(commits, window_months=6): drift, wm = analyze_drift(commits, window_months) console.print(Rule(f"[bold cyan]// DRIFT[/bold cyan] — last {wm}mo vs prior {wm}mo")) if not drift: console.print(" [dim]Not enough history for drift analysis[/dim]\n") return # Narrative sentence surging = [d for d in drift if d["pct"] >= 0.5 and d["prior"] > 0] dying = [d for d in drift if d["recent"] == 0 and d["prior"] > 0] new_dirs = [d for d in drift if d["prior"] == 0 and d["recent"] > 0] narrative = _build_narrative(surging, dying, new_dirs, drift, wm) console.print() console.print(Padding(f"[italic]{narrative}[/italic]", (0, 2))) console.print() table = Table(box=box.SIMPLE_HEAD, show_header=True, header_style="bold") table.add_column("Directory", style="cyan", no_wrap=True) table.add_column("Trend", justify="center", min_width=6) table.add_column(f"Last {wm}mo", justify="right") table.add_column(f"Prior {wm}mo", justify="right") table.add_column("Change", justify="right") table.add_column("Status") for entry in drift[:15]: d = entry["dir"] label = "." if d == "." else d arrow, status = drift_label(entry["pct"], entry["recent"], entry["prior"]) delta = entry["delta"] delta_str = f"[green]+{delta}[/green]" if delta > 0 else (f"[red]{delta}[/red]" if delta < 0 else "[dim]0[/dim]") pct_val = entry["pct"] pct_str = "new" if pct_val == float("inf") else f"{pct_val:+.0%}" table.add_row( _truncate(label, 50), arrow, str(entry["recent"]), str(entry["prior"]), delta_str, f"[dim]{status} ({pct_str})[/dim]", ) console.print(table) def _build_narrative(surging, dying, new_dirs, all_drift, wm): parts = [] if new_dirs: names = ", ".join(d["dir"] for d in new_dirs[:2]) parts.append(f"New energy in {names}.") if surging: top = surging[0] pct = top["pct"] parts.append( f"{_truncate(top['dir'], 30)} is surging ({pct:+.0%}) — " f"something is being built there." ) if dying: names = ", ".join(_truncate(d["dir"], 25) for d in dying[:2]) parts.append(f"{names} went completely silent — possibly retired.") stable = [d for d in all_drift if abs(d["pct"]) < 0.15 and d["recent"] > 3] if stable and not parts: parts.append(f"This codebase is in steady maintenance mode.") if not parts: # general trend total_recent = sum(d["recent"] for d in all_drift) total_prior = sum(d["prior"] for d in all_drift) if total_prior > 0: overall = (total_recent - total_prior) / total_prior if overall > 0.2: parts.append(f"Overall activity is up {overall:+.0%} — the project is accelerating.") elif overall < -0.2: parts.append(f"Overall activity is down {overall:+.0%} — the project is slowing.") else: parts.append("Development pace is holding steady.") return " ".join(parts) if parts else "Drift data collected." # ── Entry point ──────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="git-story: the story of your codebase") parser.add_argument("path", nargs="?", default=".", help="Path to git repo") parser.add_argument("--top", type=int, default=10, help="How many items to show per section") parser.add_argument("--window", type=int, default=6, help="Drift window in months (default: 6)") args = parser.parse_args() repo_path = Path(args.path).resolve() if not (repo_path / ".git").exists(): found = False for parent in repo_path.parents: if (parent / ".git").exists(): repo_path = parent found = True break if not found: console.print(f"[red]Error:[/red] No git repository found at {args.path}") sys.exit(1) console.print(f"\n[dim]Analyzing [cyan]{repo_path}[/cyan]...[/dim]") commits = parse_log(repo_path) if not commits: console.print("[yellow]No commits found (or empty repo).[/yellow]") sys.exit(0) data = analyze(commits) render_header(repo_path, data, commits) render_insights(data, commits) render_mood(commits) render_drift(commits, window_months=args.window) render_timeline(data["timeline"]) render_hot_zones(data["dir_stats"], top_n=args.top) render_cold_zones(data["file_stats"], top_n=args.top) render_coupling(data["coupling"], top_n=args.top) render_authors(data["author_commits"], top_n=8) render_silos(commits, data["file_stats"], top_n=args.top) console.print() if __name__ == "__main__": main()