From 055744285af4ed340a0df1094674da60690a1dec Mon Sep 17 00:00:00 2001 From: Wynd Date: Sat, 18 Jan 2025 00:14:48 +0200 Subject: [PATCH] Split the app into bin and lib --- Cargo.lock | 2 +- Cargo.toml | 6 +- src/cli.rs | 2 +- src/heatmap.rs | 9 +- src/lib.rs | 334 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 326 ++--------------------------------------------- 6 files changed, 355 insertions(+), 324 deletions(-) create mode 100644 src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index e27de68..e1d2df1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,7 +300,7 @@ dependencies = [ [[package]] name = "git-heatmap" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 99e2794..fc8ffed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ cargo-features = ["codegen-backend"] [package] name = "git-heatmap" -version = "1.2.0" +version = "1.3.0" edition = "2021" authors = ["Wynd "] description = "A simple and customizable heatmap for git repos" @@ -12,6 +12,10 @@ license = "GPL-3.0-or-later" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "libgitheatmap" +path = "src/lib.rs" + [lints.rust] unsafe_code = { level = "forbid" } diff --git a/src/cli.rs b/src/cli.rs index 02e8415..024fae2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ use clap::{arg, Parser, ValueHint}; use crate::heatmap::{ColorLogic, Format, HeatmapColors}; -#[derive(Clone, Debug, Parser, PartialEq, Eq)] +#[derive(Clone, Default, Debug, Parser, PartialEq, Eq)] #[command(version, about, author, long_about = None, args_override_self = true)] pub struct CliArgs { #[arg(long("root-dir"), value_hint = ValueHint::DirPath)] diff --git a/src/heatmap.rs b/src/heatmap.rs index 46c0e14..98f96a2 100644 --- a/src/heatmap.rs +++ b/src/heatmap.rs @@ -234,20 +234,23 @@ impl Chunk { } } -#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)] +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, ValueEnum)] pub enum HeatmapColors { + #[default] Green, Red, } -#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)] +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, ValueEnum)] pub enum ColorLogic { + #[default] ByAmount, ByWeight, } -#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)] +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, ValueEnum)] pub enum Format { + #[default] Chars, Numbers, } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0daf908 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,334 @@ +#![feature(let_chains)] + +use std::{ + cmp::Reverse, + collections::HashSet, + path::{self, PathBuf}, + sync::OnceLock, +}; + +use anyhow::anyhow; +use chrono::{DateTime, Duration, Local, NaiveDate, NaiveTime, TimeZone}; +use clap::Parser; +use cli::CliArgs; +use gix::{bstr::ByteSlice, features::hash::Sha1, traverse::commit::simple::Sorting, ObjectId}; +use heatmap::{ColorLogic, HeatmapColors}; +use itertools::Itertools; +use mailmap::Mailmap; +use rgb::Rgb; + +pub mod cli; +pub mod heatmap; +pub mod mailmap; +pub mod rgb; + +pub const ESCAPE: &str = "\x1B"; +pub const RESET: &str = "\x1B[0m"; +pub const DAYS: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; +pub static CHAR: OnceLock = OnceLock::new(); +pub static COLOR_LOGIC: OnceLock = OnceLock::new(); +pub static COLOR_MAP: OnceLock> = OnceLock::new(); + +pub const GREEN_COLOR_MAP: [Rgb; 5] = [ + Rgb(0, 0, 0), + Rgb(14, 68, 41), + Rgb(0, 109, 50), + Rgb(38, 166, 65), + Rgb(25, 255, 64), +]; + +pub const RED_COLOR_MAP: [Rgb; 5] = [ + Rgb(0, 0, 0), + Rgb(208, 169, 35), + Rgb(208, 128, 35), + Rgb(208, 78, 35), + Rgb(255, 0, 0), +]; + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct Commit { + id: ObjectId, + title: String, + author: Author, + time: DateTime, +} + +impl Commit { + pub fn new( + id: ObjectId, + title: String, + author: String, + email: String, + time: DateTime, + ) -> Self { + Self { + id, + title, + author: Author { + name: author, + email, + }, + time, + } + } +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub struct Author { + name: String, + email: String, +} + +pub fn args() -> CliArgs { + let args = CliArgs::parse(); + + CHAR.set(args.char).unwrap(); + COLOR_LOGIC.set(args.counting).unwrap(); + let color_map = match args.color_scheme { + HeatmapColors::Green => GREEN_COLOR_MAP, + HeatmapColors::Red => RED_COLOR_MAP, + }; + let color_map = color_map + .into_iter() + .map(|c| c.to_ansi()) + .collect::>(); + COLOR_MAP.set(color_map).unwrap(); + + args +} + +pub fn get_commits( + args: CliArgs, + start_date: NaiveDate, + end_date: NaiveDate, +) -> anyhow::Result<(usize, usize, Vec)> { + let mut commits: HashSet = HashSet::new(); + + let ignored_repos = args.ignored_repos.as_ref().unwrap_or(&vec![]).to_owned(); + + let (repos, branches) = match &args.root_dir { + Some(roots) => { + let mut repos: Vec = vec![]; + for root in roots { + find_git_repos(root, &mut repos, &ignored_repos); + } + let branches = vec!["".to_string(); repos.len()]; + (repos, branches) + } + None => { + let repos = match args.repos { + Some(r) => r, + None => vec![PathBuf::from(".")], + }; + + let branches = args.branches.unwrap_or_else(|| vec!["".to_string()]); + + if repos.len() > 1 && repos.len() != branches.len() { + return Err(anyhow!( + "Number of repos ({}) needs to match the number of branch lists ({})!", + repos.len(), + branches.len() + )); + } + (repos, branches) + } + }; + + let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap(); + let start_date = start_date.and_time(midnight); + let start_date = Local.from_local_datetime(&start_date).unwrap(); + + let current_time = Local::now().time(); + let end_date = end_date.and_time(current_time); + let end_date = Local.from_local_datetime(&end_date).unwrap(); + + let authors = args.authors.unwrap_or_default(); + let mut repos_count = 0; + let mut branches_count = 0; + + for (i, repo_path) in repos.iter().enumerate() { + let repo = gix::open(repo_path).unwrap(); + + let branch_names = &*branches[i]; + let mut branches = vec![]; + if branch_names.is_empty() { + branches = repo + .references()? + .prefixed("refs/heads")? + .filter_map(Result::ok) + .filter_map(|b| { + b.inner + .name + .to_string() + .strip_prefix("refs/heads/") + .map(|s| s.to_string()) + }) + .collect_vec(); + } + else { + let branch_names = branch_names.split(' ').map(|s| s.to_string()); + branches.extend(branch_names); + } + + let mailmap = Mailmap::new(repo_path); + let mut has_commits = false; + + for branch in &branches { + // When passing the default @ (HEAD) branch this might actually not exist at all + // locally so we're skipping it + let Ok(rev) = repo.rev_parse(&**branch) + else { + continue; + }; + + let branch_commits = rev + .single() + .unwrap() + .ancestors() + .sorting(Sorting::ByCommitTimeNewestFirstCutoffOlderThan { + seconds: start_date.timestamp(), + }) + .all()?; + + branch_commits + .filter_map(|c| c.ok()) + .filter_map(|c| c.object().ok()) + .filter_map(|c| { + let title = c + .message() + .ok()? + .title + .trim_ascii() + .to_str() + .ok()? + .to_string(); + + if args.no_merges { + let is_merge = c.parent_ids().count() > 1; + if is_merge { + return None; + } + } + + let author = c.author().ok()?; + + let email = author.email.to_string(); + let name = author.name.to_string(); + + let author = Author { name, email }; + let author = mailmap.resolve(author); + + if !authors.is_empty() && !authors.contains(&author.name) { + return None; + } + + let time = c.time().ok()?; + let time = + DateTime::from_timestamp_millis(time.seconds * 1000)?.with_timezone(&Local); + if time < start_date || time > end_date { + return None; + } + + has_commits = true; + + Some(Commit { + id: c.id, + title, + author, + time, + }) + }) + .for_each(|c| { + commits.insert(c); + }); + } + + if has_commits { + repos_count += 1; + branches_count += branches.len(); + } + } + + let commits = commits + .into_iter() + .sorted_by_cached_key(|a| Reverse(a.time)) + .collect_vec(); + + Ok((repos_count, branches_count, commits)) +} + +fn find_git_repos(scan_path: &path::Path, repos: &mut Vec, ignored_repos: &Vec) { + let Ok(dirs) = scan_path.read_dir() + else { + return; + }; + + let dirs: Vec<_> = dirs + .filter_map(|d| d.ok()) + .filter(|d| { + let dir_name = d.file_name().to_string_lossy().to_string(); + !ignored_repos.contains(&dir_name) + }) + .filter(|d| d.file_type().is_ok_and(|t| t.is_dir())) + .collect_vec(); + + let dirs = dirs.iter().map(|d| d.path()); + + for dir in dirs { + let filename = dir.file_name().unwrap_or_default().to_string_lossy(); + match filename.as_ref() { + ".git" => repos.push(dir), + _ => find_git_repos(&dir, repos, ignored_repos), + } + } +} + +pub fn get_default_until(since: NaiveDate) -> String { + let mut until = Local::now().date_naive(); + if since + Duration::days(365) < until { + until = since + Duration::days(365); + } + until.format("%Y-%m-%d").to_string() +} + +fn get_color(val: i32, high: i32) -> usize { + match COLOR_LOGIC.get() { + Some(logic) => match logic { + ColorLogic::ByAmount => match val { + 0 => 0, + x if x < 2 => 1, + x if x < 4 => 2, + x if x < 6 => 3, + x if x >= 6 => 4, + _ => 0, + }, + ColorLogic::ByWeight => { + let color = val as f32 / high as f32; + match color { + 0.0 => 0, + x if x <= 0.2 => 1, + x if x <= 0.4 => 2, + x if x <= 0.8 => 3, + x if x > 0.8 => 4, + _ => 0, + } + } + }, + None => 0, + } +} + +fn get_char() -> char { + *CHAR.get_or_init(|| '▩') +} + +fn get_color_map() -> Vec { + COLOR_MAP + .get_or_init(|| { + GREEN_COLOR_MAP + .into_iter() + .map(|c| c.to_ansi()) + .collect::>() + }) + .to_vec() +} diff --git a/src/main.rs b/src/main.rs index 0b4cce4..6e69dcf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,93 +1,24 @@ -#![feature(let_chains)] - -use std::{ - cmp::Reverse, - collections::HashSet, - path::{self, PathBuf}, - sync::OnceLock, -}; - -use anyhow::{anyhow, Context, Result}; -use chrono::{DateTime, Duration, Local, NaiveDate, NaiveTime, TimeZone}; -use clap::Parser; -use gix::{bstr::ByteSlice, traverse::commit::simple::Sorting, ObjectId}; -use heatmap::{ColorLogic, HeatmapColors}; -use itertools::Itertools; -use mailmap::Mailmap; -use rgb::Rgb; - -use crate::{cli::CliArgs, heatmap::Heatmap}; - -mod cli; -mod heatmap; -mod mailmap; -mod rgb; - -pub const ESCAPE: &str = "\x1B"; -pub const RESET: &str = "\x1B[0m"; -pub const DAYS: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; -pub static CHAR: OnceLock = OnceLock::new(); -static COLOR_LOGIC: OnceLock = OnceLock::new(); -pub static COLOR_MAP: OnceLock> = OnceLock::new(); - -const GREEN_COLOR_MAP: [Rgb; 5] = [ - Rgb(0, 0, 0), - Rgb(14, 68, 41), - Rgb(0, 109, 50), - Rgb(38, 166, 65), - Rgb(25, 255, 64), -]; - -const RED_COLOR_MAP: [Rgb; 5] = [ - Rgb(0, 0, 0), - Rgb(208, 169, 35), - Rgb(208, 128, 35), - Rgb(208, 78, 35), - Rgb(255, 0, 0), -]; - -#[derive(Debug, PartialEq, Eq, Hash)] -struct Commit { - id: ObjectId, - title: String, - author: Author, - time: DateTime, -} - -#[derive(Debug, PartialEq, Eq, Hash, Clone)] -struct Author { - name: String, - email: String, -} +use anyhow::{Context, Result}; +use chrono::NaiveDate; +use libgitheatmap::heatmap::Heatmap; fn main() -> Result<()> { - let args = CliArgs::parse(); - - CHAR.set(args.char).unwrap(); - COLOR_LOGIC.set(args.counting.clone()).unwrap(); - let color_map = match args.color_scheme { - HeatmapColors::Green => GREEN_COLOR_MAP, - HeatmapColors::Red => RED_COLOR_MAP, - }; - let color_map = color_map - .into_iter() - .map(|c| c.to_ansi()) - .collect::>(); - COLOR_MAP.set(color_map).unwrap(); + let args = libgitheatmap::args(); let since = NaiveDate::parse_from_str(&args.since, "%Y-%m-%d").unwrap(); let until = args .until .clone() - .unwrap_or_else(|| get_default_until(since)); + .unwrap_or_else(|| libgitheatmap::get_default_until(since)); let until = NaiveDate::parse_from_str(&until, "%Y-%m-%d").unwrap(); let split_months = args.split_months; let months_per_row = args.months_per_row; - let format = args.format.clone(); + let format = args.format; - let commits = get_commits(args, since, until).with_context(|| "Could not fetch commit list")?; + let commits = libgitheatmap::get_commits(args, since, until) + .with_context(|| "Could not fetch commit list")?; let heatmap = Heatmap::new( since, @@ -104,244 +35,3 @@ fn main() -> Result<()> { Ok(()) } - -fn get_default_until(since: NaiveDate) -> String { - let mut until = Local::now().date_naive(); - if since + Duration::days(365) < until { - until = since + Duration::days(365); - } - until.format("%Y-%m-%d").to_string() -} - -fn get_color(val: i32, high: i32) -> usize { - match COLOR_LOGIC.get() { - Some(logic) => match logic { - ColorLogic::ByAmount => match val { - 0 => 0, - x if x < 2 => 1, - x if x < 4 => 2, - x if x < 6 => 3, - x if x >= 6 => 4, - _ => 0, - }, - ColorLogic::ByWeight => { - let color = val as f32 / high as f32; - match color { - 0.0 => 0, - x if x <= 0.2 => 1, - x if x <= 0.4 => 2, - x if x <= 0.8 => 3, - x if x > 0.8 => 4, - _ => 0, - } - } - }, - None => 0, - } -} - -fn get_char() -> char { - *CHAR.get_or_init(|| '▩') -} - -fn get_color_map() -> Vec { - COLOR_MAP - .get_or_init(|| { - GREEN_COLOR_MAP - .into_iter() - .map(|c| c.to_ansi()) - .collect::>() - }) - .to_vec() -} - -fn find_git_repos( - scan_path: &path::Path, - repos: &mut Vec, - ignored_repos: &Vec, - _args: &CliArgs, -) { - let Ok(dirs) = scan_path.read_dir() - else { - return; - }; - - let dirs: Vec<_> = dirs - .filter_map(|d| d.ok()) - .filter(|d| { - let dir_name = d.file_name().to_string_lossy().to_string(); - !ignored_repos.contains(&dir_name) - }) - .filter(|d| d.file_type().is_ok_and(|t| t.is_dir())) - .collect_vec(); - - let dirs = dirs.iter().map(|d| d.path()); - - for dir in dirs { - let filename = dir.file_name().unwrap_or_default().to_string_lossy(); - match filename.as_ref() { - ".git" => repos.push(dir), - _ => find_git_repos(&dir, repos, ignored_repos, _args), - } - } -} - -fn get_commits( - args: CliArgs, - start_date: NaiveDate, - end_date: NaiveDate, -) -> Result<(usize, usize, Vec)> { - let mut commits: HashSet = HashSet::new(); - - let ignored_repos = args.ignored_repos.as_ref().unwrap_or(&vec![]).to_owned(); - - let (repos, branches) = match &args.root_dir { - Some(roots) => { - let mut repos: Vec = vec![]; - for root in roots { - find_git_repos(root, &mut repos, &ignored_repos, &args); - } - let branches = vec!["".to_string(); repos.len()]; - (repos, branches) - } - None => { - let repos = match args.repos { - Some(r) => r, - None => vec![PathBuf::from(".")], - }; - - let branches = args.branches.unwrap_or_else(|| vec!["".to_string()]); - - if repos.len() > 1 && repos.len() != branches.len() { - return Err(anyhow!( - "Number of repos ({}) needs to match the number of branch lists ({})!", - repos.len(), - branches.len() - )); - } - (repos, branches) - } - }; - - let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap(); - let start_date = start_date.and_time(midnight); - let start_date = Local.from_local_datetime(&start_date).unwrap(); - - let current_time = Local::now().time(); - let end_date = end_date.and_time(current_time); - let end_date = Local.from_local_datetime(&end_date).unwrap(); - - let authors = args.authors.unwrap_or_default(); - let mut repos_count = 0; - let mut branches_count = 0; - - for (i, repo_path) in repos.iter().enumerate() { - let repo = gix::open(repo_path).unwrap(); - - let branch_names = &*branches[i]; - let mut branches = vec![]; - if branch_names.is_empty() { - branches = repo - .references()? - .prefixed("refs/heads")? - .filter_map(Result::ok) - .filter_map(|b| { - b.inner - .name - .to_string() - .strip_prefix("refs/heads/") - .map(|s| s.to_string()) - }) - .collect_vec(); - } - else { - let branch_names = branch_names.split(' ').map(|s| s.to_string()); - branches.extend(branch_names); - } - - let mailmap = Mailmap::new(repo_path); - let mut has_commits = false; - - for branch in &branches { - // When passing the default @ (HEAD) branch this might actually not exist at all - // locally so we're skipping it - let Ok(rev) = repo.rev_parse(&**branch) - else { - continue; - }; - - let branch_commits = rev - .single() - .unwrap() - .ancestors() - .sorting(Sorting::ByCommitTimeNewestFirstCutoffOlderThan { - seconds: start_date.timestamp(), - }) - .all()?; - - branch_commits - .filter_map(|c| c.ok()) - .filter_map(|c| c.object().ok()) - .filter_map(|c| { - let title = c - .message() - .ok()? - .title - .trim_ascii() - .to_str() - .ok()? - .to_string(); - - if args.no_merges { - let is_merge = c.parent_ids().count() > 1; - if is_merge { - return None; - } - } - - let author = c.author().ok()?; - - let email = author.email.to_string(); - let name = author.name.to_string(); - - let author = Author { name, email }; - let author = mailmap.resolve(author); - - if !authors.is_empty() && !authors.contains(&author.name) { - return None; - } - - let time = c.time().ok()?; - let time = - DateTime::from_timestamp_millis(time.seconds * 1000)?.with_timezone(&Local); - if time < start_date || time > end_date { - return None; - } - - has_commits = true; - - Some(Commit { - id: c.id, - title, - author, - time, - }) - }) - .for_each(|c| { - commits.insert(c); - }); - } - - if has_commits { - repos_count += 1; - branches_count += branches.len(); - } - } - - let commits = commits - .into_iter() - .sorted_by_cached_key(|a| Reverse(a.time)) - .collect_vec(); - - Ok((repos_count, branches_count, commits)) -}