git-ember 1.4.0__tar.gz → 1.4.1__tar.gz
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.4.0/src/git_ember.egg-info → git_ember-1.4.1}/PKG-INFO +29 -8
- {git_ember-1.4.0 → git_ember-1.4.1}/README.md +28 -7
- {git_ember-1.4.0 → git_ember-1.4.1}/pyproject.toml +1 -1
- {git_ember-1.4.0 → git_ember-1.4.1/src/git_ember.egg-info}/PKG-INFO +29 -8
- {git_ember-1.4.0 → git_ember-1.4.1}/src/gitember/__init__.py +0 -1
- {git_ember-1.4.0 → git_ember-1.4.1}/src/gitember/cli.py +68 -25
- {git_ember-1.4.0 → git_ember-1.4.1}/src/gitember/config.py +37 -1
- {git_ember-1.4.0 → git_ember-1.4.1}/src/gitember/git.py +53 -100
- {git_ember-1.4.0 → git_ember-1.4.1}/src/gitember/render.py +7 -12
- {git_ember-1.4.0 → git_ember-1.4.1}/tests/test_config.py +96 -0
- {git_ember-1.4.0 → git_ember-1.4.1}/tests/test_render.py +2 -2
- {git_ember-1.4.0 → git_ember-1.4.1}/LICENSE +0 -0
- {git_ember-1.4.0 → git_ember-1.4.1}/setup.cfg +0 -0
- {git_ember-1.4.0 → git_ember-1.4.1}/src/git_ember.egg-info/SOURCES.txt +0 -0
- {git_ember-1.4.0 → git_ember-1.4.1}/src/git_ember.egg-info/dependency_links.txt +0 -0
- {git_ember-1.4.0 → git_ember-1.4.1}/src/git_ember.egg-info/entry_points.txt +0 -0
- {git_ember-1.4.0 → git_ember-1.4.1}/src/git_ember.egg-info/top_level.txt +0 -0
- {git_ember-1.4.0 → git_ember-1.4.1}/src/gitember/colors.py +0 -0
- {git_ember-1.4.0 → git_ember-1.4.1}/tests/test_git.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-ember
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: A GitHub-style heatmap of commits and local repo statistics for your terminal
|
|
5
5
|
Author-email: Luka van Rooyen <lukavrooyen@proton.me>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,9 @@ Dynamic: license-file
|
|
|
22
22
|
|
|
23
23
|
# git-ember
|
|
24
24
|
|
|
25
|
+
[](https://pypi.org/project/git-ember/)
|
|
26
|
+
[](https://opensource.org/licenses/MIT)
|
|
27
|
+
|
|
25
28
|
A GitHub-style heatmap of commit activity for your terminal.
|
|
26
29
|
|
|
27
30
|
## Overview
|
|
@@ -35,6 +38,14 @@ git-ember reads directly from your local Git repository history—no external AP
|
|
|
35
38
|
|
|
36
39
|
## Installation
|
|
37
40
|
|
|
41
|
+
### From PyPI (recommended)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install git-ember
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### From source
|
|
48
|
+
|
|
38
49
|
1. Clone the repository:
|
|
39
50
|
|
|
40
51
|
```bash
|
|
@@ -72,13 +83,18 @@ pip uninstall git-ember
|
|
|
72
83
|
|
|
73
84
|
git-ember reads configuration from `~/.config/git-ember/config.toml` (or the platform-specific config directory). CLI arguments take precedence over config values.
|
|
74
85
|
|
|
75
|
-
| Variable
|
|
76
|
-
|
|
77
|
-
| `color`
|
|
78
|
-
| `border`
|
|
79
|
-
| `week_start
|
|
80
|
-
| `ascii`
|
|
81
|
-
| `scale`
|
|
86
|
+
| Variable | Required | Default | Description |
|
|
87
|
+
|----------------|----------|-----------|--------------------------------------------------|
|
|
88
|
+
| `color` | No | `green` | Color scheme name |
|
|
89
|
+
| `border` | No | `=` | Border character |
|
|
90
|
+
| `week_start` | No | `sunday` | Week start day (sunday/monday) |
|
|
91
|
+
| `ascii` | No | `false` | Use ASCII characters |
|
|
92
|
+
| `scale` | No | `auto` | Intensity scaling (auto/adaptive) |
|
|
93
|
+
| `extended` | No | `false` | Show extended report by default |
|
|
94
|
+
| `compact` | No | `false` | Show last 4 months only |
|
|
95
|
+
| `tree` | No | `false` | Show branch tree by default |
|
|
96
|
+
| `years` | No | `1` | Number of years to show |
|
|
97
|
+
| `recent_count` | No | `5` | Number of recent commits to display |
|
|
82
98
|
|
|
83
99
|
### Example config file
|
|
84
100
|
|
|
@@ -88,6 +104,11 @@ border = "-"
|
|
|
88
104
|
week_start = "monday"
|
|
89
105
|
ascii = "true"
|
|
90
106
|
scale = "adaptive"
|
|
107
|
+
extended = "true"
|
|
108
|
+
compact = "true"
|
|
109
|
+
tree = "true"
|
|
110
|
+
years = 2
|
|
111
|
+
recent_count = 10
|
|
91
112
|
```
|
|
92
113
|
|
|
93
114
|
## Usage
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# git-ember
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/git-ember/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
3
6
|
A GitHub-style heatmap of commit activity for your terminal.
|
|
4
7
|
|
|
5
8
|
## Overview
|
|
@@ -13,6 +16,14 @@ git-ember reads directly from your local Git repository history—no external AP
|
|
|
13
16
|
|
|
14
17
|
## Installation
|
|
15
18
|
|
|
19
|
+
### From PyPI (recommended)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install git-ember
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### From source
|
|
26
|
+
|
|
16
27
|
1. Clone the repository:
|
|
17
28
|
|
|
18
29
|
```bash
|
|
@@ -50,13 +61,18 @@ pip uninstall git-ember
|
|
|
50
61
|
|
|
51
62
|
git-ember reads configuration from `~/.config/git-ember/config.toml` (or the platform-specific config directory). CLI arguments take precedence over config values.
|
|
52
63
|
|
|
53
|
-
| Variable
|
|
54
|
-
|
|
55
|
-
| `color`
|
|
56
|
-
| `border`
|
|
57
|
-
| `week_start
|
|
58
|
-
| `ascii`
|
|
59
|
-
| `scale`
|
|
64
|
+
| Variable | Required | Default | Description |
|
|
65
|
+
|----------------|----------|-----------|--------------------------------------------------|
|
|
66
|
+
| `color` | No | `green` | Color scheme name |
|
|
67
|
+
| `border` | No | `=` | Border character |
|
|
68
|
+
| `week_start` | No | `sunday` | Week start day (sunday/monday) |
|
|
69
|
+
| `ascii` | No | `false` | Use ASCII characters |
|
|
70
|
+
| `scale` | No | `auto` | Intensity scaling (auto/adaptive) |
|
|
71
|
+
| `extended` | No | `false` | Show extended report by default |
|
|
72
|
+
| `compact` | No | `false` | Show last 4 months only |
|
|
73
|
+
| `tree` | No | `false` | Show branch tree by default |
|
|
74
|
+
| `years` | No | `1` | Number of years to show |
|
|
75
|
+
| `recent_count` | No | `5` | Number of recent commits to display |
|
|
60
76
|
|
|
61
77
|
### Example config file
|
|
62
78
|
|
|
@@ -66,6 +82,11 @@ border = "-"
|
|
|
66
82
|
week_start = "monday"
|
|
67
83
|
ascii = "true"
|
|
68
84
|
scale = "adaptive"
|
|
85
|
+
extended = "true"
|
|
86
|
+
compact = "true"
|
|
87
|
+
tree = "true"
|
|
88
|
+
years = 2
|
|
89
|
+
recent_count = 10
|
|
69
90
|
```
|
|
70
91
|
|
|
71
92
|
## Usage
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-ember
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: A GitHub-style heatmap of commits and local repo statistics for your terminal
|
|
5
5
|
Author-email: Luka van Rooyen <lukavrooyen@proton.me>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,9 @@ Dynamic: license-file
|
|
|
22
22
|
|
|
23
23
|
# git-ember
|
|
24
24
|
|
|
25
|
+
[](https://pypi.org/project/git-ember/)
|
|
26
|
+
[](https://opensource.org/licenses/MIT)
|
|
27
|
+
|
|
25
28
|
A GitHub-style heatmap of commit activity for your terminal.
|
|
26
29
|
|
|
27
30
|
## Overview
|
|
@@ -35,6 +38,14 @@ git-ember reads directly from your local Git repository history—no external AP
|
|
|
35
38
|
|
|
36
39
|
## Installation
|
|
37
40
|
|
|
41
|
+
### From PyPI (recommended)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install git-ember
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### From source
|
|
48
|
+
|
|
38
49
|
1. Clone the repository:
|
|
39
50
|
|
|
40
51
|
```bash
|
|
@@ -72,13 +83,18 @@ pip uninstall git-ember
|
|
|
72
83
|
|
|
73
84
|
git-ember reads configuration from `~/.config/git-ember/config.toml` (or the platform-specific config directory). CLI arguments take precedence over config values.
|
|
74
85
|
|
|
75
|
-
| Variable
|
|
76
|
-
|
|
77
|
-
| `color`
|
|
78
|
-
| `border`
|
|
79
|
-
| `week_start
|
|
80
|
-
| `ascii`
|
|
81
|
-
| `scale`
|
|
86
|
+
| Variable | Required | Default | Description |
|
|
87
|
+
|----------------|----------|-----------|--------------------------------------------------|
|
|
88
|
+
| `color` | No | `green` | Color scheme name |
|
|
89
|
+
| `border` | No | `=` | Border character |
|
|
90
|
+
| `week_start` | No | `sunday` | Week start day (sunday/monday) |
|
|
91
|
+
| `ascii` | No | `false` | Use ASCII characters |
|
|
92
|
+
| `scale` | No | `auto` | Intensity scaling (auto/adaptive) |
|
|
93
|
+
| `extended` | No | `false` | Show extended report by default |
|
|
94
|
+
| `compact` | No | `false` | Show last 4 months only |
|
|
95
|
+
| `tree` | No | `false` | Show branch tree by default |
|
|
96
|
+
| `years` | No | `1` | Number of years to show |
|
|
97
|
+
| `recent_count` | No | `5` | Number of recent commits to display |
|
|
82
98
|
|
|
83
99
|
### Example config file
|
|
84
100
|
|
|
@@ -88,6 +104,11 @@ border = "-"
|
|
|
88
104
|
week_start = "monday"
|
|
89
105
|
ascii = "true"
|
|
90
106
|
scale = "adaptive"
|
|
107
|
+
extended = "true"
|
|
108
|
+
compact = "true"
|
|
109
|
+
tree = "true"
|
|
110
|
+
years = 2
|
|
111
|
+
recent_count = 10
|
|
91
112
|
```
|
|
92
113
|
|
|
93
114
|
## Usage
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from gitember import __version__
|
|
9
9
|
from gitember.colors import LIGHT_GRAY, PLAIN, RESET, get_color_scheme
|
|
10
10
|
from gitember.config import (
|
|
11
|
+
get_bool,
|
|
11
12
|
load_config,
|
|
12
13
|
save_config,
|
|
13
14
|
)
|
|
@@ -69,8 +70,8 @@ def parse_args() -> argparse.Namespace:
|
|
|
69
70
|
"--years",
|
|
70
71
|
"-y",
|
|
71
72
|
type=int,
|
|
72
|
-
default=
|
|
73
|
-
help="Number of years to show (default: 1,
|
|
73
|
+
default=None,
|
|
74
|
+
help="Number of years to show (default: 1, or as set in config)",
|
|
74
75
|
)
|
|
75
76
|
parser.add_argument(
|
|
76
77
|
"--border",
|
|
@@ -82,14 +83,16 @@ def parse_args() -> argparse.Namespace:
|
|
|
82
83
|
parser.add_argument(
|
|
83
84
|
"--extended",
|
|
84
85
|
"-e",
|
|
85
|
-
action=
|
|
86
|
-
|
|
86
|
+
action=argparse.BooleanOptionalAction,
|
|
87
|
+
default=None,
|
|
88
|
+
help="Show extended report with recent commits and top contributors (--no-extended to unset saved preference)",
|
|
87
89
|
)
|
|
88
90
|
parser.add_argument(
|
|
89
91
|
"--ascii",
|
|
90
92
|
"-a",
|
|
91
|
-
action=
|
|
92
|
-
|
|
93
|
+
action=argparse.BooleanOptionalAction,
|
|
94
|
+
default=None,
|
|
95
|
+
help="Use ASCII characters instead of Unicode blocks (--no-ascii to unset saved preference)",
|
|
93
96
|
)
|
|
94
97
|
parser.add_argument(
|
|
95
98
|
"--plain",
|
|
@@ -99,8 +102,9 @@ def parse_args() -> argparse.Namespace:
|
|
|
99
102
|
)
|
|
100
103
|
parser.add_argument(
|
|
101
104
|
"--compact",
|
|
102
|
-
action=
|
|
103
|
-
|
|
105
|
+
action=argparse.BooleanOptionalAction,
|
|
106
|
+
default=None,
|
|
107
|
+
help="Only show months up to the current month (--no-compact to unset saved preference)",
|
|
104
108
|
)
|
|
105
109
|
parser.add_argument(
|
|
106
110
|
"--branch",
|
|
@@ -110,8 +114,9 @@ def parse_args() -> argparse.Namespace:
|
|
|
110
114
|
parser.add_argument(
|
|
111
115
|
"--tree",
|
|
112
116
|
"-t",
|
|
113
|
-
action=
|
|
114
|
-
|
|
117
|
+
action=argparse.BooleanOptionalAction,
|
|
118
|
+
default=None,
|
|
119
|
+
help="Show branch tree under heatmap (--no-tree to unset saved preference)",
|
|
115
120
|
)
|
|
116
121
|
parser.add_argument(
|
|
117
122
|
"--scale",
|
|
@@ -201,9 +206,17 @@ def main() -> None:
|
|
|
201
206
|
config = load_config()
|
|
202
207
|
|
|
203
208
|
color_name = args.color or config.get("color", "green")
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
try:
|
|
210
|
+
config_years = max(1, int(config.get("years", "1")))
|
|
211
|
+
except ValueError:
|
|
212
|
+
config_years = 1
|
|
213
|
+
years = args.years if args.years is not None else config_years
|
|
214
|
+
border_char = (args.border[0] if args.border else None) or config.get(
|
|
215
|
+
"border", "="
|
|
216
|
+
)[0]
|
|
217
|
+
ascii_mode = (
|
|
218
|
+
args.ascii if args.ascii is not None else get_bool(config, "ascii")
|
|
219
|
+
)
|
|
207
220
|
branch = args.branch
|
|
208
221
|
week_start = args.week_start or config.get("week_start", "sunday")
|
|
209
222
|
author = args.author
|
|
@@ -235,8 +248,12 @@ def main() -> None:
|
|
|
235
248
|
should_save = (
|
|
236
249
|
args.color is not None
|
|
237
250
|
or args.week_start is not None
|
|
238
|
-
or args.ascii
|
|
251
|
+
or args.ascii is not None
|
|
239
252
|
or args.scale != "auto"
|
|
253
|
+
or args.border != "="
|
|
254
|
+
or args.extended is not None
|
|
255
|
+
or args.compact is not None
|
|
256
|
+
or args.tree is not None
|
|
240
257
|
)
|
|
241
258
|
if should_save:
|
|
242
259
|
new_config = config.copy()
|
|
@@ -244,10 +261,18 @@ def main() -> None:
|
|
|
244
261
|
new_config["color"] = args.color
|
|
245
262
|
if args.week_start is not None:
|
|
246
263
|
new_config["week_start"] = args.week_start
|
|
247
|
-
if args.ascii:
|
|
248
|
-
new_config["ascii"] = "true"
|
|
264
|
+
if args.ascii is not None:
|
|
265
|
+
new_config["ascii"] = "true" if args.ascii else "false"
|
|
249
266
|
if args.scale != "auto":
|
|
250
267
|
new_config["scale"] = args.scale
|
|
268
|
+
if args.border != "=":
|
|
269
|
+
new_config["border"] = args.border
|
|
270
|
+
if args.extended is not None:
|
|
271
|
+
new_config["extended"] = "true" if args.extended else "false"
|
|
272
|
+
if args.compact is not None:
|
|
273
|
+
new_config["compact"] = "true" if args.compact else "false"
|
|
274
|
+
if args.tree is not None:
|
|
275
|
+
new_config["tree"] = "true" if args.tree else "false"
|
|
251
276
|
save_config(new_config)
|
|
252
277
|
|
|
253
278
|
color_scheme = get_color_scheme(color_name)
|
|
@@ -257,9 +282,16 @@ def main() -> None:
|
|
|
257
282
|
|
|
258
283
|
today = dt.date.today()
|
|
259
284
|
output_parts = []
|
|
260
|
-
extended =
|
|
261
|
-
|
|
262
|
-
|
|
285
|
+
extended = (
|
|
286
|
+
args.extended
|
|
287
|
+
if args.extended is not None
|
|
288
|
+
else get_bool(config, "extended")
|
|
289
|
+
)
|
|
290
|
+
compact = (
|
|
291
|
+
args.compact
|
|
292
|
+
if args.compact is not None
|
|
293
|
+
else get_bool(config, "compact")
|
|
294
|
+
)
|
|
263
295
|
|
|
264
296
|
custom_range = args.after or args.before
|
|
265
297
|
|
|
@@ -341,7 +373,10 @@ def main() -> None:
|
|
|
341
373
|
output_parts.reverse()
|
|
342
374
|
print("\n" + "\n".join(output_parts))
|
|
343
375
|
|
|
344
|
-
|
|
376
|
+
show_tree = (
|
|
377
|
+
args.tree if args.tree is not None else get_bool(config, "tree")
|
|
378
|
+
)
|
|
379
|
+
if show_tree:
|
|
345
380
|
tree_commits = get_branch_tree(
|
|
346
381
|
repo_path,
|
|
347
382
|
branch=branch,
|
|
@@ -356,7 +391,7 @@ def main() -> None:
|
|
|
356
391
|
selected_branch=branch,
|
|
357
392
|
color_scheme=color_scheme,
|
|
358
393
|
ascii_mode=ascii_mode,
|
|
359
|
-
compact=
|
|
394
|
+
compact=compact,
|
|
360
395
|
)
|
|
361
396
|
if tree_output:
|
|
362
397
|
print(tree_output)
|
|
@@ -416,9 +451,17 @@ def main() -> None:
|
|
|
416
451
|
print(f" Longest streak: {streaks['longest_streak']} days")
|
|
417
452
|
|
|
418
453
|
if extended:
|
|
454
|
+
reset_code = RESET if not args.plain else ""
|
|
455
|
+
gray_code = LIGHT_GRAY if not args.plain else ""
|
|
456
|
+
|
|
419
457
|
print("\n--- Recent Commits ---")
|
|
458
|
+
try:
|
|
459
|
+
recent_count = max(1, int(config.get("recent_count", "5")))
|
|
460
|
+
except ValueError:
|
|
461
|
+
recent_count = 5
|
|
420
462
|
for commit in get_recent_commits(
|
|
421
463
|
repo_path,
|
|
464
|
+
count=recent_count,
|
|
422
465
|
branch=branch,
|
|
423
466
|
author=author,
|
|
424
467
|
default_branch=default_branch,
|
|
@@ -440,9 +483,9 @@ def main() -> None:
|
|
|
440
483
|
else commit["date"]
|
|
441
484
|
)
|
|
442
485
|
print(
|
|
443
|
-
f" {commit_color}{commit['hash']}{
|
|
444
|
-
f" | {author_color}{commit['author']}{
|
|
445
|
-
f" | {
|
|
486
|
+
f" {commit_color}{commit['hash']}{reset_code}"
|
|
487
|
+
f" | {author_color}{commit['author']}{reset_code}"
|
|
488
|
+
f" | {gray_code}{formatted_date} {commit_time}{reset_code}"
|
|
446
489
|
)
|
|
447
490
|
print(f" {commit['message']}")
|
|
448
491
|
|
|
@@ -456,7 +499,7 @@ def main() -> None:
|
|
|
456
499
|
merge_filter=merge_filter,
|
|
457
500
|
grep=grep,
|
|
458
501
|
):
|
|
459
|
-
print(f" {count:>4} {contrib_color}{contrib}{
|
|
502
|
+
print(f" {count:>4} {contrib_color}{contrib}{reset_code}")
|
|
460
503
|
|
|
461
504
|
if args.hourly:
|
|
462
505
|
hourly_counts = get_hourly_counts(
|
|
@@ -5,11 +5,31 @@ from pathlib import Path
|
|
|
5
5
|
__all__ = [
|
|
6
6
|
"ALLOWED_CONFIG_KEYS",
|
|
7
7
|
"config_file_path",
|
|
8
|
+
"get_bool",
|
|
8
9
|
"load_config",
|
|
9
10
|
"save_config",
|
|
10
11
|
]
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def get_bool(config: dict, key: str, default: bool = False) -> bool:
|
|
15
|
+
"""Return a config value as a Python bool.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
config: Config dict from load_config().
|
|
19
|
+
key: Key to look up.
|
|
20
|
+
default: Fallback if key is absent or unrecognised.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
True if value is "true" (case-insensitive), False otherwise.
|
|
24
|
+
"""
|
|
25
|
+
if key not in config:
|
|
26
|
+
return default
|
|
27
|
+
raw = config[key]
|
|
28
|
+
if isinstance(raw, bool):
|
|
29
|
+
return raw
|
|
30
|
+
return raw.strip().lower() == "true"
|
|
31
|
+
|
|
32
|
+
|
|
13
33
|
def config_file_path() -> Path:
|
|
14
34
|
"""Get the path to the config file.
|
|
15
35
|
|
|
@@ -31,7 +51,18 @@ def config_file_path() -> Path:
|
|
|
31
51
|
return base_path / "git-ember" / "config.toml"
|
|
32
52
|
|
|
33
53
|
|
|
34
|
-
ALLOWED_CONFIG_KEYS = {
|
|
54
|
+
ALLOWED_CONFIG_KEYS = {
|
|
55
|
+
"color",
|
|
56
|
+
"border",
|
|
57
|
+
"week_start",
|
|
58
|
+
"ascii",
|
|
59
|
+
"scale",
|
|
60
|
+
"recent_count",
|
|
61
|
+
"extended",
|
|
62
|
+
"compact",
|
|
63
|
+
"tree",
|
|
64
|
+
"years",
|
|
65
|
+
}
|
|
35
66
|
|
|
36
67
|
|
|
37
68
|
DEFAULT_CONFIG = {
|
|
@@ -40,6 +71,11 @@ DEFAULT_CONFIG = {
|
|
|
40
71
|
"week_start": "sunday",
|
|
41
72
|
"ascii": "false",
|
|
42
73
|
"scale": "auto",
|
|
74
|
+
"recent_count": "5",
|
|
75
|
+
"extended": "false",
|
|
76
|
+
"compact": "false",
|
|
77
|
+
"tree": "false",
|
|
78
|
+
"years": "1",
|
|
43
79
|
}
|
|
44
80
|
|
|
45
81
|
|
|
@@ -20,68 +20,22 @@ __all__ = [
|
|
|
20
20
|
]
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
def
|
|
24
|
-
"""Validate
|
|
23
|
+
def _validate_input(value: str, label: str) -> None:
|
|
24
|
+
"""Validate that a user-supplied string is safe to pass to git.
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if not path:
|
|
30
|
-
raise ValueError("Path cannot be empty")
|
|
31
|
-
|
|
32
|
-
if path.startswith("-"):
|
|
33
|
-
raise ValueError(f"Invalid path: {path}")
|
|
34
|
-
|
|
35
|
-
if re.search(r"[;&|`$()]", path):
|
|
36
|
-
raise ValueError(f"Invalid characters in path: {path}")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _validate_branch(branch: str) -> None:
|
|
40
|
-
"""Validate branch name doesn't contain git options or special characters.
|
|
41
|
-
|
|
42
|
-
Raises:
|
|
43
|
-
ValueError: If branch name is invalid.
|
|
44
|
-
"""
|
|
45
|
-
if not branch:
|
|
46
|
-
raise ValueError("Branch name cannot be empty")
|
|
47
|
-
|
|
48
|
-
if branch.startswith("-"):
|
|
49
|
-
raise ValueError(f"Invalid branch name: {branch}")
|
|
50
|
-
|
|
51
|
-
if re.search(r"[;&|`$()]", branch):
|
|
52
|
-
raise ValueError(f"Invalid characters in branch name: {branch}")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _validate_author(author: str) -> None:
|
|
56
|
-
"""Validate author string doesn't contain git options or special characters.
|
|
57
|
-
|
|
58
|
-
Raises:
|
|
59
|
-
ValueError: If author is invalid.
|
|
60
|
-
"""
|
|
61
|
-
if not author:
|
|
62
|
-
raise ValueError("Author cannot be empty")
|
|
63
|
-
|
|
64
|
-
if author.startswith("-"):
|
|
65
|
-
raise ValueError(f"Invalid author: {author}")
|
|
66
|
-
|
|
67
|
-
if re.search(r"[;&|`$()]", author):
|
|
68
|
-
raise ValueError(f"Invalid characters in author: {author}")
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _validate_grep(grep: str) -> None:
|
|
72
|
-
"""Validate grep string doesn't contain shell-special characters.
|
|
26
|
+
Args:
|
|
27
|
+
value: The string to validate.
|
|
28
|
+
label: Human-readable name used in error messages (e.g. "path", "branch").
|
|
73
29
|
|
|
74
30
|
Raises:
|
|
75
|
-
ValueError: If
|
|
31
|
+
ValueError: If value is empty, starts with "-", or contains shell metacharacters.
|
|
76
32
|
"""
|
|
77
|
-
if not
|
|
78
|
-
raise ValueError("
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if re.search(r"[;&|`$()]", grep):
|
|
84
|
-
raise ValueError(f"Invalid characters in grep pattern: {grep}")
|
|
33
|
+
if not value:
|
|
34
|
+
raise ValueError(f"{label} cannot be empty")
|
|
35
|
+
if value.startswith("-"):
|
|
36
|
+
raise ValueError(f"Invalid {label}: {value}")
|
|
37
|
+
if re.search(r"[;&|`$()]", value):
|
|
38
|
+
raise ValueError(f"Invalid characters in {label}: {value}")
|
|
85
39
|
|
|
86
40
|
|
|
87
41
|
def _build_log_args(
|
|
@@ -133,16 +87,16 @@ def run_git_log(
|
|
|
133
87
|
Raises:
|
|
134
88
|
ValueError: If path is not a git repository.
|
|
135
89
|
"""
|
|
136
|
-
|
|
90
|
+
_validate_input(repo_path, "path")
|
|
137
91
|
|
|
138
92
|
if branch:
|
|
139
|
-
|
|
93
|
+
_validate_input(branch, "branch name")
|
|
140
94
|
|
|
141
95
|
if author:
|
|
142
|
-
|
|
96
|
+
_validate_input(author, "author")
|
|
143
97
|
|
|
144
98
|
if grep:
|
|
145
|
-
|
|
99
|
+
_validate_input(grep, "grep pattern")
|
|
146
100
|
|
|
147
101
|
try:
|
|
148
102
|
subprocess.run(
|
|
@@ -216,7 +170,7 @@ def get_branches(repo_path: str) -> list[str]:
|
|
|
216
170
|
Returns:
|
|
217
171
|
List of branch names.
|
|
218
172
|
"""
|
|
219
|
-
|
|
173
|
+
_validate_input(repo_path, "path")
|
|
220
174
|
|
|
221
175
|
result = subprocess.run(
|
|
222
176
|
["git", "-C", repo_path, "branch", "--format=%(refname:short)"],
|
|
@@ -239,7 +193,7 @@ def get_default_branch(repo_path: str) -> str:
|
|
|
239
193
|
Returns:
|
|
240
194
|
Name of default branch, or "main" as fallback.
|
|
241
195
|
"""
|
|
242
|
-
|
|
196
|
+
_validate_input(repo_path, "path")
|
|
243
197
|
|
|
244
198
|
candidates = [
|
|
245
199
|
("main", "refs/heads/main"),
|
|
@@ -289,16 +243,16 @@ def get_recent_commits(
|
|
|
289
243
|
Returns:
|
|
290
244
|
List of dicts with keys: hash, author, date, timestamp, message.
|
|
291
245
|
"""
|
|
292
|
-
|
|
246
|
+
_validate_input(repo_path, "path")
|
|
293
247
|
|
|
294
248
|
if branch:
|
|
295
|
-
|
|
249
|
+
_validate_input(branch, "branch name")
|
|
296
250
|
|
|
297
251
|
if author:
|
|
298
|
-
|
|
252
|
+
_validate_input(author, "author")
|
|
299
253
|
|
|
300
254
|
if grep:
|
|
301
|
-
|
|
255
|
+
_validate_input(grep, "grep pattern")
|
|
302
256
|
|
|
303
257
|
if default_branch is None:
|
|
304
258
|
default_branch = get_default_branch(repo_path) if branch else None
|
|
@@ -373,16 +327,16 @@ def get_top_contributors(
|
|
|
373
327
|
Returns:
|
|
374
328
|
List of (author, commit_count) tuples, sorted by count descending.
|
|
375
329
|
"""
|
|
376
|
-
|
|
330
|
+
_validate_input(repo_path, "path")
|
|
377
331
|
|
|
378
332
|
if branch:
|
|
379
|
-
|
|
333
|
+
_validate_input(branch, "branch name")
|
|
380
334
|
|
|
381
335
|
if author:
|
|
382
|
-
|
|
336
|
+
_validate_input(author, "author")
|
|
383
337
|
|
|
384
338
|
if grep:
|
|
385
|
-
|
|
339
|
+
_validate_input(grep, "grep pattern")
|
|
386
340
|
|
|
387
341
|
if default_branch is None:
|
|
388
342
|
default_branch = get_default_branch(repo_path) if branch else None
|
|
@@ -449,16 +403,16 @@ def get_repo_stats(
|
|
|
449
403
|
Dict with keys: total_commits, num_authors, first_commit_date,
|
|
450
404
|
first_commit_timestamp, last_commit_date, last_commit_timestamp.
|
|
451
405
|
"""
|
|
452
|
-
|
|
406
|
+
_validate_input(repo_path, "path")
|
|
453
407
|
|
|
454
408
|
if branch:
|
|
455
|
-
|
|
409
|
+
_validate_input(branch, "branch name")
|
|
456
410
|
|
|
457
411
|
if author:
|
|
458
|
-
|
|
412
|
+
_validate_input(author, "author")
|
|
459
413
|
|
|
460
414
|
if grep:
|
|
461
|
-
|
|
415
|
+
_validate_input(grep, "grep pattern")
|
|
462
416
|
|
|
463
417
|
if default_branch is None:
|
|
464
418
|
default_branch = get_default_branch(repo_path) if branch else None
|
|
@@ -536,10 +490,6 @@ def get_streaks(counts: dict[dt.date, int]) -> dict[str, Any]:
|
|
|
536
490
|
|
|
537
491
|
Returns:
|
|
538
492
|
Dict with current_streak, longest_streak, longest_streak_end.
|
|
539
|
-
|
|
540
|
-
Note:
|
|
541
|
-
Current streak is limited to the last 30 days. If no commits in the
|
|
542
|
-
past 30 days, current_streak will be 0 regardless of historical activity.
|
|
543
493
|
"""
|
|
544
494
|
if not counts:
|
|
545
495
|
return {"current_streak": 0, "longest_streak": 0, "longest_streak_end": None}
|
|
@@ -567,10 +517,11 @@ def get_streaks(counts: dict[dt.date, int]) -> dict[str, Any]:
|
|
|
567
517
|
|
|
568
518
|
today = dt.date.today()
|
|
569
519
|
current = 0
|
|
570
|
-
|
|
571
|
-
|
|
520
|
+
check_date = today
|
|
521
|
+
while True:
|
|
572
522
|
if check_date in counts and counts[check_date] > 0:
|
|
573
523
|
current += 1
|
|
524
|
+
check_date -= dt.timedelta(days=1)
|
|
574
525
|
else:
|
|
575
526
|
break
|
|
576
527
|
|
|
@@ -637,32 +588,32 @@ def get_branch_tree(
|
|
|
637
588
|
Connector-only rows (/, \ lines) are included with an empty hash
|
|
638
589
|
so the renderer can display branch split/merge lines correctly.
|
|
639
590
|
"""
|
|
640
|
-
|
|
591
|
+
_validate_input(repo_path, "path")
|
|
641
592
|
|
|
642
593
|
if branch:
|
|
643
|
-
|
|
594
|
+
_validate_input(branch, "branch name")
|
|
644
595
|
|
|
645
596
|
if author:
|
|
646
|
-
|
|
597
|
+
_validate_input(author, "author")
|
|
647
598
|
|
|
648
599
|
if grep:
|
|
649
|
-
|
|
600
|
+
_validate_input(grep, "grep pattern")
|
|
650
601
|
|
|
651
602
|
if default_branch is None:
|
|
652
603
|
default_branch = get_default_branch(repo_path) if branch else None
|
|
653
604
|
log_args = _build_log_args(branch, repo_path, default_branch)
|
|
654
605
|
|
|
655
|
-
|
|
606
|
+
base_cmd = [
|
|
656
607
|
"git",
|
|
657
608
|
"-C",
|
|
658
609
|
repo_path,
|
|
659
610
|
"log",
|
|
660
611
|
"--format=COMMIT:%H|%h|%s|%d",
|
|
661
612
|
"--graph",
|
|
662
|
-
"--all",
|
|
663
|
-
*log_args,
|
|
664
|
-
f"-{limit}",
|
|
665
613
|
]
|
|
614
|
+
if not branch:
|
|
615
|
+
base_cmd.append("--all")
|
|
616
|
+
cmd = base_cmd + log_args + [f"-{limit}"]
|
|
666
617
|
|
|
667
618
|
if author:
|
|
668
619
|
cmd.append(f"--author={author}")
|
|
@@ -754,13 +705,13 @@ def get_hourly_counts(
|
|
|
754
705
|
weekday_int: 0=Monday ... 6=Sunday.
|
|
755
706
|
hour_int: 0-23.
|
|
756
707
|
"""
|
|
757
|
-
|
|
708
|
+
_validate_input(repo_path, "path")
|
|
758
709
|
if branch:
|
|
759
|
-
|
|
710
|
+
_validate_input(branch, "branch name")
|
|
760
711
|
if author:
|
|
761
|
-
|
|
712
|
+
_validate_input(author, "author")
|
|
762
713
|
if grep:
|
|
763
|
-
|
|
714
|
+
_validate_input(grep, "grep pattern")
|
|
764
715
|
|
|
765
716
|
if default_branch is None:
|
|
766
717
|
default_branch = get_default_branch(repo_path) if branch else None
|
|
@@ -780,12 +731,14 @@ def get_hourly_counts(
|
|
|
780
731
|
elif merge_filter == "merges-only":
|
|
781
732
|
cmd.append("--merges")
|
|
782
733
|
|
|
783
|
-
cmd.extend(
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
734
|
+
cmd.extend(
|
|
735
|
+
[
|
|
736
|
+
f"--since={since}",
|
|
737
|
+
f"--until={until}",
|
|
738
|
+
"--format=%ad",
|
|
739
|
+
"--date=format:%u %H",
|
|
740
|
+
]
|
|
741
|
+
)
|
|
789
742
|
|
|
790
743
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
791
744
|
if result.returncode != 0:
|
|
@@ -5,7 +5,9 @@ from typing import Any
|
|
|
5
5
|
from gitember.colors import LIGHT_GRAY, RESET, ColorScheme, PlainScheme
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
|
+
"ASCII_BLOCKS",
|
|
8
9
|
"DEFAULT_THRESHOLDS",
|
|
10
|
+
"UNICODE_BLOCKS",
|
|
9
11
|
"build_date_range",
|
|
10
12
|
"calculate_thresholds",
|
|
11
13
|
"choose_level",
|
|
@@ -17,6 +19,8 @@ __all__ = [
|
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
DEFAULT_THRESHOLDS = (0, 1, 3, 6, 10)
|
|
22
|
+
ASCII_BLOCKS = (" ", "..", "++", "%%", "##")
|
|
23
|
+
UNICODE_BLOCKS = (" ", "░░", "▒▒", "▓▓", "██")
|
|
20
24
|
|
|
21
25
|
|
|
22
26
|
def _reset(color: str) -> str:
|
|
@@ -37,10 +41,7 @@ def render_legend(
|
|
|
37
41
|
Returns:
|
|
38
42
|
Single string with legend, no trailing newline.
|
|
39
43
|
"""
|
|
40
|
-
if ascii_mode
|
|
41
|
-
block_chars = [" ", "..", "::", "==", "##"]
|
|
42
|
-
else:
|
|
43
|
-
block_chars = [" ", "░░", "▒▒", "▓▓", "██"]
|
|
44
|
+
block_chars = ASCII_BLOCKS if ascii_mode else UNICODE_BLOCKS
|
|
44
45
|
|
|
45
46
|
parts = []
|
|
46
47
|
|
|
@@ -203,10 +204,7 @@ def render_grid(
|
|
|
203
204
|
thresholds = DEFAULT_THRESHOLDS
|
|
204
205
|
all_days = build_date_range(start_date, end_date)
|
|
205
206
|
|
|
206
|
-
if ascii_mode
|
|
207
|
-
block_chars = [" ", "..", "::", "==", "##"]
|
|
208
|
-
else:
|
|
209
|
-
block_chars = [" ", "░░", "▒▒", "▓▓", "██"]
|
|
207
|
+
block_chars = ASCII_BLOCKS if ascii_mode else UNICODE_BLOCKS
|
|
210
208
|
|
|
211
209
|
if week_start == "monday":
|
|
212
210
|
offset = start_date.weekday()
|
|
@@ -380,10 +378,7 @@ def render_hourly_grid(
|
|
|
380
378
|
Returns:
|
|
381
379
|
Formatted string representation of hourly heatmap.
|
|
382
380
|
"""
|
|
383
|
-
if ascii_mode
|
|
384
|
-
block_chars = [" ", "..", "::", "==", "##"]
|
|
385
|
-
else:
|
|
386
|
-
block_chars = [" ", "░░", "▒▒", "▓▓", "██"]
|
|
381
|
+
block_chars = ASCII_BLOCKS if ascii_mode else UNICODE_BLOCKS
|
|
387
382
|
|
|
388
383
|
DAY_LABELS = {0: "Mon ", 2: "Wed ", 4: "Fri "}
|
|
389
384
|
|
|
@@ -91,3 +91,99 @@ def test_save_and_load_scale(tmp_path, monkeypatch):
|
|
|
91
91
|
result = config.load_config()
|
|
92
92
|
|
|
93
93
|
assert result["scale"] == "adaptive"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_load_config_has_all_new_keys(tmp_path, monkeypatch):
|
|
97
|
+
"""Default config contains all nine configurable keys."""
|
|
98
|
+
monkeypatch.setattr(
|
|
99
|
+
config, "config_file_path", lambda: tmp_path / "nonexistent.toml"
|
|
100
|
+
)
|
|
101
|
+
result = config.load_config()
|
|
102
|
+
|
|
103
|
+
expected_keys = {
|
|
104
|
+
"color",
|
|
105
|
+
"border",
|
|
106
|
+
"week_start",
|
|
107
|
+
"ascii",
|
|
108
|
+
"scale",
|
|
109
|
+
"recent_count",
|
|
110
|
+
"extended",
|
|
111
|
+
"compact",
|
|
112
|
+
"tree",
|
|
113
|
+
"years",
|
|
114
|
+
}
|
|
115
|
+
assert result.keys() == expected_keys
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_save_and_load_recent_count(tmp_path, monkeypatch):
|
|
119
|
+
"""Saving and loading recent_count config works."""
|
|
120
|
+
test_file = tmp_path / "config.toml"
|
|
121
|
+
monkeypatch.setattr(config, "config_file_path", lambda: test_file)
|
|
122
|
+
|
|
123
|
+
config.save_config({"color": "green", "recent_count": "10"})
|
|
124
|
+
result = config.load_config()
|
|
125
|
+
|
|
126
|
+
assert result["recent_count"] == "10"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_save_and_load_extended(tmp_path, monkeypatch):
|
|
130
|
+
"""Saving and loading extended config works."""
|
|
131
|
+
test_file = tmp_path / "config.toml"
|
|
132
|
+
monkeypatch.setattr(config, "config_file_path", lambda: test_file)
|
|
133
|
+
|
|
134
|
+
config.save_config({"color": "green", "extended": "true"})
|
|
135
|
+
result = config.load_config()
|
|
136
|
+
|
|
137
|
+
assert result["extended"] == "true"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_save_and_load_compact(tmp_path, monkeypatch):
|
|
141
|
+
"""Saving and loading compact config works."""
|
|
142
|
+
test_file = tmp_path / "config.toml"
|
|
143
|
+
monkeypatch.setattr(config, "config_file_path", lambda: test_file)
|
|
144
|
+
|
|
145
|
+
config.save_config({"color": "green", "compact": "true"})
|
|
146
|
+
result = config.load_config()
|
|
147
|
+
|
|
148
|
+
assert result["compact"] == "true"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_save_and_load_tree(tmp_path, monkeypatch):
|
|
152
|
+
"""Saving and loading tree config works."""
|
|
153
|
+
test_file = tmp_path / "config.toml"
|
|
154
|
+
monkeypatch.setattr(config, "config_file_path", lambda: test_file)
|
|
155
|
+
|
|
156
|
+
config.save_config({"color": "green", "tree": "true"})
|
|
157
|
+
result = config.load_config()
|
|
158
|
+
|
|
159
|
+
assert result["tree"] == "true"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_save_and_load_years(tmp_path, monkeypatch):
|
|
163
|
+
"""Saving and loading years config works."""
|
|
164
|
+
test_file = tmp_path / "config.toml"
|
|
165
|
+
monkeypatch.setattr(config, "config_file_path", lambda: test_file)
|
|
166
|
+
|
|
167
|
+
config.save_config({"color": "green", "years": "3"})
|
|
168
|
+
result = config.load_config()
|
|
169
|
+
|
|
170
|
+
assert result["years"] == "3"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_default_config_values(tmp_path, monkeypatch):
|
|
174
|
+
"""Default config has correct values for all keys."""
|
|
175
|
+
monkeypatch.setattr(
|
|
176
|
+
config, "config_file_path", lambda: tmp_path / "nonexistent.toml"
|
|
177
|
+
)
|
|
178
|
+
result = config.load_config()
|
|
179
|
+
|
|
180
|
+
assert result["color"] == "green"
|
|
181
|
+
assert result["border"] == "="
|
|
182
|
+
assert result["week_start"] == "sunday"
|
|
183
|
+
assert result["ascii"] == "false"
|
|
184
|
+
assert result["scale"] == "auto"
|
|
185
|
+
assert result["recent_count"] == "5"
|
|
186
|
+
assert result["extended"] == "false"
|
|
187
|
+
assert result["compact"] == "false"
|
|
188
|
+
assert result["tree"] == "false"
|
|
189
|
+
assert result["years"] == "1"
|
|
@@ -105,8 +105,8 @@ def test_render_legend_contains_all_levels():
|
|
|
105
105
|
scheme = ColorScheme("test", [""] * 5)
|
|
106
106
|
legend = render.render_legend(thresholds, scheme, ascii_mode=True)
|
|
107
107
|
assert ".." in legend
|
|
108
|
-
assert "
|
|
109
|
-
assert "
|
|
108
|
+
assert "++" in legend
|
|
109
|
+
assert "%%" in legend
|
|
110
110
|
assert "##" in legend
|
|
111
111
|
|
|
112
112
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|