git-ember 1.2.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_ember-1.2.0.dist-info/METADATA +165 -0
- git_ember-1.2.0.dist-info/RECORD +10 -0
- git_ember-1.2.0.dist-info/WHEEL +5 -0
- git_ember-1.2.0.dist-info/entry_points.txt +2 -0
- git_ember-1.2.0.dist-info/top_level.txt +1 -0
- gitember/__init__.py +1 -0
- gitember/cli.py +414 -0
- gitember/colors.py +143 -0
- gitember/git.py +535 -0
- gitember/render.py +293 -0
gitember/render.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
import datetime as dt
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from githeat.colors import RESET, LIGHT_GRAY, ColorScheme
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_THRESHOLDS = (0, 1, 3, 6, 10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def calculate_thresholds(counts: Dict[dt.date, int], mode: str = "auto") -> tuple:
|
|
12
|
+
"""Calculate intensity thresholds based on scaling mode.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
counts: Dict mapping date to commit count.
|
|
16
|
+
mode: Scaling mode - "auto" or "adaptive".
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Tuple of threshold values.
|
|
20
|
+
"""
|
|
21
|
+
if mode == "auto":
|
|
22
|
+
return (
|
|
23
|
+
0,
|
|
24
|
+
max(1, int(max_commits * 0.2)),
|
|
25
|
+
max(1, int(max_commits * 0.4)),
|
|
26
|
+
max(1, int(max_commits * 0.6)),
|
|
27
|
+
max(1, int(max_commits * 0.8)),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if mode == "adaptive":
|
|
31
|
+
sorted_values = sorted(values)
|
|
32
|
+
n = len(sorted_values)
|
|
33
|
+
return (
|
|
34
|
+
0,
|
|
35
|
+
sorted_values[int(n * 0.50)] if n > 0 else 1,
|
|
36
|
+
sorted_values[int(n * 0.75)] if n > 0 else 3,
|
|
37
|
+
sorted_values[int(n * 0.90)] if n > 0 else 6,
|
|
38
|
+
sorted_values[int(n * 0.95)] if n > 0 else 10,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return DEFAULT_THRESHOLDS
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def choose_level(count: int, thresholds: tuple = None) -> int:
|
|
45
|
+
"""Map commit count to intensity level 0-4.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
count: Number of commits.
|
|
49
|
+
thresholds: Tuple of threshold values for each level.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Intensity level (0-4), where 0 = no commits, 4 = highest activity.
|
|
53
|
+
"""
|
|
54
|
+
if thresholds is None:
|
|
55
|
+
thresholds = DEFAULT_THRESHOLDS
|
|
56
|
+
if count == 0:
|
|
57
|
+
return 0
|
|
58
|
+
if count <= thresholds[1]:
|
|
59
|
+
return 1
|
|
60
|
+
if count <= thresholds[2]:
|
|
61
|
+
return 2
|
|
62
|
+
if count <= thresholds[3]:
|
|
63
|
+
return 3
|
|
64
|
+
return 4
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_date_range(start: dt.date, end: dt.date) -> List[dt.date]:
|
|
68
|
+
"""Generate list of all dates from start to end inclusive.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
start: Start date (inclusive).
|
|
72
|
+
end: End date (inclusive).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of dates in range.
|
|
76
|
+
"""
|
|
77
|
+
days: List[dt.date] = []
|
|
78
|
+
cur = start
|
|
79
|
+
while cur <= end:
|
|
80
|
+
days.append(cur)
|
|
81
|
+
cur += dt.timedelta(days=1)
|
|
82
|
+
return days
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_month_labels(
|
|
86
|
+
start_date: dt.date, end_date: dt.date, week_start: str
|
|
87
|
+
) -> Dict[int, str]:
|
|
88
|
+
"""Get month abbreviations positioned at their week boundaries.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
start_date: Start of date range.
|
|
92
|
+
end_date: End of date range.
|
|
93
|
+
week_start: "monday" or "sunday".
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Dict mapping week index to 3-letter month abbreviation.
|
|
97
|
+
"""
|
|
98
|
+
months = {}
|
|
99
|
+
all_days = build_date_range(start_date, end_date)
|
|
100
|
+
|
|
101
|
+
if week_start == "monday":
|
|
102
|
+
offset = start_date.weekday()
|
|
103
|
+
else:
|
|
104
|
+
offset = (start_date.weekday() + 1) % 7
|
|
105
|
+
grid_start = start_date - dt.timedelta(days=offset)
|
|
106
|
+
|
|
107
|
+
for day in all_days:
|
|
108
|
+
idx = (day - grid_start).days
|
|
109
|
+
week = idx // 7
|
|
110
|
+
if day.day == 1:
|
|
111
|
+
months[week] = calendar.month_abbr[day.month]
|
|
112
|
+
|
|
113
|
+
return months
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render_grid(
|
|
117
|
+
start_date: dt.date,
|
|
118
|
+
end_date: dt.date,
|
|
119
|
+
counts: Dict[dt.date, int],
|
|
120
|
+
color_scheme: ColorScheme,
|
|
121
|
+
week_start: str,
|
|
122
|
+
border_char: str = "=",
|
|
123
|
+
ascii_mode: bool = False,
|
|
124
|
+
thresholds: tuple = None,
|
|
125
|
+
) -> str:
|
|
126
|
+
"""Render heatmap in grid style (horizontal squares).
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
start_date: Start of date range.
|
|
130
|
+
end_date: End of date range.
|
|
131
|
+
counts: Dict mapping date to commit count.
|
|
132
|
+
color_scheme: Color scheme for intensity levels.
|
|
133
|
+
week_start: "monday" or "sunday".
|
|
134
|
+
border_char: Character for horizontal borders.
|
|
135
|
+
ascii_mode: Use ASCII characters instead of Unicode blocks.
|
|
136
|
+
thresholds: Custom intensity thresholds (default: (0,1,3,6,10)).
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Formatted string representation of heatmap.
|
|
140
|
+
"""
|
|
141
|
+
if thresholds is None:
|
|
142
|
+
thresholds = DEFAULT_THRESHOLDS
|
|
143
|
+
all_days = build_date_range(start_date, end_date)
|
|
144
|
+
|
|
145
|
+
if ascii_mode:
|
|
146
|
+
block_chars = [" ", "..", "::", "==", "##"]
|
|
147
|
+
else:
|
|
148
|
+
block_chars = [" ", "░░", "▒▒", "▓▓", "██"]
|
|
149
|
+
|
|
150
|
+
if week_start == "monday":
|
|
151
|
+
offset = start_date.weekday()
|
|
152
|
+
else:
|
|
153
|
+
offset = (start_date.weekday() + 1) % 7
|
|
154
|
+
grid_start = start_date - dt.timedelta(days=offset)
|
|
155
|
+
|
|
156
|
+
total_days = (end_date - grid_start).days + 1
|
|
157
|
+
num_weeks = (total_days + 6) // 7
|
|
158
|
+
|
|
159
|
+
grid: List[List[int]] = [[0 for _ in range(num_weeks)] for _ in range(7)]
|
|
160
|
+
for day in all_days:
|
|
161
|
+
idx = (day - grid_start).days
|
|
162
|
+
week = idx // 7
|
|
163
|
+
weekday = day.weekday()
|
|
164
|
+
count = counts.get(day, 0)
|
|
165
|
+
grid[weekday][week] = choose_level(count, thresholds)
|
|
166
|
+
|
|
167
|
+
label_days = {0: "Mon ", 2: "Wed ", 4: "Fri "}
|
|
168
|
+
if week_start == "sunday":
|
|
169
|
+
order = [6, 0, 1, 2, 3, 4, 5]
|
|
170
|
+
else:
|
|
171
|
+
order = [0, 1, 2, 3, 4, 5, 6]
|
|
172
|
+
|
|
173
|
+
month_labels = get_month_labels(start_date, end_date, week_start)
|
|
174
|
+
|
|
175
|
+
lines = []
|
|
176
|
+
|
|
177
|
+
month_line = " " * 4
|
|
178
|
+
for w in range(num_weeks):
|
|
179
|
+
month_line += month_labels.get(w, " ")
|
|
180
|
+
lines.append(month_line)
|
|
181
|
+
lines.append(border_char * len(month_line))
|
|
182
|
+
|
|
183
|
+
for weekday in order:
|
|
184
|
+
label = label_days.get(weekday, " ")
|
|
185
|
+
cells = []
|
|
186
|
+
for week in range(num_weeks):
|
|
187
|
+
level = grid[weekday][week]
|
|
188
|
+
color = color_scheme.get(level)
|
|
189
|
+
cell_char = block_chars[level]
|
|
190
|
+
cells.append(f"{color}{cell_char}{RESET}")
|
|
191
|
+
lines.append(f"{label} " + "".join(cells))
|
|
192
|
+
|
|
193
|
+
lines.append(border_char * len(month_line))
|
|
194
|
+
|
|
195
|
+
return "\n".join(lines)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def render_branch_tree(
|
|
199
|
+
commits: List[Dict[str, Any]],
|
|
200
|
+
default_branch: Optional[str] = None,
|
|
201
|
+
selected_branch: Optional[str] = None,
|
|
202
|
+
color_scheme: Optional["ColorScheme"] = None,
|
|
203
|
+
ascii_mode: bool = False,
|
|
204
|
+
compact: bool = False,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Render horizontal branch tree visualization.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
commits: List of commit dicts from get_branch_tree.
|
|
210
|
+
default_branch: Name of default branch (e.g., "main").
|
|
211
|
+
selected_branch: Branch specified by user (for highlighting).
|
|
212
|
+
color_scheme: Color scheme for branch name highlighting.
|
|
213
|
+
ascii_mode: Use ASCII characters instead of Unicode.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Formatted string with branch tree visualization.
|
|
217
|
+
"""
|
|
218
|
+
if not commits:
|
|
219
|
+
return ""
|
|
220
|
+
|
|
221
|
+
node_char = "*" if ascii_mode else "•"
|
|
222
|
+
|
|
223
|
+
lines = ["Branch Tree:"]
|
|
224
|
+
branch_colors: Dict[str, str] = {}
|
|
225
|
+
color_index = 2
|
|
226
|
+
|
|
227
|
+
def get_branch_color(branch: str) -> str:
|
|
228
|
+
nonlocal color_index
|
|
229
|
+
|
|
230
|
+
if not color_scheme:
|
|
231
|
+
return ""
|
|
232
|
+
|
|
233
|
+
if selected_branch and branch == selected_branch:
|
|
234
|
+
return color_scheme.get(4)
|
|
235
|
+
|
|
236
|
+
if branch not in branch_colors:
|
|
237
|
+
branch_colors[branch] = color_scheme.get(color_index)
|
|
238
|
+
color_index += 1
|
|
239
|
+
if color_index > 4: # Wrap back to 2, keeping 0–1 reserved
|
|
240
|
+
color_index = 2
|
|
241
|
+
|
|
242
|
+
return branch_colors[branch]
|
|
243
|
+
|
|
244
|
+
max_hash_len = 7
|
|
245
|
+
max_branch_width = 0
|
|
246
|
+
for commit in commits:
|
|
247
|
+
branches = commit.get("branches", [])
|
|
248
|
+
if branches:
|
|
249
|
+
branch_str = ", ".join(branches)
|
|
250
|
+
max_branch_width = max(max_branch_width, len(branch_str))
|
|
251
|
+
|
|
252
|
+
has_branches = max_branch_width > 0
|
|
253
|
+
if has_branches:
|
|
254
|
+
separator_width = 1 + max_hash_len + 1 + 20 + 1 + max_branch_width + 1
|
|
255
|
+
else:
|
|
256
|
+
separator_width = 1 + max_hash_len + 1 + 35 + 1
|
|
257
|
+
|
|
258
|
+
for commit in commits:
|
|
259
|
+
graph_line = commit.get("graph_line", "*")
|
|
260
|
+
|
|
261
|
+
if node_char != "*":
|
|
262
|
+
graph_line = graph_line.replace("*", node_char)
|
|
263
|
+
|
|
264
|
+
hash7 = commit["hash"]
|
|
265
|
+
full_message = commit["message"]
|
|
266
|
+
max_msg_len = 20 if compact else 35
|
|
267
|
+
if len(full_message) > max_msg_len:
|
|
268
|
+
message = full_message[: max_msg_len - 3] + "..."
|
|
269
|
+
else:
|
|
270
|
+
message = full_message + " " * (max_msg_len - len(full_message))
|
|
271
|
+
branches = commit.get("branches", [])
|
|
272
|
+
|
|
273
|
+
is_connector = not hash7 and "|" in graph_line
|
|
274
|
+
|
|
275
|
+
branch_part = ""
|
|
276
|
+
if branches:
|
|
277
|
+
branch_part = " " + ", ".join(branches)
|
|
278
|
+
|
|
279
|
+
if is_connector:
|
|
280
|
+
lines.append(f"{graph_line}{' ' * separator_width}")
|
|
281
|
+
else:
|
|
282
|
+
sep_len = 5 if compact else 20
|
|
283
|
+
sep_dashes = LIGHT_GRAY + "-" * sep_len + RESET
|
|
284
|
+
branch_label = (
|
|
285
|
+
LIGHT_GRAY + branch_part.strip() + RESET if branch_part else ""
|
|
286
|
+
)
|
|
287
|
+
separator = f" {sep_dashes} {branch_label}" if branch_part else ""
|
|
288
|
+
commit_color = color_scheme.get(3) if color_scheme else ""
|
|
289
|
+
lines.append(
|
|
290
|
+
f"{graph_line} {commit_color}{hash7}{RESET} {message}{separator}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return "\n".join(lines)
|