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

View File

@ -84,6 +84,5 @@ $ git-heatmap -a "username" -a "other"
$ git-heatmap --since "2013-08-23"
# 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"
```

View File

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

View File

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

View File

@ -1,7 +1,7 @@
use std::path::PathBuf;
use chrono::{Duration, Local};
use clap::{Parser, ValueHint, arg};
use clap::{arg, Parser, ValueHint};
use crate::heatmap::{ColorLogic, Format, HeatmapColors};
@ -32,12 +32,7 @@ pub struct CliArgs {
#[arg(long("since"), default_value_t = get_since_date())]
pub since: String,
#[arg(
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."
)
)]
#[arg(long("until"))]
pub until: Option<String>,
#[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)]
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)]
pub counting: ColorLogic,
#[arg(short, long("format"), value_enum, default_value_t = Format::Chars)]
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 {
let date = Local::now() - Duration::days(364);
let date = Local::now() - Duration::days(365);
date.format("%Y-%m-%d").to_string()
}

View File

@ -1,5 +1,5 @@
use std::{
collections::{BTreeMap, HashMap},
collections::BTreeMap,
fmt::{Display, Write},
};
@ -7,26 +7,20 @@ use chrono::{Datelike, Duration, NaiveDate};
use clap::ValueEnum;
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 {
since: NaiveDate,
until: NaiveDate,
commits: Vec<Commit>,
repo_commits: Vec<(String, u64)>,
highest_count: i32,
branches: usize,
repos: usize,
chunks: Vec<Chunk>,
streak: u32,
max_streak: u32,
format: Format,
list_repos: bool,
list_days: bool,
}
#[allow(clippy::too_many_arguments)]
impl Heatmap {
pub fn new(
since: NaiveDate,
@ -37,33 +31,24 @@ impl Heatmap {
split_months: bool,
months_per_row: u16,
format: Format,
list_repos: bool,
list_days: bool,
) -> Self {
let mut chunks = vec![];
let mut highest_count: i32 = 0;
let mut grouped_commits = BTreeMap::new();
let mut repo_commits_map: HashMap<String, u64> = HashMap::new();
let mut repo_commits = Vec::<(String, u64)>::new();
let mut heatmap = Self {
since,
until,
commits,
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 record = grouped_commits.entry(commit_day).or_insert(0);
*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;
@ -71,8 +56,6 @@ impl Heatmap {
let mut chunk = Chunk::new(day_of_week);
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
// those.
@ -99,7 +82,7 @@ impl Heatmap {
chunk_idx += 1;
if chunk_idx > months_per_row - 1 {
chunks.push(chunk);
heatmap.chunks.push(chunk);
chunk = Chunk::new(day_of_week);
chunk_idx = 0;
}
@ -112,18 +95,12 @@ impl Heatmap {
let value = grouped_commits.get(&current_day);
match value {
Some(val) => {
streak += 1;
if streak > max_streak {
max_streak = streak;
}
chunk.data[day_of_week as usize].push(*val);
if *val > highest_count {
highest_count = *val;
if *val > heatmap.highest_count {
heatmap.highest_count = *val;
}
}
None => {
streak = 0;
chunk.data[day_of_week as usize].push(0);
}
}
@ -133,25 +110,10 @@ impl Heatmap {
}
if chunk_idx <= months_per_row {
chunks.push(chunk);
heatmap.chunks.push(chunk);
}
Self {
since,
until,
commits,
repo_commits,
highest_count,
branches,
repos,
chunks,
streak,
max_streak,
format,
list_repos,
list_days,
}
heatmap
}
}
@ -180,40 +142,11 @@ impl Display for Heatmap {
)
.unwrap();
writeln!(f, "{} {}", authors, authors_label).unwrap();
writeln!(f, "{} {}", 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];
writeln!(f, "{} {}\n", commits, commits_label).unwrap();
for chunk in &self.chunks {
chunk.display(self, f);
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();
@ -222,13 +155,6 @@ impl Display for Heatmap {
}
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(())
}
}

View File

@ -2,7 +2,6 @@
use std::{
cmp::Reverse,
fmt::Display,
path::{self, PathBuf},
sync::OnceLock,
};
@ -12,8 +11,8 @@ use chrono::{DateTime, Duration, Local, NaiveDate, NaiveTime, TimeZone};
use clap::Parser;
use cli::CliArgs;
use gix::{
ObjectId, ThreadSafeRepository, bstr::ByteSlice, revision::walk::Sorting,
traverse::commit::simple::CommitTimeOrder,
bstr::ByteSlice, revision::walk::Sorting, traverse::commit::simple::CommitTimeOrder, ObjectId,
ThreadSafeRepository,
};
use heatmap::{ColorLogic, HeatmapColors};
use itertools::Itertools;
@ -51,11 +50,10 @@ pub const RED_COLOR_MAP: [Rgb; 5] = [
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Commit {
pub id: ObjectId,
pub title: String,
pub author: Author,
pub repo: String,
pub time: DateTime<Local>,
id: ObjectId,
title: String,
author: Author,
time: DateTime<Local>,
}
impl Commit {
@ -64,7 +62,6 @@ impl Commit {
title: String,
author: String,
email: String,
repo: String,
time: DateTime<Local>,
) -> Self {
Self {
@ -74,7 +71,6 @@ impl Commit {
name: author,
email,
},
repo,
time,
}
}
@ -82,14 +78,8 @@ impl Commit {
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub struct Author {
pub name: String,
pub 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))
}
name: String,
email: String,
}
pub fn args() -> CliArgs {
@ -129,17 +119,11 @@ pub fn get_commits(
(repos, branches)
}
None => {
let mut repos = match args.repos {
let repos = match args.repos {
Some(r) => r,
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()]);
if repos.len() > 1 && repos.len() != branches.len() {
@ -165,24 +149,10 @@ pub fn get_commits(
let mut repos_count: usize = 0;
let mut branches_count: usize = 0;
let mut cached_commits: Vec<Commit> = vec![];
for (i, repo_path) in repos.iter().enumerate() {
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 branches = get_repo_branches(&repo, branch_names).unwrap();
@ -221,30 +191,9 @@ pub fn get_commits(
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 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 = mailmap.resolve(author);
@ -252,28 +201,19 @@ pub fn get_commits(
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,
title,
author,
repo: repo_name.to_string(),
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();

View File

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

View File

@ -1,10 +1,6 @@
use anyhow::{Context, Result};
use chrono::NaiveDate;
use libgitheatmap::{RESET, heatmap::Heatmap, rgb::Rgb};
const REPO_COLOR: Rgb = Rgb(0, 255, 0);
const AUTHOR_COLOR: Rgb = Rgb(200, 200, 0);
const TIME_COLOR: Rgb = Rgb(0, 200, 200);
use libgitheatmap::heatmap::Heatmap;
fn main() -> Result<()> {
let args = libgitheatmap::args();
@ -15,49 +11,27 @@ fn main() -> Result<()> {
.until
.clone()
.unwrap_or_else(|| libgitheatmap::get_default_until(since));
let until = match until.to_lowercase().as_str() {
"today" => chrono::Local::now().date_naive(),
_ => NaiveDate::parse_from_str(&until, "%Y-%m-%d").unwrap(),
};
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;
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")?;
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;
let heatmap = Heatmap::new(
since,
until,
commits.0,
commits.1,
commits.2,
split_months,
months_per_row,
format,
);
println!("{repo} {author} {time}\n{RESET}{message}\n",);
}
}
else {
let heatmap = Heatmap::new(
since,
until,
commits.0,
commits.1,
commits.2,
split_months,
months_per_row,
format,
list_repos,
list_days,
);
println!("{heatmap}");
}
println!("{heatmap}");
Ok(())
}