Skip to content

Commit 4834329

Browse files
authored
Merge pull request #11 from git-quick-stats/5-add-heatmap-capabilities
Add heatmap logic and tests
2 parents a26d6c1 + 7d34fb5 commit 4834329

12 files changed

+461
-115
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ Works with commands `--git-stats-by-branch` and `--csv-output-by-branch`.
311311
export _GIT_BRANCH="master"
312312
```
313313
314+
### Commit days
315+
316+
You can set the variable `_GIT_DAYS` to set the number of days for the heatmap.
317+
318+
```bash
319+
export _GIT_DAYS=30
320+
```
321+
314322
### Color Themes
315323
316324
You can change to the legacy color scheme by toggling the variable `_MENU_THEME`

git_py_stats/arg_parser.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ def parse_arguments(argv: Optional[List[str]] = None) -> Namespace:
175175
help="Show a calendar of commits by author",
176176
)
177177

178+
parser.add_argument(
179+
"-H",
180+
"--commits-heatmap",
181+
action="store_true",
182+
help="Show a heatmap of commits per day-of-week",
183+
)
184+
178185
# Suggest Options
179186
parser.add_argument(
180187
"-r",

git_py_stats/calendar_cmds.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""
2+
Functions related to the 'Calendar' section.
3+
"""
4+
5+
from typing import Optional, Dict, Union
6+
from datetime import datetime, timedelta
7+
from collections import defaultdict
8+
9+
from git_py_stats.git_operations import run_git_command
10+
11+
12+
def commits_calendar_by_author(config: Dict[str, Union[str, int]], author: Optional[str]) -> None:
13+
"""
14+
Displays a calendar of commits by author
15+
16+
Args:
17+
config: Dict[str, Union[str, int]]: Config dictionary holding env vars.
18+
author: Optional[str]: The author's name to filter commits by.
19+
20+
Returns:
21+
None
22+
"""
23+
24+
# Initialize variables similar to the Bash version
25+
author_option = f"--author={author}" if author else ""
26+
27+
# Grab the config options from our config.py.
28+
# config.py should give fallbacks for these, but for sanity,
29+
# lets also provide some defaults just in case.
30+
merges = config.get("merges", "--no-merges")
31+
since = config.get("since", "")
32+
until = config.get("until", "")
33+
log_options = config.get("log_options", "")
34+
pathspec = config.get("pathspec", "")
35+
36+
# Original git command:
37+
# git -c log.showSignature=false log --use-mailmap $_merges \
38+
# --date=iso --author="$author" "$_since" "$_until" $_log_options \
39+
# --pretty='%ad' $_pathspec
40+
cmd = [
41+
"git",
42+
"-c",
43+
"log.showSignature=false",
44+
"log",
45+
"--use-mailmap",
46+
"--date=iso",
47+
f"--author={author}",
48+
"--pretty=%ad",
49+
]
50+
51+
if author_option:
52+
cmd.append(author_option)
53+
54+
cmd.extend([since, until, log_options, merges, pathspec])
55+
56+
# Remove any empty space from the cmd
57+
cmd = [arg for arg in cmd if arg]
58+
59+
print(f"Commit Activity Calendar for '{author}'")
60+
61+
# Get commit dates
62+
output = run_git_command(cmd)
63+
if not output:
64+
print("No commits found.")
65+
return
66+
67+
print("\n Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec")
68+
69+
count = defaultdict(lambda: defaultdict(int))
70+
for line in output.strip().split("\n"):
71+
try:
72+
date_str = line.strip().split(" ")[0]
73+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
74+
weekday = date_obj.isoweekday() # 1=Mon, ..., 7=Sun
75+
month = date_obj.month
76+
count[weekday][month] += 1
77+
except ValueError:
78+
continue
79+
80+
# Print the calendar
81+
weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
82+
for d in range(1, 8):
83+
row = f"{weekdays[d-1]:<5} "
84+
for m in range(1, 13):
85+
c = count[d][m]
86+
if c == 0:
87+
out = "..."
88+
elif c <= 9:
89+
out = "░░░"
90+
elif c <= 19:
91+
out = "▒▒▒"
92+
else:
93+
out = "▓▓▓"
94+
row += out + (" " if m < 12 else "")
95+
print(row)
96+
97+
print("\nLegend: ... = 0 ░░░ = 1–9 ▒▒▒ = 10–19 ▓▓▓ = 20+ commits")
98+
99+
100+
def commits_heatmap(config: Dict[str, Union[str, int]]) -> None:
101+
"""
102+
Shows a heatmap of commits per hour of each day for the last N days.
103+
104+
Uses 256-color ANSI sequences to emulate the original tput color palette:
105+
226 (bright yellow)
106+
220 (gold)
107+
214 (orange)
108+
208 (dark orange),
109+
202 (red-orange),
110+
160 (red),
111+
88 (deep red),
112+
52 (darkest red)
113+
114+
Args:
115+
config: Dict[str, Union[str, int]]: Config dictionary holding env vars.
116+
117+
Returns:
118+
None
119+
"""
120+
121+
# ANSI color code helpers
122+
RESET = "\033[0m"
123+
124+
def ansi256(n: int) -> str:
125+
return f"\033[38;5;{n}m"
126+
127+
COLOR_BRIGHT_YELLOW = ansi256(226)
128+
COLOR_GOLD = ansi256(220)
129+
COLOR_ORANGE = ansi256(214)
130+
COLOR_DARK_ORANGE = ansi256(208)
131+
COLOR_RED_ORANGE = ansi256(202)
132+
COLOR_RED = ansi256(160)
133+
COLOR_DARK_RED = ansi256(88)
134+
COLOR_DEEPEST_RED = ansi256(52)
135+
COLOR_GRAY = ansi256(240) # Gives the dark color for no commits
136+
137+
def color_for_count(n: int) -> str:
138+
# Map counts to colors
139+
if n == 1:
140+
return COLOR_BRIGHT_YELLOW
141+
elif n == 2:
142+
return COLOR_GOLD
143+
elif n == 3:
144+
return COLOR_ORANGE
145+
elif n == 4:
146+
return COLOR_DARK_ORANGE
147+
elif n == 5:
148+
return COLOR_RED_ORANGE
149+
elif n == 6:
150+
return COLOR_RED
151+
elif 7 <= n <= 8:
152+
return COLOR_DARK_RED
153+
elif 9 <= n <= 10:
154+
return COLOR_DEEPEST_RED
155+
else:
156+
return COLOR_DEEPEST_RED # 11+
157+
158+
# Grab the config options from our config.py.
159+
# config.py should give fallbacks for these, but for sanity,
160+
# lets also provide some defaults just in case.
161+
merges = config.get("merges", "--no-merges")
162+
log_options = config.get("log_options", "")
163+
pathspec = config.get("pathspec", "--")
164+
days = int(config.get("days", 30))
165+
166+
print(f"Commit Heatmap for the last {days} days")
167+
168+
# Header bar thing
169+
header = "Day | Date/Hours |"
170+
for h in range(24):
171+
header += f" {h:2d}"
172+
print(header)
173+
print(
174+
"------------------------------------------------------------------------------------------"
175+
)
176+
177+
# Build each day row from oldest to newest, marking weekends,
178+
# and printing the row header in "DDD | YYYY-MM-DD |" format
179+
today = datetime.now().date()
180+
for delta in range(days - 1, -1, -1):
181+
day = today - timedelta(days=delta)
182+
is_weekend = day.isoweekday() > 5
183+
day_prefix_color = COLOR_GRAY if is_weekend else RESET
184+
dayname = day.strftime("%a")
185+
print(f"{day_prefix_color}{dayname} | {day.isoformat()} |", end="")
186+
187+
# Count commits per hour for this day
188+
since = f"--since={day.isoformat()} 00:00"
189+
until = f"--until={day.isoformat()} 23:59"
190+
191+
cmd = [
192+
"git",
193+
"-c",
194+
"log.showSignature=false",
195+
"log",
196+
"--use-mailmap",
197+
merges,
198+
since,
199+
until,
200+
"--pretty=%ci",
201+
log_options,
202+
pathspec,
203+
]
204+
205+
# Remove any empty space from the cmd
206+
cmd = [arg for arg in cmd if arg]
207+
208+
output = run_git_command(cmd) or ""
209+
210+
# Create 24 cell per-hour commit histrogram for the day,
211+
# grabbing only what is parseable.
212+
counts = [0] * 24
213+
if output:
214+
for line in output.splitlines():
215+
parts = line.strip().split()
216+
if len(parts) >= 2:
217+
time_part = parts[1]
218+
try:
219+
hour = int(time_part.split(":")[0])
220+
if 0 <= hour <= 23:
221+
counts[hour] += 1
222+
except ValueError:
223+
continue
224+
225+
# Render the cells
226+
for hour in range(24):
227+
n = counts[hour]
228+
if n == 0:
229+
# gray dot for zero commits
230+
print(f" {COLOR_GRAY}.{RESET} ", end="")
231+
else:
232+
c = color_for_count(n)
233+
print(f"{c}{RESET}", end="")
234+
# End the row/reset
235+
print(RESET)
236+
237+
# Match original version in the bash impl
238+
print(
239+
"------------------------------------------------------------------------------------------"
240+
)
241+
# Legend
242+
print("\nLegend:")
243+
print(f" {COLOR_BRIGHT_YELLOW}{RESET} 1 commit")
244+
print(f" {COLOR_GOLD}{RESET} 2 commits")
245+
print(f" {COLOR_ORANGE}{RESET} 3 commits")
246+
print(f" {COLOR_DARK_ORANGE}{RESET} 4 commits")
247+
print(f" {COLOR_RED_ORANGE}{RESET} 5 commits")
248+
print(f" {COLOR_RED}{RESET} 6 commits")
249+
print(f" {COLOR_DARK_RED}{RESET} 7–8 commits")
250+
print(f" {COLOR_DEEPEST_RED}{RESET} 9–10 commits")
251+
print(f" {COLOR_DEEPEST_RED}{RESET} 11+ commits")
252+
print(f" {COLOR_GRAY}.{RESET} = no commits\n")

git_py_stats/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def get_config() -> Dict[str, Union[str, int]]:
3737
- Any other value defaults to '--no-merges' currently.
3838
_GIT_LIMIT (int): Limits the git log output. Defaults to 10.
3939
_GIT_LOG_OPTIONS (str): Additional git log options. Default is empty.
40+
_GIT_DAYS (int): Defines number of days for the heatmap. Default is empty.
4041
_MENU_THEME (str): Toggles between the default theme and legacy theme.
4142
- 'legacy' to set the legacy theme
4243
- 'none' to disable the menu theme
@@ -117,6 +118,18 @@ def get_config() -> Dict[str, Union[str, int]]:
117118
else:
118119
config["log_options"] = ""
119120

121+
# _GIT_DAYS
122+
git_days: Optional[str] = os.environ.get("_GIT_DAYS")
123+
if git_days:
124+
# Slight sanitization, but we're still gonna wild west this a bit
125+
try:
126+
config["days"] = int(git_days)
127+
except ValueError:
128+
print("Invalid value for _GIT_DAYS. Using default value 30.")
129+
config["days"] = 30
130+
else:
131+
config["days"] = 30
132+
120133
# _MENU_THEME
121134
menu_theme: Optional[str] = os.environ.get("_MENU_THEME")
122135
if menu_theme == "legacy":

0 commit comments

Comments
 (0)