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.
gitember/colors.py ADDED
@@ -0,0 +1,143 @@
1
+ RESET = "\x1b[0m"
2
+ LIGHT_GRAY = "\x1b[38;5;250m"
3
+
4
+
5
+ class ColorScheme:
6
+ """Color scheme with 5 intensity levels (0-4)."""
7
+
8
+ def __init__(self, name: str, levels: list[str]):
9
+ self.name = name
10
+ self.levels = levels
11
+
12
+ def get(self, level: int) -> str:
13
+ """Return ANSI code for intensity level 0-4."""
14
+ return self.levels[min(level, 4)]
15
+
16
+
17
+ GREEN = ColorScheme(
18
+ "green",
19
+ [
20
+ "\x1b[38;5;234m", # 0: dark grey (no commits)
21
+ "\x1b[38;5;22m", # 1: darker green
22
+ "\x1b[38;5;28m", # 2: dark green
23
+ "\x1b[38;5;34m", # 3: medium green
24
+ "\x1b[38;5;40m", # 4: bright green
25
+ ],
26
+ )
27
+
28
+ BLUE = ColorScheme(
29
+ "blue",
30
+ [
31
+ "\x1b[38;5;234m", # 0: dark grey
32
+ "\x1b[38;5;17m", # 1: dark blue
33
+ "\x1b[38;5;19m", # 2: blue
34
+ "\x1b[38;5;21m", # 3: bright blue
35
+ "\x1b[38;5;33m", # 4: brightest blue
36
+ ],
37
+ )
38
+
39
+ ORANGE = ColorScheme(
40
+ "orange",
41
+ [
42
+ "\x1b[38;5;234m", # 0: dark grey
43
+ "\x1b[38;5;130m", # 1: dark orange
44
+ "\x1b[38;5;166m", # 2: orange
45
+ "\x1b[38;5;208m", # 3: bright orange
46
+ "\x1b[38;5;214m", # 4: brightest orange
47
+ ],
48
+ )
49
+
50
+ PURPLE = ColorScheme(
51
+ "purple",
52
+ [
53
+ "\x1b[38;5;234m", # 0: dark grey
54
+ "\x1b[38;5;54m", # 1: dark purple
55
+ "\x1b[38;5;92m", # 2: purple
56
+ "\x1b[38;5;129m", # 3: bright purple
57
+ "\x1b[38;5;141m", # 4: brightest purple
58
+ ],
59
+ )
60
+
61
+ MONO = ColorScheme(
62
+ "mono",
63
+ [
64
+ "\x1b[38;5;234m", # 0: dark grey
65
+ "\x1b[38;5;235m", # 1: dark grey
66
+ "\x1b[38;5;240m", # 2: grey
67
+ "\x1b[38;5;250m", # 3: light grey
68
+ "\x1b[38;5;255m", # 4: white
69
+ ],
70
+ )
71
+
72
+ RED = ColorScheme(
73
+ "red",
74
+ [
75
+ "\x1b[38;5;234m", # 0: dark grey
76
+ "\x1b[38;5;52m", # 1: dark red
77
+ "\x1b[38;5;124m", # 2: red
78
+ "\x1b[38;5;160m", # 3: bright red
79
+ "\x1b[38;5;196m", # 4: brightest red
80
+ ],
81
+ )
82
+
83
+ YELLOW = ColorScheme(
84
+ "yellow",
85
+ [
86
+ "\x1b[38;5;234m", # 0: dark grey
87
+ "\x1b[38;5;58m", # 1: dark yellow
88
+ "\x1b[38;5;100m", # 2: olive
89
+ "\x1b[38;5;178m", # 3: yellow
90
+ "\x1b[38;5;226m", # 4: bright yellow
91
+ ],
92
+ )
93
+
94
+ TEAL = ColorScheme(
95
+ "teal",
96
+ [
97
+ "\x1b[38;5;234m", # 0: dark grey
98
+ "\x1b[38;5;23m", # 1: dark teal
99
+ "\x1b[38;5;29m", # 2: teal
100
+ "\x1b[38;5;37m", # 3: bright teal
101
+ "\x1b[38;5;45m", # 4: brightest teal
102
+ ],
103
+ )
104
+
105
+ PINK = ColorScheme(
106
+ "pink",
107
+ [
108
+ "\x1b[38;5;234m", # 0: dark grey
109
+ "\x1b[38;5;125m", # 1: dark pink
110
+ "\x1b[38;5;169m", # 2: pink
111
+ "\x1b[38;5;183m", # 3: bright pink
112
+ "\x1b[38;5;219m", # 4: brightest pink
113
+ ],
114
+ )
115
+
116
+ AQUA = ColorScheme(
117
+ "aqua",
118
+ [
119
+ "\x1b[38;5;234m", # 0: dark grey
120
+ "\x1b[38;5;30m", # 1: dark aqua
121
+ "\x1b[38;5;37m", # 2: aqua
122
+ "\x1b[38;5;38m", # 3: bright aqua
123
+ "\x1b[38;5;51m", # 4: brightest aqua
124
+ ],
125
+ )
126
+
127
+ COLOR_SCHEMES = {
128
+ "green": GREEN,
129
+ "blue": BLUE,
130
+ "orange": ORANGE,
131
+ "purple": PURPLE,
132
+ "red": RED,
133
+ "yellow": YELLOW,
134
+ "teal": TEAL,
135
+ "pink": PINK,
136
+ "aqua": AQUA,
137
+ "mono": MONO,
138
+ }
139
+
140
+
141
+ def get_color_scheme(name: str) -> ColorScheme:
142
+ """Get color scheme by name, defaulting to green."""
143
+ return COLOR_SCHEMES.get(name, GREEN)
gitember/git.py ADDED
@@ -0,0 +1,535 @@
1
+ import datetime as dt
2
+ import subprocess
3
+ from collections import Counter
4
+ from typing import Any, Dict, List, Tuple
5
+ import re
6
+
7
+ COMMIT_PREFIX = "COMMIT:"
8
+
9
+
10
+ def _validate_path(path: str) -> None:
11
+ """Validate path doesn't contain git options or special characters.
12
+
13
+ Raises:
14
+ ValueError: If path is invalid.
15
+ """
16
+ if not path:
17
+ raise ValueError("Path cannot be empty")
18
+
19
+ if path.startswith("-"):
20
+ raise ValueError(f"Invalid path: {path}")
21
+
22
+ if re.search(r"[;&|`$()]", path):
23
+ raise ValueError(f"Invalid characters in path: {path}")
24
+
25
+
26
+ def _validate_branch(branch: str) -> None:
27
+ """Validate branch name doesn't contain git options or special characters.
28
+
29
+ Raises:
30
+ ValueError: If branch name is invalid.
31
+ """
32
+ if not branch:
33
+ raise ValueError("Branch name cannot be empty")
34
+
35
+ if branch.startswith("-"):
36
+ raise ValueError(f"Invalid branch name: {branch}")
37
+
38
+ if re.search(r"[;&|`$()]", branch):
39
+ raise ValueError(f"Invalid characters in branch name: {branch}")
40
+
41
+
42
+ def _build_log_args(branch: str | None, repo_path: str) -> List[str]:
43
+ """Build git log arguments for branch filtering.
44
+
45
+ Args:
46
+ branch: Optional branch name to filter by.
47
+ repo_path: Path to git repository.
48
+
49
+ Returns:
50
+ List of git log arguments (may be empty).
51
+ """
52
+ if not branch:
53
+ return ["--all"]
54
+
55
+ default_branch = get_default_branch(repo_path)
56
+ if branch != default_branch:
57
+ return [branch, "--not", default_branch]
58
+ return [branch]
59
+
60
+
61
+ def run_git_log(
62
+ start_date: dt.date,
63
+ end_date: dt.date,
64
+ repo_path: str,
65
+ branch: str | None = None,
66
+ ) -> Dict[dt.date, int]:
67
+ """Get commit counts per day within a date range.
68
+
69
+ Args:
70
+ start_date: Start of date range (inclusive).
71
+ end_date: End of date range (inclusive).
72
+ repo_path: Path to git repository.
73
+ branch: Optional branch name to filter by. Defaults to all branches.
74
+
75
+ Returns:
76
+ Dict mapping date to commit count.
77
+
78
+ Raises:
79
+ ValueError: If path is not a git repository.
80
+ """
81
+ _validate_path(repo_path)
82
+
83
+ if branch:
84
+ _validate_branch(branch)
85
+
86
+ try:
87
+ subprocess.run(
88
+ ["git", "-C", repo_path, "rev-parse", "--is-inside-work-tree"],
89
+ check=True,
90
+ stdout=subprocess.DEVNULL,
91
+ stderr=subprocess.DEVNULL,
92
+ )
93
+ except subprocess.CalledProcessError:
94
+ raise ValueError(f"Not a git repository: {repo_path}")
95
+
96
+ since = start_date.isoformat()
97
+ until = end_date.isoformat()
98
+
99
+ cmd = [
100
+ "git",
101
+ "-C",
102
+ repo_path,
103
+ "log",
104
+ ]
105
+
106
+ cmd.extend(_build_log_args(branch, repo_path))
107
+
108
+ cmd.extend(
109
+ [
110
+ f"--since={since}",
111
+ f"--until={until}",
112
+ "--date=short",
113
+ "--pretty=%ad",
114
+ ]
115
+ )
116
+
117
+ result = subprocess.run(cmd, capture_output=True, text=True)
118
+
119
+ if result.returncode != 0:
120
+ err = result.stderr.strip() or result.stdout.strip()
121
+ raise ValueError(err or f"git log failed for {repo_path}")
122
+
123
+ counter: Counter = Counter()
124
+ for line in result.stdout.splitlines():
125
+ line = line.strip()
126
+ if not line:
127
+ continue
128
+ try:
129
+ y, m, d = map(int, line.split("-"))
130
+ counter[dt.date(y, m, d)] += 1
131
+ except (ValueError, IndexError):
132
+ continue
133
+ return dict(counter)
134
+
135
+
136
+ def get_branches(repo_path: str) -> List[str]:
137
+ """Get list of local branch names.
138
+
139
+ Args:
140
+ repo_path: Path to git repository.
141
+
142
+ Returns:
143
+ List of branch names.
144
+ """
145
+ _validate_path(repo_path)
146
+
147
+ result = subprocess.run(
148
+ ["git", "-C", repo_path, "branch", "--format=%(refname:short)"],
149
+ capture_output=True,
150
+ text=True,
151
+ )
152
+
153
+ if result.returncode != 0:
154
+ return []
155
+
156
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
157
+
158
+
159
+ def get_default_branch(repo_path: str) -> str:
160
+ """Get the default branch name (e.g., main, master).
161
+
162
+ Args:
163
+ repo_path: Path to git repository.
164
+
165
+ Returns:
166
+ Name of default branch, or "main" as fallback.
167
+ """
168
+ _validate_path(repo_path)
169
+
170
+ # Check common default branch names in order of popularity
171
+ for branch in ["main", "master", "origin/main", "origin/master"]:
172
+ result = subprocess.run(
173
+ ["git", "-C", repo_path, "rev-parse", "--verify", f"refs/heads/{branch}"],
174
+ capture_output=True,
175
+ )
176
+ if result.returncode == 0:
177
+ return branch
178
+
179
+ # Fallback: get current HEAD branch if it's not main/master
180
+ result = subprocess.run(
181
+ ["git", "-C", repo_path, "symbolic-ref", "refs/heads/HEAD"],
182
+ capture_output=True,
183
+ text=True,
184
+ )
185
+ if result.returncode == 0:
186
+ current = result.stdout.strip().replace("refs/heads/", "")
187
+ if current not in ["main", "master"]:
188
+ return current
189
+
190
+ # Ultimate fallback for bare repos or unusual setups
191
+ return "main"
192
+
193
+
194
+ def get_recent_commits(
195
+ repo_path: str, count: int = 5, branch: str | None = None
196
+ ) -> List[Dict[str, str]]:
197
+ """Get recent commits with metadata.
198
+
199
+ Args:
200
+ repo_path: Path to git repository.
201
+ count: Number of commits to retrieve.
202
+ branch: Optional branch name to filter by.
203
+
204
+ Returns:
205
+ List of dicts with keys: hash, author, date, timestamp, message.
206
+ """
207
+ _validate_path(repo_path)
208
+
209
+ if branch:
210
+ _validate_branch(branch)
211
+
212
+ cmd = [
213
+ "git",
214
+ "-C",
215
+ repo_path,
216
+ "log",
217
+ f"-{count}",
218
+ "--pretty=format:%h|%an|%ad %at|%s",
219
+ "--date=short",
220
+ ]
221
+ cmd.extend(_build_log_args(branch, repo_path))
222
+
223
+ result = subprocess.run(cmd, capture_output=True, text=True)
224
+
225
+ if result.returncode != 0:
226
+ return []
227
+
228
+ commits = []
229
+ for line in result.stdout.splitlines():
230
+ if not line:
231
+ continue
232
+ parts = line.split("|", 3)
233
+ if len(parts) == 4:
234
+ datetime_parts = parts[2].rsplit(" ", 1)
235
+ date = datetime_parts[0]
236
+ timestamp = datetime_parts[1] if len(datetime_parts) > 1 else ""
237
+ commits.append(
238
+ {
239
+ "hash": parts[0],
240
+ "author": parts[1],
241
+ "date": date,
242
+ "timestamp": timestamp,
243
+ "message": parts[3],
244
+ }
245
+ )
246
+ return commits
247
+
248
+
249
+ def get_top_contributors(
250
+ repo_path: str, limit: int = 10, branch: str | None = None
251
+ ) -> List[Tuple[str, int]]:
252
+ """Get top contributors by commit count.
253
+
254
+ Args:
255
+ repo_path: Path to git repository.
256
+ limit: Maximum number of contributors to return.
257
+ branch: Optional branch name to filter by.
258
+
259
+ Returns:
260
+ List of (author, commit_count) tuples, sorted by count descending.
261
+ """
262
+ _validate_path(repo_path)
263
+
264
+ if branch:
265
+ _validate_branch(branch)
266
+
267
+ cmd = [
268
+ "git",
269
+ "-C",
270
+ repo_path,
271
+ "shortlog",
272
+ "-s",
273
+ "-n",
274
+ ]
275
+ cmd.extend(_build_log_args(branch, repo_path))
276
+
277
+ result = subprocess.run(cmd, capture_output=True, text=True)
278
+
279
+ if result.returncode != 0:
280
+ return []
281
+
282
+ contributors = []
283
+ for line in result.stdout.splitlines():
284
+ line = line.strip()
285
+ if not line:
286
+ continue
287
+ parts = line.split(None, 1)
288
+ if len(parts) == 2:
289
+ try:
290
+ count = int(parts[0])
291
+ contributors.append((parts[1], count))
292
+ except ValueError:
293
+ continue
294
+ return contributors[:limit]
295
+
296
+
297
+ def get_repo_stats(repo_path: str, branch: str | None = None) -> Dict[str, Any]:
298
+ """Get repository statistics.
299
+
300
+ Args:
301
+ repo_path: Path to git repository.
302
+ branch: Optional branch name to filter by.
303
+
304
+ Returns:
305
+ Dict with keys: total_commits, num_authors, first_commit_date,
306
+ first_commit_timestamp, last_commit_date, last_commit_timestamp.
307
+ """
308
+ _validate_path(repo_path)
309
+
310
+ if branch:
311
+ _validate_branch(branch)
312
+
313
+ log_args = _build_log_args(branch, repo_path)
314
+
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
+ )
328
+
329
+ if result.returncode != 0:
330
+ err = result.stderr.strip() or result.stdout.strip()
331
+ raise ValueError(err or f"git log failed for {repo_path}")
332
+
333
+ first_commit_timestamp = 0
334
+ first_commit_date = ""
335
+ last_commit_timestamp = ""
336
+ last_commit_date = ""
337
+ authors: set[str] = set()
338
+ total_commits = 0
339
+
340
+ for line in result.stdout.splitlines():
341
+ if not line:
342
+ continue
343
+ total_commits += 1
344
+ parts = line.split("|")
345
+ if len(parts) < 3:
346
+ continue
347
+
348
+ author, timestamp, date = parts[0], parts[1], parts[2]
349
+ authors.add(author)
350
+
351
+ ts = int(timestamp) if timestamp.isdigit() else 0
352
+ if ts:
353
+ if first_commit_timestamp == 0 or ts < first_commit_timestamp:
354
+ first_commit_timestamp = ts
355
+ first_commit_date = date
356
+ if not last_commit_timestamp or ts > int(last_commit_timestamp):
357
+ last_commit_timestamp = str(ts)
358
+ last_commit_date = date
359
+
360
+ return {
361
+ "total_commits": total_commits,
362
+ "num_authors": len(authors),
363
+ "first_commit_date": first_commit_date,
364
+ "first_commit_timestamp": first_commit_timestamp,
365
+ "last_commit_date": last_commit_date,
366
+ "last_commit_timestamp": last_commit_timestamp,
367
+ }
368
+
369
+
370
+ def get_streaks(counts: Dict[dt.date, int]) -> Dict[str, Any]:
371
+ """Calculate current and longest streaks from commit counts.
372
+
373
+ Args:
374
+ counts: Dict mapping date to commit count.
375
+
376
+ Returns:
377
+ Dict with current_streak, longest_streak, longest_streak_end.
378
+ """
379
+ if not counts:
380
+ return {"current_streak": 0, "longest_streak": 0, "longest_streak_end": None}
381
+
382
+ sorted_dates = sorted(counts.keys())
383
+
384
+ longest = 0
385
+ longest_end = None
386
+ streak = 0
387
+ prev_date = None
388
+
389
+ for date in sorted_dates:
390
+ if prev_date is not None and (date - prev_date).days != 1:
391
+ streak = 0
392
+
393
+ if counts[date] > 0:
394
+ streak += 1
395
+ if streak >= longest:
396
+ longest = streak
397
+ longest_end = date
398
+ else:
399
+ streak = 0
400
+
401
+ prev_date = date
402
+
403
+ today = dt.date.today()
404
+ current = 0
405
+ for i in range(30):
406
+ check_date = today - dt.timedelta(days=i)
407
+ if check_date in counts and counts[check_date] > 0:
408
+ current += 1
409
+ else:
410
+ break
411
+
412
+ return {
413
+ "current_streak": current,
414
+ "longest_streak": longest,
415
+ "longest_streak_end": longest_end,
416
+ }
417
+
418
+
419
+ def _parse_graph_line(line: str) -> tuple[str, str]:
420
+ """Extract the graph prefix and content from a git log line.
421
+
422
+ Args:
423
+ line: Raw line from git log --graph output.
424
+
425
+ Returns:
426
+ Tuple of (graph_line, content) where graph_line contains
427
+ the graph characters (|, /, \, *, spaces) and content is
428
+ the remaining text after the graph prefix.
429
+ """
430
+ graph_line = ""
431
+ idx = 0
432
+ while idx < len(line):
433
+ c = line[idx]
434
+ if c in "*|/\\":
435
+ graph_line += c
436
+ idx += 1
437
+ while idx < len(line) and line[idx] == " ":
438
+ graph_line += line[idx]
439
+ idx += 1
440
+ elif c == " " and graph_line:
441
+ graph_line += c
442
+ idx += 1
443
+ else:
444
+ break
445
+
446
+ content = line[idx:].lstrip()
447
+ return graph_line, content
448
+
449
+
450
+ def get_branch_tree(
451
+ repo_path: str, branch: str | None = None, limit: int = 20
452
+ ) -> List[Dict[str, Any]]:
453
+ """Get branch tree data for visualization.
454
+
455
+ Args:
456
+ repo_path: Path to git repository.
457
+ branch: Optional branch name to filter by.
458
+ limit: Maximum number of commits to return.
459
+
460
+ Returns:
461
+ List of dicts with keys: hash, message, date, branches, graph_line.
462
+ Connector-only rows (/, \\ lines) are included with an empty hash
463
+ so the renderer can display branch split/merge lines correctly.
464
+ """
465
+ _validate_path(repo_path)
466
+
467
+ if branch:
468
+ _validate_branch(branch)
469
+
470
+ log_args = _build_log_args(branch, repo_path)
471
+
472
+ cmd = (
473
+ [
474
+ "git",
475
+ "-C",
476
+ repo_path,
477
+ "log",
478
+ "--format=COMMIT:%H|%h|%s|%d",
479
+ "--graph",
480
+ "--all",
481
+ ]
482
+ + log_args
483
+ + [f"-{limit}"]
484
+ )
485
+
486
+ result = subprocess.run(cmd, capture_output=True, text=True)
487
+ if result.returncode != 0:
488
+ return []
489
+
490
+ commits = []
491
+ for line in result.stdout.splitlines():
492
+ if not line:
493
+ continue
494
+
495
+ graph_line, content = _parse_graph_line(line)
496
+
497
+ if not content.startswith(COMMIT_PREFIX):
498
+ if graph_line:
499
+ commits.append(
500
+ {
501
+ "hash": "",
502
+ "message": "",
503
+ "branches": [],
504
+ "graph_line": graph_line,
505
+ }
506
+ )
507
+ continue
508
+
509
+ content = content[len(COMMIT_PREFIX) :]
510
+ parts = content.split("|")
511
+
512
+ if len(parts) >= 3:
513
+ hash7 = parts[1]
514
+ message = parts[2]
515
+
516
+ branches = []
517
+ if len(parts) > 3 and parts[3].strip():
518
+ branches_raw = parts[3].strip()
519
+ branches_raw = branches_raw.strip("()")
520
+ if branches_raw:
521
+ for b in branches_raw.replace("HEAD -> ", "").split(","):
522
+ b = b.strip()
523
+ if b and not b.startswith("tag:"):
524
+ branches.append(b)
525
+
526
+ commits.append(
527
+ {
528
+ "hash": hash7,
529
+ "message": message,
530
+ "branches": branches,
531
+ "graph_line": graph_line,
532
+ }
533
+ )
534
+
535
+ return commits