use anyhow::{Context, Result};
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
use clap::Parser;
use git2::{Repository, Sort};
use plotters::prelude::*;
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "git-graph-analyzer")]
#[command(about = "Analyzes a git repository and plots lines of code over time")]
struct Args {
#[arg(short, long)]
repo: Option<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
title: Option<String>,
}
#[derive(Debug)]
struct CommitData {
date: DateTime<Utc>,
insertions: usize,
}
fn analyze_repository(repo_path: &PathBuf) -> Result<Vec<CommitData>> {
let repo = Repository::open(repo_path)
.with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(Sort::TIME | Sort::REVERSE)?;
let mut commits = Vec::new();
for oid in revwalk {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let timestamp = commit.time().seconds();
let date = Utc.timestamp_opt(timestamp, 0).single().unwrap_or_else(Utc::now);
let tree = commit.tree()?;
let parent_tree = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
let stats = diff.stats()?;
commits.push(CommitData {
date,
insertions: stats.insertions(),
});
}
Ok(commits)
}
fn get_week_start(date: &DateTime<Utc>) -> NaiveDate {
let weekday = date.weekday().num_days_from_monday();
date.date_naive() - chrono::Duration::days(weekday as i64)
}
fn aggregate_by_week(commits: &[CommitData]) -> (BTreeMap<NaiveDate, i64>, BTreeMap<NaiveDate, i64>) {
let mut lines_added: BTreeMap<NaiveDate, i64> = BTreeMap::new();
for commit in commits {
let week = get_week_start(&commit.date);
*lines_added.entry(week).or_insert(0) += commit.insertions as i64;
}
let mut total_lines: BTreeMap<NaiveDate, i64> = BTreeMap::new();
let mut running_total: i64 = 0;
for (week, added) in &lines_added {
running_total += added;
total_lines.insert(*week, running_total);
}
(lines_added, total_lines)
}
fn generate_chart(
lines_added: &BTreeMap<NaiveDate, i64>,
total_lines: &BTreeMap<NaiveDate, i64>,
output_path: &PathBuf,
title: &str,
) -> Result<()> {
if lines_added.is_empty() {
anyhow::bail!("No data to plot");
}
let root = BitMapBackend::new(output_path, (1200, 800)).into_drawing_area();
root.fill(&WHITE)?;
let min_date = *lines_added.keys().next().unwrap();
let max_date = *lines_added.keys().last().unwrap();
let (min_date, max_date) = if min_date == max_date {
(min_date - chrono::Duration::days(7), max_date + chrono::Duration::days(7))
} else {
(min_date, max_date)
};
let max_added = *lines_added.values().max().unwrap();
let max_total = *total_lines.values().max().unwrap();
let y1_max = (max_added as f64 * 1.15) as i64;
let y2_max = (max_total as f64 * 1.15) as i64;
let chart_title = format!("{} - Lines of Code (Weekly)", title);
let mut chart = ChartBuilder::on(&root)
.caption(&chart_title, ("sans-serif", 40).into_font())
.margin(20)
.x_label_area_size(60)
.y_label_area_size(100)
.right_y_label_area_size(100)
.build_cartesian_2d(min_date..max_date, 0i64..y1_max)?
.set_secondary_coord(min_date..max_date, 0i64..y2_max);
chart
.configure_mesh()
.x_labels(8)
.y_labels(8)
.x_label_formatter(&|d| d.format("%b %d").to_string())
.y_label_formatter(&|v| format!("{}", v))
.x_desc("Week")
.y_desc("Lines Added This Week")
.axis_desc_style(("sans-serif", 20).into_font().color(&BLUE))
.y_label_style(("sans-serif", 14).into_font().color(&BLUE))
.label_style(("sans-serif", 14))
.draw()?;
chart
.configure_secondary_axes()
.y_desc("Total Lines of Code")
.axis_desc_style(("sans-serif", 20).into_font().color(&RGBColor(34, 139, 34)))
.label_style(("sans-serif", 14).into_font().color(&RGBColor(34, 139, 34)))
.draw()?;
let num_points = lines_added.len() as i64;
let point_size = if num_points > 100 { 3 } else if num_points > 50 { 4 } else { 6 };
let bar_width = if num_points > 100 { 1 } else if num_points > 50 { 2 } else { 3 };
let total_points: Vec<(NaiveDate, i64)> = total_lines.iter().map(|(k, v)| (*k, *v)).collect();
chart.draw_secondary_series(AreaSeries::new(
total_points.iter().cloned(),
0,
RGBColor(34, 139, 34).mix(0.2).filled(),
))?;
chart
.draw_secondary_series(LineSeries::new(
total_points.clone(),
RGBColor(34, 139, 34).stroke_width(3),
))?
.label("Total Lines of Code")
.legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], RGBColor(34, 139, 34).stroke_width(3)));
chart.draw_secondary_series(
total_points
.iter()
.map(|(x, y)| Circle::new((*x, *y), point_size, RGBColor(34, 139, 34).filled())),
)?;
chart
.draw_series(lines_added.iter().map(|(date, value)| {
Rectangle::new(
[
(*date - chrono::Duration::days(bar_width), 0),
(*date + chrono::Duration::days(bar_width), *value),
],
BLUE.mix(0.7).filled(),
)
}))?
.label("Lines Added This Week")
.legend(|(x, y)| Rectangle::new([(x, y - 5), (x + 20, y + 5)], BLUE.mix(0.7).filled()));
chart
.configure_series_labels()
.background_style(WHITE.mix(0.95))
.border_style(BLACK.mix(0.5))
.label_font(("sans-serif", 18))
.position(SeriesLabelPosition::UpperLeft)
.draw()?;
root.present()?;
Ok(())
}
fn get_folder_name(path: &PathBuf) -> String {
path.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "repository".to_string())
}
fn main() -> Result<()> {
let args = Args::parse();
let repo_path = args.repo.unwrap_or_else(|| PathBuf::from("."));
let folder_name = get_folder_name(&repo_path);
let title = args.title.unwrap_or_else(|| folder_name.clone());
let output_path = args.output.unwrap_or_else(|| {
PathBuf::from(format!("{}_loc.png", folder_name))
});
println!("Analyzing repository: {:?}", repo_path);
let commits = analyze_repository(&repo_path)?;
println!("Found {} commits", commits.len());
if commits.is_empty() {
anyhow::bail!("No commits found in repository");
}
let (lines_added, total_lines) = aggregate_by_week(&commits);
println!("Aggregated into {} weeks", lines_added.len());
let total = total_lines.values().last().copied().unwrap_or(0);
println!("Total lines of code: {}", total);
generate_chart(&lines_added, &total_lines, &output_path, &title)?;
println!("Chart saved to: {:?}", output_path);
Ok(())
}