Compare commits

...

7 Commits

Author SHA1 Message Date
Wynd 728ad3ff26 Merge branch 'feat/rayon' 2025-01-19 00:50:52 +02:00
Wynd 1d48f9ffc5 Updated depedencies 2025-01-19 00:46:02 +02:00
Wynd 21e0247d7b Some cleanup and parallel gix feature 2025-01-19 00:15:43 +02:00
Wynd 54d36fafcb Using rayon for discovering repos via dir walking 2025-01-18 21:13:26 +02:00
Wynd b1a5df8659 Rayon testing 2025-01-18 10:34:03 +02:00
Wynd 8084309af5 Added divan for benchmarking and some benchmarks 2025-01-18 00:33:24 +02:00
Wynd 055744285a Split the app into bin and lib 2025-01-18 00:14:48 +02:00
9 changed files with 1319 additions and 562 deletions

3
.gitignore vendored
View File

@ -1,2 +1,3 @@
/target
profile.json
profile.json
perf.data*

1006
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ cargo-features = ["codegen-backend"]
[package]
name = "git-heatmap"
version = "1.2.0"
version = "1.3.0"
edition = "2021"
authors = ["Wynd <wyndftw@proton.me>"]
description = "A simple and customizable heatmap for git repos"
@ -12,15 +12,41 @@ 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"
bench = false
[lints.rust]
unsafe_code = { level = "forbid" }
[dependencies]
gix = { version = "0.66.0", default-features = false, features = ["mailmap"] }
clap = { version = "4.5.20", features = ["derive"] }
chrono = { version = "0.4.38" }
itertools = { version = "0.13.0" }
anyhow = { version = "1.0.89" }
gix = { version = "0.70.0", default-features = false, features = [
"mailmap",
"parallel",
] }
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.17" }
mockd = { version = "0.4.35", features = [
"datetime",
"words",
"name",
"contact",
] }
[[bench]]
name = "commits"
harness = false
[[bench]]
name = "heatmap"
harness = false
[profile.dev]
codegen-backend = "cranelift"

63
benches/commits.rs 100644
View File

@ -0,0 +1,63 @@
use anyhow::Context;
use chrono::NaiveDate;
use libgitheatmap::{self, cli::CliArgs};
fn main() {
divan::main();
}
#[divan::bench]
fn current_repo_commits() {
let home_dir = std::env::current_dir().unwrap();
let args = CliArgs {
root_dir: Some(vec![home_dir]),
authors: None,
char: '',
repos: None,
ignored_repos: None,
branches: None,
since: "2024-01-01".to_string(),
until: None,
split_months: false,
months_per_row: 13,
no_merges: false,
..Default::default()
};
let since = NaiveDate::parse_from_str(&args.since, "%Y-%m-%d").unwrap();
let until = NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap();
libgitheatmap::get_commits(args, since, until)
.with_context(|| "Could not fetch commit list")
.unwrap();
}
#[divan::bench(sample_count = 20)]
fn all_repos_commits() {
let home_dir = std::path::PathBuf::from("/home");
let args = CliArgs {
root_dir: Some(vec![home_dir]),
authors: None,
char: '',
repos: None,
ignored_repos: None,
branches: None,
since: "2024-01-01".to_string(),
until: None,
split_months: false,
months_per_row: 13,
no_merges: false,
..Default::default()
};
let since = NaiveDate::parse_from_str(&args.since, "%Y-%m-%d").unwrap();
let until = NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap();
libgitheatmap::get_commits(args, since, until)
.with_context(|| "Could not fetch commit list")
.unwrap();
}

55
benches/heatmap.rs 100644
View File

@ -0,0 +1,55 @@
use std::sync::OnceLock;
use chrono::{Local, NaiveDate};
use gix::ObjectId;
use libgitheatmap::{
heatmap::{self, Heatmap},
Commit,
};
static COMMITS: OnceLock<Vec<Commit>> = OnceLock::new();
fn main() {
let mut commits: Vec<Commit> = vec![];
for _n in 0..1000 {
let id = ObjectId::Sha1([0u8; 20]);
let title = mockd::words::sentence(10);
let author = mockd::name::full();
let email = mockd::contact::email();
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, time));
}
COMMITS.set(commits).expect("unable to generate commits");
divan::main();
}
#[divan::bench]
fn heatmap_generation() {
let since = NaiveDate::parse_from_str("2024-01-01", "%Y-%m-%d").unwrap();
let until = NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap();
let commits_vec = COMMITS
.get()
.expect("unable to access commits list")
.to_vec();
let commits = commits_vec.len();
let repos = 1;
let _heatmap = Heatmap::new(
since,
until,
commits,
repos,
commits_vec,
false,
13,
heatmap::Format::Chars,
);
}

View File

@ -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)]

View File

@ -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,
}

379
src/lib.rs 100644
View File

@ -0,0 +1,379 @@
#![feature(let_chains)]
use std::{
cmp::Reverse,
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, revision::walk::Sorting, traverse::commit::simple::CommitTimeOrder, ObjectId,
ThreadSafeRepository,
};
use heatmap::{ColorLogic, HeatmapColors};
use itertools::Itertools;
use mailmap::Mailmap;
use rayon::prelude::*;
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<char> = OnceLock::new();
pub static COLOR_LOGIC: OnceLock<ColorLogic> = OnceLock::new();
pub static COLOR_MAP: OnceLock<Vec<String>> = 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(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Commit {
id: ObjectId,
title: String,
author: Author,
time: DateTime<Local>,
}
impl Commit {
pub fn new(
id: ObjectId,
title: String,
author: String,
email: String,
time: DateTime<Local>,
) -> 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::<Vec<_>>();
COLOR_MAP.set(color_map).unwrap();
args
}
pub fn get_commits(
args: CliArgs,
start_date: NaiveDate,
end_date: NaiveDate,
) -> anyhow::Result<(usize, usize, Vec<Commit>)> {
let mut commits: Vec<Commit> = vec![];
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<PathBuf> = 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: usize = 0;
let mut branches_count: usize = 0;
for (i, repo_path) in repos.iter().enumerate() {
let repo = ThreadSafeRepository::open(repo_path).unwrap();
let branch_names = &*branches[i];
let branches = get_repo_branches(&repo, branch_names).unwrap();
let mailmap = Mailmap::new(repo_path);
let branch_commits: Vec<_> = branches
.par_iter()
.filter_map(|branch| get_commit_ids(&repo, branch, start_date))
.reduce(Vec::new, |mut c, n| {
c.extend(n);
c
});
let repo = repo.to_thread_local();
let branch_commits = branch_commits
.into_iter()
.unique()
.filter_map(|c| repo.find_commit(c).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;
}
Some(Commit {
id: c.id,
title,
author,
time,
})
})
.collect_vec();
if !branch_commits.is_empty() {
repos_count += 1;
branches_count += branches.len();
}
commits.extend(branch_commits);
}
commits.par_sort_by_cached_key(|a| Reverse(a.time));
Ok((repos_count, branches_count, commits))
}
fn get_repo_branches(repo: &ThreadSafeRepository, branch_names: &str) -> Option<Vec<String>> {
if branch_names.is_empty() {
let repo = repo.to_thread_local();
let Ok(refs) = repo.references()
else {
return None;
};
let Ok(prefix) = refs.prefixed("refs/heads")
else {
return None;
};
let branches = prefix
.filter_map(Result::ok)
.filter_map(|b| {
b.inner
.name
.to_string()
.strip_prefix("refs/heads/")
.map(|s| s.to_string())
})
.collect();
Some(branches)
}
else {
Some(branch_names.split(' ').map(|s| s.to_string()).collect())
}
}
fn get_commit_ids(
repo: &ThreadSafeRepository,
branch: &str,
start_date: DateTime<Local>,
) -> Option<Vec<ObjectId>> {
let repo = repo.to_thread_local();
// When passing the default @ (HEAD) branch this might actually not exist at all
// locally so we're skipping it
let rev = repo.rev_parse(branch).ok()?;
let branch_commits = rev
.single()
.unwrap()
.ancestors()
.sorting(Sorting::ByCommitTimeCutoff {
order: CommitTimeOrder::NewestFirst,
seconds: start_date.timestamp(),
})
.all()
.ok()?;
let commits = branch_commits
.filter_map(|c| c.ok())
.map(|c| c.id)
.collect();
Some(commits)
}
fn find_git_repos(scan_path: &path::Path, repos: &mut Vec<PathBuf>, ignored_repos: &Vec<String>) {
if let Some(path) = walk_dir(scan_path, ignored_repos) {
repos.extend(path)
}
}
pub fn walk_dir(scan_path: &path::Path, ignored_repos: &Vec<String>) -> Option<Vec<PathBuf>> {
let Ok(dirs) = scan_path.read_dir()
else {
return None;
};
let dirs: Vec<PathBuf> = dirs
.par_bridge()
.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()))
.filter_map(|d| {
let dir = d.path();
let filename = dir.file_name().unwrap_or_default().to_string_lossy();
match filename.as_ref() {
".git" => Some(vec![dir]),
_ => walk_dir(&dir, ignored_repos),
}
})
.reduce(Vec::new, |mut c, n| {
c.extend(n);
c
});
Some(dirs)
}
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<String> {
COLOR_MAP
.get_or_init(|| {
GREEN_COLOR_MAP
.into_iter()
.map(|c| c.to_ansi())
.collect::<Vec<_>>()
})
.to_vec()
}

View File

@ -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<char> = OnceLock::new();
static COLOR_LOGIC: OnceLock<ColorLogic> = OnceLock::new();
pub static COLOR_MAP: OnceLock<Vec<String>> = 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<Local>,
}
#[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::<Vec<_>>();
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<String> {
COLOR_MAP
.get_or_init(|| {
GREEN_COLOR_MAP
.into_iter()
.map(|c| c.to_ansi())
.collect::<Vec<_>>()
})
.to_vec()
}
fn find_git_repos(
scan_path: &path::Path,
repos: &mut Vec<PathBuf>,
ignored_repos: &Vec<String>,
_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<Commit>)> {
let mut commits: HashSet<Commit> = 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<PathBuf> = 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))
}