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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-ember
3
- Version: 1.4.0
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
+ [![PyPI](https://img.shields.io/pypi/v/git-ember)](https://pypi.org/project/git-ember/)
26
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 | Required | Default | Description |
76
- |------------|----------|-----------|-------------------------------------|
77
- | `color` | No | `green` | Color scheme name |
78
- | `border` | No | `=` | Border character |
79
- | `week_start`| No | `sunday` | Week start day (sunday/monday) |
80
- | `ascii` | No | `false` | Use ASCII characters |
81
- | `scale` | No | `auto` | Intensity scaling (auto/adaptive) |
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
+ [![PyPI](https://img.shields.io/pypi/v/git-ember)](https://pypi.org/project/git-ember/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 | Required | Default | Description |
54
- |------------|----------|-----------|-------------------------------------|
55
- | `color` | No | `green` | Color scheme name |
56
- | `border` | No | `=` | Border character |
57
- | `week_start`| No | `sunday` | Week start day (sunday/monday) |
58
- | `ascii` | No | `false` | Use ASCII characters |
59
- | `scale` | No | `auto` | Intensity scaling (auto/adaptive) |
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
  [project]
2
2
  name = "git-ember"
3
- version = "1.4.0"
3
+ version = "1.4.1"
4
4
  description = "A GitHub-style heatmap of commits and local repo statistics for your terminal"
5
5
  readme = "README.md"
6
6
  authors = [{name = "Luka van Rooyen", email = "lukavrooyen@proton.me"}]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-ember
3
- Version: 1.4.0
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
+ [![PyPI](https://img.shields.io/pypi/v/git-ember)](https://pypi.org/project/git-ember/)
26
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 | Required | Default | Description |
76
- |------------|----------|-----------|-------------------------------------|
77
- | `color` | No | `green` | Color scheme name |
78
- | `border` | No | `=` | Border character |
79
- | `week_start`| No | `sunday` | Week start day (sunday/monday) |
80
- | `ascii` | No | `false` | Use ASCII characters |
81
- | `scale` | No | `auto` | Intensity scaling (auto/adaptive) |
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,4 +1,3 @@
1
- # in __init__.py
2
1
  from importlib.metadata import PackageNotFoundError, version
3
2
 
4
3
  try:
@@ -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=1,
73
- help="Number of years to show (default: 1, max recommended: 10)",
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="store_true",
86
- help="Show extended report with recent commits and top contributors",
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="store_true",
92
- help="Use ASCII characters instead of Unicode blocks",
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="store_true",
103
- help="Only show months up to the current month",
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="store_true",
114
- help="Show branch tree under heatmap",
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
- years = args.years
205
- border_char = args.border[0] if args.border else "=" # prevent escape codes
206
- ascii_mode = args.ascii or config.get("ascii", "false") == "true"
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 = args.extended
261
- compact = args.compact
262
- scale_mode = args.scale
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
- if args.tree:
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=args.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']}{RESET}"
444
- f" | {author_color}{commit['author']}{RESET}"
445
- f" | {LIGHT_GRAY}{formatted_date} {commit_time}{RESET}"
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}{RESET}")
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 = {"color", "border", "week_start", "ascii", "scale"}
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 _validate_path(path: str) -> None:
24
- """Validate path doesn't contain git options or special characters.
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
- Raises:
27
- ValueError: If path is invalid.
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 grep string is invalid.
31
+ ValueError: If value is empty, starts with "-", or contains shell metacharacters.
76
32
  """
77
- if not grep:
78
- raise ValueError("Grep pattern cannot be empty")
79
-
80
- if grep.startswith("-"):
81
- raise ValueError(f"Invalid grep pattern: {grep}")
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
- _validate_path(repo_path)
90
+ _validate_input(repo_path, "path")
137
91
 
138
92
  if branch:
139
- _validate_branch(branch)
93
+ _validate_input(branch, "branch name")
140
94
 
141
95
  if author:
142
- _validate_author(author)
96
+ _validate_input(author, "author")
143
97
 
144
98
  if grep:
145
- _validate_grep(grep)
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
- _validate_path(repo_path)
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
- _validate_path(repo_path)
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
- _validate_path(repo_path)
246
+ _validate_input(repo_path, "path")
293
247
 
294
248
  if branch:
295
- _validate_branch(branch)
249
+ _validate_input(branch, "branch name")
296
250
 
297
251
  if author:
298
- _validate_author(author)
252
+ _validate_input(author, "author")
299
253
 
300
254
  if grep:
301
- _validate_grep(grep)
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
- _validate_path(repo_path)
330
+ _validate_input(repo_path, "path")
377
331
 
378
332
  if branch:
379
- _validate_branch(branch)
333
+ _validate_input(branch, "branch name")
380
334
 
381
335
  if author:
382
- _validate_author(author)
336
+ _validate_input(author, "author")
383
337
 
384
338
  if grep:
385
- _validate_grep(grep)
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
- _validate_path(repo_path)
406
+ _validate_input(repo_path, "path")
453
407
 
454
408
  if branch:
455
- _validate_branch(branch)
409
+ _validate_input(branch, "branch name")
456
410
 
457
411
  if author:
458
- _validate_author(author)
412
+ _validate_input(author, "author")
459
413
 
460
414
  if grep:
461
- _validate_grep(grep)
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
- for i in range(30):
571
- check_date = today - dt.timedelta(days=i)
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
- _validate_path(repo_path)
591
+ _validate_input(repo_path, "path")
641
592
 
642
593
  if branch:
643
- _validate_branch(branch)
594
+ _validate_input(branch, "branch name")
644
595
 
645
596
  if author:
646
- _validate_author(author)
597
+ _validate_input(author, "author")
647
598
 
648
599
  if grep:
649
- _validate_grep(grep)
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
- cmd = [
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
- _validate_path(repo_path)
708
+ _validate_input(repo_path, "path")
758
709
  if branch:
759
- _validate_branch(branch)
710
+ _validate_input(branch, "branch name")
760
711
  if author:
761
- _validate_author(author)
712
+ _validate_input(author, "author")
762
713
  if grep:
763
- _validate_grep(grep)
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
- f"--since={since}",
785
- f"--until={until}",
786
- "--format=%ad",
787
- "--date=format:%u %H",
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 "::" in legend
109
- assert "==" in legend
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