git-ember 1.2.1__tar.gz → 1.3.0__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.2.1
3
+ Version: 1.3.0
4
4
  Summary: A GitHub-style heatmap of commits for your terminal
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-ember"
3
- version = "1.2.1"
3
+ version = "1.3.0"
4
4
  description = "A GitHub-style heatmap of commits for your terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-ember
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: A GitHub-style heatmap of commits for your terminal
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -9,5 +9,9 @@ src/git_ember.egg-info/top_level.txt
9
9
  src/gitember/__init__.py
10
10
  src/gitember/cli.py
11
11
  src/gitember/colors.py
12
+ src/gitember/config.py
12
13
  src/gitember/git.py
13
- src/gitember/render.py
14
+ src/gitember/render.py
15
+ tests/test_config.py
16
+ tests/test_git.py
17
+ tests/test_render.py
@@ -1,13 +1,13 @@
1
1
  import argparse
2
2
  import calendar
3
3
  import datetime as dt
4
- import os
5
4
  import sys
6
5
  import time
7
6
  from pathlib import Path
8
7
 
9
8
  from gitember import __version__
10
9
  from gitember.colors import get_color_scheme, RESET, LIGHT_GRAY
10
+ from gitember.config import config_file_path, ALLOWED_CONFIG_KEYS, load_config, save_config
11
11
  from gitember.git import (
12
12
  run_git_log,
13
13
  get_recent_commits,
@@ -18,87 +18,10 @@ from gitember.git import (
18
18
  get_default_branch,
19
19
  get_streaks,
20
20
  )
21
- from gitember.render import render_grid, render_branch_tree, calculate_thresholds
21
+ from gitember.render import render_grid, render_branch_tree, calculate_thresholds, render_legend
22
22
 
23
23
 
24
- def config_file_path() -> Path:
25
- """Get the path to the config file.
26
-
27
- Returns:
28
- Path to config.toml in platform-specific config directory.
29
- """
30
- if sys.platform.startswith("win"):
31
- base = os.getenv("APPDATA")
32
- if base:
33
- base_path = Path(base)
34
- else:
35
- base_path = Path.home() / "AppData" / "Roaming"
36
- else:
37
- xdg = os.getenv("XDG_CONFIG_HOME")
38
- if xdg:
39
- base_path = Path(xdg)
40
- else:
41
- base_path = Path.home() / ".config"
42
- return base_path / "git-ember" / "config.toml"
43
-
44
-
45
- ALLOWED_CONFIG_KEYS = {"color", "border"}
46
- # NOTE: branch is intentionally excluded from config to avoid user confusion
47
-
48
-
49
- def load_config() -> dict:
50
- """Load config from file, returning defaults if not exists.
51
-
52
- Returns:
53
- Dict with keys: color, border.
54
- """
55
- path = config_file_path()
56
- if not path.exists():
57
- return {
58
- "color": "green",
59
- "border": "=",
60
- }
61
-
62
- config = {"color": "green", "border": "="}
63
- try:
64
- text = path.read_text(encoding="utf-8")
65
- except OSError:
66
- return config
67
-
68
- # Simple TOML-like parsing: key=value, ignore comments
69
- for line in text.splitlines():
70
- stripped = line.strip()
71
- # Skip empty lines and comments
72
- if not stripped or stripped.startswith("#"):
73
- continue
74
- if "=" in stripped:
75
- key, _, value = stripped.partition("=")
76
- key = key.strip()
77
- # Strip quotes from value - handles both "value" and 'value'
78
- value = value.strip().strip('"').strip("'")
79
- # Only allow predefined keys (whitelist for security)
80
- if key in ALLOWED_CONFIG_KEYS:
81
- config[key] = value
82
- return config
83
-
84
-
85
- def save_config(config: dict) -> None:
86
- """Save config to file.
87
-
88
- Args:
89
- config: Dict with keys: week_start, style, color, border.
90
- """
91
- path = config_file_path()
92
- try:
93
- path.parent.mkdir(parents=True, exist_ok=True)
94
- lines = []
95
- for k, v in config.items():
96
- if k in ALLOWED_CONFIG_KEYS:
97
- escaped = v.replace('"', '\\"')
98
- lines.append(f'{k} = "{escaped}"')
99
- path.write_text("\n".join(lines), encoding="utf-8")
100
- except OSError:
101
- pass
24
+ __all__ = ["main"]
102
25
 
103
26
 
104
27
  def parse_args() -> argparse.Namespace:
@@ -198,6 +121,19 @@ def parse_args() -> argparse.Namespace:
198
121
  type=str,
199
122
  help="Show commits before this date (YYYY-MM-DD)",
200
123
  )
124
+ parser.add_argument(
125
+ "--week-start",
126
+ type=str,
127
+ choices=["sunday", "monday"],
128
+ default="sunday",
129
+ help="First day of week: sunday or monday (default: sunday)",
130
+ )
131
+ parser.add_argument(
132
+ "--author",
133
+ "-A",
134
+ type=str,
135
+ help="Filter commits by author name or email (partial match)",
136
+ )
201
137
  parser.add_argument(
202
138
  "--version",
203
139
  "-V",
@@ -244,6 +180,8 @@ def main() -> None:
244
180
  border_char = args.border[0] if args.border else "=" # prevent escape codes
245
181
  ascii_mode = args.ascii
246
182
  branch = args.branch
183
+ week_start = args.week_start or config.get("week_start", "sunday")
184
+ author = args.author
247
185
 
248
186
  if branch:
249
187
  valid_branches = get_branches(repo_path)
@@ -255,6 +193,8 @@ def main() -> None:
255
193
  new_config = config.copy()
256
194
  if args.color:
257
195
  new_config["color"] = args.color
196
+ if args.week_start is not None:
197
+ new_config["week_start"] = args.week_start
258
198
  save_config(new_config)
259
199
 
260
200
  color_scheme = get_color_scheme(color_name)
@@ -267,6 +207,9 @@ def main() -> None:
267
207
 
268
208
  custom_range = args.after or args.before
269
209
 
210
+ start_date = dt.date.today() - dt.timedelta(days=365)
211
+ end_date = dt.date.today()
212
+
270
213
  if args.after:
271
214
  try:
272
215
  start_date = dt.date.fromisoformat(args.after)
@@ -303,7 +246,9 @@ def main() -> None:
303
246
  end_date = dt.date(year, 12, 31)
304
247
 
305
248
  try:
306
- counts = run_git_log(start_date, end_date, repo_path, branch=branch)
249
+ counts = run_git_log(
250
+ start_date, end_date, repo_path, branch=branch, author=author
251
+ )
307
252
  except ValueError as e:
308
253
  print(f"Error: {e}")
309
254
  sys.exit(1)
@@ -315,24 +260,26 @@ def main() -> None:
315
260
  end_date,
316
261
  counts,
317
262
  color_scheme,
318
- "sunday",
263
+ week_start,
319
264
  border_char,
320
265
  ascii_mode,
321
266
  thresholds=thresholds,
322
267
  )
323
268
 
269
+ legend = render_legend(thresholds, color_scheme, ascii_mode)
270
+
324
271
  if custom_range:
325
- output_parts.append(heatmap)
272
+ output_parts.append(f"{heatmap}\n {legend}")
326
273
  elif num_iterations > 1:
327
- output_parts.append(f"[{year}]\n{heatmap}")
274
+ output_parts.append(f"[{year}]\n{heatmap}\n {legend}")
328
275
  else:
329
- output_parts.append(heatmap)
276
+ output_parts.append(f"{heatmap}\n {legend}")
330
277
 
331
278
  output_parts.reverse()
332
279
  print("\n" + "\n".join(output_parts))
333
280
 
334
281
  if args.tree:
335
- tree_commits = get_branch_tree(repo_path, branch=branch)
282
+ tree_commits = get_branch_tree(repo_path, branch=branch, author=author)
336
283
  default_branch = get_default_branch(repo_path)
337
284
  tree_output = render_branch_tree(
338
285
  tree_commits,
@@ -348,7 +295,7 @@ def main() -> None:
348
295
  show_stats = args.stats or extended
349
296
 
350
297
  if show_stats:
351
- stats = get_repo_stats(repo_path, branch=branch)
298
+ stats = get_repo_stats(repo_path, branch=branch, author=author)
352
299
 
353
300
  print("\n--- Statistics ---")
354
301
  print(f" Total commits: {stats.get('total_commits', 0)}")
@@ -365,7 +312,7 @@ def main() -> None:
365
312
  streak_start_date = dt.date.today() - dt.timedelta(days=years * 365)
366
313
  streak_end_date = dt.date.today()
367
314
  streak_counts = run_git_log(
368
- streak_start_date, streak_end_date, repo_path, branch=branch
315
+ streak_start_date, streak_end_date, repo_path, branch=branch, author=author
369
316
  )
370
317
  streaks = get_streaks(streak_counts)
371
318
 
@@ -384,7 +331,7 @@ def main() -> None:
384
331
 
385
332
  if extended:
386
333
  print("\n--- Recent Commits ---")
387
- for commit in get_recent_commits(repo_path, branch=branch):
334
+ for commit in get_recent_commits(repo_path, branch=branch, author=author):
388
335
  commit_color = color_scheme.get(3)
389
336
  author_color = color_scheme.get(2)
390
337
 
@@ -404,10 +351,10 @@ def main() -> None:
404
351
  )
405
352
  print(f" {commit['message']}")
406
353
 
407
- print("\n--- Top Contributors ---")
408
- author_color = color_scheme.get(2)
409
- for author, count in get_top_contributors(repo_path, branch=branch):
410
- print(f" {count:>4} {author_color}{author}{RESET}")
354
+ print("\n--- Top Contributors ---")
355
+ contrib_color = color_scheme.get(2)
356
+ for contrib, count in get_top_contributors(repo_path, branch=branch, author=author):
357
+ print(f" {count:>4} {contrib_color}{contrib}{RESET}")
411
358
 
412
359
 
413
360
  if __name__ == "__main__":
@@ -2,6 +2,15 @@ RESET = "\x1b[0m"
2
2
  LIGHT_GRAY = "\x1b[38;5;250m"
3
3
 
4
4
 
5
+ __all__ = [
6
+ "COLOR_SCHEMES",
7
+ "ColorScheme",
8
+ "get_color_scheme",
9
+ "RESET",
10
+ "LIGHT_GRAY",
11
+ ]
12
+
13
+
5
14
  class ColorScheme:
6
15
  """Color scheme with 5 intensity levels (0-4)."""
7
16
 
@@ -0,0 +1,91 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+
6
+ __all__ = [
7
+ "config_file_path",
8
+ "ALLOWED_CONFIG_KEYS",
9
+ "load_config",
10
+ "save_config",
11
+ ]
12
+
13
+
14
+ def config_file_path() -> Path:
15
+ """Get the path to the config file.
16
+
17
+ Returns:
18
+ Path to config.toml in platform-specific config directory.
19
+ """
20
+ if sys.platform.startswith("win"):
21
+ base = os.getenv("APPDATA")
22
+ if base:
23
+ base_path = Path(base)
24
+ else:
25
+ base_path = Path.home() / "AppData" / "Roaming"
26
+ else:
27
+ xdg = os.getenv("XDG_CONFIG_HOME")
28
+ if xdg:
29
+ base_path = Path(xdg)
30
+ else:
31
+ base_path = Path.home() / ".config"
32
+ return base_path / "git-ember" / "config.toml"
33
+
34
+
35
+ ALLOWED_CONFIG_KEYS = {"color", "border", "week_start"}
36
+ # NOTE: branch is intentionally excluded from config to avoid user confusion
37
+
38
+
39
+ def load_config() -> dict:
40
+ """Load config from file, returning defaults if not exists.
41
+
42
+ Returns:
43
+ Dict with keys: color, border.
44
+ """
45
+ path = config_file_path()
46
+ if not path.exists():
47
+ return {
48
+ "color": "green",
49
+ "border": "=",
50
+ }
51
+
52
+ config = {"color": "green", "border": "="}
53
+ try:
54
+ text = path.read_text(encoding="utf-8")
55
+ except OSError:
56
+ return config
57
+
58
+ # Simple TOML-like parsing: key=value, ignore comments
59
+ for line in text.splitlines():
60
+ stripped = line.strip()
61
+ # Skip empty lines and comments
62
+ if not stripped or stripped.startswith("#"):
63
+ continue
64
+ if "=" in stripped:
65
+ key, _, value = stripped.partition("=")
66
+ key = key.strip()
67
+ # Strip quotes from value - handles both "value" and 'value'
68
+ value = value.strip().strip('"').strip("'")
69
+ # Only allow predefined keys (whitelist for security)
70
+ if key in ALLOWED_CONFIG_KEYS:
71
+ config[key] = value
72
+ return config
73
+
74
+
75
+ def save_config(config: dict) -> None:
76
+ """Save config to file.
77
+
78
+ Args:
79
+ config: Dict with keys: week_start, style, color, border.
80
+ """
81
+ path = config_file_path()
82
+ try:
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ lines = []
85
+ for k, v in config.items():
86
+ if k in ALLOWED_CONFIG_KEYS:
87
+ escaped = v.replace('"', '\\"')
88
+ lines.append(f'{k} = "{escaped}"')
89
+ path.write_text("\n".join(lines), encoding="utf-8")
90
+ except OSError:
91
+ pass
@@ -7,6 +7,18 @@ import re
7
7
  COMMIT_PREFIX = "COMMIT:"
8
8
 
9
9
 
10
+ __all__ = [
11
+ "run_git_log",
12
+ "get_branches",
13
+ "get_default_branch",
14
+ "get_recent_commits",
15
+ "get_top_contributors",
16
+ "get_repo_stats",
17
+ "get_streaks",
18
+ "get_branch_tree",
19
+ ]
20
+
21
+
10
22
  def _validate_path(path: str) -> None:
11
23
  """Validate path doesn't contain git options or special characters.
12
24
 
@@ -39,12 +51,31 @@ def _validate_branch(branch: str) -> None:
39
51
  raise ValueError(f"Invalid characters in branch name: {branch}")
40
52
 
41
53
 
42
- def _build_log_args(branch: str | None, repo_path: str) -> List[str]:
54
+ def _validate_author(author: str) -> None:
55
+ """Validate author string doesn't contain git options or special characters.
56
+
57
+ Raises:
58
+ ValueError: If author is invalid.
59
+ """
60
+ if not author:
61
+ raise ValueError("Author cannot be empty")
62
+
63
+ if author.startswith("-"):
64
+ raise ValueError(f"Invalid author: {author}")
65
+
66
+ if re.search(r"[;&|`$()]", author):
67
+ raise ValueError(f"Invalid characters in author: {author}")
68
+
69
+
70
+ def _build_log_args(
71
+ branch: str | None, repo_path: str, default_branch: str | None = None
72
+ ) -> List[str]:
43
73
  """Build git log arguments for branch filtering.
44
74
 
45
75
  Args:
46
76
  branch: Optional branch name to filter by.
47
77
  repo_path: Path to git repository.
78
+ default_branch: Optional default branch name for comparison.
48
79
 
49
80
  Returns:
50
81
  List of git log arguments (may be empty).
@@ -52,8 +83,7 @@ def _build_log_args(branch: str | None, repo_path: str) -> List[str]:
52
83
  if not branch:
53
84
  return ["--all"]
54
85
 
55
- default_branch = get_default_branch(repo_path)
56
- if branch != default_branch:
86
+ if default_branch and branch != default_branch:
57
87
  return [branch, "--not", default_branch]
58
88
  return [branch]
59
89
 
@@ -63,6 +93,7 @@ def run_git_log(
63
93
  end_date: dt.date,
64
94
  repo_path: str,
65
95
  branch: str | None = None,
96
+ author: str | None = None,
66
97
  ) -> Dict[dt.date, int]:
67
98
  """Get commit counts per day within a date range.
68
99
 
@@ -71,6 +102,7 @@ def run_git_log(
71
102
  end_date: End of date range (inclusive).
72
103
  repo_path: Path to git repository.
73
104
  branch: Optional branch name to filter by. Defaults to all branches.
105
+ author: Optional author to filter by. Defaults to all authors.
74
106
 
75
107
  Returns:
76
108
  Dict mapping date to commit count.
@@ -83,6 +115,9 @@ def run_git_log(
83
115
  if branch:
84
116
  _validate_branch(branch)
85
117
 
118
+ if author:
119
+ _validate_author(author)
120
+
86
121
  try:
87
122
  subprocess.run(
88
123
  ["git", "-C", repo_path, "rev-parse", "--is-inside-work-tree"],
@@ -103,7 +138,11 @@ def run_git_log(
103
138
  "log",
104
139
  ]
105
140
 
106
- cmd.extend(_build_log_args(branch, repo_path))
141
+ default_branch = get_default_branch(repo_path) if branch else None
142
+ cmd.extend(_build_log_args(branch, repo_path, default_branch))
143
+
144
+ if author:
145
+ cmd.append(f"--author={author}")
107
146
 
108
147
  cmd.extend(
109
148
  [
@@ -178,7 +217,7 @@ def get_default_branch(repo_path: str) -> str:
178
217
 
179
218
  # Fallback: get current HEAD branch if it's not main/master
180
219
  result = subprocess.run(
181
- ["git", "-C", repo_path, "symbolic-ref", "refs/heads/HEAD"],
220
+ ["git", "-C", repo_path, "symbolic-ref", "HEAD"],
182
221
  capture_output=True,
183
222
  text=True,
184
223
  )
@@ -192,7 +231,7 @@ def get_default_branch(repo_path: str) -> str:
192
231
 
193
232
 
194
233
  def get_recent_commits(
195
- repo_path: str, count: int = 5, branch: str | None = None
234
+ repo_path: str, count: int = 5, branch: str | None = None, author: str | None = None
196
235
  ) -> List[Dict[str, str]]:
197
236
  """Get recent commits with metadata.
198
237
 
@@ -200,6 +239,7 @@ def get_recent_commits(
200
239
  repo_path: Path to git repository.
201
240
  count: Number of commits to retrieve.
202
241
  branch: Optional branch name to filter by.
242
+ author: Optional author to filter by.
203
243
 
204
244
  Returns:
205
245
  List of dicts with keys: hash, author, date, timestamp, message.
@@ -209,6 +249,10 @@ def get_recent_commits(
209
249
  if branch:
210
250
  _validate_branch(branch)
211
251
 
252
+ if author:
253
+ _validate_author(author)
254
+
255
+ default_branch = get_default_branch(repo_path) if branch else None
212
256
  cmd = [
213
257
  "git",
214
258
  "-C",
@@ -218,7 +262,10 @@ def get_recent_commits(
218
262
  "--pretty=format:%h|%an|%ad %at|%s",
219
263
  "--date=short",
220
264
  ]
221
- cmd.extend(_build_log_args(branch, repo_path))
265
+ cmd.extend(_build_log_args(branch, repo_path, default_branch))
266
+
267
+ if author:
268
+ cmd.append(f"--author={author}")
222
269
 
223
270
  result = subprocess.run(cmd, capture_output=True, text=True)
224
271
 
@@ -247,7 +294,10 @@ def get_recent_commits(
247
294
 
248
295
 
249
296
  def get_top_contributors(
250
- repo_path: str, limit: int = 10, branch: str | None = None
297
+ repo_path: str,
298
+ limit: int = 10,
299
+ branch: str | None = None,
300
+ author: str | None = None,
251
301
  ) -> List[Tuple[str, int]]:
252
302
  """Get top contributors by commit count.
253
303
 
@@ -255,6 +305,7 @@ def get_top_contributors(
255
305
  repo_path: Path to git repository.
256
306
  limit: Maximum number of contributors to return.
257
307
  branch: Optional branch name to filter by.
308
+ author: Optional author to filter by.
258
309
 
259
310
  Returns:
260
311
  List of (author, commit_count) tuples, sorted by count descending.
@@ -264,6 +315,10 @@ def get_top_contributors(
264
315
  if branch:
265
316
  _validate_branch(branch)
266
317
 
318
+ if author:
319
+ _validate_author(author)
320
+
321
+ default_branch = get_default_branch(repo_path) if branch else None
267
322
  cmd = [
268
323
  "git",
269
324
  "-C",
@@ -272,7 +327,10 @@ def get_top_contributors(
272
327
  "-s",
273
328
  "-n",
274
329
  ]
275
- cmd.extend(_build_log_args(branch, repo_path))
330
+ cmd.extend(_build_log_args(branch, repo_path, default_branch))
331
+
332
+ if author:
333
+ cmd.append(f"--author={author}")
276
334
 
277
335
  result = subprocess.run(cmd, capture_output=True, text=True)
278
336
 
@@ -294,12 +352,15 @@ def get_top_contributors(
294
352
  return contributors[:limit]
295
353
 
296
354
 
297
- def get_repo_stats(repo_path: str, branch: str | None = None) -> Dict[str, Any]:
355
+ def get_repo_stats(
356
+ repo_path: str, branch: str | None = None, author: str | None = None
357
+ ) -> Dict[str, Any]:
298
358
  """Get repository statistics.
299
359
 
300
360
  Args:
301
361
  repo_path: Path to git repository.
302
362
  branch: Optional branch name to filter by.
363
+ author: Optional author to filter by.
303
364
 
304
365
  Returns:
305
366
  Dict with keys: total_commits, num_authors, first_commit_date,
@@ -310,21 +371,26 @@ def get_repo_stats(repo_path: str, branch: str | None = None) -> Dict[str, Any]:
310
371
  if branch:
311
372
  _validate_branch(branch)
312
373
 
313
- log_args = _build_log_args(branch, repo_path)
374
+ if author:
375
+ _validate_author(author)
314
376
 
315
- result = subprocess.run(
316
- [
317
- "git",
318
- "-C",
319
- repo_path,
320
- "log",
321
- "--format=%an|%at|%ad",
322
- "--date=format:%d-%m-%Y %H:%M",
323
- ]
324
- + log_args,
325
- capture_output=True,
326
- text=True,
327
- )
377
+ default_branch = get_default_branch(repo_path) if branch else None
378
+ log_args = _build_log_args(branch, repo_path, default_branch)
379
+
380
+ cmd = [
381
+ "git",
382
+ "-C",
383
+ repo_path,
384
+ "log",
385
+ "--format=%an|%at|%ad",
386
+ "--date=format:%d-%m-%Y %H:%M",
387
+ ]
388
+ cmd.extend(log_args)
389
+
390
+ if author:
391
+ cmd.append(f"--author={author}")
392
+
393
+ result = subprocess.run(cmd, capture_output=True, text=True)
328
394
 
329
395
  if result.returncode != 0:
330
396
  err = result.stderr.strip() or result.stdout.strip()
@@ -448,7 +514,10 @@ def _parse_graph_line(line: str) -> tuple[str, str]:
448
514
 
449
515
 
450
516
  def get_branch_tree(
451
- repo_path: str, branch: str | None = None, limit: int = 20
517
+ repo_path: str,
518
+ branch: str | None = None,
519
+ limit: int = 20,
520
+ author: str | None = None,
452
521
  ) -> List[Dict[str, Any]]:
453
522
  """Get branch tree data for visualization.
454
523
 
@@ -456,10 +525,11 @@ def get_branch_tree(
456
525
  repo_path: Path to git repository.
457
526
  branch: Optional branch name to filter by.
458
527
  limit: Maximum number of commits to return.
528
+ author: Optional author to filter by.
459
529
 
460
530
  Returns:
461
531
  List of dicts with keys: hash, message, date, branches, graph_line.
462
- Connector-only rows (/, \\ lines) are included with an empty hash
532
+ Connector-only rows (/, \ lines) are included with an empty hash
463
533
  so the renderer can display branch split/merge lines correctly.
464
534
  """
465
535
  _validate_path(repo_path)
@@ -467,7 +537,11 @@ def get_branch_tree(
467
537
  if branch:
468
538
  _validate_branch(branch)
469
539
 
470
- log_args = _build_log_args(branch, repo_path)
540
+ if author:
541
+ _validate_author(author)
542
+
543
+ default_branch = get_default_branch(repo_path) if branch else None
544
+ log_args = _build_log_args(branch, repo_path, default_branch)
471
545
 
472
546
  cmd = (
473
547
  [
@@ -483,6 +557,9 @@ def get_branch_tree(
483
557
  + [f"-{limit}"]
484
558
  )
485
559
 
560
+ if author:
561
+ cmd.append(f"--author={author}")
562
+
486
563
  result = subprocess.run(cmd, capture_output=True, text=True)
487
564
  if result.returncode != 0:
488
565
  return []
@@ -5,9 +5,61 @@ from typing import Any, Dict, List, Optional
5
5
  from gitember.colors import RESET, LIGHT_GRAY, ColorScheme
6
6
 
7
7
 
8
+ __all__ = [
9
+ "render_grid",
10
+ "render_branch_tree",
11
+ "calculate_thresholds",
12
+ "render_legend",
13
+ "choose_level",
14
+ "build_date_range",
15
+ "DEFAULT_THRESHOLDS",
16
+ ]
17
+
18
+
8
19
  DEFAULT_THRESHOLDS = (0, 1, 3, 6, 10)
9
20
 
10
21
 
22
+ def render_legend(
23
+ thresholds: tuple, color_scheme: ColorScheme, ascii_mode: bool = False
24
+ ) -> str:
25
+ """Render a legend row showing all intensity levels.
26
+
27
+ Args:
28
+ thresholds: Tuple of 5 threshold values.
29
+ color_scheme: Color scheme for the blocks.
30
+ ascii_mode: Use ASCII characters instead of Unicode.
31
+
32
+ Returns:
33
+ Single string with legend, no trailing newline.
34
+ """
35
+ if ascii_mode:
36
+ block_chars = [" ", "..", "::", "==", "##"]
37
+ else:
38
+ block_chars = [" ", "░░", "▒▒", "▓▓", "██"]
39
+
40
+ parts = []
41
+
42
+ t = thresholds
43
+ parts.append(f"{color_scheme.get(0)}No commits{RESET}")
44
+
45
+ if t[1] != t[2]:
46
+ label = f"{t[1]}-{t[2]}"
47
+ parts.append(f"{color_scheme.get(1)}{block_chars[1]}{label}{RESET}")
48
+
49
+ if t[2] != t[3]:
50
+ label = f"{t[3] + 1}-{t[4]}"
51
+ parts.append(f"{color_scheme.get(2)}{block_chars[2]}{label}{RESET}")
52
+
53
+ if t[3] != t[4]:
54
+ label = f"{t[4] + 1}+"
55
+ parts.append(f"{color_scheme.get(3)}{block_chars[3]}{label}{RESET}")
56
+
57
+ label = f"{t[4] + 1}+"
58
+ parts.append(f"{color_scheme.get(4)}{block_chars[4]}{label}{RESET}")
59
+
60
+ return " ".join(parts)
61
+
62
+
11
63
  def calculate_thresholds(counts: Dict[dt.date, int], mode: str = "auto") -> tuple:
12
64
  """Calculate intensity thresholds based on scaling mode.
13
65
 
@@ -200,7 +252,7 @@ def render_branch_tree(
200
252
  commits: List[Dict[str, Any]],
201
253
  default_branch: Optional[str] = None,
202
254
  selected_branch: Optional[str] = None,
203
- color_scheme: Optional["ColorScheme"] = None,
255
+ color_scheme: Optional[ColorScheme] = None,
204
256
  ascii_mode: bool = False,
205
257
  compact: bool = False,
206
258
  ) -> str:
@@ -0,0 +1,62 @@
1
+ import datetime as dt
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from gitember import config, git
7
+
8
+
9
+ def test_load_config_defaults(tmp_path, monkeypatch):
10
+ """When no config file exists, returns dict with color and border keys."""
11
+ monkeypatch.setattr(
12
+ config, "config_file_path", lambda: tmp_path / "nonexistent.toml"
13
+ )
14
+ result = config.load_config()
15
+ assert "color" in result
16
+ assert "border" in result
17
+ assert result["color"] == "green"
18
+ assert result["border"] == "="
19
+
20
+
21
+ def test_save_and_load_roundtrip(tmp_path, monkeypatch):
22
+ """Saving a config dict and loading it back returns the same values."""
23
+ test_file = tmp_path / "config.toml"
24
+ monkeypatch.setattr(config, "config_file_path", lambda: test_file)
25
+
26
+ config.save_config({"color": "blue", "border": "*", "week_start": "monday"})
27
+ result = config.load_config()
28
+
29
+ assert result["color"] == "blue"
30
+ assert result["border"] == "*"
31
+ assert result["week_start"] == "monday"
32
+
33
+
34
+ def test_load_config_ignores_unknown_keys(tmp_path, monkeypatch):
35
+ """A config file containing an unknown key does not include it in the returned dict."""
36
+ test_file = tmp_path / "config.toml"
37
+ monkeypatch.setattr(config, "config_file_path", lambda: test_file)
38
+
39
+ test_file.write_text('color = "blue"\nbadkey = "value"\n')
40
+ result = config.load_config()
41
+
42
+ assert "badkey" not in result
43
+ assert result["color"] == "blue"
44
+
45
+
46
+ def test_save_config_only_writes_allowed_keys(tmp_path, monkeypatch):
47
+ """Passing a dict with an extra key does not write that key to disk."""
48
+ test_file = tmp_path / "config.toml"
49
+ monkeypatch.setattr(config, "config_file_path", lambda: test_file)
50
+
51
+ config.save_config({"color": "blue", "secret": "password"})
52
+ content = test_file.read_text()
53
+
54
+ assert "secret" not in content
55
+ assert "color" in content
56
+
57
+
58
+ def test_config_file_path_returns_path():
59
+ """Returns a Path object ending in config.toml."""
60
+ result = config.config_file_path()
61
+ assert isinstance(result, Path)
62
+ assert result.name == "config.toml"
@@ -0,0 +1,94 @@
1
+ import datetime as dt
2
+
3
+ import pytest
4
+
5
+ from gitember import git
6
+
7
+
8
+ def test_run_git_log_empty_repo(repo):
9
+ """Returns empty dict when no commits exist in range."""
10
+ result = git.run_git_log(
11
+ dt.date(2024, 1, 1),
12
+ dt.date(2024, 12, 31),
13
+ str(repo),
14
+ )
15
+ assert result == {}
16
+
17
+
18
+ def test_run_git_log_counts_commits(repo_with_commits):
19
+ """Returns non-empty dict when commits exist in date range."""
20
+ result = git.run_git_log(
21
+ dt.date(2024, 1, 1),
22
+ dt.date(2024, 12, 31),
23
+ str(repo_with_commits),
24
+ )
25
+ assert len(result) > 0
26
+
27
+
28
+ def test_run_git_log_invalid_path(tmp_path):
29
+ """Raises ValueError for a non-git directory."""
30
+ with pytest.raises(ValueError):
31
+ git.run_git_log(
32
+ dt.date(2024, 1, 1),
33
+ dt.date(2024, 12, 31),
34
+ str(tmp_path),
35
+ )
36
+
37
+
38
+ def test_get_branches_returns_list(repo):
39
+ """Returns a list of branches, possibly empty for fresh repos."""
40
+ result = git.get_branches(str(repo))
41
+ assert isinstance(result, list)
42
+
43
+
44
+ def test_get_default_branch(repo):
45
+ """Returns a non-empty string for a valid repo."""
46
+ result = git.get_default_branch(str(repo))
47
+ assert isinstance(result, str)
48
+ assert len(result) > 0
49
+
50
+
51
+ def test_get_streaks_empty():
52
+ """Empty dict returns all zeros."""
53
+ result = git.get_streaks({})
54
+ assert result["current_streak"] == 0
55
+ assert result["longest_streak"] == 0
56
+
57
+
58
+ def test_get_streaks_consecutive():
59
+ """3 consecutive days with commits returns current_streak or longest_streak >= 3."""
60
+ counts = {
61
+ dt.date(2024, 1, 1): 1,
62
+ dt.date(2024, 1, 2): 1,
63
+ dt.date(2024, 1, 3): 1,
64
+ }
65
+ result = git.get_streaks(counts)
66
+ assert result["current_streak"] >= 3 or result["longest_streak"] >= 3
67
+
68
+
69
+ def test_get_streaks_gap_resets():
70
+ """A gap in dates resets the streak count."""
71
+ counts = {
72
+ dt.date(2024, 1, 1): 1,
73
+ dt.date(2024, 1, 2): 1,
74
+ dt.date(2024, 1, 4): 1,
75
+ }
76
+ result = git.get_streaks(counts)
77
+ assert result["longest_streak"] == 2
78
+ assert result["current_streak"] == 0
79
+
80
+
81
+ def test_get_top_contributors_ordering(repo_with_commits):
82
+ """Author with more commits appears first."""
83
+ result = git.get_top_contributors(str(repo_with_commits))
84
+ assert len(result) >= 2
85
+ assert result[0][1] >= result[1][1]
86
+
87
+
88
+ def test_get_repo_stats_keys(repo_with_commits):
89
+ """Returned dict contains all expected keys."""
90
+ result = git.get_repo_stats(str(repo_with_commits))
91
+ assert "total_commits" in result
92
+ assert "num_authors" in result
93
+ assert "first_commit_date" in result
94
+ assert "last_commit_date" in result
@@ -0,0 +1,158 @@
1
+ import datetime as dt
2
+
3
+ from gitember import render
4
+ from gitember.colors import ColorScheme
5
+
6
+
7
+ def test_choose_level_zero():
8
+ """Count of 0 always returns level 0."""
9
+ for count in [0]:
10
+ level = render.choose_level(count, (0, 1, 3, 6, 10))
11
+ assert level == 0
12
+
13
+
14
+ def test_choose_level_boundaries():
15
+ """Counts at each threshold boundary map to the correct level."""
16
+ thresholds = (0, 2, 5, 8, 12)
17
+ assert render.choose_level(0, thresholds) == 0
18
+ assert render.choose_level(1, thresholds) == 1
19
+ assert render.choose_level(2, thresholds) == 1
20
+ assert render.choose_level(3, thresholds) == 2
21
+ assert render.choose_level(5, thresholds) == 2
22
+ assert render.choose_level(6, thresholds) == 3
23
+ assert render.choose_level(8, thresholds) == 3
24
+ assert render.choose_level(9, thresholds) == 4
25
+ assert render.choose_level(12, thresholds) == 4
26
+ assert render.choose_level(100, thresholds) == 4
27
+
28
+
29
+ def test_choose_level_max():
30
+ """A very large count returns level 4."""
31
+ level = render.choose_level(10000, (0, 1, 3, 6, 10))
32
+ assert level == 4
33
+
34
+
35
+ def test_build_date_range_single_day():
36
+ """Start == end returns a list of one date."""
37
+ date = dt.date(2024, 1, 1)
38
+ result = render.build_date_range(date, date)
39
+ assert len(result) == 1
40
+ assert result[0] == date
41
+
42
+
43
+ def test_build_date_range_span():
44
+ """Correct number of days returned for a known range."""
45
+ start = dt.date(2024, 1, 1)
46
+ end = dt.date(2024, 1, 10)
47
+ result = render.build_date_range(start, end)
48
+ assert len(result) == 10
49
+
50
+
51
+ def test_build_date_range_order():
52
+ """Dates are in ascending order."""
53
+ start = dt.date(2024, 1, 5)
54
+ end = dt.date(2024, 1, 10)
55
+ result = render.build_date_range(start, end)
56
+ for i in range(len(result) - 1):
57
+ assert result[i] < result[i + 1]
58
+
59
+
60
+ def test_calculate_thresholds_auto():
61
+ """Verify the 5 returned values are non-decreasing and first value is 0."""
62
+ counts = {dt.date(2024, 1, i): i % 10 for i in range(1, 30)}
63
+ result = render.calculate_thresholds(counts, mode="auto")
64
+ assert len(result) == 5
65
+ assert result[0] == 0
66
+ for i in range(1, 5):
67
+ assert result[i] >= result[i - 1]
68
+
69
+
70
+ def test_calculate_thresholds_adaptive():
71
+ """Same monotonicity check with a realistic counts dict."""
72
+ counts = {
73
+ dt.date(2024, 1, 1): 0,
74
+ dt.date(2024, 1, 2): 1,
75
+ dt.date(2024, 1, 3): 2,
76
+ dt.date(2024, 1, 4): 5,
77
+ dt.date(2024, 1, 5): 10,
78
+ }
79
+ result = render.calculate_thresholds(counts, mode="adaptive")
80
+ assert len(result) == 5
81
+ assert result[0] == 0
82
+ for i in range(1, 5):
83
+ assert result[i] >= result[i - 1]
84
+
85
+
86
+ def test_calculate_thresholds_empty():
87
+ """Empty counts dict does not raise and returns a valid 5-tuple."""
88
+ result = render.calculate_thresholds({}, mode="auto")
89
+ assert len(result) == 5
90
+ assert result[0] == 0
91
+
92
+
93
+ def test_render_legend_skips_duplicate_thresholds():
94
+ """When two adjacent threshold values are equal, that level does not appear."""
95
+ thresholds = (0, 1, 1, 1, 5)
96
+ scheme = ColorScheme("test", [""] * 5)
97
+ legend = render.render_legend(thresholds, scheme, ascii_mode=True)
98
+ assert "2-1" not in legend
99
+ assert "1-1" not in legend
100
+
101
+
102
+ def test_render_legend_contains_all_levels():
103
+ """With well-spread thresholds, all 4 block levels appear in the output."""
104
+ thresholds = (0, 2, 5, 10, 20)
105
+ scheme = ColorScheme("test", [""] * 5)
106
+ legend = render.render_legend(thresholds, scheme, ascii_mode=True)
107
+ assert ".." in legend
108
+ assert "::" in legend
109
+ assert "==" in legend
110
+ assert "##" in legend
111
+
112
+
113
+ def test_render_grid_returns_string():
114
+ """Smoke test that render_grid returns a non-empty string for a small date range."""
115
+ counts = {dt.date(2024, 1, 1): 0, dt.date(2024, 1, 2): 1}
116
+ scheme = ColorScheme("green", [""] * 5)
117
+ result = render.render_grid(
118
+ dt.date(2024, 1, 1),
119
+ dt.date(2024, 1, 7),
120
+ counts,
121
+ scheme,
122
+ "sunday",
123
+ "=",
124
+ False,
125
+ )
126
+ assert isinstance(result, str)
127
+ assert len(result) > 0
128
+
129
+
130
+ def test_render_grid_month_labels():
131
+ """Output contains at least one 3-letter month abbreviation."""
132
+ counts = {dt.date(2024, 6, 15): 1}
133
+ scheme = ColorScheme("green", [""] * 5)
134
+ result = render.render_grid(
135
+ dt.date(2024, 6, 1),
136
+ dt.date(2024, 6, 30),
137
+ counts,
138
+ scheme,
139
+ "sunday",
140
+ "=",
141
+ False,
142
+ )
143
+ months = [
144
+ "Jan",
145
+ "Feb",
146
+ "Mar",
147
+ "Apr",
148
+ "May",
149
+ "Jun",
150
+ "Jul",
151
+ "Aug",
152
+ "Sep",
153
+ "Oct",
154
+ "Nov",
155
+ "Dec",
156
+ ]
157
+ has_month = any(m in result for m in months)
158
+ assert has_month, "Expected at least one month label in output"
File without changes
File without changes