git-wrapped 1.0.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.
- git_wrapped/__init__.py +3 -0
- git_wrapped/__main__.py +5 -0
- git_wrapped/analyzer.py +497 -0
- git_wrapped/cli.py +132 -0
- git_wrapped/display.py +742 -0
- git_wrapped-1.0.0.dist-info/METADATA +160 -0
- git_wrapped-1.0.0.dist-info/RECORD +11 -0
- git_wrapped-1.0.0.dist-info/WHEEL +5 -0
- git_wrapped-1.0.0.dist-info/entry_points.txt +2 -0
- git_wrapped-1.0.0.dist-info/licenses/LICENSE +21 -0
- git_wrapped-1.0.0.dist-info/top_level.txt +1 -0
git_wrapped/__init__.py
ADDED
git_wrapped/__main__.py
ADDED
git_wrapped/analyzer.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Git history analyzer — parses git log and computes wrapped statistics."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timedelta, date as date_type
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Dict, Tuple, Optional, Set
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Language detection by file extension
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
EXTENSION_LANGUAGES: Dict[str, str] = {
|
|
16
|
+
".py": "Python", ".pyi": "Python",
|
|
17
|
+
".js": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
|
|
18
|
+
".ts": "TypeScript", ".mts": "TypeScript",
|
|
19
|
+
".jsx": "React JSX", ".tsx": "React TSX",
|
|
20
|
+
".java": "Java",
|
|
21
|
+
".go": "Go",
|
|
22
|
+
".rs": "Rust",
|
|
23
|
+
".rb": "Ruby",
|
|
24
|
+
".php": "PHP",
|
|
25
|
+
".c": "C", ".h": "C/C++",
|
|
26
|
+
".cpp": "C++", ".cc": "C++", ".cxx": "C++", ".hpp": "C++",
|
|
27
|
+
".cs": "C#",
|
|
28
|
+
".swift": "Swift",
|
|
29
|
+
".kt": "Kotlin", ".kts": "Kotlin",
|
|
30
|
+
".scala": "Scala",
|
|
31
|
+
".r": "R", ".R": "R",
|
|
32
|
+
".sh": "Shell", ".bash": "Shell", ".zsh": "Shell",
|
|
33
|
+
".html": "HTML", ".htm": "HTML",
|
|
34
|
+
".css": "CSS", ".scss": "SCSS", ".sass": "Sass", ".less": "Less",
|
|
35
|
+
".sql": "SQL",
|
|
36
|
+
".yaml": "YAML", ".yml": "YAML",
|
|
37
|
+
".json": "JSON",
|
|
38
|
+
".xml": "XML",
|
|
39
|
+
".md": "Markdown", ".mdx": "Markdown",
|
|
40
|
+
".toml": "TOML",
|
|
41
|
+
".lua": "Lua",
|
|
42
|
+
".dart": "Dart",
|
|
43
|
+
".ex": "Elixir", ".exs": "Elixir",
|
|
44
|
+
".erl": "Erlang",
|
|
45
|
+
".hs": "Haskell",
|
|
46
|
+
".ml": "OCaml",
|
|
47
|
+
".clj": "Clojure",
|
|
48
|
+
".vue": "Vue",
|
|
49
|
+
".svelte": "Svelte",
|
|
50
|
+
".tf": "Terraform",
|
|
51
|
+
".proto": "Protobuf",
|
|
52
|
+
".graphql": "GraphQL", ".gql": "GraphQL",
|
|
53
|
+
".dockerfile": "Docker",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Files that imply a language regardless of extension
|
|
57
|
+
SPECIAL_FILES: Dict[str, str] = {
|
|
58
|
+
"Dockerfile": "Docker",
|
|
59
|
+
"Makefile": "Make",
|
|
60
|
+
"CMakeLists.txt": "CMake",
|
|
61
|
+
"Vagrantfile": "Ruby",
|
|
62
|
+
"Gemfile": "Ruby",
|
|
63
|
+
"Rakefile": "Ruby",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Data classes
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Commit:
|
|
73
|
+
hash: str
|
|
74
|
+
author: str
|
|
75
|
+
email: str
|
|
76
|
+
date: datetime
|
|
77
|
+
message: str
|
|
78
|
+
files: List[Tuple[int, int, str]] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class WrappedStats:
|
|
83
|
+
# Core numbers
|
|
84
|
+
total_commits: int = 0
|
|
85
|
+
total_files_changed: int = 0
|
|
86
|
+
total_insertions: int = 0
|
|
87
|
+
total_deletions: int = 0
|
|
88
|
+
|
|
89
|
+
# Date range
|
|
90
|
+
first_commit: Optional[datetime] = None
|
|
91
|
+
last_commit: Optional[datetime] = None
|
|
92
|
+
active_days: int = 0
|
|
93
|
+
|
|
94
|
+
# Time patterns (hour 0‑23, weekday 0=Mon, month 1‑12)
|
|
95
|
+
commits_by_hour: Dict[int, int] = field(default_factory=lambda: defaultdict(int))
|
|
96
|
+
commits_by_weekday: Dict[int, int] = field(default_factory=lambda: defaultdict(int))
|
|
97
|
+
commits_by_month: Dict[int, int] = field(default_factory=lambda: defaultdict(int))
|
|
98
|
+
daily_counts: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
|
|
99
|
+
|
|
100
|
+
# Top items
|
|
101
|
+
top_files: List[Tuple[str, int]] = field(default_factory=list)
|
|
102
|
+
languages: Dict[str, int] = field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
# Streaks
|
|
105
|
+
longest_streak: int = 0
|
|
106
|
+
current_streak: int = 0
|
|
107
|
+
busiest_day: Tuple[str, int] = ("", 0)
|
|
108
|
+
|
|
109
|
+
# Fun stats
|
|
110
|
+
longest_message: str = ""
|
|
111
|
+
shortest_message: str = ""
|
|
112
|
+
avg_message_length: float = 0.0
|
|
113
|
+
holiday_commits: List[str] = field(default_factory=list)
|
|
114
|
+
most_productive_month: str = ""
|
|
115
|
+
|
|
116
|
+
# Personality
|
|
117
|
+
personality: str = ""
|
|
118
|
+
personality_emoji: str = ""
|
|
119
|
+
personality_description: str = ""
|
|
120
|
+
traits: List[Tuple[str, str]] = field(default_factory=list)
|
|
121
|
+
|
|
122
|
+
# Authors
|
|
123
|
+
authors: Dict[str, int] = field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
# Meta
|
|
126
|
+
year: Optional[int] = None
|
|
127
|
+
repo_name: str = ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Notable dates (for fun‑fact detection)
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
HOLIDAYS: Dict[str, str] = {
|
|
135
|
+
"01-01": "New Year's Day",
|
|
136
|
+
"02-14": "Valentine's Day",
|
|
137
|
+
"03-17": "St. Patrick's Day",
|
|
138
|
+
"04-01": "April Fools' Day",
|
|
139
|
+
"07-04": "Independence Day",
|
|
140
|
+
"10-31": "Halloween",
|
|
141
|
+
"12-25": "Christmas",
|
|
142
|
+
"12-31": "New Year's Eve",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
MONTH_NAMES = [
|
|
146
|
+
"", "January", "February", "March", "April", "May", "June",
|
|
147
|
+
"July", "August", "September", "October", "November", "December",
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Git log parsing
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
_COMMIT_PREFIX = ">>>GW:"
|
|
156
|
+
|
|
157
|
+
def _run_git(args: List[str], cwd: str) -> str:
|
|
158
|
+
"""Run a git command and return stdout."""
|
|
159
|
+
try:
|
|
160
|
+
result = subprocess.run(
|
|
161
|
+
["git"] + args,
|
|
162
|
+
capture_output=True,
|
|
163
|
+
text=True,
|
|
164
|
+
cwd=cwd,
|
|
165
|
+
timeout=120,
|
|
166
|
+
)
|
|
167
|
+
except FileNotFoundError:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
"git is not installed or not in your PATH. "
|
|
170
|
+
"Install git and try again."
|
|
171
|
+
)
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
raise RuntimeError("git command timed out — is the repository very large?")
|
|
174
|
+
|
|
175
|
+
if result.returncode != 0:
|
|
176
|
+
stderr = result.stderr.strip()
|
|
177
|
+
if "not a git repository" in stderr:
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
f"'{cwd}' is not a git repository. "
|
|
180
|
+
"Run git-wrapped inside a repo or use --path."
|
|
181
|
+
)
|
|
182
|
+
raise RuntimeError(f"git error: {stderr}")
|
|
183
|
+
return result.stdout
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_git_log(
|
|
187
|
+
repo_path: str = ".",
|
|
188
|
+
year: Optional[int] = None,
|
|
189
|
+
author: Optional[str] = None,
|
|
190
|
+
) -> List[Commit]:
|
|
191
|
+
"""Parse git log and return a list of Commit objects."""
|
|
192
|
+
|
|
193
|
+
fmt = f"{_COMMIT_PREFIX}%H%x00%an%x00%ae%x00%aI%x00%s"
|
|
194
|
+
cmd = ["log", f"--format={fmt}", "--numstat", "--no-merges"]
|
|
195
|
+
|
|
196
|
+
if year:
|
|
197
|
+
cmd += [f"--after={year}-01-01", f"--before={year + 1}-01-01"]
|
|
198
|
+
if author:
|
|
199
|
+
cmd += ["--author", author]
|
|
200
|
+
|
|
201
|
+
raw = _run_git(cmd, cwd=repo_path)
|
|
202
|
+
if not raw.strip():
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
commits: List[Commit] = []
|
|
206
|
+
current: Optional[Commit] = None
|
|
207
|
+
|
|
208
|
+
for line in raw.splitlines():
|
|
209
|
+
if line.startswith(_COMMIT_PREFIX):
|
|
210
|
+
if current is not None:
|
|
211
|
+
commits.append(current)
|
|
212
|
+
|
|
213
|
+
payload = line[len(_COMMIT_PREFIX):]
|
|
214
|
+
parts = payload.split("\x00", 4)
|
|
215
|
+
if len(parts) < 5:
|
|
216
|
+
current = None
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
sha, name, email, datestr, message = parts
|
|
220
|
+
try:
|
|
221
|
+
dt = datetime.fromisoformat(datestr)
|
|
222
|
+
except ValueError:
|
|
223
|
+
current = None
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
current = Commit(
|
|
227
|
+
hash=sha, author=name, email=email, date=dt, message=message
|
|
228
|
+
)
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
# numstat line: "10\t5\tfilename"
|
|
232
|
+
if current is not None and "\t" in line:
|
|
233
|
+
parts = line.split("\t", 2)
|
|
234
|
+
if len(parts) == 3:
|
|
235
|
+
try:
|
|
236
|
+
adds = int(parts[0]) if parts[0] != "-" else 0
|
|
237
|
+
dels = int(parts[1]) if parts[1] != "-" else 0
|
|
238
|
+
except ValueError:
|
|
239
|
+
continue
|
|
240
|
+
current.files.append((adds, dels, parts[2]))
|
|
241
|
+
|
|
242
|
+
if current is not None:
|
|
243
|
+
commits.append(current)
|
|
244
|
+
|
|
245
|
+
return commits
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _detect_language(filename: str) -> Optional[str]:
|
|
249
|
+
"""Detect programming language from filename."""
|
|
250
|
+
basename = Path(filename).name
|
|
251
|
+
if basename in SPECIAL_FILES:
|
|
252
|
+
return SPECIAL_FILES[basename]
|
|
253
|
+
ext = Path(filename).suffix.lower()
|
|
254
|
+
return EXTENSION_LANGUAGES.get(ext)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Statistics
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _calculate_streaks(stats: WrappedStats, all_dates: Set[str]) -> None:
|
|
262
|
+
if not all_dates:
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
date_objects = sorted(
|
|
266
|
+
datetime.strptime(d, "%Y-%m-%d").date() for d in all_dates
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Longest streak
|
|
270
|
+
longest = current = 1
|
|
271
|
+
for i in range(1, len(date_objects)):
|
|
272
|
+
if (date_objects[i] - date_objects[i - 1]).days == 1:
|
|
273
|
+
current += 1
|
|
274
|
+
longest = max(longest, current)
|
|
275
|
+
else:
|
|
276
|
+
current = 1
|
|
277
|
+
stats.longest_streak = longest
|
|
278
|
+
|
|
279
|
+
# Current streak (from today backwards)
|
|
280
|
+
today = date_type.today()
|
|
281
|
+
streak = 0
|
|
282
|
+
check = today
|
|
283
|
+
# Allow a 1‑day gap (today might not have commits yet)
|
|
284
|
+
if check.strftime("%Y-%m-%d") not in all_dates:
|
|
285
|
+
check -= timedelta(days=1)
|
|
286
|
+
while check.strftime("%Y-%m-%d") in all_dates:
|
|
287
|
+
streak += 1
|
|
288
|
+
check -= timedelta(days=1)
|
|
289
|
+
stats.current_streak = streak
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _detect_holidays(daily_counts: Dict[str, int]) -> List[str]:
|
|
293
|
+
found = []
|
|
294
|
+
for date_str in daily_counts:
|
|
295
|
+
md = date_str[5:] # "MM-DD"
|
|
296
|
+
if md in HOLIDAYS:
|
|
297
|
+
found.append(HOLIDAYS[md])
|
|
298
|
+
return found
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _determine_personality(stats: WrappedStats, commits: List[Commit]) -> None:
|
|
302
|
+
total = stats.total_commits or 1
|
|
303
|
+
|
|
304
|
+
# Time buckets
|
|
305
|
+
night = sum(stats.commits_by_hour.get(h, 0) for h in list(range(22, 24)) + list(range(0, 5)))
|
|
306
|
+
morning = sum(stats.commits_by_hour.get(h, 0) for h in range(5, 12))
|
|
307
|
+
evening = sum(stats.commits_by_hour.get(h, 0) for h in range(17, 22))
|
|
308
|
+
|
|
309
|
+
night_pct = night / total
|
|
310
|
+
morning_pct = morning / total
|
|
311
|
+
|
|
312
|
+
weekend = stats.commits_by_weekday.get(5, 0) + stats.commits_by_weekday.get(6, 0)
|
|
313
|
+
weekend_pct = weekend / total
|
|
314
|
+
|
|
315
|
+
# Primary personality
|
|
316
|
+
if night_pct > 0.30:
|
|
317
|
+
stats.personality = "Night Owl"
|
|
318
|
+
stats.personality_emoji = "\U0001f989"
|
|
319
|
+
peak = max(range(20, 24), key=lambda h: stats.commits_by_hour.get(h, 0))
|
|
320
|
+
stats.personality_description = (
|
|
321
|
+
f"While others sleep, you ship code. "
|
|
322
|
+
f"{night_pct:.0%} of your commits land after 10 PM. "
|
|
323
|
+
f"Peak hour: {peak}:00."
|
|
324
|
+
)
|
|
325
|
+
elif morning_pct > 0.45:
|
|
326
|
+
stats.personality = "Early Bird"
|
|
327
|
+
stats.personality_emoji = "\U0001f426"
|
|
328
|
+
stats.personality_description = (
|
|
329
|
+
f"You catch the worm! {morning_pct:.0%} of your commits "
|
|
330
|
+
f"are shipped before noon."
|
|
331
|
+
)
|
|
332
|
+
elif weekend_pct > 0.30:
|
|
333
|
+
stats.personality = "Weekend Warrior"
|
|
334
|
+
stats.personality_emoji = "\u2694\ufe0f"
|
|
335
|
+
stats.personality_description = (
|
|
336
|
+
f"Weekends are for coding! {weekend_pct:.0%} of your "
|
|
337
|
+
f"commits happen on Saturday & Sunday."
|
|
338
|
+
)
|
|
339
|
+
elif stats.longest_streak >= 14:
|
|
340
|
+
stats.personality = "Streak Master"
|
|
341
|
+
stats.personality_emoji = "\U0001f525"
|
|
342
|
+
stats.personality_description = (
|
|
343
|
+
f"Your incredible {stats.longest_streak}-day commit streak "
|
|
344
|
+
f"shows legendary dedication."
|
|
345
|
+
)
|
|
346
|
+
elif stats.total_insertions > stats.total_deletions * 3:
|
|
347
|
+
stats.personality = "Feature Machine"
|
|
348
|
+
stats.personality_emoji = "\U0001f680"
|
|
349
|
+
stats.personality_description = (
|
|
350
|
+
f"You're a builder! {stats.total_insertions:,} lines added "
|
|
351
|
+
f"vs {stats.total_deletions:,} deleted."
|
|
352
|
+
)
|
|
353
|
+
elif stats.total_deletions > stats.total_insertions * 0.7:
|
|
354
|
+
stats.personality = "Code Surgeon"
|
|
355
|
+
stats.personality_emoji = "\u2702\ufe0f"
|
|
356
|
+
stats.personality_description = (
|
|
357
|
+
f"Less is more. You removed {stats.total_deletions:,} lines — "
|
|
358
|
+
f"cleaning up the codebase one commit at a time."
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
stats.personality = "Balanced Builder"
|
|
362
|
+
stats.personality_emoji = "\u2696\ufe0f"
|
|
363
|
+
stats.personality_description = (
|
|
364
|
+
"You strike the perfect balance between building new features "
|
|
365
|
+
"and keeping the codebase clean."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Traits
|
|
369
|
+
traits: List[Tuple[str, str]] = []
|
|
370
|
+
|
|
371
|
+
avg_files = stats.total_files_changed / total
|
|
372
|
+
if avg_files > 5:
|
|
373
|
+
traits.append(("\U0001f3d7\ufe0f", f"Big Changer — avg {avg_files:.1f} files/commit"))
|
|
374
|
+
elif avg_files < 2:
|
|
375
|
+
traits.append(("\U0001f3af", f"Surgical Committer — avg {avg_files:.1f} files/commit"))
|
|
376
|
+
|
|
377
|
+
if stats.avg_message_length > 60:
|
|
378
|
+
traits.append(("\U0001f4dd", f"Storyteller — avg {stats.avg_message_length:.0f}-char messages"))
|
|
379
|
+
elif stats.avg_message_length < 15:
|
|
380
|
+
traits.append(("\u26a1", f"Terse Messenger — avg {stats.avg_message_length:.0f}-char messages"))
|
|
381
|
+
|
|
382
|
+
if stats.longest_streak >= 7:
|
|
383
|
+
traits.append(("\U0001f525", f"On Fire — {stats.longest_streak}-day commit streak"))
|
|
384
|
+
|
|
385
|
+
if weekend_pct > 0.15:
|
|
386
|
+
traits.append(("\U0001f3e0", f"Weekend Coder — {weekend_pct:.0%} on Sat/Sun"))
|
|
387
|
+
|
|
388
|
+
# File-pattern traits
|
|
389
|
+
md_count = sum(c for f, c in stats.top_files if f.endswith((".md", ".mdx", ".rst")))
|
|
390
|
+
test_count = sum(c for f, c in stats.top_files if "test" in f.lower() or "spec" in f.lower())
|
|
391
|
+
|
|
392
|
+
if md_count > total * 0.08:
|
|
393
|
+
traits.append(("\U0001f4da", "Documentation Hero"))
|
|
394
|
+
if test_count > total * 0.10:
|
|
395
|
+
traits.append(("\U0001f9ea", "Test Champion"))
|
|
396
|
+
|
|
397
|
+
stats.traits = traits[:6]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Public API
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def analyze(
|
|
405
|
+
repo_path: str = ".",
|
|
406
|
+
year: Optional[int] = None,
|
|
407
|
+
author: Optional[str] = None,
|
|
408
|
+
) -> WrappedStats:
|
|
409
|
+
"""Analyze a git repository and return WrappedStats."""
|
|
410
|
+
|
|
411
|
+
# Resolve repo name
|
|
412
|
+
try:
|
|
413
|
+
origin = _run_git(["remote", "get-url", "origin"], cwd=repo_path).strip()
|
|
414
|
+
repo_name = Path(origin.rstrip(".git")).stem
|
|
415
|
+
except RuntimeError:
|
|
416
|
+
repo_name = Path(repo_path).resolve().name
|
|
417
|
+
|
|
418
|
+
commits = parse_git_log(repo_path, year=year, author=author)
|
|
419
|
+
if not commits:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
"No commits found. Check --year and --author filters, "
|
|
422
|
+
"or make sure you're inside a git repository."
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
stats = WrappedStats(repo_name=repo_name, year=year)
|
|
426
|
+
stats.total_commits = len(commits)
|
|
427
|
+
|
|
428
|
+
file_counter: Counter = Counter()
|
|
429
|
+
lang_counter: Counter = Counter()
|
|
430
|
+
msg_lengths: List[Tuple[int, str]] = []
|
|
431
|
+
all_dates: Set[str] = set()
|
|
432
|
+
author_counter: Counter = Counter()
|
|
433
|
+
|
|
434
|
+
for commit in commits:
|
|
435
|
+
stats.commits_by_hour[commit.date.hour] += 1
|
|
436
|
+
stats.commits_by_weekday[commit.date.weekday()] += 1
|
|
437
|
+
stats.commits_by_month[commit.date.month] += 1
|
|
438
|
+
|
|
439
|
+
ds = commit.date.strftime("%Y-%m-%d")
|
|
440
|
+
stats.daily_counts[ds] += 1
|
|
441
|
+
all_dates.add(ds)
|
|
442
|
+
|
|
443
|
+
author_counter[commit.author] += 1
|
|
444
|
+
|
|
445
|
+
for adds, dels, fname in commit.files:
|
|
446
|
+
stats.total_insertions += adds
|
|
447
|
+
stats.total_deletions += dels
|
|
448
|
+
file_counter[fname] += 1
|
|
449
|
+
|
|
450
|
+
lang = _detect_language(fname)
|
|
451
|
+
if lang:
|
|
452
|
+
lang_counter[lang] += adds + dels
|
|
453
|
+
|
|
454
|
+
stats.total_files_changed += len(commit.files)
|
|
455
|
+
msg_lengths.append((len(commit.message), commit.message))
|
|
456
|
+
|
|
457
|
+
# Dates
|
|
458
|
+
stats.first_commit = min(c.date for c in commits)
|
|
459
|
+
stats.last_commit = max(c.date for c in commits)
|
|
460
|
+
stats.active_days = len(all_dates)
|
|
461
|
+
|
|
462
|
+
# Top files
|
|
463
|
+
stats.top_files = file_counter.most_common(10)
|
|
464
|
+
|
|
465
|
+
# Languages
|
|
466
|
+
stats.languages = dict(lang_counter.most_common(10))
|
|
467
|
+
|
|
468
|
+
# Authors
|
|
469
|
+
stats.authors = dict(author_counter.most_common(10))
|
|
470
|
+
|
|
471
|
+
# Messages
|
|
472
|
+
if msg_lengths:
|
|
473
|
+
msg_lengths.sort(key=lambda x: x[0])
|
|
474
|
+
stats.shortest_message = msg_lengths[0][1]
|
|
475
|
+
stats.longest_message = msg_lengths[-1][1]
|
|
476
|
+
stats.avg_message_length = sum(m[0] for m in msg_lengths) / len(msg_lengths)
|
|
477
|
+
|
|
478
|
+
# Busiest day
|
|
479
|
+
if stats.daily_counts:
|
|
480
|
+
bd = max(stats.daily_counts.items(), key=lambda x: x[1])
|
|
481
|
+
stats.busiest_day = bd
|
|
482
|
+
|
|
483
|
+
# Most productive month
|
|
484
|
+
if stats.commits_by_month:
|
|
485
|
+
best_month = max(stats.commits_by_month, key=stats.commits_by_month.get) # type: ignore[arg-type]
|
|
486
|
+
stats.most_productive_month = MONTH_NAMES[best_month]
|
|
487
|
+
|
|
488
|
+
# Streaks
|
|
489
|
+
_calculate_streaks(stats, all_dates)
|
|
490
|
+
|
|
491
|
+
# Holidays
|
|
492
|
+
stats.holiday_commits = _detect_holidays(stats.daily_counts)
|
|
493
|
+
|
|
494
|
+
# Personality
|
|
495
|
+
_determine_personality(stats, commits)
|
|
496
|
+
|
|
497
|
+
return stats
|
git_wrapped/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""CLI entry point for git-wrapped."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from git_wrapped import __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="git-wrapped",
|
|
16
|
+
description="Spotify Wrapped, but for your Git history.",
|
|
17
|
+
epilog="Example: git-wrapped --year 2025 --path ~/projects/myrepo",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--path", "-p",
|
|
21
|
+
default=".",
|
|
22
|
+
help="Path to the git repository (default: current directory)",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--year", "-y",
|
|
26
|
+
type=int,
|
|
27
|
+
default=None,
|
|
28
|
+
help="Year to analyze (default: all time)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--author", "-a",
|
|
32
|
+
default=None,
|
|
33
|
+
help="Filter by author name or email (supports partial match)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--no-animate",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Disable loading animation and section pauses",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--json",
|
|
42
|
+
action="store_true",
|
|
43
|
+
dest="json_output",
|
|
44
|
+
help="Output raw stats as JSON instead of the visual display",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--compare",
|
|
48
|
+
nargs=2,
|
|
49
|
+
type=int,
|
|
50
|
+
metavar=("YEAR1", "YEAR2"),
|
|
51
|
+
help="Compare two years side by side (e.g. --compare 2024 2025)",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--share",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Generate a copy-pasteable share card for social media",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--version", "-v",
|
|
60
|
+
action="version",
|
|
61
|
+
version=f"git-wrapped {__version__}",
|
|
62
|
+
)
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _serialize_stats(stats) -> dict:
|
|
67
|
+
"""Convert WrappedStats to a JSON-serializable dict."""
|
|
68
|
+
data = {}
|
|
69
|
+
for key, value in stats.__dict__.items():
|
|
70
|
+
if isinstance(value, datetime):
|
|
71
|
+
data[key] = value.isoformat()
|
|
72
|
+
elif isinstance(value, dict):
|
|
73
|
+
data[key] = {str(k): v for k, v in value.items()}
|
|
74
|
+
elif isinstance(value, list):
|
|
75
|
+
serialized = []
|
|
76
|
+
for item in value:
|
|
77
|
+
if isinstance(item, tuple):
|
|
78
|
+
serialized.append(list(item))
|
|
79
|
+
else:
|
|
80
|
+
serialized.append(item)
|
|
81
|
+
data[key] = serialized
|
|
82
|
+
elif isinstance(value, tuple):
|
|
83
|
+
data[key] = list(value)
|
|
84
|
+
else:
|
|
85
|
+
data[key] = value
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main() -> None:
|
|
90
|
+
parser = _build_parser()
|
|
91
|
+
args = parser.parse_args()
|
|
92
|
+
|
|
93
|
+
# Resolve repo path
|
|
94
|
+
repo_path = str(Path(args.path).resolve())
|
|
95
|
+
|
|
96
|
+
from git_wrapped.analyzer import analyze
|
|
97
|
+
|
|
98
|
+
# --compare mode: side-by-side two years
|
|
99
|
+
if args.compare:
|
|
100
|
+
y1, y2 = args.compare
|
|
101
|
+
try:
|
|
102
|
+
stats1 = analyze(repo_path=repo_path, year=y1, author=args.author)
|
|
103
|
+
stats2 = analyze(repo_path=repo_path, year=y2, author=args.author)
|
|
104
|
+
except (RuntimeError, ValueError) as exc:
|
|
105
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
from git_wrapped.display import display_compare
|
|
108
|
+
display_compare(stats1, stats2, animate=not args.no_animate)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Normal single-year mode
|
|
112
|
+
try:
|
|
113
|
+
stats = analyze(
|
|
114
|
+
repo_path=repo_path,
|
|
115
|
+
year=args.year,
|
|
116
|
+
author=args.author,
|
|
117
|
+
)
|
|
118
|
+
except (RuntimeError, ValueError) as exc:
|
|
119
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
if args.json_output:
|
|
123
|
+
print(json.dumps(_serialize_stats(stats), indent=2))
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if args.share:
|
|
127
|
+
from git_wrapped.display import display_share_card
|
|
128
|
+
display_share_card(stats)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
from git_wrapped.display import display_wrapped
|
|
132
|
+
display_wrapped(stats, animate=not args.no_animate)
|