|
#!/usr/bin/env python3 |
|
import argparse |
|
import subprocess |
|
import os |
|
import datetime |
|
import sys |
|
import calendar |
|
from collections import defaultdict |
|
|
|
|
|
def run_git_command(cmd): |
|
"""Execute a git command and return the output.""" |
|
try: |
|
result = subprocess.run( |
|
cmd, shell=True, check=True, text=True, capture_output=True) |
|
return result.stdout |
|
except subprocess.CalledProcessError as e: |
|
print(f"Error executing git command: {e}", file=sys.stderr) |
|
print(f"Command output: {e.stderr}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
|
|
def get_commits(author, since, until=None): |
|
"""Get git commits for a specific author within a date range.""" |
|
cmd = f'git log --author="{author}" --since="{since}"' |
|
if until: |
|
cmd += f' --until="{until}"' |
|
cmd += ' --pretty=format:"%h|%ad|%s" --date=format:\'%Y-%m-%d\' --numstat' |
|
|
|
return run_git_command(cmd) |
|
|
|
|
|
def parse_commit_data(git_output): |
|
"""Parse git log output into structured data.""" |
|
commits = [] |
|
current_commit = None |
|
|
|
for line in git_output.strip().split('\n'): |
|
if not line.strip(): |
|
continue |
|
|
|
if '|' in line: # This is a commit header line |
|
if current_commit: |
|
commits.append(current_commit) |
|
|
|
parts = line.split('|') |
|
current_commit = { |
|
'hash': parts[0], |
|
'date': parts[1], |
|
'subject': parts[2], |
|
'files': [], |
|
'added': 0, |
|
'deleted': 0 |
|
} |
|
elif current_commit is not None: # This is a file stats line |
|
parts = line.split('\t') |
|
if len(parts) >= 3: |
|
try: |
|
added = int(parts[0]) if parts[0] != '-' else 0 |
|
deleted = int(parts[1]) if parts[1] != '-' else 0 |
|
|
|
current_commit['added'] += added |
|
current_commit['deleted'] += deleted |
|
current_commit['files'].append({ |
|
'file': parts[2], |
|
'added': added, |
|
'deleted': deleted |
|
}) |
|
except ValueError: |
|
# Skip lines that don't match our expected format |
|
continue |
|
|
|
# Add the last commit |
|
if current_commit: |
|
commits.append(current_commit) |
|
|
|
return commits |
|
|
|
|
|
def get_business_days_in_month(year, month): |
|
"""Calculate the number of business days (Mon-Fri) in a given month.""" |
|
cal = calendar.monthcalendar(year, month) |
|
return sum(1 for week in cal for day in range(5) if week[day] != 0) |
|
|
|
|
|
def analyze_commits(commits, since_date, until_date=None): |
|
"""Analyze commit data and generate statistics.""" |
|
total_added = sum(commit['added'] for commit in commits) |
|
total_deleted = sum(commit['deleted'] for commit in commits) |
|
total_modified = total_added + total_deleted |
|
|
|
# Aggregate commits by date |
|
daily_stats = defaultdict(lambda: {'added': 0, 'deleted': 0, 'commits': 0}) |
|
for commit in commits: |
|
date = commit['date'] |
|
daily_stats[date]['added'] += commit['added'] |
|
daily_stats[date]['deleted'] += commit['deleted'] |
|
daily_stats[date]['commits'] += 1 |
|
|
|
# Organize by month |
|
monthly_stats = defaultdict( |
|
lambda: {'added': 0, 'deleted': 0, 'commits': 0, 'business_days': 0}) |
|
|
|
# Parse the since and until dates |
|
if isinstance(since_date, str): |
|
since_date = datetime.datetime.strptime(since_date, "%Y-%m-%d").date() |
|
|
|
if until_date and isinstance(until_date, str): |
|
until_date = datetime.datetime.strptime(until_date, "%Y-%m-%d").date() |
|
else: |
|
until_date = datetime.datetime.now().date() |
|
|
|
# Calculate business days for each month in the range |
|
current_date = datetime.date(since_date.year, since_date.month, 1) |
|
while current_date <= until_date: |
|
year_month = current_date.strftime("%Y-%m") |
|
monthly_stats[year_month]['business_days'] = get_business_days_in_month( |
|
current_date.year, current_date.month) |
|
|
|
# Move to next month |
|
if current_date.month == 12: |
|
current_date = datetime.date(current_date.year + 1, 1, 1) |
|
else: |
|
current_date = datetime.date( |
|
current_date.year, current_date.month + 1, 1) |
|
|
|
# Aggregate commit stats by month |
|
for date, stats in daily_stats.items(): |
|
year_month = date[:7] # YYYY-MM |
|
monthly_stats[year_month]['added'] += stats['added'] |
|
monthly_stats[year_month]['deleted'] += stats['deleted'] |
|
monthly_stats[year_month]['commits'] += stats['commits'] |
|
|
|
# Calculate total business days |
|
total_business_days = sum(stats['business_days'] |
|
for stats in monthly_stats.values()) |
|
|
|
return { |
|
'total_commits': len(commits), |
|
'total_added': total_added, |
|
'total_deleted': total_deleted, |
|
'total_modified': total_modified, |
|
'daily_stats': dict(daily_stats), |
|
'monthly_stats': dict(monthly_stats), |
|
'total_business_days': total_business_days |
|
} |
|
|
|
|
|
def generate_text_report(author, analysis, lines_per_day, output_dir): |
|
"""Generate a plain text report of the analysis.""" |
|
os.makedirs(output_dir, exist_ok=True) |
|
report_path = os.path.join( |
|
output_dir, f"{author.replace(' ', '_')}-commit-report.txt") |
|
|
|
with open(report_path, 'w') as f: |
|
f.write(f"Summary of Commits by {author}\n") |
|
f.write("------------------------------------------\n\n") |
|
|
|
f.write("Summary Statistics:\n") |
|
f.write(f"Total Commits: {analysis['total_commits']}\n") |
|
f.write(f"Total Lines Added: {analysis['total_added']}\n") |
|
f.write(f"Total Lines Deleted: {analysis['total_deleted']}\n") |
|
f.write(f"Total Lines Modified: {analysis['total_modified']}\n") |
|
days_worked = analysis['total_modified'] / lines_per_day |
|
f.write( |
|
f"Estimated Working Days: {days_worked:.2f} days (at {lines_per_day} lines/day)\n") |
|
f.write( |
|
f"Total Business Days in Period: {analysis['total_business_days']} days\n") |
|
utilization = (days_worked / analysis['total_business_days']) * \ |
|
100 if analysis['total_business_days'] > 0 else 0 |
|
f.write(f"Calendar Utilization: {utilization:.2f}%\n\n") |
|
|
|
f.write("Daily Work Breakdown:\n") |
|
f.write( |
|
"| Date | Lines Added | Lines Deleted | Total Lines | Est. Days |\n") |
|
f.write( |
|
"|------------|-------------|---------------|-------------|----------|\n") |
|
|
|
for date, stats in sorted(analysis['daily_stats'].items()): |
|
total = stats['added'] + stats['deleted'] |
|
f.write( |
|
f"| {date} | {stats['added']} | {stats['deleted']} | {total} | {total / lines_per_day:.2f} |\n") |
|
|
|
f.write("\nMonthly Distribution of Work:\n") |
|
f.write( |
|
"| Month | Lines | Est. Days | Business Days | Utilization | % of Total Work |\n") |
|
f.write( |
|
"|----------|-------|-----------|--------------|-------------|----------------|\n") |
|
|
|
for month, stats in sorted(analysis['monthly_stats'].items()): |
|
total = stats['added'] + stats['deleted'] |
|
est_days = total / lines_per_day |
|
business_days = stats['business_days'] |
|
utilization = (est_days / business_days) * \ |
|
100 if business_days > 0 else 0 |
|
|
|
work_percentage = 0 |
|
if analysis['total_modified'] > 0: |
|
work_percentage = (total / analysis['total_modified']) * 100 |
|
|
|
month_name = datetime.datetime.strptime( |
|
month, "%Y-%m").strftime("%B %Y") |
|
|
|
f.write( |
|
f"| {month_name} | {total} | {est_days:.2f} | {business_days} | " |
|
f"{utilization:.2f}% | {work_percentage:.2f}% |\n") |
|
|
|
return report_path |
|
|
|
|
|
def generate_markdown_report(author, analysis, lines_per_day, output_dir): |
|
"""Generate a markdown report of the analysis.""" |
|
os.makedirs(output_dir, exist_ok=True) |
|
report_path = os.path.join( |
|
output_dir, f"{author.replace(' ', '_')}-contribution-summary.md") |
|
|
|
with open(report_path, 'w') as f: |
|
f.write(f"# {author} Contribution Analysis\n\n") |
|
|
|
f.write("## Overview\n\n") |
|
f.write(f"This report analyzes the git commits made by {author}, ") |
|
f.write( |
|
f"calculating estimated working days based on an assumption of {lines_per_day} lines of code modified per working day.\n\n") |
|
|
|
f.write("## Summary Statistics\n\n") |
|
f.write(f"- **Total Commits:** {analysis['total_commits']}\n") |
|
f.write(f"- **Total Lines Added:** {analysis['total_added']:,}\n") |
|
f.write(f"- **Total Lines Deleted:** {analysis['total_deleted']:,}\n") |
|
f.write( |
|
f"- **Total Lines Modified:** {analysis['total_modified']:,}\n") |
|
days_worked = analysis['total_modified'] / lines_per_day |
|
f.write( |
|
f"- **Estimated Working Days:** {days_worked:.2f} days " |
|
f"(at {lines_per_day} lines/day)\n") |
|
f.write( |
|
f"- **Total Business Days in Period:** {analysis['total_business_days']} days\n") |
|
utilization = (days_worked / analysis['total_business_days']) * \ |
|
100 if analysis['total_business_days'] > 0 else 0 |
|
f.write(f"- **Calendar Utilization:** {utilization:.2f}%\n\n") |
|
|
|
f.write("## Monthly Distribution\n\n") |
|
f.write( |
|
"| Month | Lines Modified | Est. Working Days | Business Days | Utilization | % of Total Work |\n") |
|
f.write( |
|
"|-------|---------------|-----------------|--------------|-------------|----------------|\n") |
|
|
|
for month, stats in sorted(analysis['monthly_stats'].items()): |
|
total = stats['added'] + stats['deleted'] |
|
est_days = total / lines_per_day |
|
business_days = stats['business_days'] |
|
utilization = (est_days / business_days) * \ |
|
100 if business_days > 0 else 0 |
|
|
|
work_percentage = 0 |
|
if analysis['total_modified'] > 0: |
|
work_percentage = (total / analysis['total_modified']) * 100 |
|
|
|
month_name = datetime.datetime.strptime( |
|
month, "%Y-%m").strftime("%B %Y") |
|
|
|
f.write( |
|
f"| {month_name} | {total:,} | {est_days:.2f} | {business_days} | " |
|
f"{utilization:.2f}% | {work_percentage:.2f}% |\n") |
|
|
|
f.write("\n## Top Contributions by Impact\n\n") |
|
f.write( |
|
"| Date | Description | Lines Modified | Est. Days | % of Total Work |\n") |
|
f.write("|------|-------------|---------------|----------|----------------|\n") |
|
|
|
# Sort commits by impact (lines modified) |
|
commits_by_impact = [] |
|
for date, stats in analysis['daily_stats'].items(): |
|
# Find a commit message for this date |
|
message = next( |
|
(commit['subject'] for commit in analysis['raw_commits'] if commit['date'] == date), "") |
|
total_lines = stats['added'] + stats['deleted'] |
|
commits_by_impact.append({ |
|
'date': date, |
|
'message': message, |
|
'total': total_lines, |
|
'est_days': total_lines / lines_per_day, |
|
'percentage': (total_lines / analysis['total_modified'] * 100) if analysis['total_modified'] > 0 else 0 |
|
}) |
|
|
|
# Sort by total lines modified, descending |
|
commits_by_impact.sort(key=lambda x: x['total'], reverse=True) |
|
|
|
# Display top 5 commits or all if less than 5 |
|
for commit in commits_by_impact[:5]: |
|
f.write( |
|
f"| {commit['date']} | {commit['message']} | {commit['total']:,} | " |
|
f"{commit['est_days']:.2f} | {commit['percentage']:.2f}% |\n") |
|
|
|
f.write("\n## Conclusion\n\n") |
|
days_worked = analysis['total_modified'] / lines_per_day |
|
f.write( |
|
f"Based on the assumption of {lines_per_day} lines of code per working day, ") |
|
f.write( |
|
f"{author} has contributed approximately **{days_worked:.2f} working days** ") |
|
f.write( |
|
f"worth of development effort during this period, across {analysis['total_business_days']} ") |
|
f.write( |
|
f"available business days ({utilization:.2f}% calendar utilization).") |
|
|
|
if commits_by_impact: |
|
f.write( |
|
f" The most significant contribution was \"{commits_by_impact[0]['message']}\" " |
|
f"on {commits_by_impact[0]['date']}, ") |
|
|
|
f.write( |
|
f"which represented {commits_by_impact[0]['percentage']:.2f}% of the total work done during this period.") |
|
|
|
return report_path |
|
|
|
|
|
def main(): |
|
parser = argparse.ArgumentParser( |
|
description="Analyze git contributions by a developer.") |
|
parser.add_argument( |
|
"--author", |
|
required=True, |
|
help="Git author name or email pattern" |
|
) |
|
parser.add_argument( |
|
"--since", |
|
required=True, |
|
help="Start date for commit analysis (YYYY-MM-DD)" |
|
) |
|
parser.add_argument( |
|
"--until", |
|
help="End date for commit analysis (YYYY-MM-DD)" |
|
) |
|
parser.add_argument( |
|
"--lines-per-day", |
|
type=int, |
|
default=200, |
|
help="Assumed lines of code per working day (default: 200)" |
|
) |
|
parser.add_argument( |
|
"--output", |
|
default="./reports", |
|
help="Output directory for reports (default: ./reports)" |
|
) |
|
|
|
args = parser.parse_args() |
|
|
|
print(f"Analyzing contributions for author: {args.author}") |
|
until_date = args.until if args.until else 'now' |
|
print(f"Date range: {args.since} to {until_date}") |
|
print(f"Lines per day assumption: {args.lines_per_day}") |
|
|
|
# Get commit data |
|
print("Retrieving git commit history...") |
|
git_output = get_commits(args.author, args.since, args.until) |
|
|
|
# Parse and analyze the data |
|
print("Analyzing commit data...") |
|
commits = parse_commit_data(git_output) |
|
if not commits: |
|
print( |
|
f"No commits found for {args.author} in the specified date range.") |
|
sys.exit(0) |
|
|
|
analysis = analyze_commits(commits, args.since, args.until) |
|
# Add raw commits for use in report generation |
|
analysis['raw_commits'] = commits |
|
|
|
# Generate reports |
|
print("Generating reports...") |
|
text_report = generate_text_report( |
|
args.author, analysis, args.lines_per_day, args.output) |
|
markdown_report = generate_markdown_report( |
|
args.author, analysis, args.lines_per_day, args.output) |
|
|
|
print(f"\nAnalysis complete!") |
|
print(f"Text report: {text_report}") |
|
print(f"Markdown report: {markdown_report}") |
|
days_worked = analysis['total_modified'] / args.lines_per_day |
|
utilization = (days_worked / analysis['total_business_days']) * \ |
|
100 if analysis['total_business_days'] > 0 else 0 |
|
print(f"\nTotal estimated working days: {days_worked:.2f} days") |
|
print( |
|
f"Total business days in period: {analysis['total_business_days']} days") |
|
print(f"Calendar utilization: {utilization:.2f}%") |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |