diff --git a/Cargo.toml b/Cargo.toml index fc6ef15..be77299 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ rand = "0.8.5" clap = { version = "4.4.8", features = ["derive"] } # TUI crossterm = { version = "0.27", optional = true } -ratatui = { version = "0.24", features = ["all-widgets"], optional = true } +ratatui = { version = "0.26", features = ["all-widgets"], optional = true } # GUI eframe = { version = "0.24", optional = true } egui_plot = { version = "0.24", optional = true } diff --git a/src/gui.rs b/src/gui.rs index c6c2120..57c50e7 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -1,10 +1,9 @@ use std::collections::HashMap; -use chrono::{DateTime, Local, Utc, NaiveDateTime}; use eframe::{egui::{self, Layout}, emath::{Vec2b, Align}}; use egui_plot::{Plot, Legend, Line, PlotPoints}; -use crate::CommitActivityData; +use crate::{timestamp_to_str, CommitActivityData}; fn tuple_to_vec(vec: Vec<(f64, f64)>) -> Vec<[f64; 2]> { vec.iter().map(|(x,y)| [*x,*y]).collect() @@ -28,12 +27,12 @@ struct GitStatsApp { impl GitStatsApp { fn new(data: CommitActivityData) -> Self { let mut author_data = HashMap::new(); - for (author, data) in data.per_author { + for (author, data) in data.timeline_per_author { author_data.insert(author, tuple_to_vec(data)); } Self { - total_data: tuple_to_vec(data.total), + total_data: tuple_to_vec(data.timeline), author_data, lock: true, } @@ -88,21 +87,3 @@ impl eframe::App for GitStatsApp { } } -pub fn timestamp_to_str(t: i64, date: bool, time: bool) -> String { - format!( - "{}", - DateTime::::from(DateTime::::from_utc( - NaiveDateTime::from_timestamp(t, 0), - Utc - )) - .format(if date && time { - "%Y/%m/%d %H:%M:%S" - } else if date { - "%Y/%m/%d" - } else if time { - "%H:%M:%S" - } else { - "%s" - }) - ) -} diff --git a/src/main.rs b/src/main.rs index 21f8f09..66c1369 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use clap::{Parser, Subcommand}; -use git2::{Repository, Commit, Oid}; +use git2::{Commit, Oid, Repository}; use chrono::{DateTime, Utc, Duration}; #[cfg(feature = "tui")] @@ -11,31 +11,45 @@ mod tui; mod gui; #[derive(Parser)] -struct CliArgs { +/// a simple git statistics display tool in your terminal +struct GittsArgs { + /// path of git repo to operate on, defaults to cwd path: Option, #[clap(subcommand)] + /// display mode for statistics, defaults to basic mode: Option, + + #[arg(short = '1', long, default_value_t = false)] + /// reduce per-author commits by 1 when zeroed (helps with TUI overlaps) + minus_one: bool, } #[derive(Subcommand)] enum CliViewMode { - + /// simple no-dependency display mode, lists authors and their commit count Basic, #[cfg(feature = "tui")] + /// terminal user interface, shows a timeline plot with all commits and per author Tui { - #[clap(long, default_value_t = false)] - no_total: bool, + #[clap(short, long, default_value_t = false)] + /// draw a line for total commits + total: bool, + + #[clap(short = 'a', long, default_value_t = 5)] + /// max authors to display in legend + max_authors: usize, }, #[cfg(feature = "gui")] + /// graphical user interface, shows a whole plot window with togglable user and total stats Gui, } fn main() { - let args = CliArgs::parse(); + let args = GittsArgs::parse(); let repo = Repository::open(args.path.as_deref().unwrap_or(".")).unwrap(); @@ -56,14 +70,14 @@ fn main() { } } - let data = process(store).unwrap(); + let data = process(store, args.minus_one).unwrap(); match args.mode.unwrap_or(CliViewMode::Basic) { CliViewMode::Basic => basic_display(data), #[cfg(feature = "tui")] - CliViewMode::Tui { no_total } => tui::display(data, !no_total).unwrap(), + CliViewMode::Tui { total, max_authors } => tui::display(data, total, max_authors).unwrap(), #[cfg(feature = "gui")] CliViewMode::Gui => gui::display(data).unwrap(), @@ -71,80 +85,121 @@ fn main() { } } -pub fn author_display(commit: &Commit, full: bool) -> String { - if full { - format!("{} <{}>", commit.author().name().unwrap_or(""), commit.author().email().unwrap_or("")) - } else { - commit.author().name().unwrap_or("").to_string() - } +pub fn author_display(commit: &Commit) -> String { + format!("{} <{}>", commit.author().name().unwrap_or(""), commit.author().email().unwrap_or("")) } type Data = Vec<(f64, f64)>; pub struct CommitActivityData { - pub total: Data, - pub per_author: HashMap, + pub timeline: Data, + pub timeline_per_author: HashMap, + pub count_per_author: HashMap, + pub authors_by_count: Vec, pub oldest: DateTime, + pub now: DateTime, pub max_count: f64, } pub fn process( commits: HashMap, Commit)>, + minus_one: bool, ) -> Result> { let now = Utc::now(); - let min = *commits.values().map(|(t, _c)| t).min().unwrap(); - let bins = (now - min).num_weeks() as usize; + let oldest = *commits.values().map(|(t, _c)| t).min().unwrap(); + let bins = (now - oldest).num_weeks() as usize; - let mut data = vec![(0f64, 0f64); bins + 1]; - let authors : HashSet = commits.values().map(|(_t, c)| crate::author_display(c, false)).collect(); - let mut authors_data : HashMap> = HashMap::new(); + let mut timeline = vec![(0f64, 0f64); bins + 1]; + let authors : HashSet = commits.values().map(|(_t, c)| crate::author_display(c)).collect(); + let mut timeline_per_author : HashMap> = HashMap::new(); + let mut count_per_author : HashMap = HashMap::new(); for author in authors { - authors_data.insert(author, vec![(0f64, -1.0f64); bins + 1]); + timeline_per_author.insert(author.clone(), vec![(0f64, if minus_one { -1.0f64 } else { 0.0f64 }); bins + 1]); + count_per_author.insert(author, 0); } - let mut y_max = 0.; + let mut max_count = 0.; for (t, c) in commits.values() { - let bin = (*t - min).num_weeks() as usize; - data[bin].1 += 1.; - if data[bin].1 > y_max { - y_max = data[bin].1; - } - if let Some(x) = authors_data.get_mut(&crate::author_display(c, false)) { - if x[bin].1 < 0. { x[bin].1 = 0. }; - if bin > 0 && x[bin-1].1 < 0. { x[bin-1].1 = 0. }; - if bin < x.len() - 1 && x[bin+1].1 < 0. { x[bin+1].1 = 0. }; - x[bin].1 += 1.; + let bin = (*t - oldest).num_weeks() as usize; + let author = crate::author_display(c); + timeline[bin].1 += 1.; + if timeline[bin].1 > max_count { + max_count = timeline[bin].1; } + + let author_count = count_per_author + .get_mut(&author) + .expect("unknown author, but we checked them all before?"); + *author_count += 1; + + let author_timeline = timeline_per_author + .get_mut(&author) + .expect("unknown author, but we checked them all before?"); + // TODO ugly trick + if author_timeline[bin].1 < 0. { author_timeline[bin].1 = 0. }; + if bin > 0 && author_timeline[bin-1].1 < 0. { author_timeline[bin-1].1 = 0. }; + if bin < author_timeline.len() - 1 && author_timeline[bin+1].1 < 0. { author_timeline[bin+1].1 = 0. }; + // TODO these are needed to "hide" zero lines in TUI and avoid overlaps, ew! + author_timeline[bin].1 += 1.; } - for (_name, author_d) in authors_data.iter_mut() { - let mut min_mut = min; + for (_name, author_d) in timeline_per_author.iter_mut() { + let mut min_mut = oldest; for (x, _) in author_d.iter_mut() { *x = min_mut.timestamp() as f64; min_mut += Duration::weeks(1); } } - let mut min_mut = min; - for (x, _) in data.iter_mut() { + let mut min_mut = oldest; + for (x, _) in timeline.iter_mut() { *x = min_mut.timestamp() as f64; min_mut += Duration::weeks(1); } + let mut authors_by_count = count_per_author + .clone() + .into_iter() + .collect::>(); + authors_by_count.sort_by(|a, b| a.1.cmp(&b.1).reverse()); + let authors_by_count = authors_by_count + .iter() + .map(|(u, _c)| u) + .cloned() + .collect(); + Ok(CommitActivityData { - total: data, - per_author: authors_data, - oldest: min, - max_count: y_max, + now, + timeline, + timeline_per_author, + count_per_author, + authors_by_count, + oldest, + max_count, }) } +pub fn timestamp_to_str(t: i64, date: bool, time: bool) -> String { + let mut fmt = String::new(); + if date { fmt.push_str("%Y/%m/%d") } + if date && time { fmt.push(' ') } + if time { fmt.push_str("%H:%M:%S") } + if !date && !time { fmt.push_str("%s") } + DateTime::from_timestamp(t, 0) + .unwrap_or(DateTime::UNIX_EPOCH) + .format(&fmt) + .to_string() +} + pub fn basic_display(data: CommitActivityData) { - let total : u64 = data.total.iter().map(|(_x, y)| *y as u64).sum(); + let total : u64 = data.timeline.iter().map(|(_x, y)| *y as u64).sum(); println!("total commits: {}", total); - for (name, author_data) in data.per_author { - let author_total : u64 = author_data.iter().map(|(_x, y)| *y as u64).sum(); - println!("{} commits: {}", name, author_total); + for name in data.authors_by_count { + let author_count = data.count_per_author.get(&name).expect("author in list but has no count?"); + let author_timeline = data.timeline_per_author.get(&name).expect("author in list but has no timeline?"); + let oldest = timestamp_to_str(author_timeline.iter().find(|x| x.1 > 0.0).unwrap().0 as i64, true, true); + let newest = timestamp_to_str(author_timeline.iter().rev().find(|x| x.1 > 0.0).unwrap().0 as i64, true, true); + println!("-{:>75} :{:5} commits (from {} to {})", name, author_count, oldest, newest); } } diff --git a/src/tui.rs b/src/tui.rs index 7f6d3b5..81cc062 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -16,14 +16,20 @@ const COLORS : [Color; 10] = [ Color::LightMagenta, ]; -pub fn display(data: crate::CommitActivityData, total: bool) -> Result<(), Box> { +pub fn display(data: crate::CommitActivityData, total: bool, max_authors: usize) -> Result<(), Box> { // prepare chart widget let mut datasets = Vec::new(); + let authors : Vec = data.authors_by_count.iter().take(max_authors).cloned().collect(); - for (name, author_d) in data.per_author.iter() { + for name in data.authors_by_count.iter() { + let author_d = data.timeline_per_author.get(name).expect("author in list has no data"); + let author_count = data.count_per_author.get(name).expect("author in list has no count"); + let mut ds = Dataset::default(); + if authors.contains(name) { + ds = ds.name(format!("{:4}| {}", author_count, name.clone())); + } datasets.push( - Dataset::default() - .name(name.clone()) + ds .marker(symbols::Marker::Braille) .graph_type(GraphType::Line) .style(Style::default().fg(COLORS.choose(&mut rand::thread_rng()).cloned().unwrap())) @@ -37,7 +43,7 @@ pub fn display(data: crate::CommitActivityData, total: bool) -> Result<(), Box