git-contrib-tree 0.1.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-contrib-tree
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
git-contrib-tree
|
|
4
|
+
|
|
5
|
+
Analyze git repository contributions and display a tree of files with top contributors for each.
|
|
6
|
+
|
|
7
|
+
Repository: https://gitlab.com/wykwit/git-contrib-tree
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
16
|
+
|
|
17
|
+
TOP_CONTRIBUTORS_LIMIT = 3
|
|
18
|
+
NULL_SEPARATOR = "\x00"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitContributionAnalyzer:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
repo_path: str = ".",
|
|
25
|
+
since: Optional[str] = None,
|
|
26
|
+
until: Optional[str] = None,
|
|
27
|
+
subtree_path: Optional[str] = None,
|
|
28
|
+
author_emails: Optional[Set[str]] = None,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Initialize the analyzer.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
repo_path: Path to the git repository
|
|
35
|
+
since: Start date for analysis (git date format)
|
|
36
|
+
until: End date for analysis (git date format)
|
|
37
|
+
subtree_path: Path within repo to analyze (e.g., "src/models")
|
|
38
|
+
author_emails: Filter to only show contributions by these author emails (set of emails)
|
|
39
|
+
"""
|
|
40
|
+
self.repo_path = Path(repo_path).resolve()
|
|
41
|
+
self.since = since
|
|
42
|
+
self.until = until
|
|
43
|
+
self.subtree_path = subtree_path
|
|
44
|
+
self.author_emails = author_emails
|
|
45
|
+
self._file_contributors_cache: Dict[str, List[Tuple[str, int]]] = {}
|
|
46
|
+
|
|
47
|
+
if not self._is_git_repo():
|
|
48
|
+
raise ValueError(f"{self.repo_path} is not a git repository")
|
|
49
|
+
|
|
50
|
+
def _is_git_repo(self) -> bool:
|
|
51
|
+
"""Check if the path is a git repository."""
|
|
52
|
+
try:
|
|
53
|
+
subprocess.run(
|
|
54
|
+
["git", "rev-parse", "--git-dir"],
|
|
55
|
+
cwd=self.repo_path,
|
|
56
|
+
capture_output=True,
|
|
57
|
+
check=True,
|
|
58
|
+
)
|
|
59
|
+
return True
|
|
60
|
+
except subprocess.CalledProcessError:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def _build_git_log_base_args(self) -> List[str]:
|
|
64
|
+
"""Build base git log command with common date filters."""
|
|
65
|
+
args = ["git", "log"]
|
|
66
|
+
if self.since:
|
|
67
|
+
args.extend(["--since", self.since])
|
|
68
|
+
if self.until:
|
|
69
|
+
args.extend(["--until", self.until])
|
|
70
|
+
return args
|
|
71
|
+
|
|
72
|
+
def _parse_author_line(self, line: str) -> Optional[Tuple[str, str]]:
|
|
73
|
+
"""
|
|
74
|
+
Parse a line containing author name and email.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
line: Line in format "name\x00email"
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Tuple of (author_name, author_email) or None if invalid
|
|
81
|
+
"""
|
|
82
|
+
if not line or NULL_SEPARATOR not in line:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
parts = line.split(NULL_SEPARATOR)
|
|
86
|
+
if len(parts) < 2:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
return parts[0], parts[1]
|
|
90
|
+
|
|
91
|
+
def _should_include_author(self, author_email: str) -> bool:
|
|
92
|
+
"""Check if author should be included based on email filter."""
|
|
93
|
+
if not self.author_emails:
|
|
94
|
+
return True
|
|
95
|
+
return author_email in self.author_emails
|
|
96
|
+
|
|
97
|
+
def _get_git_log_args(self, file_path: Optional[str] = None) -> List[str]:
|
|
98
|
+
"""Build git log command arguments."""
|
|
99
|
+
args = self._build_git_log_base_args()
|
|
100
|
+
args.append("--format=%aN%x00%aE")
|
|
101
|
+
|
|
102
|
+
if file_path:
|
|
103
|
+
args.extend(["--", file_path])
|
|
104
|
+
|
|
105
|
+
return args
|
|
106
|
+
|
|
107
|
+
def _get_top_contributors(
|
|
108
|
+
self, author_commits: Dict[str, int]
|
|
109
|
+
) -> List[Tuple[str, int]]:
|
|
110
|
+
"""
|
|
111
|
+
Get top N contributors from author commits dictionary.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
author_commits: Dictionary mapping author names to commit counts
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
List of top contributors as (author_name, commit_count) tuples
|
|
118
|
+
"""
|
|
119
|
+
if not author_commits:
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
sorted_authors = sorted(
|
|
123
|
+
author_commits.items(), key=lambda x: x[1], reverse=True
|
|
124
|
+
)
|
|
125
|
+
return sorted_authors[:TOP_CONTRIBUTORS_LIMIT]
|
|
126
|
+
|
|
127
|
+
def get_contributors(
|
|
128
|
+
self, file_path: Optional[str] = None
|
|
129
|
+
) -> List[Tuple[str, int]]:
|
|
130
|
+
"""
|
|
131
|
+
Get top 3 contributors for a file or entire project.
|
|
132
|
+
Uses caching to avoid duplicate git log calls.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
file_path: Path to file relative to repo root, or None for entire project
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of tuples (author_name, commit_count) for top 3 contributors
|
|
139
|
+
"""
|
|
140
|
+
cache_key = file_path or "__PROJECT__"
|
|
141
|
+
|
|
142
|
+
if cache_key in self._file_contributors_cache:
|
|
143
|
+
return self._file_contributors_cache[cache_key]
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
result = subprocess.run(
|
|
147
|
+
self._get_git_log_args(file_path),
|
|
148
|
+
cwd=self.repo_path,
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
check=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
author_commits = defaultdict(int)
|
|
155
|
+
for line in result.stdout.strip().split("\n"):
|
|
156
|
+
parsed = self._parse_author_line(line)
|
|
157
|
+
if not parsed:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
author_name, author_email = parsed
|
|
161
|
+
if not self._should_include_author(author_email):
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
author_commits[author_name] += 1
|
|
165
|
+
|
|
166
|
+
top_contributors = self._get_top_contributors(author_commits)
|
|
167
|
+
self._file_contributors_cache[cache_key] = top_contributors
|
|
168
|
+
|
|
169
|
+
return top_contributors
|
|
170
|
+
|
|
171
|
+
except subprocess.CalledProcessError:
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
def _preload_all_contributors(self, files: List[str]):
|
|
175
|
+
"""
|
|
176
|
+
Preload contributors for all files using git log --name-only.
|
|
177
|
+
This is much faster than running git log once per file.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
files: List of file paths to analyze
|
|
181
|
+
"""
|
|
182
|
+
if not files:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
args = self._build_git_log_base_args()
|
|
186
|
+
args.extend(["--name-only", "--format=%H%x00%aN%x00%aE"])
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
result = subprocess.run(
|
|
190
|
+
args, cwd=self.repo_path, capture_output=True, text=True, check=True
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
file_authors: Dict[str, Dict[str, int]] = defaultdict(
|
|
194
|
+
lambda: defaultdict(int)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
lines = result.stdout.split("\n")
|
|
198
|
+
current_author: Optional[str] = None
|
|
199
|
+
current_email: Optional[str] = None
|
|
200
|
+
|
|
201
|
+
for line in lines:
|
|
202
|
+
if NULL_SEPARATOR in line:
|
|
203
|
+
parts = line.split(NULL_SEPARATOR)
|
|
204
|
+
if len(parts) >= 3:
|
|
205
|
+
current_author = parts[1].strip()
|
|
206
|
+
current_email = parts[2].strip()
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
line = line.strip()
|
|
210
|
+
if not line or not current_author or not current_email:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
if not self._should_include_author(current_email):
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
file_authors[line][current_author] += 1
|
|
217
|
+
|
|
218
|
+
for file_path in files:
|
|
219
|
+
if file_path in file_authors:
|
|
220
|
+
top_contributors = self._get_top_contributors(
|
|
221
|
+
file_authors[file_path]
|
|
222
|
+
)
|
|
223
|
+
self._file_contributors_cache[file_path] = top_contributors
|
|
224
|
+
else:
|
|
225
|
+
self._file_contributors_cache[file_path] = []
|
|
226
|
+
|
|
227
|
+
except subprocess.CalledProcessError:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
def get_all_contributor_emails(self) -> List[Tuple[str, str, int]]:
|
|
231
|
+
"""
|
|
232
|
+
Get all unique contributor emails with their names and commit counts.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of tuples (author_name, author_email, commit_count) sorted by commit count
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
args = self._build_git_log_base_args()
|
|
239
|
+
args.append("--format=%aN%x00%aE")
|
|
240
|
+
|
|
241
|
+
if self.subtree_path:
|
|
242
|
+
args.extend(["--", self.subtree_path])
|
|
243
|
+
|
|
244
|
+
result = subprocess.run(
|
|
245
|
+
args, cwd=self.repo_path, capture_output=True, text=True, check=True
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
author_data: Dict[str, Tuple[str, int]] = {}
|
|
249
|
+
|
|
250
|
+
for line in result.stdout.strip().split("\n"):
|
|
251
|
+
parsed = self._parse_author_line(line)
|
|
252
|
+
if not parsed:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
author_name, author_email = parsed
|
|
256
|
+
|
|
257
|
+
if author_email in author_data:
|
|
258
|
+
name, count = author_data[author_email]
|
|
259
|
+
author_data[author_email] = (name, count + 1)
|
|
260
|
+
else:
|
|
261
|
+
author_data[author_email] = (author_name, 1)
|
|
262
|
+
|
|
263
|
+
result_list = [
|
|
264
|
+
(name, email, count) for email, (name, count) in author_data.items()
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
return sorted(result_list, key=lambda x: x[2], reverse=True)
|
|
268
|
+
|
|
269
|
+
except subprocess.CalledProcessError:
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
def get_tracked_files(self, subtree_path: Optional[str] = None) -> List[str]:
|
|
273
|
+
"""Get list of all tracked files in the repository or subtree."""
|
|
274
|
+
try:
|
|
275
|
+
args = ["git", "ls-files"]
|
|
276
|
+
if subtree_path:
|
|
277
|
+
args.append(subtree_path)
|
|
278
|
+
|
|
279
|
+
result = subprocess.run(
|
|
280
|
+
args, cwd=self.repo_path, capture_output=True, text=True, check=True
|
|
281
|
+
)
|
|
282
|
+
return [f for f in result.stdout.strip().split("\n") if f]
|
|
283
|
+
except subprocess.CalledProcessError:
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
def build_file_tree(self, subtree_path: Optional[str] = None) -> Dict:
|
|
287
|
+
"""
|
|
288
|
+
Build a complete tree structure of ALL files (no depth limit here).
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
subtree_path: Path within repo to analyze (e.g., "src/models")
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Nested dictionary representing file tree
|
|
295
|
+
"""
|
|
296
|
+
files = self.get_tracked_files(subtree_path)
|
|
297
|
+
tree: Dict = {}
|
|
298
|
+
|
|
299
|
+
for file_path in files:
|
|
300
|
+
file_path_relative = self._get_relative_path(file_path, subtree_path)
|
|
301
|
+
if not file_path_relative:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
self._add_to_tree(tree, file_path_relative, file_path)
|
|
305
|
+
|
|
306
|
+
return tree
|
|
307
|
+
|
|
308
|
+
def _get_relative_path(
|
|
309
|
+
self, file_path: str, subtree_path: Optional[str]
|
|
310
|
+
) -> Optional[str]:
|
|
311
|
+
"""
|
|
312
|
+
Get relative path for a file within a subtree.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
file_path: Full file path
|
|
316
|
+
subtree_path: Optional subtree path to make relative to
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Relative path or None if file is not in subtree
|
|
320
|
+
"""
|
|
321
|
+
if not subtree_path:
|
|
322
|
+
return file_path
|
|
323
|
+
|
|
324
|
+
if file_path.startswith(subtree_path + "/"):
|
|
325
|
+
return file_path[len(subtree_path) + 1 :]
|
|
326
|
+
elif file_path == subtree_path:
|
|
327
|
+
return file_path.split("/")[-1]
|
|
328
|
+
else:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
def _add_to_tree(self, tree: Dict, relative_path: str, full_path: str):
|
|
332
|
+
"""
|
|
333
|
+
Add a file to the tree structure.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
tree: Tree dictionary to modify
|
|
337
|
+
relative_path: Relative path of the file
|
|
338
|
+
full_path: Full path for git operations
|
|
339
|
+
"""
|
|
340
|
+
parts = relative_path.split("/")
|
|
341
|
+
current = tree
|
|
342
|
+
|
|
343
|
+
for part in parts[:-1]:
|
|
344
|
+
if part not in current:
|
|
345
|
+
current[part] = {}
|
|
346
|
+
current = current[part]
|
|
347
|
+
|
|
348
|
+
current[parts[-1]] = full_path
|
|
349
|
+
|
|
350
|
+
def format_contributors(self, contributors: List[Tuple[str, int]]) -> str:
|
|
351
|
+
"""Format contributors list for display."""
|
|
352
|
+
if not contributors:
|
|
353
|
+
return "X"
|
|
354
|
+
|
|
355
|
+
return ", ".join(f"{name} ({count})" for name, count in contributors)
|
|
356
|
+
|
|
357
|
+
def _get_all_files_from_tree(self, tree: Dict) -> List[str]:
|
|
358
|
+
"""
|
|
359
|
+
Recursively get all file paths from a tree structure.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
tree: The tree structure or subtree
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
List of all file paths in this tree
|
|
366
|
+
"""
|
|
367
|
+
files = []
|
|
368
|
+
for name, value in tree.items():
|
|
369
|
+
if isinstance(value, dict):
|
|
370
|
+
files.extend(self._get_all_files_from_tree(value))
|
|
371
|
+
else:
|
|
372
|
+
files.append(value)
|
|
373
|
+
return files
|
|
374
|
+
|
|
375
|
+
def _calculate_directory_contributors(
|
|
376
|
+
self, tree: Dict
|
|
377
|
+
) -> Dict[str, List[Tuple[str, int]]]:
|
|
378
|
+
"""
|
|
379
|
+
Pre-calculate contributors for all directories in the tree.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
tree: The complete file tree
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Dictionary mapping directory paths to their top 3 contributors
|
|
386
|
+
"""
|
|
387
|
+
dir_contributors = {}
|
|
388
|
+
|
|
389
|
+
def process_subtree(subtree: Dict, path: str = "") -> Dict[str, int]:
|
|
390
|
+
"""Process subtree and return aggregated author commits."""
|
|
391
|
+
author_commits = defaultdict(int)
|
|
392
|
+
|
|
393
|
+
for name, value in subtree.items():
|
|
394
|
+
if isinstance(value, dict):
|
|
395
|
+
subpath = f"{path}/{name}" if path else name
|
|
396
|
+
sub_author_commits = process_subtree(value, subpath)
|
|
397
|
+
|
|
398
|
+
for author, count in sub_author_commits.items():
|
|
399
|
+
author_commits[author] += count
|
|
400
|
+
else:
|
|
401
|
+
file_path = value
|
|
402
|
+
contributors = self.get_contributors(file_path)
|
|
403
|
+
for author, count in contributors:
|
|
404
|
+
author_commits[author] += count
|
|
405
|
+
|
|
406
|
+
if author_commits:
|
|
407
|
+
sorted_authors = sorted(
|
|
408
|
+
author_commits.items(), key=lambda x: x[1], reverse=True
|
|
409
|
+
)
|
|
410
|
+
dir_contributors[path] = sorted_authors[:3]
|
|
411
|
+
|
|
412
|
+
return author_commits
|
|
413
|
+
|
|
414
|
+
process_subtree(tree)
|
|
415
|
+
return dir_contributors
|
|
416
|
+
|
|
417
|
+
def print_tree(self, max_depth: int = -1):
|
|
418
|
+
"""
|
|
419
|
+
Print the file tree with contributors.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
max_depth: Maximum depth to traverse (0 = project only, -1 = unlimited)
|
|
423
|
+
"""
|
|
424
|
+
if max_depth == 0:
|
|
425
|
+
if self.subtree_path:
|
|
426
|
+
contributors = self.get_contributors(self.subtree_path)
|
|
427
|
+
print(f"Path: {self.subtree_path}")
|
|
428
|
+
else:
|
|
429
|
+
contributors = self.get_contributors()
|
|
430
|
+
print(f"Project: {self.repo_path.name}")
|
|
431
|
+
print(f" Top contributors: {self.format_contributors(contributors)}")
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
tree = self.build_file_tree(self.subtree_path)
|
|
435
|
+
all_files = self._get_all_files_from_tree(tree)
|
|
436
|
+
self._preload_all_contributors(all_files)
|
|
437
|
+
dir_contributors = self._calculate_directory_contributors(tree)
|
|
438
|
+
|
|
439
|
+
if self.subtree_path:
|
|
440
|
+
print(f"Repository: {self.repo_path.name} (path: {self.subtree_path})")
|
|
441
|
+
else:
|
|
442
|
+
print(f"Repository: {self.repo_path.name}")
|
|
443
|
+
print()
|
|
444
|
+
|
|
445
|
+
self._print_tree_recursive(tree, "", max_depth, 1, "", dir_contributors)
|
|
446
|
+
|
|
447
|
+
def _should_print_item(
|
|
448
|
+
self, is_directory: bool, contributors: List[Tuple[str, int]]
|
|
449
|
+
) -> bool:
|
|
450
|
+
"""
|
|
451
|
+
Determine if a tree item should be printed.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
is_directory: Whether the item is a directory
|
|
455
|
+
contributors: List of contributors for the item
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
True if the item should be printed
|
|
459
|
+
"""
|
|
460
|
+
if not self.author_emails:
|
|
461
|
+
return True
|
|
462
|
+
return bool(contributors)
|
|
463
|
+
|
|
464
|
+
def _print_tree_recursive(
|
|
465
|
+
self,
|
|
466
|
+
tree: Dict,
|
|
467
|
+
prefix: str,
|
|
468
|
+
max_depth: int,
|
|
469
|
+
depth: int,
|
|
470
|
+
current_path: str,
|
|
471
|
+
dir_contributors: Dict[str, List[Tuple[str, int]]],
|
|
472
|
+
):
|
|
473
|
+
"""Recursively print the tree structure up to max_depth."""
|
|
474
|
+
items = sorted(tree.items())
|
|
475
|
+
|
|
476
|
+
for i, (name, value) in enumerate(items):
|
|
477
|
+
is_last = i == len(items) - 1
|
|
478
|
+
connector = "└── " if is_last else "├── "
|
|
479
|
+
is_directory = isinstance(value, dict)
|
|
480
|
+
|
|
481
|
+
if is_directory:
|
|
482
|
+
dir_path = f"{current_path}/{name}" if current_path else name
|
|
483
|
+
contributors = dir_contributors.get(dir_path, [])
|
|
484
|
+
|
|
485
|
+
if not self._should_print_item(True, contributors):
|
|
486
|
+
continue
|
|
487
|
+
|
|
488
|
+
contrib_str = self.format_contributors(contributors)
|
|
489
|
+
print(f"{prefix}{connector}{name}/ - {contrib_str}")
|
|
490
|
+
|
|
491
|
+
if max_depth == -1 or depth < max_depth:
|
|
492
|
+
new_prefix = prefix + (" " if is_last else "│ ")
|
|
493
|
+
self._print_tree_recursive(
|
|
494
|
+
value,
|
|
495
|
+
new_prefix,
|
|
496
|
+
max_depth,
|
|
497
|
+
depth + 1,
|
|
498
|
+
dir_path,
|
|
499
|
+
dir_contributors,
|
|
500
|
+
)
|
|
501
|
+
else:
|
|
502
|
+
if max_depth == -1 or depth <= max_depth:
|
|
503
|
+
contributors = self.get_contributors(value)
|
|
504
|
+
|
|
505
|
+
if not self._should_print_item(False, contributors):
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
contrib_str = self.format_contributors(contributors)
|
|
509
|
+
print(f"{prefix}{connector}{name} - {contrib_str}")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def parse_args():
|
|
513
|
+
"""Parse command line arguments."""
|
|
514
|
+
parser = argparse.ArgumentParser(
|
|
515
|
+
description="Analyze git contributions and display file tree with top contributors",
|
|
516
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
517
|
+
epilog="""
|
|
518
|
+
Examples:
|
|
519
|
+
%(prog)s # Analyze entire repository
|
|
520
|
+
%(prog)s --depth 0 # Show only project-level contributors
|
|
521
|
+
%(prog)s --depth 2 # Show files up to depth 2
|
|
522
|
+
%(prog)s --path src # Analyze only src directory
|
|
523
|
+
%(prog)s --path src/models --depth 1 # Analyze src/models up to depth 1
|
|
524
|
+
%(prog)s --since "2024-01-01" # Analyze from specific date
|
|
525
|
+
%(prog)s --since "6 months ago" # Analyze last 6 months
|
|
526
|
+
%(prog)s --until "2024-12-31" # Analyze until specific date
|
|
527
|
+
%(prog)s --repo /path/to/repo # Analyze different repository
|
|
528
|
+
%(prog)s --email user@example.com # Show only contributions by specific author
|
|
529
|
+
%(prog)s --email user1@ex.com,user2@ex.com # Show contributions by multiple authors
|
|
530
|
+
""",
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
parser.add_argument(
|
|
534
|
+
"--repo",
|
|
535
|
+
default=".",
|
|
536
|
+
help="Path to git repository (default: current directory)",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
parser.add_argument(
|
|
540
|
+
"--depth",
|
|
541
|
+
type=int,
|
|
542
|
+
default=-1,
|
|
543
|
+
help="Maximum tree depth (0 = project only, -1 = unlimited, default: -1)",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
parser.add_argument(
|
|
547
|
+
"--path", help="Path within repository to analyze (e.g., 'src', 'src/models')"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
parser.add_argument(
|
|
551
|
+
"--since",
|
|
552
|
+
help="Show commits after this date (git date format, e.g., '2024-01-01', '6 months ago')",
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
parser.add_argument(
|
|
556
|
+
"--until", help="Show commits before this date (git date format)"
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
parser.add_argument(
|
|
560
|
+
"--email",
|
|
561
|
+
help="Filter to only show contributions by these author emails (comma-separated, e.g., 'user1@example.com,user2@example.com')",
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
parser.add_argument(
|
|
565
|
+
"--list-emails",
|
|
566
|
+
action="store_true",
|
|
567
|
+
help="List all contributor emails and exit",
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
return parser.parse_args()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def main():
|
|
574
|
+
"""Main entry point."""
|
|
575
|
+
args = parse_args()
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
author_emails = None
|
|
579
|
+
if args.email:
|
|
580
|
+
author_emails = set(
|
|
581
|
+
email.strip() for email in args.email.split(",") if email.strip()
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
analyzer = GitContributionAnalyzer(
|
|
585
|
+
repo_path=args.repo,
|
|
586
|
+
since=args.since,
|
|
587
|
+
until=args.until,
|
|
588
|
+
subtree_path=args.path,
|
|
589
|
+
author_emails=author_emails,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
if args.list_emails:
|
|
593
|
+
contributors = analyzer.get_all_contributor_emails()
|
|
594
|
+
|
|
595
|
+
if not contributors:
|
|
596
|
+
print("No contributors found.", file=sys.stderr)
|
|
597
|
+
sys.exit(1)
|
|
598
|
+
|
|
599
|
+
print("Contributors:")
|
|
600
|
+
for name, email, count in contributors:
|
|
601
|
+
print(f" {name} <{email}> - {count} commit{'s' if count != 1 else ''}")
|
|
602
|
+
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
analyzer.print_tree(max_depth=args.depth)
|
|
606
|
+
|
|
607
|
+
except ValueError as e:
|
|
608
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
609
|
+
sys.exit(1)
|
|
610
|
+
except KeyboardInterrupt:
|
|
611
|
+
print("\nAborted.", file=sys.stderr)
|
|
612
|
+
sys.exit(130)
|
|
613
|
+
except Exception as e:
|
|
614
|
+
print(f"Unexpected error: {e}", file=sys.stderr)
|
|
615
|
+
sys.exit(1)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
if __name__ == "__main__":
|
|
619
|
+
main()
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-contrib-tree
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Analyze and visualize git repository contributions with a file tree showing top contributors.
|
|
5
|
+
Project-URL: Repository, https://gitlab.com/wykwit/git-contrib-tree
|
|
6
|
+
Project-URL: Issues, https://gitlab.com/wykwit/git-contrib-tree/issues
|
|
7
|
+
Author-email: "Wiktor W." <wykwit@disroot.org>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: analysis,cli,contributions,git,tree,visualization
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.6
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# git-contrib-tree
|
|
29
|
+
|
|
30
|
+
A Python tool to analyze and visualize git repository contributions by displaying a file tree with the top 3 contributors for each file and directory.
|
|
31
|
+
|
|
32
|
+
> **Note:** This project was created with the assistance of Large Language Models (LLMs) for code generation, optimization, and documentation.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- Display repository file tree with top contributors per file
|
|
37
|
+
- Show top 3 contributors for directories (aggregated from all files within)
|
|
38
|
+
- Show commit counts alongside contributor names
|
|
39
|
+
- Filter by date range (--since/--until)
|
|
40
|
+
- Filter by contributor email(s)
|
|
41
|
+
- List all contributor emails
|
|
42
|
+
- Control tree depth (useful for large repositories)
|
|
43
|
+
- Analyze specific subtrees within a repository
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- Python 3.6+
|
|
48
|
+
- Git installed and accessible in PATH
|
|
49
|
+
- A git repository to analyze
|
|
50
|
+
|
|
51
|
+
No external dependencies required - uses only Python standard library and git.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
### Using uv (Recommended)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv pip install git-contrib-tree
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Using pip
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install git-contrib-tree
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Arch Linux (AUR)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Using an AUR helper
|
|
71
|
+
paru -S git-contrib-tree
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This installs the `git-contrib-tree` command, making it available as both:
|
|
75
|
+
- A standalone command: `git-contrib-tree`
|
|
76
|
+
- A git subcommand: `git contrib-tree`
|
|
77
|
+
|
|
78
|
+
### Development Installation
|
|
79
|
+
|
|
80
|
+
For development, clone the repository and install in editable mode:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git clone https://gitlab.com/wykwit/git-contrib-tree.git
|
|
84
|
+
cd git-contrib-tree
|
|
85
|
+
uv pip install -e .
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Standalone Script
|
|
89
|
+
|
|
90
|
+
The `git-contrib-tree` script is a self-contained Python file with no external dependencies. You can use it directly without installing the package:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# Download the script
|
|
94
|
+
curl -O https://gitlab.com/wykwit/git-contrib-tree/-/raw/main/git-contrib-tree
|
|
95
|
+
chmod +x git-contrib-tree
|
|
96
|
+
|
|
97
|
+
# Run it directly
|
|
98
|
+
./git-contrib-tree --help
|
|
99
|
+
|
|
100
|
+
# Or with Python
|
|
101
|
+
python3 git-contrib-tree --help
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
This is useful for:
|
|
105
|
+
- One-off usage without installation
|
|
106
|
+
- Including in other projects or scripts
|
|
107
|
+
- Running on systems where you can't install packages
|
|
108
|
+
|
|
109
|
+
## Quick Start
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Analyze current directory
|
|
113
|
+
git contrib-tree
|
|
114
|
+
|
|
115
|
+
# Show project-level overview
|
|
116
|
+
git contrib-tree --depth 0
|
|
117
|
+
|
|
118
|
+
# See who worked on what in the last quarter
|
|
119
|
+
git contrib-tree --since "3 months ago"
|
|
120
|
+
|
|
121
|
+
# List all contributors
|
|
122
|
+
git contrib-tree --list-emails
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Usage
|
|
126
|
+
|
|
127
|
+
### Basic Usage
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# Analyze current directory
|
|
131
|
+
git contrib-tree
|
|
132
|
+
|
|
133
|
+
# Analyze specific repository
|
|
134
|
+
git contrib-tree --repo /path/to/repo
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Depth Control
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# Show only project-level contributors
|
|
141
|
+
git contrib-tree --depth 0
|
|
142
|
+
|
|
143
|
+
# Show root level only
|
|
144
|
+
git contrib-tree --depth 1
|
|
145
|
+
|
|
146
|
+
# Show files up to depth 2
|
|
147
|
+
git contrib-tree --depth 2
|
|
148
|
+
|
|
149
|
+
# Show all files (default)
|
|
150
|
+
git contrib-tree --depth -1
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Note:** Depth controls what is *displayed*, not what is analyzed. Directory summaries always include all files within them, even if those files are not shown due to depth limits.
|
|
154
|
+
|
|
155
|
+
### Analyze Specific Path
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# Analyze only the src directory
|
|
159
|
+
git contrib-tree --path src
|
|
160
|
+
|
|
161
|
+
# Analyze only src/models with depth 1
|
|
162
|
+
git contrib-tree --path src/models --depth 1
|
|
163
|
+
|
|
164
|
+
# Analyze specific file
|
|
165
|
+
git contrib-tree --path README.md --depth 0
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Date Filtering
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# Analyze contributions from specific date
|
|
172
|
+
git contrib-tree --since "2024-01-01"
|
|
173
|
+
|
|
174
|
+
# Analyze last 6 months
|
|
175
|
+
git contrib-tree --since "6 months ago"
|
|
176
|
+
|
|
177
|
+
# Analyze until specific date
|
|
178
|
+
git contrib-tree --until "2024-12-31"
|
|
179
|
+
|
|
180
|
+
# Analyze specific date range
|
|
181
|
+
git contrib-tree --since "2024-01-01" --until "2024-12-31"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Supported date formats:**
|
|
185
|
+
- Specific dates: `"2024-01-01"`, `"Jan 1 2024"`
|
|
186
|
+
- Relative dates: `"6 months ago"`, `"1 year ago"`, `"2 weeks ago"`
|
|
187
|
+
- ISO format: `"2024-01-01T00:00:00"`
|
|
188
|
+
|
|
189
|
+
### Author Filtering
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
# List all contributor emails
|
|
193
|
+
git contrib-tree --list-emails
|
|
194
|
+
|
|
195
|
+
# Filter by single author email
|
|
196
|
+
git contrib-tree --email user@example.com
|
|
197
|
+
|
|
198
|
+
# Filter by multiple authors (comma-separated)
|
|
199
|
+
git contrib-tree --email user1@example.com,user2@example.com
|
|
200
|
+
|
|
201
|
+
# Combine with other filters
|
|
202
|
+
git contrib-tree --email user@example.com --since "6 months ago" --depth 2
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Combined Examples
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
# Analyze last year with depth 2
|
|
209
|
+
git contrib-tree --since "1 year ago" --depth 2
|
|
210
|
+
|
|
211
|
+
# Analyze specific repo and time period
|
|
212
|
+
git contrib-tree --repo ~/projects/myapp --since "2024-01-01" --depth 3
|
|
213
|
+
|
|
214
|
+
# Analyze src directory from last 6 months
|
|
215
|
+
git contrib-tree --path src --since "6 months ago" --depth 2
|
|
216
|
+
|
|
217
|
+
# See specific author's contributions in a directory
|
|
218
|
+
git contrib-tree --path src --email user@example.com
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Output Examples
|
|
222
|
+
|
|
223
|
+
### Project Level (--depth 0)
|
|
224
|
+
```
|
|
225
|
+
Project: my-repository
|
|
226
|
+
Top contributors: John Doe (150), Jane Smith (89), Bob Johnson (45)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Tree View (Full Depth)
|
|
230
|
+
```
|
|
231
|
+
Repository: my-repository
|
|
232
|
+
|
|
233
|
+
├── README.md - John Doe (5), Jane Smith (2)
|
|
234
|
+
├── src/ - John Doe (48), Jane Smith (23), Bob Johnson (10)
|
|
235
|
+
│ ├── main.py - John Doe (25), Bob Johnson (10), Jane Smith (3)
|
|
236
|
+
│ ├── utils.py - Jane Smith (15), John Doe (8)
|
|
237
|
+
│ └── models/ - Bob Johnson (20), John Doe (15), Jane Smith (5)
|
|
238
|
+
│ └── user.py - Bob Johnson (20), John Doe (5)
|
|
239
|
+
└── tests/ - Jane Smith (12), John Doe (8), Bob Johnson (2)
|
|
240
|
+
└── test_main.py - Jane Smith (12), John Doe (8), Bob Johnson (2)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Tree View with Depth Limit (--depth 1)
|
|
244
|
+
```
|
|
245
|
+
Repository: my-repository
|
|
246
|
+
|
|
247
|
+
├── README.md - John Doe (5), Jane Smith (2)
|
|
248
|
+
├── src/ - John Doe (48), Jane Smith (23), Bob Johnson (10)
|
|
249
|
+
└── tests/ - Jane Smith (12), John Doe (8), Bob Johnson (2)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### List Contributors (--list-emails)
|
|
253
|
+
```
|
|
254
|
+
Contributors:
|
|
255
|
+
John Doe <john@example.com> - 150 commits
|
|
256
|
+
Jane Smith <jane@example.com> - 89 commits
|
|
257
|
+
Bob Johnson <bob@example.com> - 45 commits
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Note:**
|
|
261
|
+
- Directory summaries show aggregated contributions from all files within that directory and its subdirectories
|
|
262
|
+
- Even when depth is limited, directory summaries include contributions from all nested files
|
|
263
|
+
- Files/directories with no contributions from filtered authors are hidden when using `--email`
|
|
264
|
+
|
|
265
|
+
## Command Line Options
|
|
266
|
+
|
|
267
|
+
| Option | Default | Description |
|
|
268
|
+
|--------|---------|-------------|
|
|
269
|
+
| `--repo PATH` | `.` | Path to git repository |
|
|
270
|
+
| `--depth N` | `-1` | Maximum tree depth to display (0=project only, -1=unlimited) |
|
|
271
|
+
| `--path PATH` | None | Analyze only this path within the repository |
|
|
272
|
+
| `--since DATE` | None | Show commits after this date |
|
|
273
|
+
| `--until DATE` | None | Show commits before this date |
|
|
274
|
+
| `--email EMAILS` | None | Filter by author email(s), comma-separated |
|
|
275
|
+
| `--list-emails` | False | List all contributor emails and exit |
|
|
276
|
+
|
|
277
|
+
## How It Works
|
|
278
|
+
|
|
279
|
+
The tool analyzes git history efficiently using batch operations:
|
|
280
|
+
|
|
281
|
+
1. **File Discovery**: Uses `git ls-files` to get all tracked files (optionally filtered by path)
|
|
282
|
+
2. **Tree Building**: Builds a complete file tree structure in memory
|
|
283
|
+
3. **Batch Loading**: Runs a single `git log --name-only` command to get all commits and affected files
|
|
284
|
+
4. **Efficient Parsing**: Parses the output to build contributor data for all files simultaneously
|
|
285
|
+
5. **Bottom-Up Aggregation**: Calculates directory contributors by aggregating from files within
|
|
286
|
+
6. **Smart Display**: Displays the tree up to the specified depth limit while maintaining accurate summaries
|
|
287
|
+
|
|
288
|
+
## Use Cases
|
|
289
|
+
|
|
290
|
+
- **Code review**: Identify file owners and subject matter experts
|
|
291
|
+
- **Team analysis**: Understand contribution patterns across your codebase
|
|
292
|
+
- **Onboarding**: Help new team members identify who to ask about specific files
|
|
293
|
+
- **Project planning**: Visualize which parts of the codebase have concentrated knowledge
|
|
294
|
+
- **Historical analysis**: Track how contributions have changed over time using date filters
|
|
295
|
+
- **Individual tracking**: See what files a specific contributor has worked on
|
|
296
|
+
- **Refactoring**: Identify highly-modified files that might benefit from cleanup
|
|
297
|
+
|
|
298
|
+
## Troubleshooting
|
|
299
|
+
|
|
300
|
+
**"Not a git repository" error:**
|
|
301
|
+
- Ensure you're in a git repository or use `--repo` to specify the path
|
|
302
|
+
- Run `git status` to verify the directory is a git repository
|
|
303
|
+
|
|
304
|
+
**No commits shown:**
|
|
305
|
+
- Check your date filters (`--since`, `--until`) - they might be excluding all commits
|
|
306
|
+
- Verify files exist in git: `git ls-files`
|
|
307
|
+
- Ensure the specified `--path` exists in the repository
|
|
308
|
+
- Check that the `--email` filter matches actual contributor emails (use `--list-emails`)
|
|
309
|
+
|
|
310
|
+
**Unexpected contributor names:**
|
|
311
|
+
- Git uses the name from commits, which may vary if contributors use different names
|
|
312
|
+
- Check commit history: `git log --format=%aN | sort -u`
|
|
313
|
+
- Use `--list-emails` to see all contributor emails and their associated names
|
|
314
|
+
|
|
315
|
+
## License
|
|
316
|
+
|
|
317
|
+
This project is dual-licensed under the MIT License and WTFPL.
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
MIT License
|
|
321
|
+
|
|
322
|
+
Copyright (c) 2025 wykwit
|
|
323
|
+
|
|
324
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
325
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
326
|
+
in the Software without restriction, including without limitation the rights
|
|
327
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
328
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
329
|
+
furnished to do so, subject to the following conditions:
|
|
330
|
+
|
|
331
|
+
The above copyright notice and this permission notice shall be included in all
|
|
332
|
+
copies or substantial portions of the Software.
|
|
333
|
+
|
|
334
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
335
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
336
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
337
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
338
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
339
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
340
|
+
SOFTWARE.
|
|
341
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
git-contrib-tree,sha256=N49o2IIUbm2tmEGg-oIJpNXa5FzKEWZU_GI9n9YNu08,20304
|
|
2
|
+
wrapper.py,sha256=x2GrZB-lcUiVxiNsC1XArbgmQfBR6X-eINMthS1b7Qo,1331
|
|
3
|
+
git_contrib_tree-0.1.0.dist-info/METADATA,sha256=MG4ELbxeyKUyjycBlPUE_Re-c2_uAr_0czHZIpD2ya8,10957
|
|
4
|
+
git_contrib_tree-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
git_contrib_tree-0.1.0.dist-info/entry_points.txt,sha256=FGjWBFPrtlP0PKovuDiCdTlwB7pCynhKcFX_cFMHFgY,50
|
|
6
|
+
git_contrib_tree-0.1.0.dist-info/licenses/LICENSE,sha256=3tle-gBYyVD4DOfL44yyGV62Q4CyTpYitpBUso8-QU4,1063
|
|
7
|
+
git_contrib_tree-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 wykwit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
wrapper.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wrapper module for git-contrib-tree.
|
|
3
|
+
|
|
4
|
+
This wrapper allows us to keep the git-contrib-tree filename (with dash)
|
|
5
|
+
while providing a valid Python module for package entry points.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
"""Entry point that loads and executes git-contrib-tree."""
|
|
15
|
+
# Find the script in the same directory as this wrapper
|
|
16
|
+
script_path = Path(__file__).parent / "git-contrib-tree"
|
|
17
|
+
|
|
18
|
+
if not script_path.exists():
|
|
19
|
+
print(f"Error: Could not find git-contrib-tree at {script_path}", file=sys.stderr)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
# Use spec_from_loader with SourceFileLoader to load files without .py extension
|
|
23
|
+
from importlib.machinery import SourceFileLoader
|
|
24
|
+
loader = SourceFileLoader("git_contrib_tree_mod", str(script_path))
|
|
25
|
+
spec = importlib.util.spec_from_loader("git_contrib_tree_mod", loader)
|
|
26
|
+
|
|
27
|
+
if spec is None or spec.loader is None:
|
|
28
|
+
print(f"Error: Could not create loader for git-contrib-tree from {script_path}", file=sys.stderr)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
module = importlib.util.module_from_spec(spec)
|
|
32
|
+
sys.modules["git_contrib_tree_mod"] = module
|
|
33
|
+
spec.loader.exec_module(module)
|
|
34
|
+
|
|
35
|
+
# Execute the main function from the loaded module
|
|
36
|
+
return module.main()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
sys.exit(main())
|