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.
- {git_ember-1.4.1/src/git_ember.egg-info → git_ember-1.4.2}/PKG-INFO +29 -1
- {git_ember-1.4.1 → git_ember-1.4.2}/README.md +28 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/pyproject.toml +1 -1
- {git_ember-1.4.1 → git_ember-1.4.2/src/git_ember.egg-info}/PKG-INFO +29 -1
- {git_ember-1.4.1 → git_ember-1.4.2}/src/git_ember.egg-info/SOURCES.txt +1 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/src/gitember/cli.py +82 -31
- {git_ember-1.4.1 → git_ember-1.4.2}/src/gitember/colors.py +2 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/src/gitember/config.py +29 -18
- {git_ember-1.4.1 → git_ember-1.4.2}/src/gitember/git.py +20 -9
- {git_ember-1.4.1 → git_ember-1.4.2}/src/gitember/render.py +24 -5
- git_ember-1.4.2/tests/test_cli.py +725 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/tests/test_config.py +100 -0
- git_ember-1.4.2/tests/test_git.py +642 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/tests/test_render.py +101 -0
- git_ember-1.4.1/tests/test_git.py +0 -182
- {git_ember-1.4.1 → git_ember-1.4.2}/LICENSE +0 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/setup.cfg +0 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/src/git_ember.egg-info/dependency_links.txt +0 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/src/git_ember.egg-info/entry_points.txt +0 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/src/git_ember.egg-info/top_level.txt +0 -0
- {git_ember-1.4.1 → git_ember-1.4.2}/src/gitember/__init__.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.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
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-ember
|
|
3
|
-
Version: 1.4.
|
|
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.
|
|
@@ -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
|
|
404
|
+
output_parts.append(f"{heatmap}\n{legend}\n")
|
|
368
405
|
elif num_iterations > 1:
|
|
369
|
-
output_parts.append(f"[{year}]\n{heatmap}\n
|
|
406
|
+
output_parts.append(f"[{year}]\n{heatmap}\n{legend}\n")
|
|
370
407
|
else:
|
|
371
|
-
output_parts.append(f"{heatmap}\n
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
else
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
value
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
126
|
-
if k in
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
326
|
-
if
|
|
327
|
-
|
|
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 =
|
|
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
|