git2report 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
git2report/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Git2Report: turn Git history into a plain-language, evidence-linked progress report."""
2
+ __version__ = "0.1.0"
git2report/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from git2report.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
git2report/branches.py ADDED
@@ -0,0 +1,34 @@
1
+ """Open branches = work that exists but is not merged yet. Used for the 'In progress' section."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from git2report.models import BranchInfo
9
+
10
+
11
+ def _git(repo: Path, *args: str) -> str:
12
+ r = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, encoding="utf-8", errors="replace")
13
+ if r.returncode != 0:
14
+ raise RuntimeError(r.stderr.strip())
15
+ return r.stdout
16
+
17
+
18
+ def open_branches(repo: Path, since: datetime, base: str = "HEAD") -> list[BranchInfo]:
19
+ """Branches not merged into `base` with activity since `since`. Never raises: returns [] on any problem."""
20
+ try:
21
+ fmt = "%(refname:short)|%(committerdate:short)|%(subject)"
22
+ out = _git(Path(repo), "for-each-ref", f"--no-merged={base}", f"--format={fmt}", "refs/heads", "refs/remotes")
23
+ found: dict[str, BranchInfo] = {}
24
+ for line in out.splitlines():
25
+ name, date, subject = line.split("|", 2)
26
+ short = name.split("/", 1)[1] if name.startswith("origin/") else name
27
+ if name == "origin" or short == "HEAD" or short in found or date < since.date().isoformat():
28
+ continue
29
+ ahead = int(_git(Path(repo), "rev-list", "--count", f"{base}..{name}").strip() or 0)
30
+ if ahead:
31
+ found[short] = BranchInfo(short, date, ahead, subject)
32
+ return sorted(found.values(), key=lambda b: b.last_date, reverse=True)
33
+ except Exception:
34
+ return []
git2report/cache.py ADDED
@@ -0,0 +1,78 @@
1
+ """Smart summary cache for Git2Report.
2
+
3
+ Caches AI module summaries and executive summaries by commit hashes,
4
+ preventing redundant LLM calls and enabling sub-second re-runs.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger("git2report.cache")
15
+ DEFAULT_CACHE_FILE = Path(".git2report_cache.json")
16
+
17
+
18
+ def compute_cache_key(prefix: str, module: str, commit_hashes: list[str], audience: str, model: str) -> str:
19
+ """Create a deterministic SHA-256 cache key based on inputs."""
20
+ sorted_hashes = sorted(commit_hashes)
21
+ payload = f"{prefix}::{module}::{','.join(sorted_hashes)}::{audience}::{model}"
22
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
23
+
24
+
25
+ class SummaryCache:
26
+ def __init__(self, cache_file: Path | str = DEFAULT_CACHE_FILE, enabled: bool = True):
27
+ self.cache_file = Path(cache_file)
28
+ self.enabled = enabled
29
+ self._data: dict[str, dict[str, Any]] = {}
30
+ self._load()
31
+
32
+ def _load(self) -> None:
33
+ if not self.enabled or not self.cache_file.exists():
34
+ return
35
+ try:
36
+ raw = self.cache_file.read_text(encoding="utf-8")
37
+ self._data = json.loads(raw)
38
+ except Exception as exc:
39
+ logger.debug(f"Failed to load cache: {exc}")
40
+ self._data = {}
41
+
42
+ def _save(self) -> None:
43
+ if not self.enabled:
44
+ return
45
+ try:
46
+ self.cache_file.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
47
+ except Exception as exc:
48
+ logger.debug(f"Failed to save cache: {exc}")
49
+
50
+ def get(self, key: str) -> dict[str, Any] | None:
51
+ if not self.enabled:
52
+ return None
53
+ return self._data.get(key)
54
+
55
+ def set(self, key: str, summary: str, mode: str = "ai", meta: dict[str, Any] | None = None) -> None:
56
+ if not self.enabled:
57
+ return
58
+ entry = {
59
+ "summary": summary,
60
+ "mode": mode,
61
+ "meta": meta or {},
62
+ }
63
+ self._data[key] = entry
64
+ self._save()
65
+
66
+ def clear(self) -> int:
67
+ count = len(self._data)
68
+ self._data = {}
69
+ if self.cache_file.exists():
70
+ try:
71
+ self.cache_file.unlink()
72
+ except Exception:
73
+ pass
74
+ return count
75
+
76
+
77
+ # Global default cache instance
78
+ default_cache = SummaryCache()
git2report/charts.py ADDED
@@ -0,0 +1,155 @@
1
+ """Visual analytics and chart generators for Git2Report.
2
+
3
+ Renders vector drawings for ReportLab PDF and embedded responsive SVG/HTML charts
4
+ without requiring external heavy chart dependencies.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from typing import Any
10
+
11
+ from reportlab.lib import colors
12
+ from reportlab.graphics.shapes import Drawing, Group, Line, Rect, String
13
+
14
+
15
+ TYPE_COLORS = {
16
+ "feat": colors.HexColor("#059669"), # Emerald Green
17
+ "fix": colors.HexColor("#DC2626"), # Red
18
+ "perf": colors.HexColor("#7C3AED"), # Purple
19
+ "refactor": colors.HexColor("#D97706"), # Amber
20
+ "docs": colors.HexColor("#0284C7"), # Cyan
21
+ "test": colors.HexColor("#0D9488"), # Teal
22
+ "style": colors.HexColor("#64748B"), # Slate
23
+ "chore": colors.HexColor("#94A3B8"), # Light Slate
24
+ "other": colors.HexColor("#3B82F6"), # Blue
25
+ }
26
+
27
+
28
+ def build_type_breakdown_bar(commits_by_type: dict[str, int], width: float = 490, height: float = 38) -> Drawing:
29
+ """Creates a sleek, segmented horizontal bar drawing showing commit type percentages."""
30
+ d = Drawing(width, height)
31
+ total = sum(commits_by_type.values())
32
+ if total <= 0:
33
+ return d
34
+
35
+ bar_y = 16
36
+ bar_h = 14
37
+ cur_x = 0.0
38
+
39
+ # Draw segments
40
+ for ctype, count in commits_by_type.items():
41
+ if count <= 0:
42
+ continue
43
+ seg_w = (count / total) * width
44
+ color = TYPE_COLORS.get(ctype.lower(), TYPE_COLORS["other"])
45
+ d.add(Rect(cur_x, bar_y, seg_w, bar_h, fillColor=color, strokeColor=colors.white, strokeWidth=1))
46
+
47
+ # Label percentage inside segment if wide enough
48
+ if seg_w > 26:
49
+ pct_text = f"{int(round((count / total) * 100))}%"
50
+ d.add(String(cur_x + seg_w / 2, bar_y + 3.5, pct_text, textAnchor="middle",
51
+ fontName="Helvetica-Bold", fontSize=7.5, fillColor=colors.white))
52
+ cur_x += seg_w
53
+
54
+ # Draw Legend below the bar
55
+ leg_x = 0.0
56
+ for ctype, count in list(commits_by_type.items())[:6]:
57
+ if count <= 0:
58
+ continue
59
+ color = TYPE_COLORS.get(ctype.lower(), TYPE_COLORS["other"])
60
+ # Dot
61
+ d.add(Rect(leg_x, 3, 7, 7, fillColor=color, strokeColor=color, rx=1.5, ry=1.5))
62
+ label = f"{ctype.upper()} ({count})"
63
+ d.add(String(leg_x + 10, 3, label, fontName="Helvetica", fontSize=7, fillColor=colors.HexColor("#475569")))
64
+ leg_x += len(label) * 4.4 + 16
65
+
66
+ return d
67
+
68
+
69
+ def build_module_churn_chart(sections: list[Any], width: float = 490, height: float = 85) -> Drawing:
70
+ """Creates a horizontal vector bar chart showing code churn (+added / -deleted) per module."""
71
+ d = Drawing(width, height)
72
+ if not sections:
73
+ return d
74
+
75
+ # Collect up to 4 top modules by line changes
76
+ module_data = []
77
+ for s in sections[:4]:
78
+ added = sum(getattr(cm, "added", 0) for cm in getattr(s, "commits", []))
79
+ deleted = sum(getattr(cm, "deleted", 0) for cm in getattr(s, "commits", []))
80
+ module_data.append((s.module, added, deleted))
81
+
82
+ if not module_data:
83
+ return d
84
+
85
+ max_val = max(max(a, d_) for _, a, d_ in module_data) or 1
86
+ row_h = 16
87
+ start_y = height - 12
88
+ label_w = 90
89
+ bar_max_w = width - label_w - 45
90
+
91
+ green = colors.HexColor("#059669")
92
+ red = colors.HexColor("#DC2626")
93
+
94
+ for i, (mod, added, deleted) in enumerate(module_data):
95
+ y = start_y - (i * (row_h + 3))
96
+
97
+ # Module Name label
98
+ mod_name = mod[:14] + ".." if len(mod) > 16 else mod
99
+ d.add(String(0, y + 2, mod_name, fontName="Helvetica-Bold", fontSize=7.5, fillColor=colors.HexColor("#1E293B")))
100
+
101
+ # Added bar (Green)
102
+ w_add = max(2.0, (added / max_val) * (bar_max_w / 2)) if added > 0 else 0
103
+ if w_add > 0:
104
+ d.add(Rect(label_w, y, w_add, 7, fillColor=green, strokeColor=green, rx=1, ry=1))
105
+
106
+ # Deleted bar (Red)
107
+ w_del = max(2.0, (deleted / max_val) * (bar_max_w / 2)) if deleted > 0 else 0
108
+ if w_del > 0:
109
+ d.add(Rect(label_w + w_add + 4, y, w_del, 7, fillColor=red, strokeColor=red, rx=1, ry=1))
110
+
111
+ # Stat text
112
+ stat_text = f"+{added} / -{deleted}"
113
+ d.add(String(label_w + w_add + w_del + 10, y + 1, stat_text, fontName="Courier", fontSize=6.5, fillColor=colors.HexColor("#64748B")))
114
+
115
+ return d
116
+
117
+
118
+ def generate_svg_churn_chart(sections: list[Any]) -> str:
119
+ """Generates an embedded responsive SVG for the interactive HTML report dashboard."""
120
+ module_data = []
121
+ for s in sections[:5]:
122
+ added = sum(getattr(cm, "added", 0) for cm in getattr(s, "commits", []))
123
+ deleted = sum(getattr(cm, "deleted", 0) for cm in getattr(s, "commits", []))
124
+ module_data.append((s.module, added, deleted))
125
+
126
+ if not module_data:
127
+ return ""
128
+
129
+ max_val = max(max(a, d_) for _, a, d_ in module_data) or 1
130
+ svg_height = len(module_data) * 36 + 20
131
+ rows = []
132
+
133
+ for i, (mod, added, deleted) in enumerate(module_data):
134
+ y = 20 + i * 36
135
+ w_add = (added / max_val) * 220 if added > 0 else 0
136
+ w_del = (deleted / max_val) * 220 if deleted > 0 else 0
137
+
138
+ rows.append(f"""
139
+ <g class="chart-row">
140
+ <text x="0" y="{y + 14}" fill="currentColor" font-size="12" font-weight="600">{mod}</text>
141
+ <rect x="130" y="{y}" width="{w_add:.1f}" height="16" rx="3" fill="#10b981" opacity="0.9">
142
+ <title>Added: +{added} lines</title>
143
+ </rect>
144
+ <rect x="{135 + w_add:.1f}" y="{y}" width="{w_del:.1f}" height="16" rx="3" fill="#ef4444" opacity="0.9">
145
+ <title>Deleted: -{deleted} lines</title>
146
+ </rect>
147
+ <text x="{145 + w_add + w_del:.1f}" y="{y + 13}" fill="var(--text-muted)" font-size="11" font-family="monospace">+{added} / -{deleted}</text>
148
+ </g>
149
+ """)
150
+
151
+ return f"""
152
+ <svg viewBox="0 0 650 {svg_height}" width="100%" height="{svg_height}" xmlns="http://www.w3.org/2000/svg" style="overflow: visible;">
153
+ {''.join(rows)}
154
+ </svg>
155
+ """
git2report/chunker.py ADDED
@@ -0,0 +1,47 @@
1
+ """STEP 5: shrink a module's code changes to fit the AI's limit.
2
+ Drop junk files, rank files (source before tests, big before small), split into hunks,
3
+ keep only the added/removed lines, and stop when the token budget is full."""
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ from git2report.config import Config
10
+ from git2report.models import CommitRecord
11
+ from git2report.rules import is_junk, is_test, tokens
12
+
13
+
14
+ @dataclass
15
+ class Chunk:
16
+ text: str
17
+ raw_tokens: int
18
+ used_tokens: int
19
+ hunks_dropped: int
20
+
21
+
22
+ def chunk_module(commits: list[CommitRecord], cfg: Config, budget: int | None = None) -> Chunk:
23
+ budget = cfg.token_budget if budget is None else budget
24
+ all_files = [f for c in commits for f in c.files]
25
+ raw = sum(tokens(f.diff) for f in all_files)
26
+ files = [f for f in all_files if not is_junk(f.path, cfg.junk) and f.diff.strip()]
27
+ files.sort(key=lambda f: (is_test(f.path), -(f.added + f.deleted)))
28
+ parts: list[str] = []
29
+ used = dropped = 0
30
+ for f in files:
31
+ for hunk in re.split(r"(?m)^(?=@@)", f.diff):
32
+ if not hunk.strip():
33
+ continue
34
+ body = "\n".join(
35
+ line for line in hunk.splitlines()
36
+ if line[:1] in ("+", "-") and not line.startswith(("+++", "---"))
37
+ )
38
+ if not body.strip():
39
+ continue
40
+ piece = f"# {f.path}\n{body}"
41
+ cost = tokens(piece)
42
+ if used + cost <= budget:
43
+ parts.append(piece)
44
+ used += cost
45
+ else:
46
+ dropped += 1
47
+ return Chunk("\n".join(parts), raw, used, dropped)
git2report/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """Command line: git2report --repo . --since 7d --audience guide --out report.pdf"""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from git2report import __version__
12
+ from git2report.models import GitReportError
13
+ from git2report.pipeline import Options, run_pipeline
14
+ from git2report.report import render_html, render_markdown, render_pdf
15
+ from git2report.summarizer import get_summarizer
16
+ from git2report.timeutil import parse_since
17
+
18
+
19
+ def _preprocess_argv(argv: list[str]) -> list[str]:
20
+ result = []
21
+ i = 0
22
+ while i < len(argv):
23
+ arg = argv[i]
24
+ if arg == "--ai":
25
+ result.extend(["--llm", "gemini"])
26
+ i += 1
27
+ continue
28
+ if arg == "--ollama":
29
+ result.extend(["--llm", "ollama"])
30
+ i += 1
31
+ continue
32
+ if arg == "--llm":
33
+ if i + 1 < len(argv) and not argv[i + 1].startswith("-") and argv[i + 1].lower() in ("gemini", "ollama", "offline"):
34
+ result.extend(["--llm", argv[i + 1].lower()])
35
+ i += 2
36
+ continue
37
+ # When --llm is passed alone, default to gemini
38
+ result.extend(["--llm", "gemini"])
39
+ i += 1
40
+ continue
41
+ result.append(arg)
42
+ i += 1
43
+ return result
44
+
45
+
46
+ if len(sys.argv) > 1:
47
+ sys.argv = [sys.argv[0]] + _preprocess_argv(sys.argv[1:])
48
+
49
+
50
+ class AppWrapper(typer.Typer):
51
+ def __call__(self, *args, **kwargs):
52
+ if not args and not kwargs and len(sys.argv) > 1:
53
+ sys.argv = [sys.argv[0]] + _preprocess_argv(sys.argv[1:])
54
+ return super().__call__(*args, **kwargs)
55
+
56
+
57
+ app = AppWrapper(add_completion=False, help="Turn Git history into a plain-language, evidence-linked progress report.")
58
+
59
+
60
+ class Audience(str, Enum):
61
+ guide = "guide"
62
+ client = "client"
63
+ team = "team"
64
+
65
+
66
+ class Llm(str, Enum):
67
+ offline = "offline"
68
+ gemini = "gemini"
69
+ ollama = "ollama"
70
+
71
+
72
+ def _version(value: bool):
73
+ if value:
74
+ typer.echo(f"git2report {__version__}")
75
+ raise typer.Exit()
76
+
77
+
78
+ @app.command()
79
+ def main(
80
+ repo: str = typer.Option(".", "--repo", "-r", help="Path to the Git repository or remote Git URL."),
81
+ since: str = typer.Option("7d", "--since", "-s", help="How far back: 7d, 2w, 12h or a date like 2026-10-01."),
82
+ audience: Audience = typer.Option(Audience.guide, "--audience", "-a", help="Who will read the report."),
83
+ llm: Llm = typer.Option(Llm.offline, "--llm", help="'offline' uses commit messages; 'gemini' uses Google Gemini; 'ollama' uses local Ollama."),
84
+ model: str = typer.Option("", "--model", help="AI model name (default: gemini-2.5-flash for gemini, llama3:8b for ollama)."),
85
+ api_key: Optional[str] = typer.Option(None, "--api-key", envvar="GEMINI_API_KEY", help="API key for Gemini (or set GEMINI_API_KEY env var)."),
86
+ out: Path = typer.Option(Path("report.pdf"), "--out", "-o", help="Output file: .pdf, .md, or .html"),
87
+ both: bool = typer.Option(False, "--both", help="Write both the PDF and the Markdown file."),
88
+ html: bool = typer.Option(False, "--html", help="Write an interactive HTML report dashboard."),
89
+ all_formats: bool = typer.Option(False, "--all", help="Write PDF, Markdown, and HTML reports."),
90
+ planned: list[str] = typer.Option([], "--next", "-n", help="A planned goal for next period (repeat the option for several)."),
91
+ branches: bool = typer.Option(True, "--branches/--no-branches", help="List unmerged branches as 'in progress'."),
92
+ artifacts: Optional[Path] = typer.Option(None, "--artifacts", help="Folder to save every step's result (JSON/text)."),
93
+ ui: bool = typer.Option(False, "--ui", help="Launch the interactive Web Studio GUI in your browser."),
94
+ port: int = typer.Option(5000, "--port", help="Port to run the Web Studio GUI on."),
95
+ no_cache: bool = typer.Option(False, "--no-cache", help="Disable the smart summary cache."),
96
+ clear_cache: bool = typer.Option(False, "--clear-cache", help="Clear the smart summary cache."),
97
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Hide progress messages."),
98
+ version: bool = typer.Option(False, "--version", callback=_version, is_eager=True, help="Show version."),
99
+ ):
100
+ if clear_cache:
101
+ from git2report.cache import default_cache
102
+ count = default_cache.clear()
103
+ typer.echo(f"Cleared {count} entries from smart summary cache.")
104
+ raise typer.Exit()
105
+
106
+ if ui:
107
+ from git2report.web import launch_web_server
108
+ launch_web_server(port=port)
109
+ raise typer.Exit()
110
+
111
+ if no_cache:
112
+ from git2report.cache import default_cache
113
+ default_cache.enabled = False
114
+
115
+ log = (lambda _m: None) if quiet else (lambda m: typer.echo(m, err=True))
116
+ try:
117
+ raw_repo = str(repo).strip().replace("\\", "/")
118
+ if raw_repo.startswith("https:/") and not raw_repo.startswith("https://"):
119
+ raw_repo = "https://" + raw_repo[len("https:/"):]
120
+ elif raw_repo.startswith("http:/") and not raw_repo.startswith("http://"):
121
+ raw_repo = "http://" + raw_repo[len("http:/"):]
122
+
123
+ if raw_repo.startswith(("http://", "https://", "git@")):
124
+ repo_name = raw_repo.rstrip("/").split("/")[-1]
125
+ if repo_name.endswith(".git"):
126
+ repo_name = repo_name[:-4]
127
+ local_dir = Path("cloned_repos") / repo_name
128
+ local_dir.parent.mkdir(parents=True, exist_ok=True)
129
+ if not (local_dir / ".git").exists():
130
+ log(f"Cloning remote repository {raw_repo} into {local_dir}...")
131
+ import subprocess
132
+ r = subprocess.run(["git", "clone", raw_repo, str(local_dir)], capture_output=True, text=True)
133
+ if r.returncode != 0:
134
+ raise GitReportError(f"Failed to clone repository: {r.stderr.strip()}")
135
+ repo_path = local_dir
136
+ else:
137
+ repo_path = Path(repo)
138
+
139
+ chosen_model = model or ("gemini-2.5-flash" if llm == Llm.gemini else ("llama3:8b" if llm == Llm.ollama else ""))
140
+ opts = Options(repo=repo_path, since=parse_since(since), audience=audience.value, artifacts=artifacts,
141
+ planned=planned, include_branches=branches)
142
+ data = run_pipeline(opts, get_summarizer(llm.value, chosen_model, api_key=api_key), log=log)
143
+ written = []
144
+ out.parent.mkdir(parents=True, exist_ok=True)
145
+ is_html = out.suffix.lower() == ".html"
146
+ is_md = out.suffix.lower() == ".md"
147
+ is_pdf = out.suffix.lower() == ".pdf" or (not is_html and not is_md)
148
+
149
+ if all_formats or both or is_md:
150
+ md_path = out.with_suffix(".md")
151
+ md_path.write_text(render_markdown(data), encoding="utf-8")
152
+ written.append(md_path)
153
+
154
+ if all_formats or html or is_html:
155
+ html_path = out.with_suffix(".html")
156
+ html_path.write_text(render_html(data), encoding="utf-8")
157
+ written.append(html_path)
158
+
159
+ if all_formats or both or is_pdf:
160
+ pdf_path = out if out.suffix.lower() == ".pdf" else out.with_suffix(".pdf")
161
+ render_pdf(data, pdf_path)
162
+ written.append(pdf_path)
163
+ except GitReportError as exc:
164
+ typer.secho(f"Error: {exc}", fg=typer.colors.RED, err=True)
165
+ raise typer.Exit(code=1)
166
+ for p in written:
167
+ typer.echo(f"Wrote {p}")
168
+ if llm == Llm.offline:
169
+ typer.echo("Note: offline mode used (no AI). Add --llm gemini for AI-written plain-language sentences.", err=True)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ app()
@@ -0,0 +1,56 @@
1
+ """STEP 3: group commits by module using three rules, in this order:
2
+ 1. the scope in the message, e.g. feat(auth): ...
3
+ 2. the folder with the most changed lines
4
+ 3. the commit type, e.g. docs: ...
5
+ Every commit also records WHY it landed in its module, so each decision can be explained."""
6
+ from __future__ import annotations
7
+
8
+ from git2report.config import Config
9
+ from git2report.models import CommitRecord
10
+ from git2report.rules import is_junk, is_test, parse_conventional
11
+
12
+
13
+ def _pretty(name: str, cfg: Config) -> str:
14
+ key = name.strip().lower()
15
+ if key in cfg.aliases:
16
+ return cfg.aliases[key]
17
+ return name.replace("-", " ").replace("_", " ").strip().title()
18
+
19
+
20
+ def _top_folder(commit: CommitRecord, cfg: Config) -> str | None:
21
+ """Folder with the most changed lines. Test files are ignored unless the commit touched nothing else,
22
+ otherwise a commit that changes source AND tests would be filed under 'Tests'."""
23
+ def totals(include_tests: bool) -> dict[str, int]:
24
+ out: dict[str, int] = {}
25
+ for f in commit.files:
26
+ if is_junk(f.path, cfg.junk) or (is_test(f.path) and not include_tests):
27
+ continue
28
+ parts = f.path.replace("\\", "/").split("/")[:-1] # folders only
29
+ parts = [p for p in parts if p.lower() not in cfg.generic_folders]
30
+ if parts:
31
+ out[parts[0]] = out.get(parts[0], 0) + f.added + f.deleted
32
+ return out
33
+
34
+ t = totals(False) or totals(True)
35
+ return max(t, key=t.get) if t else None
36
+
37
+
38
+ def module_of(commit: CommitRecord, cfg: Config) -> tuple[str, str]:
39
+ ctype, scope, _ = parse_conventional(commit.subject)
40
+ if scope:
41
+ return _pretty(scope, cfg), "scope in message"
42
+ folder = _top_folder(commit, cfg)
43
+ if folder:
44
+ return _pretty(folder, cfg), "top folder by lines"
45
+ if ctype and ctype in cfg.type_modules:
46
+ return cfg.type_modules[ctype], "commit type"
47
+ return "Other", "fallback"
48
+
49
+
50
+ def cluster(commits: list[CommitRecord], cfg: Config) -> dict[str, list[CommitRecord]]:
51
+ groups: dict[str, list[CommitRecord]] = {}
52
+ for c in commits:
53
+ c.module, c.module_reason = module_of(c, cfg)
54
+ groups.setdefault(c.module, []).append(c)
55
+ # Biggest modules first; ties by name so the output is stable.
56
+ return dict(sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[0])))
git2report/config.py ADDED
@@ -0,0 +1,28 @@
1
+ """Tunable rules in one place. Change these instead of hunting through the code."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass
8
+ class Config:
9
+ # Files that are generated by tools and say nothing about the developer's effort.
10
+ junk: tuple[str, ...] = (
11
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "Pipfile.lock",
12
+ ".min.js", ".min.css", "dist/", "build/", "node_modules/",
13
+ )
14
+ # Commits whose subject has one of these words are noise, unless they changed a lot.
15
+ noise_words: tuple[str, ...] = ("wip", "typo", "typos", "formatting", "whitespace")
16
+ keep_if_lines: int = 30 # a noisy commit with >= this many changed lines is kept
17
+ token_budget: int = 1500 # max tokens of code sent to the AI per module
18
+ # Readable module names for common commit scopes / folders.
19
+ aliases: dict[str, str] = field(default_factory=lambda: {
20
+ "ui": "UI", "db": "Database", "auth": "Authentication", "jobs": "Job Search",
21
+ "api": "API", "ci": "CI/CD", "docs": "Documentation", "deps": "Dependencies",
22
+ })
23
+ # Used when a commit has no scope and no useful folder: map the commit TYPE to a module.
24
+ type_modules: dict[str, str] = field(default_factory=lambda: {
25
+ "docs": "Documentation", "ci": "CI/CD", "build": "Build", "test": "Testing", "chore": "Maintenance",
26
+ })
27
+ # Folders that carry no meaning as a module name.
28
+ generic_folders: tuple[str, ...] = ("src", "lib", "app", "source", "packages")