Skip to content

Commit 7d34fb5

Browse files
committed
Add heatmap logic and tests
* The calendar option has been expanding, so it makes sense to start breaking out the calendar options to its own area. Refactor necessary tests, functionality, etc. into their own separate files so we can continue expanding on the calendar option functionality * Adds general heatmap logic, tests, and necessary helper functions * Also adds all necessary hooks and documentation changes to handle the new heatmap feature Closes #10
1 parent a26d6c1 commit 7d34fb5

File tree

12 files changed

+461
-115
lines changed

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)