Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .

- name: Test CLI runs
run: |
gitstats --version
gitstats --help

- name: Test on a git repo
run: |
gitstats stats .

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install linters
run: |
python -m pip install --upgrade pip
pip install ruff

- name: Run Ruff linter
run: ruff check .

- name: Run Ruff formatter check
run: ruff format --check .
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

> Beautiful git statistics in your terminal

[![CI](https://github.com/mloncarevich/gitstats/actions/workflows/ci.yml/badge.svg)](https://github.com/mloncarevich/gitstats/actions/workflows/ci.yml)
[![Python](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

## ✨ Features

- 📈 Commit statistics at a glance
- 🗓️ Activity heatmap (coming soon)
- 🗓️ Activity heatmap by day of week
- ⏰ Peak coding hours analysis
- 👥 Contributor insights
- 🎨 Beautiful terminal output

Expand Down
42 changes: 22 additions & 20 deletions gitstats/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,84 +42,86 @@ def stats(
) -> None:
"""Show commit statistics for a git repository."""
from gitstats.parser import get_commit_stats

console.print(f"\n[bold]📊 Git Statistics for:[/] [cyan]{path}[/]\n")

stats = get_commit_stats(path)

if not stats:
console.print("[red]No commits found or not a git repository.[/]")
raise typer.Exit(1)

console.print(f"[bold]Total commits:[/] [green]{stats['total_commits']}[/]")
console.print(f"[bold]Contributors:[/] [green]{stats['total_authors']}[/]")
console.print(f"[bold]First commit:[/] [yellow]{stats['first_commit']}[/]")
console.print(f"[bold]Latest commit:[/] [yellow]{stats['last_commit']}[/]")
console.print()

# Show author breakdown table
if stats.get("author_stats"):
_print_author_table(stats["author_stats"])

# Show activity heatmap
_print_activity_heatmap(stats["commits"])


def _print_author_table(author_stats: list[dict]) -> None:
"""Print a table of author statistics."""
table = Table(title="👥 Commits by Author", show_header=True, header_style="bold cyan")

table.add_column("Author", style="white", no_wrap=True)
table.add_column("Commits", justify="right", style="green")
table.add_column("Percentage", justify="right", style="yellow")
table.add_column("", justify="left", style="blue") # Progress bar

for stat in author_stats:
bar_width = int(stat["percentage"] / 2) # Max 50 chars for 100%
bar = "█" * bar_width

table.add_row(
stat["author"],
str(stat["commits"]),
f"{stat['percentage']:.1f}%",
bar,
)

console.print(table)
console.print()


def _print_activity_heatmap(commits: list[dict]) -> None:
"""Print activity heatmap by day and hour."""
from gitstats.parser import get_weekly_activity, get_hourly_activity
from gitstats.parser import get_hourly_activity, get_weekly_activity

# Weekly activity
weekly = get_weekly_activity(commits)

console.print("[bold]📅 Activity by Day of Week[/]\n")

max_commits = max(d["commits"] for d in weekly) if weekly else 1

for day_stat in weekly:
bar_width = int((day_stat["commits"] / max_commits) * 30) if max_commits else 0
bar = "█" * bar_width

console.print(
f" [cyan]{day_stat['day']}[/] │ [green]{bar:<30}[/] {day_stat['commits']:>3} commits"
)

console.print()

# Hourly activity (simplified - peak hours)
hourly = get_hourly_activity(commits)
peak_hours = sorted(hourly, key=lambda x: x["commits"], reverse=True)[:3]

if peak_hours and peak_hours[0]["commits"] > 0:
console.print("[bold]⏰ Peak Coding Hours[/]\n")
for h in peak_hours:
if h["commits"] > 0:
hour_str = f"{h['hour']:02d}:00"
console.print(f" [yellow]{hour_str}[/] - {h['commits']} commits ({h['percentage']:.1f}%)")
console.print(
f" [yellow]{hour_str}[/] - {h['commits']} commits ({h['percentage']:.1f}%)"
)
console.print()


Expand Down
96 changes: 49 additions & 47 deletions gitstats/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@
def get_commit_stats(repo_path: str = ".") -> dict | None:
"""
Parse git log and return commit statistics.

Args:
repo_path: Path to the git repository

Returns:
Dictionary with commit statistics or None if not a git repo
"""
path = Path(repo_path).resolve()

if not (path / ".git").exists():
return None

try:
# Get all commits with author and date
result = subprocess.run(
Expand All @@ -31,38 +31,40 @@ def get_commit_stats(repo_path: str = ".") -> dict | None:
)
except subprocess.CalledProcessError:
return None

lines = result.stdout.strip().split("\n")

if not lines or lines[0] == "":
return None

commits = []
authors = set()

for line in lines:
if not line:
continue
parts = line.split("|")
if len(parts) >= 4:
commit_hash, author_name, author_email, date_str = parts[:4]
authors.add(author_name)
commits.append({
"hash": commit_hash,
"author": author_name,
"email": author_email,
"date": datetime.fromisoformat(date_str),
})

commits.append(
{
"hash": commit_hash,
"author": author_name,
"email": author_email,
"date": datetime.fromisoformat(date_str),
}
)

if not commits:
return None

# Sort by date
commits.sort(key=lambda x: x["date"])

# Calculate author statistics
author_stats = get_author_stats(commits)

return {
"total_commits": len(commits),
"total_authors": len(authors),
Expand All @@ -77,58 +79,58 @@ def get_commit_stats(repo_path: str = ".") -> dict | None:
def get_author_stats(commits: list[dict]) -> list[dict]:
"""
Calculate commit statistics per author.

Args:
commits: List of commit dictionaries

Returns:
List of author statistics sorted by commit count (descending)
"""
from collections import Counter

author_counts = Counter(commit["author"] for commit in commits)
total = len(commits)

stats = []
for author, count in author_counts.most_common():
percentage = (count / total) * 100
stats.append({
"author": author,
"commits": count,
"percentage": percentage,
})

stats.append(
{
"author": author,
"commits": count,
"percentage": percentage,
}
)

return stats


def get_activity_heatmap(commits: list[dict]) -> dict:
"""
Calculate commit activity by day of week and hour.

Args:
commits: List of commit dictionaries

Returns:
Dictionary with heatmap data (day -> hour -> count)
"""
from collections import defaultdict

# Initialize heatmap: 7 days x 24 hours
heatmap = defaultdict(lambda: defaultdict(int))

for commit in commits:
date = commit["date"]
day = date.weekday() # 0=Monday, 6=Sunday
hour = date.hour
heatmap[day][hour] += 1

# Find max for normalization
max_count = max(
count
for day_data in heatmap.values()
for count in day_data.values()
) if heatmap else 1

max_count = (
max(count for day_data in heatmap.values() for count in day_data.values()) if heatmap else 1
)

return {
"data": dict(heatmap),
"max_count": max_count,
Expand All @@ -138,19 +140,19 @@ def get_activity_heatmap(commits: list[dict]) -> dict:
def get_weekly_activity(commits: list[dict]) -> list[dict]:
"""
Calculate commit activity by day of week.

Args:
commits: List of commit dictionaries

Returns:
List of day statistics
"""
from collections import Counter

days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
day_counts = Counter(commit["date"].weekday() for commit in commits)
total = len(commits)

return [
{
"day": days[i],
Expand All @@ -164,18 +166,18 @@ def get_weekly_activity(commits: list[dict]) -> list[dict]:
def get_hourly_activity(commits: list[dict]) -> list[dict]:
"""
Calculate commit activity by hour of day.

Args:
commits: List of commit dictionaries

Returns:
List of hourly statistics
"""
from collections import Counter

hour_counts = Counter(commit["date"].hour for commit in commits)
total = len(commits)

return [
{
"hour": h,
Expand Down
Loading
Loading