Compare commits

..

No commits in common. "master" and "v1.3.0" have entirely different histories.

10 changed files with 458 additions and 862 deletions

989
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,8 @@ cargo-features = ["codegen-backend"]
[package] [package]
name = "git-heatmap" name = "git-heatmap"
version = "1.4.1" version = "1.3.0"
edition = "2024" edition = "2021"
authors = ["Wynd <wyndftw@proton.me>"] authors = ["Wynd <wyndftw@proton.me>"]
description = "A simple and customizable heatmap for git repos" description = "A simple and customizable heatmap for git repos"
readme = "README.md" readme = "README.md"
@ -21,19 +21,24 @@ bench = false
unsafe_code = { level = "forbid" } unsafe_code = { level = "forbid" }
[dependencies] [dependencies]
gix = { version = "0.73", default-features = false, features = [ gix = { version = "0.70.0", default-features = false, features = [
"mailmap", "mailmap",
"parallel", "parallel",
] } ] }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5.26", features = ["derive"] }
chrono = { version = "0.4" } chrono = { version = "0.4.39" }
itertools = { version = "0.14" } itertools = { version = "0.14.0" }
anyhow = { version = "1.0" } anyhow = { version = "1.0.95" }
rayon = { version = "1.11" } rayon = { version = "1.10.0" }
[dev-dependencies] [dev-dependencies]
divan = { version = "0.1" } divan = { version = "0.1.17" }
mockd = { version = "0.4", features = ["datetime", "words", "name", "contact"] } mockd = { version = "0.4.35", features = [
"datetime",
"words",
"name",
"contact",
] }
[[bench]] [[bench]]
name = "commits" name = "commits"

View File

@ -84,6 +84,5 @@ $ git-heatmap -a "username" -a "other"
$ git-heatmap --since "2013-08-23" $ git-heatmap --since "2013-08-23"
# or choose a time span, both --since and --until must use a YYYY-MM-DD format # or choose a time span, both --since and --until must use a YYYY-MM-DD format
# --until can optionally use `today` as a keyword for the current date
$ git-heatmap --since "2013-08-23" --until "2024-08-23" $ git-heatmap --since "2013-08-23" --until "2024-08-23"
``` ```

View File

@ -3,8 +3,8 @@ use std::sync::OnceLock;
use chrono::{Local, NaiveDate}; use chrono::{Local, NaiveDate};
use gix::ObjectId; use gix::ObjectId;
use libgitheatmap::{ use libgitheatmap::{
Commit,
heatmap::{self, Heatmap}, heatmap::{self, Heatmap},
Commit,
}; };
static COMMITS: OnceLock<Vec<Commit>> = OnceLock::new(); static COMMITS: OnceLock<Vec<Commit>> = OnceLock::new();
@ -16,13 +16,12 @@ fn main() {
let title = mockd::words::sentence(10); let title = mockd::words::sentence(10);
let author = mockd::name::full(); let author = mockd::name::full();
let email = mockd::contact::email(); let email = mockd::contact::email();
let repo = "project".to_string();
let time = mockd::datetime::date_range( let time = mockd::datetime::date_range(
"2024-01-01T00:00:00Z".to_string(), "2024-01-01T00:00:00Z".to_string(),
"2025-01-01T00:00:00Z".to_string(), "2025-01-01T00:00:00Z".to_string(),
) )
.with_timezone(&Local); .with_timezone(&Local);
commits.push(Commit::new(id, title, author, email, repo, time)); commits.push(Commit::new(id, title, author, email, time));
} }
COMMITS.set(commits).expect("unable to generate commits"); COMMITS.set(commits).expect("unable to generate commits");
@ -52,7 +51,5 @@ fn heatmap_generation() {
false, false,
13, 13,
heatmap::Format::Chars, heatmap::Format::Chars,
true,
true,
); );
} }

View File

@ -1,2 +1,2 @@
[toolchain] [toolchain]
channel = "nightly-2025-04-07" channel = "nightly-2024-12-02"

View File

@ -1,7 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use chrono::{Duration, Local}; use chrono::{Duration, Local};
use clap::{Parser, ValueHint, arg}; use clap::{arg, Parser, ValueHint};
use crate::heatmap::{ColorLogic, Format, HeatmapColors}; use crate::heatmap::{ColorLogic, Format, HeatmapColors};
@ -32,12 +32,7 @@ pub struct CliArgs {
#[arg(long("since"), default_value_t = get_since_date())] #[arg(long("since"), default_value_t = get_since_date())]
pub since: String, pub since: String,
#[arg( #[arg(long("until"))]
long("until"),
help(
"Optional upper limit for the time slice being checked, defaults to 365 days after the `since` date.\n`today` can be used for current date."
)
)]
pub until: Option<String>, pub until: Option<String>,
#[arg(long("split-months"), help("Split months"), default_value_t = false)] #[arg(long("split-months"), help("Split months"), default_value_t = false)]
@ -53,30 +48,14 @@ pub struct CliArgs {
#[arg(long("no-merges"), default_value_t = false)] #[arg(long("no-merges"), default_value_t = false)]
pub no_merges: bool, pub no_merges: bool,
// Experimental
#[arg(long("no-diff"), default_value_t = false)]
pub no_diff: bool,
#[arg(long("use-author-time"), default_value_t = false)]
pub use_author_time: bool,
#[arg(long("counting"), value_enum, default_value_t = ColorLogic::ByWeight)] #[arg(long("counting"), value_enum, default_value_t = ColorLogic::ByWeight)]
pub counting: ColorLogic, pub counting: ColorLogic,
#[arg(short, long("format"), value_enum, default_value_t = Format::Chars)] #[arg(short, long("format"), value_enum, default_value_t = Format::Chars)]
pub format: Format, pub format: Format,
#[arg(long("list-repos"), default_value_t = false)]
pub list_repos: bool,
#[arg(long("list-days"), default_value_t = false)]
pub list_days: bool,
#[arg(long("list-commits"), default_value_t = false)]
pub list_commits: bool,
} }
fn get_since_date() -> String { fn get_since_date() -> String {
let date = Local::now() - Duration::days(364); let date = Local::now() - Duration::days(365);
date.format("%Y-%m-%d").to_string() date.format("%Y-%m-%d").to_string()
} }

View File

@ -1,5 +1,5 @@
use std::{ use std::{
collections::{BTreeMap, HashMap}, collections::BTreeMap,
fmt::{Display, Write}, fmt::{Display, Write},
}; };
@ -7,26 +7,20 @@ use chrono::{Datelike, Duration, NaiveDate};
use clap::ValueEnum; use clap::ValueEnum;
use itertools::Itertools; use itertools::Itertools;
use crate::{Commit, DAYS, RESET, get_char, get_color, get_color_map}; use crate::{get_char, get_color, get_color_map, Commit, DAYS, RESET};
pub struct Heatmap { pub struct Heatmap {
since: NaiveDate, since: NaiveDate,
until: NaiveDate, until: NaiveDate,
commits: Vec<Commit>, commits: Vec<Commit>,
repo_commits: Vec<(String, u64)>,
highest_count: i32, highest_count: i32,
branches: usize, branches: usize,
repos: usize, repos: usize,
chunks: Vec<Chunk>, chunks: Vec<Chunk>,
streak: u32,
max_streak: u32,
format: Format, format: Format,
list_repos: bool,
list_days: bool,
} }
#[allow(clippy::too_many_arguments)]
impl Heatmap { impl Heatmap {
pub fn new( pub fn new(
since: NaiveDate, since: NaiveDate,
@ -37,33 +31,24 @@ impl Heatmap {
split_months: bool, split_months: bool,
months_per_row: u16, months_per_row: u16,
format: Format, format: Format,
list_repos: bool,
list_days: bool,
) -> Self { ) -> Self {
let mut chunks = vec![]; let mut heatmap = Self {
let mut highest_count: i32 = 0; since,
let mut grouped_commits = BTreeMap::new(); until,
let mut repo_commits_map: HashMap<String, u64> = HashMap::new(); commits,
let mut repo_commits = Vec::<(String, u64)>::new(); highest_count: 0,
branches,
repos,
chunks: vec![],
format,
};
for commit in &commits { let mut grouped_commits = BTreeMap::new();
for commit in &heatmap.commits {
let commit_day = commit.time.date_naive(); let commit_day = commit.time.date_naive();
let record = grouped_commits.entry(commit_day).or_insert(0); let record = grouped_commits.entry(commit_day).or_insert(0);
*record += 1; *record += 1;
if list_repos {
let commits = repo_commits_map.entry(commit.repo.clone()).or_insert(0);
*commits += 1;
}
}
if list_repos {
repo_commits.extend(
repo_commits_map
.into_iter()
.sorted_by(|c1, c2| c2.1.cmp(&c1.1))
.map(|e| (e.0, e.1)),
);
} }
let mut current_day = since; let mut current_day = since;
@ -71,8 +56,6 @@ impl Heatmap {
let mut chunk = Chunk::new(day_of_week); let mut chunk = Chunk::new(day_of_week);
let mut chunk_idx = 0; let mut chunk_idx = 0;
let mut streak = 0;
let mut max_streak = 0;
// Track the very first day of the heatmap, as we don't want the extra spacing in front of // Track the very first day of the heatmap, as we don't want the extra spacing in front of
// those. // those.
@ -99,7 +82,7 @@ impl Heatmap {
chunk_idx += 1; chunk_idx += 1;
if chunk_idx > months_per_row - 1 { if chunk_idx > months_per_row - 1 {
chunks.push(chunk); heatmap.chunks.push(chunk);
chunk = Chunk::new(day_of_week); chunk = Chunk::new(day_of_week);
chunk_idx = 0; chunk_idx = 0;
} }
@ -112,18 +95,12 @@ impl Heatmap {
let value = grouped_commits.get(&current_day); let value = grouped_commits.get(&current_day);
match value { match value {
Some(val) => { Some(val) => {
streak += 1;
if streak > max_streak {
max_streak = streak;
}
chunk.data[day_of_week as usize].push(*val); chunk.data[day_of_week as usize].push(*val);
if *val > highest_count { if *val > heatmap.highest_count {
highest_count = *val; heatmap.highest_count = *val;
} }
} }
None => { None => {
streak = 0;
chunk.data[day_of_week as usize].push(0); chunk.data[day_of_week as usize].push(0);
} }
} }
@ -133,25 +110,10 @@ impl Heatmap {
} }
if chunk_idx <= months_per_row { if chunk_idx <= months_per_row {
chunks.push(chunk); heatmap.chunks.push(chunk);
} }
Self { heatmap
since,
until,
commits,
repo_commits,
highest_count,
branches,
repos,
chunks,
streak,
max_streak,
format,
list_repos,
list_days,
}
} }
} }
@ -180,40 +142,11 @@ impl Display for Heatmap {
) )
.unwrap(); .unwrap();
writeln!(f, "{} {}", authors, authors_label).unwrap(); writeln!(f, "{} {}", authors, authors_label).unwrap();
writeln!(f, "{} {}", commits, commits_label).unwrap(); writeln!(f, "{} {}\n", commits, commits_label).unwrap();
if self.list_repos {
for (repo, repo_commits) in &self.repo_commits {
let commits_label = if *repo_commits == 1 {
"commit"
}
else {
"commits"
};
writeln!(f, " {}: {} {}", repo, repo_commits, commits_label).unwrap();
}
}
writeln!(
f,
"{} current streak | {} longest streak",
self.streak, self.max_streak
)
.unwrap();
writeln!(f).unwrap();
let mut per_day_commits: [i32; 7] = [0, 0, 0, 0, 0, 0, 0];
for chunk in &self.chunks { for chunk in &self.chunks {
chunk.display(self, f); chunk.display(self, f);
writeln!(f).unwrap(); writeln!(f).unwrap();
if self.list_days {
per_day_commits
.iter_mut()
.enumerate()
.for_each(|(i, v)| *v += chunk.data[i].iter().sum::<i32>());
}
} }
write!(f, "\nLess ").unwrap(); write!(f, "\nLess ").unwrap();
@ -222,13 +155,6 @@ impl Display for Heatmap {
} }
writeln!(f, " More").unwrap(); writeln!(f, " More").unwrap();
if self.list_days {
writeln!(f).unwrap();
for day in 0..DAYS.len() {
writeln!(f, "{}: {}", DAYS[day], per_day_commits[day]).unwrap();
}
}
Ok(()) Ok(())
} }
} }

View File

@ -2,7 +2,6 @@
use std::{ use std::{
cmp::Reverse, cmp::Reverse,
fmt::Display,
path::{self, PathBuf}, path::{self, PathBuf},
sync::OnceLock, sync::OnceLock,
}; };
@ -12,8 +11,8 @@ use chrono::{DateTime, Duration, Local, NaiveDate, NaiveTime, TimeZone};
use clap::Parser; use clap::Parser;
use cli::CliArgs; use cli::CliArgs;
use gix::{ use gix::{
ObjectId, ThreadSafeRepository, bstr::ByteSlice, revision::walk::Sorting, bstr::ByteSlice, revision::walk::Sorting, traverse::commit::simple::CommitTimeOrder, ObjectId,
traverse::commit::simple::CommitTimeOrder, ThreadSafeRepository,
}; };
use heatmap::{ColorLogic, HeatmapColors}; use heatmap::{ColorLogic, HeatmapColors};
use itertools::Itertools; use itertools::Itertools;
@ -51,11 +50,10 @@ pub const RED_COLOR_MAP: [Rgb; 5] = [
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Commit { pub struct Commit {
pub id: ObjectId, id: ObjectId,
pub title: String, title: String,
pub author: Author, author: Author,
pub repo: String, time: DateTime<Local>,
pub time: DateTime<Local>,
} }
impl Commit { impl Commit {
@ -64,7 +62,6 @@ impl Commit {
title: String, title: String,
author: String, author: String,
email: String, email: String,
repo: String,
time: DateTime<Local>, time: DateTime<Local>,
) -> Self { ) -> Self {
Self { Self {
@ -74,7 +71,6 @@ impl Commit {
name: author, name: author,
email, email,
}, },
repo,
time, time,
} }
} }
@ -82,14 +78,8 @@ impl Commit {
#[derive(Debug, PartialEq, Eq, Hash, Clone)] #[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub struct Author { pub struct Author {
pub name: String, name: String,
pub email: String, email: String,
}
impl Display for Author {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{} <{}>", self.name, self.email))
}
} }
pub fn args() -> CliArgs { pub fn args() -> CliArgs {
@ -129,17 +119,11 @@ pub fn get_commits(
(repos, branches) (repos, branches)
} }
None => { None => {
let mut repos = match args.repos { let repos = match args.repos {
Some(r) => r, Some(r) => r,
None => vec![PathBuf::from(".")], None => vec![PathBuf::from(".")],
}; };
// Turn any potential relative paths (such as `.`) into an absolute path
repos = repos
.iter_mut()
.filter_map(|p| std::fs::canonicalize(p).ok())
.collect_vec();
let branches = args.branches.unwrap_or_else(|| vec!["".to_string()]); let branches = args.branches.unwrap_or_else(|| vec!["".to_string()]);
if repos.len() > 1 && repos.len() != branches.len() { if repos.len() > 1 && repos.len() != branches.len() {
@ -165,24 +149,10 @@ pub fn get_commits(
let mut repos_count: usize = 0; let mut repos_count: usize = 0;
let mut branches_count: usize = 0; let mut branches_count: usize = 0;
let mut cached_commits: Vec<Commit> = vec![];
for (i, repo_path) in repos.iter().enumerate() { for (i, repo_path) in repos.iter().enumerate() {
let repo = ThreadSafeRepository::open(repo_path).unwrap(); let repo = ThreadSafeRepository::open(repo_path).unwrap();
let mut repo_name = repo_path.file_name();
if repo_path.ends_with(gix::discover::DOT_GIT_DIR) {
if let Some(parent) = repo_path.parent() {
repo_name = parent.file_name();
};
}
let repo_name = repo_name
.iter()
.filter_map(|r| r.to_str())
.next()
.unwrap_or("unknown");
let branch_names = &*branches[i]; let branch_names = &*branches[i];
let branches = get_repo_branches(&repo, branch_names).unwrap(); let branches = get_repo_branches(&repo, branch_names).unwrap();
@ -221,30 +191,9 @@ pub fn get_commits(
let author = c.author().ok()?; let author = c.author().ok()?;
// Ignores commits with different commit / author times
// Usually due to rebases or cherry picking, however other edge cases may apply too
if args.no_diff {
let commit_info = c.committer().unwrap();
if commit_info.time != author.time {
return None;
}
}
let email = author.email.to_string(); let email = author.email.to_string();
let name = author.name.to_string(); let name = author.name.to_string();
let time = if args.use_author_time {
author.time().ok()?
}
else {
c.time().ok()?
};
let time =
DateTime::from_timestamp_millis(time.seconds * 1000)?.with_timezone(&Local);
if time < start_date || time > end_date {
return None;
}
let author = Author { name, email }; let author = Author { name, email };
let author = mailmap.resolve(author); let author = mailmap.resolve(author);
@ -252,28 +201,19 @@ pub fn get_commits(
return None; return None;
} }
let commit = Commit { 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;
}
Some(Commit {
id: c.id, id: c.id,
title, title,
author, author,
repo: repo_name.to_string(),
time, time,
}; })
if args.use_author_time {
for other in &cached_commits {
if other.author == commit.author
&& other.title == commit.title
&& other.time == commit.time
{
return None;
}
}
cached_commits.push(commit.clone());
}
Some(commit)
}) })
.collect_vec(); .collect_vec();

View File

@ -6,7 +6,6 @@ pub struct Mailmap {
entries: Vec<MapEntry>, entries: Vec<MapEntry>,
} }
#[allow(dead_code)]
#[derive(Debug)] #[derive(Debug)]
struct MapEntry { struct MapEntry {
new_name: Option<String>, new_name: Option<String>,

View File

@ -1,10 +1,6 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::NaiveDate; use chrono::NaiveDate;
use libgitheatmap::{RESET, heatmap::Heatmap, rgb::Rgb}; use libgitheatmap::heatmap::Heatmap;
const REPO_COLOR: Rgb = Rgb(0, 255, 0);
const AUTHOR_COLOR: Rgb = Rgb(200, 200, 0);
const TIME_COLOR: Rgb = Rgb(0, 200, 200);
fn main() -> Result<()> { fn main() -> Result<()> {
let args = libgitheatmap::args(); let args = libgitheatmap::args();
@ -15,34 +11,15 @@ fn main() -> Result<()> {
.until .until
.clone() .clone()
.unwrap_or_else(|| libgitheatmap::get_default_until(since)); .unwrap_or_else(|| libgitheatmap::get_default_until(since));
let until = match until.to_lowercase().as_str() { let until = NaiveDate::parse_from_str(&until, "%Y-%m-%d").unwrap();
"today" => chrono::Local::now().date_naive(),
_ => NaiveDate::parse_from_str(&until, "%Y-%m-%d").unwrap(),
};
let split_months = args.split_months; let split_months = args.split_months;
let months_per_row = args.months_per_row; let months_per_row = args.months_per_row;
let format = args.format; let format = args.format;
let list_repos = args.list_repos;
let list_days = args.list_days;
let list_commits = args.list_commits;
let mut commits = libgitheatmap::get_commits(args, since, until) let commits = libgitheatmap::get_commits(args, since, until)
.with_context(|| "Could not fetch commit list")?; .with_context(|| "Could not fetch commit list")?;
if list_commits {
// Newer entries at the bottom, older entries at top
commits.2.reverse();
for commit in commits.2 {
let repo = format!("{}{}", REPO_COLOR.to_ansi(), commit.repo);
let author = format!("{}{}", AUTHOR_COLOR.to_ansi(), commit.author);
let time = format!("{}{}", TIME_COLOR.to_ansi(), commit.time);
let message = commit.title;
println!("{repo} {author} {time}\n{RESET}{message}\n",);
}
}
else {
let heatmap = Heatmap::new( let heatmap = Heatmap::new(
since, since,
until, until,
@ -52,12 +29,9 @@ fn main() -> Result<()> {
split_months, split_months,
months_per_row, months_per_row,
format, format,
list_repos,
list_days,
); );
println!("{heatmap}"); println!("{heatmap}");
}
Ok(()) Ok(())
} }