git-ember 1.2.0__py3-none-any.whl

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.
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-ember
3
+ Version: 1.2.0
4
+ Summary: A GitHub-style heatmap of commits for your terminal
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: ruff>=0.15.9
8
+
9
+ # git-ember
10
+
11
+ A GitHub-style heatmap of commit activity for your terminal.
12
+
13
+ ## Overview
14
+
15
+ `git-ember` displays a colored grid representing commit activity over time, similar to the contribution graph shown on GitHub profiles. It reads directly from your local Git history—no external APIs or network requests required.
16
+
17
+ The tool supports multiple color schemes, branch filtering, custom date ranges, and can display repository statistics, recent commits, and top contributors.
18
+
19
+ ## Prerequisites
20
+
21
+ - Python 3.11 or higher
22
+ - Git installed and available in PATH
23
+
24
+ ## Installation
25
+
26
+ Clone the repository:
27
+
28
+ ```bash
29
+ git clone https://codeberg.org/lukavr05/git-ember.git
30
+ cd git-ember
31
+ ```
32
+
33
+
34
+ ### Running without installation
35
+
36
+ ```bash
37
+ PYTHONPATH=src python3 main.py .
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ git-ember reads configuration from `~/.config/git-ember/config.toml`. CLI arguments take precedence over config values.
43
+
44
+ | Variable | Required | Default | Description |
45
+ |----------|----------|---------|-------------|
46
+ | `color` | No | `green` | Color scheme name |
47
+ | `border` | No | `=` | Border character |
48
+
49
+ ## Usage
50
+
51
+ Show commit heatmap for the current year:
52
+
53
+ ```bash
54
+ git-ember
55
+ ```
56
+
57
+ Show heatmap for a specific repository:
58
+
59
+ ```bash
60
+ git-ember /path/to/repo
61
+ ```
62
+
63
+ Show multiple years:
64
+
65
+ ```bash
66
+ git-ember --years 2
67
+ git-ember -y 3
68
+ ```
69
+
70
+ Use different color schemes:
71
+
72
+ ```bash
73
+ git-ember --color blue
74
+ git-ember --color orange
75
+ git-ember --color purple
76
+ git-ember --color mono
77
+ ```
78
+
79
+ Show heatmap for a specific branch:
80
+
81
+ ```bash
82
+ git-ember --branch feature-x
83
+ ```
84
+
85
+ Display branch tree visualization:
86
+
87
+ ```bash
88
+ git-ember --tree
89
+ git-ember --branch feature-x --tree
90
+ ```
91
+
92
+ Show custom date range:
93
+
94
+ ```bash
95
+ git-ember --after 2025-01-01 --before 2025-06-30
96
+ git-ember --after 2025-01-01
97
+ ```
98
+
99
+ Show extended report with recent commits and top contributors:
100
+
101
+ ```bash
102
+ git-ember --extended
103
+ git-ember -e
104
+ ```
105
+
106
+ Show repository statistics:
107
+
108
+ ```bash
109
+ git-ember --stats
110
+ git-ember -S
111
+ ```
112
+
113
+ ### Command-line options
114
+
115
+ | Flag | Alias | Description | Default |
116
+ |------|-------|-------------|---------|
117
+ | `--color` | `-c` | Color scheme | `green` |
118
+ | `--years` | `-y` | Number of years to show | `1` |
119
+ | `--border` | `-b` | Border character | `=` |
120
+ | `--extended` | `-e` | Show recent commits and top contributors | `false` |
121
+ | `--stats` | `-S` | Show repository statistics | `false` |
122
+ | `--ascii` | `-a` | Use ASCII characters instead of Unicode | `false` |
123
+ | `--compact` | - | Show last 4 months only | `false` |
124
+ | `--branch` | - | Show heatmap for specific branch | all branches |
125
+ | `--tree` | `-t` | Show branch tree under heatmap | `false` |
126
+ | `--scale` | - | Intensity scaling: auto or adaptive | `auto` |
127
+ | `--after` | - | Show commits after date (YYYY-MM-DD) | - |
128
+ | `--before` | - | Show commits before date (YYYY-MM-DD) | - |
129
+ | `--version` | `-V` | Show version | - |
130
+ | `--help` | `-h` | Show help | - |
131
+
132
+ ## Project Structure
133
+
134
+ ```
135
+ git-ember/
136
+ ├── main.py # Entry point
137
+ ├── pyproject.toml # Package configuration
138
+ ├── Makefile # Build targets
139
+ ├── .python-version # Python version (3.11)
140
+ ├── README.md # This file
141
+ ├── docs/
142
+ │ ├── CHANGELOG.md # Version history
143
+ │ └── PLAN.md # Feature planning
144
+ └── src/
145
+ └── githeat/
146
+ ├── __init__.py # Package version
147
+ ├── cli.py # CLI argument parsing and config
148
+ ├── git.py # Git command execution and parsing
149
+ ├── render.py # Grid and branch tree rendering
150
+ └── colors.py # ANSI color scheme definitions
151
+ ```
152
+
153
+ ## Development
154
+
155
+ Run the linter:
156
+
157
+ ```bash
158
+ make lint
159
+ ```
160
+
161
+ > ⚠️ Note: No test suite exists currently.
162
+
163
+ ## License
164
+
165
+ No LICENSE file exists in this repository.
@@ -0,0 +1,10 @@
1
+ gitember/__init__.py,sha256=MpAT5hgNoHnTtG1XRD_GV_A7QrHVU6vJjGSw_8qMGA4,22
2
+ gitember/cli.py,sha256=lfK87jtzweUyMOkFhYBw3_m3g8IdKw3ugw1aY5DdVIc,11764
3
+ gitember/colors.py,sha256=v5bHcCj9pdSqcieG3X-WzeSgsLVpLtNsSbf-eEHvZGY,3344
4
+ gitember/git.py,sha256=8YpoAml6bFumCN3hoNmqdv-YVX-XbbTpwu4_rWwuwXI,14303
5
+ gitember/render.py,sha256=zadIVrKCIXaaE6rDi23So656rvrH36qNqxGhgM8angk,8691
6
+ git_ember-1.2.0.dist-info/METADATA,sha256=tQk4rqXb6xt19NUtr_DUTd8IWfLffyvrJaf8bW_iNUM,3978
7
+ git_ember-1.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ git_ember-1.2.0.dist-info/entry_points.txt,sha256=-IiSHTFzrEdM25fuz-psxq-U24lv7XWCZ2tOpHpHk2s,48
9
+ git_ember-1.2.0.dist-info/top_level.txt,sha256=VEJk48Zmg73VytgvOKH_qeGSq6GGADX5-fjFElHBNPY,9
10
+ git_ember-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-ember = gitember.cli:main
@@ -0,0 +1 @@
1
+ gitember
gitember/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.2.0"
gitember/cli.py ADDED
@@ -0,0 +1,414 @@
1
+ import argparse
2
+ import calendar
3
+ import datetime as dt
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from githeat import __version__
10
+ from githeat.colors import get_color_scheme, RESET, LIGHT_GRAY
11
+ from githeat.git import (
12
+ run_git_log,
13
+ get_recent_commits,
14
+ get_top_contributors,
15
+ get_repo_stats,
16
+ get_branches,
17
+ get_branch_tree,
18
+ get_default_branch,
19
+ get_streaks,
20
+ )
21
+ from githeat.render import render_grid, render_branch_tree, calculate_thresholds
22
+
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
102
+
103
+
104
+ def parse_args() -> argparse.Namespace:
105
+ """Parse command line arguments.
106
+
107
+ Returns:
108
+ Parsed arguments namespace.
109
+ """
110
+ parser = argparse.ArgumentParser(
111
+ description="Display a GitHub-style heatmap of commits in your terminal.",
112
+ )
113
+ parser.add_argument(
114
+ "path",
115
+ nargs="?",
116
+ default=".",
117
+ help="Path to git repository (default: current directory)",
118
+ )
119
+ parser.add_argument(
120
+ "--color",
121
+ "-c",
122
+ choices=[
123
+ "green",
124
+ "blue",
125
+ "orange",
126
+ "purple",
127
+ "red",
128
+ "yellow",
129
+ "teal",
130
+ "pink",
131
+ "aqua",
132
+ "mono",
133
+ ],
134
+ help="Color scheme",
135
+ )
136
+ parser.add_argument(
137
+ "--years",
138
+ "-y",
139
+ type=int,
140
+ default=1,
141
+ help="Number of years to show (default: 1)",
142
+ )
143
+ parser.add_argument(
144
+ "--border",
145
+ "-b",
146
+ type=str,
147
+ default="=",
148
+ help="Border character (default: =)",
149
+ )
150
+ parser.add_argument(
151
+ "--extended",
152
+ "-e",
153
+ action="store_true",
154
+ help="Show extended report with recent commits and top contributors",
155
+ )
156
+ parser.add_argument(
157
+ "--stats",
158
+ "-S",
159
+ action="store_true",
160
+ help="Show repository statistics (total commits, authors, dates)",
161
+ )
162
+ parser.add_argument(
163
+ "--ascii",
164
+ "-a",
165
+ action="store_true",
166
+ help="Use ASCII characters instead of Unicode blocks",
167
+ )
168
+ parser.add_argument(
169
+ "--compact",
170
+ action="store_true",
171
+ help="Only show months up to the current month",
172
+ )
173
+ parser.add_argument(
174
+ "--branch",
175
+ action="store",
176
+ help="Show heatmap for specific branch instead of all branches",
177
+ )
178
+ parser.add_argument(
179
+ "--tree",
180
+ "-t",
181
+ action="store_true",
182
+ help="Show branch tree under heatmap",
183
+ )
184
+ parser.add_argument(
185
+ "--scale",
186
+ type=str,
187
+ choices=["auto", "adaptive"],
188
+ default="auto",
189
+ help="Intensity scaling mode: auto or adaptive (default: auto)",
190
+ )
191
+ parser.add_argument(
192
+ "--after",
193
+ type=str,
194
+ help="Show commits after this date (YYYY-MM-DD)",
195
+ )
196
+ parser.add_argument(
197
+ "--before",
198
+ type=str,
199
+ help="Show commits before this date (YYYY-MM-DD)",
200
+ )
201
+ parser.add_argument(
202
+ "--version",
203
+ "-V",
204
+ action="version",
205
+ version=f"git-ember {__version__}",
206
+ )
207
+ return parser.parse_args()
208
+
209
+
210
+ def get_repo_path(path: str) -> str:
211
+ """Resolve path to absolute path.
212
+
213
+ Args:
214
+ path: Relative or absolute path to git repository.
215
+
216
+ Returns:
217
+ Absolute path as string.
218
+
219
+ Raises:
220
+ ValueError: If path is unsafe (contains suspicious characters).
221
+ """
222
+ if not path:
223
+ raise ValueError("Path cannot be empty")
224
+
225
+ import re
226
+
227
+ if re.search(r"[;&|`$()]", path):
228
+ raise ValueError(f"Invalid characters in path: {path}")
229
+
230
+ resolved = Path(path).resolve()
231
+
232
+ return str(resolved)
233
+
234
+
235
+ def main() -> None:
236
+ """Main entry point."""
237
+ args = parse_args()
238
+ repo_path = get_repo_path(args.path)
239
+
240
+ config = load_config()
241
+
242
+ color_name = args.color or config.get("color", "green")
243
+ years = args.years
244
+ border_char = args.border[0] if args.border else "=" # prevent escape codes
245
+ ascii_mode = args.ascii
246
+ branch = args.branch
247
+
248
+ if branch:
249
+ valid_branches = get_branches(repo_path)
250
+ if branch not in valid_branches:
251
+ print(f"Error: Branch '{branch}' not found")
252
+ sys.exit(1)
253
+
254
+ if args.color is not None:
255
+ new_config = config.copy()
256
+ if args.color:
257
+ new_config["color"] = args.color
258
+ save_config(new_config)
259
+
260
+ color_scheme = get_color_scheme(color_name)
261
+
262
+ today = dt.date.today()
263
+ output_parts = []
264
+ extended = args.extended
265
+ compact = args.compact
266
+ scale_mode = args.scale
267
+
268
+ custom_range = args.after or args.before
269
+
270
+ if args.after:
271
+ try:
272
+ start_date = dt.date.fromisoformat(args.after)
273
+ except ValueError:
274
+ print("Error: Invalid --after date. Use YYYY-MM-DD format.")
275
+ sys.exit(1)
276
+ if args.before:
277
+ try:
278
+ end_date = dt.date.fromisoformat(args.before)
279
+ except ValueError:
280
+ print("Error: Invalid --before date. Use YYYY-MM-DD format.")
281
+ sys.exit(1)
282
+
283
+ if custom_range:
284
+ num_iterations = 1
285
+ else:
286
+ num_iterations = years
287
+
288
+ for i in range(num_iterations):
289
+ if custom_range:
290
+ pass
291
+ else:
292
+ year = today.year - i
293
+ current_month = today.month
294
+
295
+ if compact and i == 0:
296
+ start_month = max(1, current_month - 3)
297
+ start_date = dt.date(year, start_month, 1)
298
+ end_date = dt.date(
299
+ year, current_month, calendar.monthrange(year, current_month)[1]
300
+ )
301
+ else:
302
+ start_date = dt.date(year, 1, 1)
303
+ end_date = dt.date(year, 12, 31)
304
+
305
+ try:
306
+ counts = run_git_log(start_date, end_date, repo_path, branch=branch)
307
+ except ValueError as e:
308
+ print(f"Error: {e}")
309
+ sys.exit(1)
310
+
311
+ thresholds = calculate_thresholds(counts, scale_mode)
312
+
313
+ heatmap = render_grid(
314
+ start_date,
315
+ end_date,
316
+ counts,
317
+ color_scheme,
318
+ "sunday",
319
+ border_char,
320
+ ascii_mode,
321
+ thresholds=thresholds,
322
+ )
323
+
324
+ if custom_range:
325
+ output_parts.append(heatmap)
326
+ elif num_iterations > 1:
327
+ output_parts.append(f"[{year}]\n{heatmap}")
328
+ else:
329
+ output_parts.append(heatmap)
330
+
331
+ output_parts.reverse()
332
+ print("\n" + "\n".join(output_parts))
333
+
334
+ if args.tree:
335
+ tree_commits = get_branch_tree(repo_path, branch=branch)
336
+ default_branch = get_default_branch(repo_path)
337
+ tree_output = render_branch_tree(
338
+ tree_commits,
339
+ default_branch=default_branch,
340
+ selected_branch=branch,
341
+ color_scheme=color_scheme,
342
+ ascii_mode=ascii_mode,
343
+ compact=args.compact,
344
+ )
345
+ if tree_output:
346
+ print(tree_output)
347
+
348
+ show_stats = args.stats or extended
349
+
350
+ if show_stats:
351
+ stats = get_repo_stats(repo_path, branch=branch)
352
+
353
+ print("\n--- Statistics ---")
354
+ print(f" Total commits: {stats.get('total_commits', 0)}")
355
+ print(f" Authors: {stats.get('num_authors', 0)}")
356
+
357
+ first_commit_date = stats.get("first_commit_date", "")
358
+ if first_commit_date:
359
+ print(f" First commit: {first_commit_date}")
360
+
361
+ last_commit_date = stats.get("last_commit_date", "")
362
+ if last_commit_date:
363
+ print(f" Last commit: {last_commit_date}")
364
+
365
+ streak_start_date = dt.date.today() - dt.timedelta(days=years * 365)
366
+ streak_end_date = dt.date.today()
367
+ streak_counts = run_git_log(
368
+ streak_start_date, streak_end_date, repo_path, branch=branch
369
+ )
370
+ streaks = get_streaks(streak_counts)
371
+
372
+ if streaks["current_streak"] > 0:
373
+ print(f" Current streak: {streaks['current_streak']} days")
374
+
375
+ if streaks["longest_streak"] > 0:
376
+ longest_end = streaks["longest_streak_end"]
377
+ if longest_end:
378
+ month_name = longest_end.strftime("%b %Y")
379
+ print(
380
+ f" Longest streak: {streaks['longest_streak']} days ({month_name})"
381
+ )
382
+ else:
383
+ print(f" Longest streak: {streaks['longest_streak']} days")
384
+
385
+ if extended:
386
+ print("\n--- Recent Commits ---")
387
+ for commit in get_recent_commits(repo_path, branch=branch):
388
+ commit_color = color_scheme.get(3)
389
+ author_color = color_scheme.get(2)
390
+
391
+ commit_time = (
392
+ time.strftime("%H:%M", time.localtime(int(commit["timestamp"])))
393
+ if commit["timestamp"]
394
+ else ""
395
+ )
396
+ date_parts = commit["date"].split("-")
397
+ formatted_date = (
398
+ f"{date_parts[2]}-{date_parts[1]}-{date_parts[0]}"
399
+ if len(date_parts) == 3
400
+ else commit["date"]
401
+ )
402
+ print(
403
+ f" {commit_color}{commit['hash']}{RESET} | {author_color}{commit['author']}{RESET} | {LIGHT_GRAY}{formatted_date} {commit_time}{RESET}"
404
+ )
405
+ print(f" {commit['message']}")
406
+
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}")
411
+
412
+
413
+ if __name__ == "__main__":
414
+ main()