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.
- git_ember-1.2.0.dist-info/METADATA +165 -0
- git_ember-1.2.0.dist-info/RECORD +10 -0
- git_ember-1.2.0.dist-info/WHEEL +5 -0
- git_ember-1.2.0.dist-info/entry_points.txt +2 -0
- git_ember-1.2.0.dist-info/top_level.txt +1 -0
- gitember/__init__.py +1 -0
- gitember/cli.py +414 -0
- gitember/colors.py +143 -0
- gitember/git.py +535 -0
- gitember/render.py +293 -0
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
|