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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-contrib-tree = wrapper:main
@@ -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())