git-ember 1.4.1__tar.gz → 1.4.2__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.1
3
+ Version: 1.4.2
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
@@ -65,6 +65,14 @@ pip install git-ember
65
65
  git-ember --version
66
66
  ```
67
67
 
68
+ ### Using uv
69
+
70
+ If you use [uv](https://github.com/astral-sh/uv) as your package manager:
71
+
72
+ ```bash
73
+ uv pip install git-ember
74
+ ```
75
+
68
76
  ### Running without installation
69
77
 
70
78
  If you prefer not to install the package, run directly:
@@ -203,6 +211,18 @@ git-ember --plain
203
211
  git-ember --plain > heatmap.txt
204
212
  ```
205
213
 
214
+ Reset configuration to defaults:
215
+
216
+ ```bash
217
+ git-ember --reset-default
218
+ ```
219
+
220
+ Show debug information:
221
+
222
+ ```bash
223
+ git-ember --verbose
224
+ ```
225
+
206
226
  ### Command-line options
207
227
 
208
228
  | Flag | Alias | Description | Default |
@@ -225,7 +245,9 @@ git-ember --plain > heatmap.txt
225
245
  | `--merges-only` | - | Show only merge commits | `false` |
226
246
  | `--grep` | - | Filter commits by message content | - |
227
247
  | `--hourly` | - | Show hour-of-day x day-of-week heatmap | `false` |
248
+ | `--reset-default` | - | Reset config to defaults | - |
228
249
  | `--version` | `-V` | Show version | - |
250
+ | `--verbose` | `-v` | Show debug information | - |
229
251
  | `--help` | `-h` | Show help | - |
230
252
 
231
253
  ## Project Structure
@@ -269,6 +291,12 @@ Run tests:
269
291
  pytest
270
292
  ```
271
293
 
294
+ Run tests with coverage:
295
+
296
+ ```bash
297
+ pytest --cov=gitember --cov-report=term-missing
298
+ ```
299
+
272
300
  ## License
273
301
 
274
302
  MIT License — see [LICENSE](LICENSE) file.
@@ -43,6 +43,14 @@ pip install git-ember
43
43
  git-ember --version
44
44
  ```
45
45
 
46
+ ### Using uv
47
+
48
+ If you use [uv](https://github.com/astral-sh/uv) as your package manager:
49
+
50
+ ```bash
51
+ uv pip install git-ember
52
+ ```
53
+
46
54
  ### Running without installation
47
55
 
48
56
  If you prefer not to install the package, run directly:
@@ -181,6 +189,18 @@ git-ember --plain
181
189
  git-ember --plain > heatmap.txt
182
190
  ```
183
191
 
192
+ Reset configuration to defaults:
193
+
194
+ ```bash
195
+ git-ember --reset-default
196
+ ```
197
+
198
+ Show debug information:
199
+
200
+ ```bash
201
+ git-ember --verbose
202
+ ```
203
+
184
204
  ### Command-line options
185
205
 
186
206
  | Flag | Alias | Description | Default |
@@ -203,7 +223,9 @@ git-ember --plain > heatmap.txt
203
223
  | `--merges-only` | - | Show only merge commits | `false` |
204
224
  | `--grep` | - | Filter commits by message content | - |
205
225
  | `--hourly` | - | Show hour-of-day x day-of-week heatmap | `false` |
226
+ | `--reset-default` | - | Reset config to defaults | - |
206
227
  | `--version` | `-V` | Show version | - |
228
+ | `--verbose` | `-v` | Show debug information | - |
207
229
  | `--help` | `-h` | Show help | - |
208
230
 
209
231
  ## Project Structure
@@ -247,6 +269,12 @@ Run tests:
247
269
  pytest
248
270
  ```
249
271
 
272
+ Run tests with coverage:
273
+
274
+ ```bash
275
+ pytest --cov=gitember --cov-report=term-missing
276
+ ```
277
+
250
278
  ## License
251
279
 
252
280
  MIT License — see [LICENSE](LICENSE) file.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-ember"
3
- version = "1.4.1"
3
+ version = "1.4.2"
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.1
3
+ Version: 1.4.2
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
@@ -65,6 +65,14 @@ pip install git-ember
65
65
  git-ember --version
66
66
  ```
67
67
 
68
+ ### Using uv
69
+
70
+ If you use [uv](https://github.com/astral-sh/uv) as your package manager:
71
+
72
+ ```bash
73
+ uv pip install git-ember
74
+ ```
75
+
68
76
  ### Running without installation
69
77
 
70
78
  If you prefer not to install the package, run directly:
@@ -203,6 +211,18 @@ git-ember --plain
203
211
  git-ember --plain > heatmap.txt
204
212
  ```
205
213
 
214
+ Reset configuration to defaults:
215
+
216
+ ```bash
217
+ git-ember --reset-default
218
+ ```
219
+
220
+ Show debug information:
221
+
222
+ ```bash
223
+ git-ember --verbose
224
+ ```
225
+
206
226
  ### Command-line options
207
227
 
208
228
  | Flag | Alias | Description | Default |
@@ -225,7 +245,9 @@ git-ember --plain > heatmap.txt
225
245
  | `--merges-only` | - | Show only merge commits | `false` |
226
246
  | `--grep` | - | Filter commits by message content | - |
227
247
  | `--hourly` | - | Show hour-of-day x day-of-week heatmap | `false` |
248
+ | `--reset-default` | - | Reset config to defaults | - |
228
249
  | `--version` | `-V` | Show version | - |
250
+ | `--verbose` | `-v` | Show debug information | - |
229
251
  | `--help` | `-h` | Show help | - |
230
252
 
231
253
  ## Project Structure
@@ -269,6 +291,12 @@ Run tests:
269
291
  pytest
270
292
  ```
271
293
 
294
+ Run tests with coverage:
295
+
296
+ ```bash
297
+ pytest --cov=gitember --cov-report=term-missing
298
+ ```
299
+
272
300
  ## License
273
301
 
274
302
  MIT License — see [LICENSE](LICENSE) file.
@@ -12,6 +12,7 @@ src/gitember/colors.py
12
12
  src/gitember/config.py
13
13
  src/gitember/git.py
14
14
  src/gitember/render.py
15
+ tests/test_cli.py
15
16
  tests/test_config.py
16
17
  tests/test_git.py
17
18
  tests/test_render.py
@@ -6,8 +6,9 @@ import time
6
6
  from pathlib import Path
7
7
 
8
8
  from gitember import __version__
9
- from gitember.colors import LIGHT_GRAY, PLAIN, RESET, get_color_scheme
9
+ from gitember.colors import BOLD, LIGHT_GRAY, PLAIN, RESET, get_color_scheme
10
10
  from gitember.config import (
11
+ DEFAULT_CONFIG,
11
12
  get_bool,
12
13
  load_config,
13
14
  save_config,
@@ -34,6 +35,23 @@ from gitember.render import (
34
35
  __all__ = ["main"]
35
36
 
36
37
 
38
+ def highlight_grep(text: str, pattern: str, bold_code: str, reset_code: str) -> str:
39
+ """Highlight pattern matches in text using bold."""
40
+ if not pattern:
41
+ return text
42
+ import re
43
+
44
+ try:
45
+ return re.sub(
46
+ f"({re.escape(pattern)})",
47
+ f"{bold_code}\\1{reset_code}",
48
+ text,
49
+ flags=re.IGNORECASE,
50
+ )
51
+ except re.error:
52
+ return text
53
+
54
+
37
55
  def parse_args() -> argparse.Namespace:
38
56
  """Parse command line arguments.
39
57
 
@@ -175,6 +193,17 @@ def parse_args() -> argparse.Namespace:
175
193
  action="version",
176
194
  version=f"git-ember {__version__}",
177
195
  )
196
+ parser.add_argument(
197
+ "--reset-default",
198
+ action="store_true",
199
+ help="Reset all config values to their defaults",
200
+ )
201
+ parser.add_argument(
202
+ "-v",
203
+ "--verbose",
204
+ action="store_true",
205
+ help="Show debug information including git commands",
206
+ )
178
207
  return parser.parse_args()
179
208
 
180
209
 
@@ -202,21 +231,28 @@ def main() -> None:
202
231
  """Main entry point."""
203
232
  args = parse_args()
204
233
  repo_path = get_repo_path(args.path)
234
+ verbose = getattr(args, "verbose", False)
235
+ start_time = time.perf_counter()
205
236
 
206
237
  config = load_config()
207
238
 
239
+ if args.reset_default:
240
+ config = DEFAULT_CONFIG.copy()
241
+ save_config(config)
242
+ print("Config reset to defaults.")
243
+ sys.exit(0)
244
+
208
245
  color_name = args.color or config.get("color", "green")
209
246
  try:
210
247
  config_years = max(1, int(config.get("years", "1")))
211
248
  except ValueError:
212
249
  config_years = 1
213
250
  years = args.years if args.years is not None else config_years
251
+ years = max(1, years)
214
252
  border_char = (args.border[0] if args.border else None) or config.get(
215
253
  "border", "="
216
254
  )[0]
217
- ascii_mode = (
218
- args.ascii if args.ascii is not None else get_bool(config, "ascii")
219
- )
255
+ ascii_mode = args.ascii if args.ascii is not None else get_bool(config, "ascii")
220
256
  branch = args.branch
221
257
  week_start = args.week_start or config.get("week_start", "sunday")
222
258
  author = args.author
@@ -230,7 +266,7 @@ def main() -> None:
230
266
 
231
267
  grep = args.grep
232
268
 
233
- default_branch = get_default_branch(repo_path)
269
+ default_branch = get_default_branch(repo_path, verbose=verbose)
234
270
 
235
271
  MAX_YEARS = 10
236
272
  if years > MAX_YEARS:
@@ -240,7 +276,7 @@ def main() -> None:
240
276
  years = MAX_YEARS
241
277
 
242
278
  if branch:
243
- valid_branches = get_branches(repo_path)
279
+ valid_branches = get_branches(repo_path, verbose=verbose)
244
280
  if branch not in valid_branches:
245
281
  print(f"Error: Branch '{branch}' not found")
246
282
  sys.exit(1)
@@ -283,15 +319,9 @@ def main() -> None:
283
319
  today = dt.date.today()
284
320
  output_parts = []
285
321
  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")
322
+ args.extended if args.extended is not None else get_bool(config, "extended")
294
323
  )
324
+ compact = args.compact if args.compact is not None else get_bool(config, "compact")
295
325
 
296
326
  custom_range = args.after or args.before
297
327
 
@@ -311,6 +341,12 @@ def main() -> None:
311
341
  print("Error: Invalid --before date. Use YYYY-MM-DD format.")
312
342
  sys.exit(1)
313
343
 
344
+ if custom_range and start_date > end_date:
345
+ after_str = args.after or "--after not set"
346
+ before_str = args.before or "--before not set"
347
+ print(f"Error: {after_str} must be before {before_str}")
348
+ sys.exit(1)
349
+
314
350
  if custom_range:
315
351
  num_iterations = 1
316
352
  else:
@@ -343,6 +379,7 @@ def main() -> None:
343
379
  default_branch=default_branch,
344
380
  merge_filter=merge_filter,
345
381
  grep=grep,
382
+ verbose=verbose,
346
383
  )
347
384
  except ValueError as e:
348
385
  print(f"Error: {e}")
@@ -364,18 +401,16 @@ def main() -> None:
364
401
  legend = render_legend(thresholds, color_scheme, ascii_mode)
365
402
 
366
403
  if custom_range:
367
- output_parts.append(f"{heatmap}\n {legend}")
404
+ output_parts.append(f"{heatmap}\n{legend}\n")
368
405
  elif num_iterations > 1:
369
- output_parts.append(f"[{year}]\n{heatmap}\n {legend}")
406
+ output_parts.append(f"[{year}]\n{heatmap}\n{legend}\n")
370
407
  else:
371
- output_parts.append(f"{heatmap}\n {legend}")
408
+ output_parts.append(f"{heatmap}\n{legend}\n")
372
409
 
373
410
  output_parts.reverse()
374
411
  print("\n" + "\n".join(output_parts))
375
412
 
376
- show_tree = (
377
- args.tree if args.tree is not None else get_bool(config, "tree")
378
- )
413
+ show_tree = args.tree if args.tree is not None else get_bool(config, "tree")
379
414
  if show_tree:
380
415
  tree_commits = get_branch_tree(
381
416
  repo_path,
@@ -385,6 +420,8 @@ def main() -> None:
385
420
  merge_filter=merge_filter,
386
421
  grep=grep,
387
422
  )
423
+ tree_reset = RESET if not args.plain else ""
424
+ tree_bold = BOLD if not args.plain else ""
388
425
  tree_output = render_branch_tree(
389
426
  tree_commits,
390
427
  default_branch=default_branch,
@@ -392,6 +429,9 @@ def main() -> None:
392
429
  color_scheme=color_scheme,
393
430
  ascii_mode=ascii_mode,
394
431
  compact=compact,
432
+ grep=grep,
433
+ bold_code=tree_bold,
434
+ reset_code=tree_reset,
395
435
  )
396
436
  if tree_output:
397
437
  print(tree_output)
@@ -471,23 +511,30 @@ def main() -> None:
471
511
  commit_color = color_scheme.get(3)
472
512
  author_color = color_scheme.get(2)
473
513
 
474
- commit_time = (
475
- time.strftime("%H:%M", time.localtime(int(commit["timestamp"])))
476
- if commit["timestamp"]
477
- else ""
478
- )
514
+ commit_time = ""
515
+ if commit.get("timestamp"):
516
+ try:
517
+ commit_time = time.strftime("%H:%M", time.localtime(int(commit["timestamp"])))
518
+ except (ValueError, OSError):
519
+ pass
479
520
  date_parts = commit["date"].split("-")
480
- formatted_date = (
481
- f"{date_parts[2]}-{date_parts[1]}-{date_parts[0]}"
482
- if len(date_parts) == 3
483
- else commit["date"]
484
- )
521
+ try:
522
+ if len(date_parts) == 3:
523
+ formatted_date = f"{date_parts[2]}-{date_parts[1]}-{date_parts[0]}"
524
+ else:
525
+ formatted_date = commit["date"]
526
+ except (ValueError, IndexError):
527
+ formatted_date = commit["date"]
485
528
  print(
486
529
  f" {commit_color}{commit['hash']}{reset_code}"
487
530
  f" | {author_color}{commit['author']}{reset_code}"
488
531
  f" | {gray_code}{formatted_date} {commit_time}{reset_code}"
489
532
  )
490
- print(f" {commit['message']}")
533
+ bold_code = BOLD if not args.plain else ""
534
+ highlighted_msg = highlight_grep(
535
+ commit["message"], grep, bold_code + commit_color, reset_code
536
+ )
537
+ print(f" {highlighted_msg}")
491
538
 
492
539
  print("\n--- Top Contributors ---")
493
540
  contrib_color = color_scheme.get(2)
@@ -528,6 +575,10 @@ def main() -> None:
528
575
  print(hourly_grid)
529
576
  print(f" {legend}")
530
577
 
578
+ if verbose:
579
+ elapsed = time.perf_counter() - start_time
580
+ print(f"\n[DEBUG] Completed in {elapsed:.3f}s", file=sys.stderr)
581
+
531
582
 
532
583
  if __name__ == "__main__":
533
584
  main()
@@ -1,8 +1,10 @@
1
1
  RESET = "\x1b[0m"
2
2
  LIGHT_GRAY = "\x1b[38;5;250m"
3
+ BOLD = "\x1b[1m"
3
4
 
4
5
 
5
6
  __all__ = [
7
+ "BOLD",
6
8
  "COLOR_SCHEMES",
7
9
  "LIGHT_GRAY",
8
10
  "PLAIN",
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import sys
3
+ import tomllib
3
4
  from pathlib import Path
4
5
 
5
6
  __all__ = [
@@ -27,7 +28,9 @@ def get_bool(config: dict, key: str, default: bool = False) -> bool:
27
28
  raw = config[key]
28
29
  if isinstance(raw, bool):
29
30
  return raw
30
- return raw.strip().lower() == "true"
31
+ if isinstance(raw, int):
32
+ return bool(raw)
33
+ return str(raw).strip().lower() == "true"
31
34
 
32
35
 
33
36
  def config_file_path() -> Path:
@@ -95,23 +98,32 @@ def load_config() -> dict:
95
98
  except OSError:
96
99
  return config
97
100
 
98
- # Simple TOML-like parsing: key=value, ignore comments
99
- for line in text.splitlines():
100
- stripped = line.strip()
101
- # Skip empty lines and comments
102
- if not stripped or stripped.startswith("#"):
103
- continue
104
- if "=" in stripped:
105
- key, _, value = stripped.partition("=")
106
- key = key.strip()
107
- # Strip quotes from value - handles both "value" and 'value'
108
- value = value.strip().strip('"').strip("'")
109
- # Only allow predefined keys (whitelist for security)
110
- if key in ALLOWED_CONFIG_KEYS:
101
+ try:
102
+ parsed = tomllib.loads(text)
103
+ except ValueError:
104
+ return config
105
+
106
+ for key in ALLOWED_CONFIG_KEYS:
107
+ if key in parsed:
108
+ value = parsed[key]
109
+ if isinstance(value, bool):
110
+ config[key] = "true" if value else "false"
111
+ elif isinstance(value, int):
112
+ config[key] = str(value)
113
+ elif isinstance(value, str):
111
114
  config[key] = value
112
115
  return config
113
116
 
114
117
 
118
+ def _serialize_value(value: str) -> str:
119
+ """Serialize a config value to TOML string."""
120
+ if value.lower() in ("true", "false"):
121
+ return value.lower()
122
+ if value.isdigit():
123
+ return value
124
+ return f'"{value}"'
125
+
126
+
115
127
  def save_config(config: dict) -> None:
116
128
  """Save config to file.
117
129
 
@@ -122,10 +134,9 @@ def save_config(config: dict) -> None:
122
134
  try:
123
135
  path.parent.mkdir(parents=True, exist_ok=True)
124
136
  lines = []
125
- for k, v in config.items():
126
- if k in ALLOWED_CONFIG_KEYS:
127
- escaped = v.replace('"', '\\"')
128
- lines.append(f'{k} = "{escaped}"')
137
+ for k in ALLOWED_CONFIG_KEYS:
138
+ if k in config:
139
+ lines.append(f"{k} = {_serialize_value(config[k])}")
129
140
  path.write_text("\n".join(lines), encoding="utf-8")
130
141
  except OSError:
131
142
  pass
@@ -1,10 +1,12 @@
1
1
  import datetime as dt
2
2
  import re
3
3
  import subprocess
4
+ import sys
4
5
  from collections import Counter
5
6
  from typing import Any
6
7
 
7
8
  COMMIT_PREFIX = "COMMIT:"
9
+ GIT_TIMEOUT = 30 # seconds
8
10
 
9
11
 
10
12
  __all__ = [
@@ -68,6 +70,7 @@ def run_git_log(
68
70
  default_branch: str | None = None,
69
71
  merge_filter: str | None = None,
70
72
  grep: str | None = None,
73
+ verbose: bool = False,
71
74
  ) -> dict[dt.date, int]:
72
75
  """Get commit counts per day within a date range.
73
76
 
@@ -102,6 +105,7 @@ def run_git_log(
102
105
  subprocess.run(
103
106
  ["git", "-C", repo_path, "rev-parse", "--is-inside-work-tree"],
104
107
  check=True,
108
+ timeout=GIT_TIMEOUT,
105
109
  stdout=subprocess.DEVNULL,
106
110
  stderr=subprocess.DEVNULL,
107
111
  )
@@ -142,7 +146,10 @@ def run_git_log(
142
146
  ]
143
147
  )
144
148
 
145
- result = subprocess.run(cmd, capture_output=True, text=True)
149
+ if verbose:
150
+ print(f"[DEBUG] git {' '.join(cmd)}", file=sys.stderr)
151
+
152
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=GIT_TIMEOUT)
146
153
 
147
154
  if result.returncode != 0:
148
155
  err = result.stderr.strip() or result.stdout.strip()
@@ -161,7 +168,7 @@ def run_git_log(
161
168
  return dict(counter)
162
169
 
163
170
 
164
- def get_branches(repo_path: str) -> list[str]:
171
+ def get_branches(repo_path: str, verbose: bool = False) -> list[str]:
165
172
  """Get list of local branch names.
166
173
 
167
174
  Args:
@@ -184,7 +191,7 @@ def get_branches(repo_path: str) -> list[str]:
184
191
  return [line.strip() for line in result.stdout.splitlines() if line.strip()]
185
192
 
186
193
 
187
- def get_default_branch(repo_path: str) -> str:
194
+ def get_default_branch(repo_path: str, verbose: bool = False) -> str:
188
195
  """Get the default branch name (e.g., main, master).
189
196
 
190
197
  Args:
@@ -278,7 +285,7 @@ def get_recent_commits(
278
285
  elif merge_filter == "merges-only":
279
286
  cmd.append("--merges")
280
287
 
281
- result = subprocess.run(cmd, capture_output=True, text=True)
288
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=GIT_TIMEOUT)
282
289
 
283
290
  if result.returncode != 0:
284
291
  return []
@@ -361,7 +368,7 @@ def get_top_contributors(
361
368
  elif merge_filter == "merges-only":
362
369
  cmd.append("--merges")
363
370
 
364
- result = subprocess.run(cmd, capture_output=True, text=True)
371
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=GIT_TIMEOUT)
365
372
 
366
373
  if result.returncode != 0:
367
374
  return []
@@ -439,7 +446,7 @@ def get_repo_stats(
439
446
  elif merge_filter == "merges-only":
440
447
  cmd.append("--merges")
441
448
 
442
- result = subprocess.run(cmd, capture_output=True, text=True)
449
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=GIT_TIMEOUT)
443
450
 
444
451
  if result.returncode != 0:
445
452
  err = result.stderr.strip() or result.stdout.strip()
@@ -515,7 +522,11 @@ def get_streaks(counts: dict[dt.date, int]) -> dict[str, Any]:
515
522
 
516
523
  prev_date = date
517
524
 
518
- today = dt.date.today()
525
+ if sorted_dates:
526
+ today = sorted_dates[-1]
527
+ else:
528
+ today = dt.date.today()
529
+
519
530
  current = 0
520
531
  check_date = today
521
532
  while True:
@@ -626,7 +637,7 @@ def get_branch_tree(
626
637
  elif merge_filter == "merges-only":
627
638
  cmd.append("--merges")
628
639
 
629
- result = subprocess.run(cmd, capture_output=True, text=True)
640
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=GIT_TIMEOUT)
630
641
  if result.returncode != 0:
631
642
  return []
632
643
 
@@ -740,7 +751,7 @@ def get_hourly_counts(
740
751
  ]
741
752
  )
742
753
 
743
- result = subprocess.run(cmd, capture_output=True, text=True)
754
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=GIT_TIMEOUT)
744
755
  if result.returncode != 0:
745
756
  return {}
746
757
 
@@ -69,7 +69,7 @@ def render_legend(
69
69
  return " ".join(parts)
70
70
 
71
71
 
72
- def calculate_thresholds(counts: dict[dt.date, int], mode: str = "auto") -> tuple:
72
+ def calculate_thresholds(counts: dict, mode: str = "auto") -> tuple:
73
73
  """Calculate intensity thresholds based on scaling mode.
74
74
 
75
75
  Args:
@@ -261,6 +261,9 @@ def render_branch_tree(
261
261
  color_scheme: ColorScheme | PlainScheme | None = None,
262
262
  ascii_mode: bool = False,
263
263
  compact: bool = False,
264
+ grep: str | None = None,
265
+ bold_code: str = "",
266
+ reset_code: str = "",
264
267
  ) -> str:
265
268
  """Render horizontal branch tree visualization.
266
269
 
@@ -322,11 +325,27 @@ def render_branch_tree(
322
325
 
323
326
  hash7 = commit["hash"]
324
327
  full_message = commit["message"]
325
- max_msg_len = 20 if compact else 35
326
- if len(full_message) > max_msg_len:
327
- message = full_message[: max_msg_len - 3] + "..."
328
+
329
+ if grep and bold_code and reset_code:
330
+ import re
331
+
332
+ try:
333
+ highlighted = re.sub(
334
+ f"({re.escape(grep)})",
335
+ f"{bold_code}\\1{reset_code}",
336
+ full_message,
337
+ flags=re.IGNORECASE,
338
+ )
339
+ except re.error:
340
+ highlighted = full_message
341
+ else:
342
+ highlighted = full_message
343
+
344
+ max_msg_len = 35 if compact else 50
345
+ if len(highlighted) > max_msg_len:
346
+ message = highlighted[: max_msg_len - 3] + "..."
328
347
  else:
329
- message = full_message + " " * (max_msg_len - len(full_message))
348
+ message = highlighted + " " * (max_msg_len - len(highlighted))
330
349
  branches = commit.get("branches", [])
331
350
 
332
351
  is_connector = not hash7 and "|" in graph_line