Git repository summary in your terminal
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

744 lines
23 KiB

use {
crate::{
image_backends::ImageBackend,
language::Language,
license::Detector,
{AsciiArt, CommitInfo, Error, InfoFieldOn},
},
colored::{Color, ColoredString, Colorize},
git2::Repository,
image::DynamicImage,
std::{ffi::OsStr, fmt::Write, fs},
tokio::process::Command,
};
type Result<T> = std::result::Result<T, crate::Error>;
const LICENSE_FILES: [&str; 3] = ["LICENSE", "LICENCE", "COPYING"];
pub struct Info {
git_version: String,
git_username: String,
project_name: String,
current_commit: CommitInfo,
version: String,
creation_date: String,
dominant_language: Language,
languages: Vec<(Language, f64)>,
authors: Vec<(String, usize, usize)>,
last_change: String,
repo_url: String,
commits: String,
pending: String,
repo_size: String,
number_of_lines: usize,
license: String,
custom_logo: Language,
custom_colors: Vec<String>,
disable_fields: InfoFieldOn,
bold_enabled: bool,
no_color_blocks: bool,
custom_image: Option<DynamicImage>,
image_backend: Option<Box<dyn ImageBackend>>,
}
impl std::fmt::Display for Info {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let mut buf = String::new();
let color = match self.colors().get(0) {
Some(&c) => c,
None => Color::White,
};
if !self.disable_fields.git_info {
let git_info_length;
if self.git_username != "" {
git_info_length = self.git_username.len() + self.git_version.len() + 3;
write!(
&mut buf,
"{} ~ ",
&self.get_formatted_info_label(&self.git_username, color)
)?;
} else {
git_info_length = self.git_version.len();
}
write_buf(
&mut buf,
&self.get_formatted_info_label(&self.git_version, color),
"",
)?;
let separator = "-".repeat(git_info_length);
write_buf(
&mut buf,
&self.get_formatted_info_label("", color),
&separator,
)?;
}
if !self.disable_fields.project {
write_buf(
&mut buf,
&self.get_formatted_info_label("Project: ", color),
&self.project_name,
)?;
}
if !self.disable_fields.head {
write_buf(
&mut buf,
&self.get_formatted_info_label("HEAD: ", color),
&self.current_commit,
)?;
}
if !self.disable_fields.pending && self.pending != "" {
write_buf(
&mut buf,
&self.get_formatted_info_label("Pending: ", color),
&self.pending,
)?;
}
if !self.disable_fields.version {
write_buf(
&mut buf,
&self.get_formatted_info_label("Version: ", color),
&self.version,
)?;
}
if !self.disable_fields.created {
write_buf(
&mut buf,
&self.get_formatted_info_label("Created: ", color),
&self.creation_date,
)?;
}
if !self.disable_fields.languages && !self.languages.is_empty() {
if self.languages.len() > 1 {
let title = "Languages: ";
let pad = " ".repeat(title.len());
let mut s = String::from("");
let languages: Vec<(String, f64)> = {
let mut iter = self.languages.iter().map(|x| (format!("{}", x.0), x.1));
if self.languages.len() > 6 {
let mut languages = iter.by_ref().take(6).collect::<Vec<_>>();
let other_sum = iter.fold(0.0, |acc, x| acc + x.1);
languages.push(("Other".to_owned(), other_sum));
languages
} else {
iter.collect()
}
};
for (cnt, language) in languages.iter().enumerate() {
let formatted_number = format!("{:.*}", 1, language.1);
if cnt != 0 && cnt % 3 == 0 {
s = s + &format!("\n{}{} ({} %) ", pad, language.0, formatted_number);
} else {
s = s + &format!("{} ({} %) ", language.0, formatted_number);
}
}
writeln!(buf, "{}{}", &self.get_formatted_info_label(title, color), s)?;
} else {
write_buf(
&mut buf,
&self.get_formatted_info_label("Language: ", color),
&self.dominant_language,
)?;
};
}
if !self.disable_fields.authors && !self.authors.is_empty() {
let title = if self.authors.len() > 1 {
"Authors: "
} else {
"Author: "
};
writeln!(
buf,
"{}{}% {} {}",
&self.get_formatted_info_label(title, color),
self.authors[0].2,
self.authors[0].0,
self.authors[0].1
)?;
let title = " ".repeat(title.len());
for author in self.authors.iter().skip(1) {
writeln!(
buf,
"{}{}% {} {}",
&self.get_formatted_info_label(&title, color),
author.2,
author.0,
author.1
)?;
}
}
if !self.disable_fields.last_change {
write_buf(
&mut buf,
&self.get_formatted_info_label("Last change: ", color),
&self.last_change,
)?;
}
if !self.disable_fields.repo {
write_buf(
&mut buf,
&self.get_formatted_info_label("Repo: ", color),
&self.repo_url,
)?;
}
if !self.disable_fields.commits {
write_buf(
&mut buf,
&self.get_formatted_info_label("Commits: ", color),
&self.commits,
)?;
}
if !self.disable_fields.lines_of_code {
write_buf(
&mut buf,
&self.get_formatted_info_label("Lines of code: ", color),
&self.number_of_lines,
)?;
}
if !self.disable_fields.size {
write_buf(
&mut buf,
&self.get_formatted_info_label("Size: ", color),
&self.repo_size,
)?;
}
if !self.disable_fields.license {
write_buf(
&mut buf,
&self.get_formatted_info_label("License: ", color),
&self.license,
)?;
}
if !self.no_color_blocks {
writeln!(
buf,
"\n{0}{1}{2}{3}{4}{5}{6}{7}",
" ".on_black(),
" ".on_red(),
" ".on_green(),
" ".on_yellow(),
" ".on_blue(),
" ".on_magenta(),
" ".on_cyan(),
" ".on_white()
)?;
}
let center_pad = " ";
let mut info_lines = buf.lines();
if let Some(custom_image) = &self.custom_image {
if let Some(image_backend) = &self.image_backend {
writeln!(
f,
"{}",
image_backend.add_image(
info_lines.map(|s| format!("{}{}", center_pad, s)).collect(),
custom_image
)
)?;
} else {
panic!("No image backend found")
}
} else {
let mut logo_lines = AsciiArt::new(self.get_ascii(), self.colors(), self.bold_enabled);
loop {
match (logo_lines.next(), info_lines.next()) {
(Some(logo_line), Some(info_line)) => {
writeln!(f, "{}{}{:^}", logo_line, center_pad, info_line)?
}
(Some(logo_line), None) => writeln!(f, "{}", logo_line)?,
(None, Some(info_line)) => writeln!(
f,
"{:<width$}{}{:^}",
"",
center_pad,
info_line,
width = logo_lines.width()
)?,
(None, None) => {
writeln!(f, "\n")?;
break;
}
}
}
}
Ok(())
}
}
impl Info {
#[tokio::main]
pub async fn new(
dir: &str,
logo: Language,
colors: Vec<String>,
disabled: InfoFieldOn,
bold_flag: bool,
custom_image: Option<DynamicImage>,
image_backend: Option<Box<dyn ImageBackend>>,
no_merges: bool,
color_blocks_flag: bool,
author_nb: usize,
ignored_directories: Vec<&str>,
) -> Result<Info> {
let repo = Repository::discover(&dir).map_err(|_| Error::NotGitRepo)?;
let workdir = repo.workdir().ok_or(Error::BareGitRepo)?;
let workdir_str = workdir.to_str().unwrap();
let (languages_stats, number_of_lines) =
Language::get_language_stats(workdir_str, ignored_directories)?;
let (
(repository_name, repository_url),
git_history,
current_commit_info,
(git_v, git_user),
version,
pending,
repo_size,
project_license,
dominant_language,
) = futures::join!(
Info::get_repo_name_and_url(&repo),
Info::get_git_history(workdir_str, no_merges),
Info::get_current_commit_info(&repo),
Info::get_git_version_and_username(workdir_str),
Info::get_version(workdir_str),
Info::get_pending_changes(workdir_str),
Info::get_packed_size(workdir_str),
Info::get_project_license(workdir_str),
Language::get_dominant_language(&languages_stats)
);
let creation_date = Info::get_creation_date(&git_history);
let number_of_commits = Info::get_number_of_commits(&git_history);
let authors = Info::get_authors(&git_history, author_nb);
let last_change = Info::get_date_of_last_commit(&git_history);
Ok(Info {
git_version: git_v,
git_username: git_user,
project_name: repository_name,
current_commit: current_commit_info?,
version: version?,
creation_date: creation_date?,
dominant_language,
languages: languages_stats,
authors,
last_change: last_change?,
repo_url: repository_url,
commits: number_of_commits,
pending: pending?,
repo_size: repo_size?,
number_of_lines,
license: project_license?,
custom_logo: logo,
custom_colors: colors,
disable_fields: disabled,
bold_enabled: bold_flag,
no_color_blocks: color_blocks_flag,
custom_image,
image_backend,
})
}
async fn get_git_history(dir: &str, no_merges: bool) -> Vec<String> {
let mut args = vec!["-C", dir, "log"];
if no_merges {
args.push("--no-merges");
}
args.push("--pretty=%cr\t%an");
let output = Command::new("git")
.args(args)
.output()
.await
.expect("Failed to execute git.");
let output = String::from_utf8_lossy(&output.stdout);
output.lines().map(|x| x.to_string()).collect::<Vec<_>>()
}
async fn get_repo_name_and_url(repo: &Repository) -> (String, String) {
let config = repo.config().map_err(|_| Error::NoGitData);
let mut remote_url = String::new();
let mut repository_name = String::new();
let mut remote_upstream: Option<String> = None;
for entry in &config.unwrap().entries(None).unwrap() {
let entry = entry.unwrap();
match entry.name().unwrap() {
"remote.origin.url" => remote_url = entry.value().unwrap().to_string(),
"remote.upstream.url" => remote_upstream = Some(entry.value().unwrap().to_string()),
_ => (),
}
}
if let Some(url) = remote_upstream {
remote_url = url;
}
let url = remote_url;
let name_parts: Vec<&str> = url.split('/').collect();
if !name_parts.is_empty() {
repository_name = name_parts[name_parts.len() - 1].to_string();
}
if repository_name.contains(".git") {
let repo_name = repository_name.clone();
let parts: Vec<&str> = repo_name.split(".git").collect();
repository_name = parts[0].to_string();
}
(repository_name, name_parts.join("/"))
}
async fn get_current_commit_info(repo: &Repository) -> Result<CommitInfo> {
let head = repo.head().map_err(|_| Error::ReferenceInfoError)?;
let head_oid = head.target().ok_or(Error::ReferenceInfoError)?;
let refs = repo.references().map_err(|_| Error::ReferenceInfoError)?;
let refs_info = refs
.filter_map(|reference| match reference {
Ok(reference) => match (reference.target(), reference.shorthand()) {
(Some(oid), Some(shorthand)) if oid == head_oid => {
Some(if reference.is_tag() {
String::from("tags/") + shorthand
} else {
String::from(shorthand)
})
}
_ => None,
},
Err(_) => None,
})
.collect::<Vec<String>>();
Ok(CommitInfo::new(head_oid, refs_info))
}
fn get_authors(git_history: &[String], n: usize) -> Vec<(String, usize, usize)> {
let mut authors = std::collections::HashMap::new();
let mut total_commits = 0;
for line in git_history {
let commit_author = line.split('\t').collect::<Vec<_>>()[1].to_string();
let commit_count = authors.entry(commit_author.to_string()).or_insert(0);
*commit_count += 1;
total_commits += 1;
}
let mut authors: Vec<(String, usize)> = authors.into_iter().collect();
authors.sort_by(|(_, a_count), (_, b_count)| b_count.cmp(a_count));
authors.truncate(n);
let authors: Vec<(String, usize, usize)> = authors
.into_iter()
.map(|(author, count)| {
(
author.trim_matches('\'').to_string(),
count,
count * 100 / total_commits,
)
})
.collect();
authors
}
async fn get_git_version_and_username(dir: &str) -> (String, String) {
let version = Command::new("git")
.arg("--version")
.output()
.await
.expect("Failed to execute git.");
let version = String::from_utf8_lossy(&version.stdout).replace('\n', "");
let username = Command::new("git")
.arg("-C")
.arg(dir)
.arg("config")
.arg("--get")
.arg("user.name")
.output()
.await
.expect("Failed to execute git.");
let username = String::from_utf8_lossy(&username.stdout).replace('\n', "");
(version, username)
}
async fn get_version(dir: &str) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.arg("describe")
.arg("--abbrev=0")
.arg("--tags")
.output()
.await
.expect("Failed to execute git.");
let output = String::from_utf8_lossy(&output.stdout);
if output == "" {
Ok("??".into())
} else {
Ok(output.to_string().replace('\n', ""))
}
}
fn get_number_of_commits(git_history: &[String]) -> String {
let number_of_commits = git_history.len();
number_of_commits.to_string()
}
async fn get_pending_changes(dir: &str) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.arg("status")
.arg("--porcelain")
.output()
.await
.expect("Failed to execute git.");
let output = String::from_utf8_lossy(&output.stdout);
if output == "" {
Ok("".into())
} else {
let lines = output.lines();
let mut deleted = 0;
let mut added = 0;
let mut modified = 0;
for line in lines {
let prefix = &line[..2];
match prefix.trim() {
"D" => deleted += 1,
"A" | "AM" | "??" => added += 1,
"M" | "MM" | "R" => modified += 1,
_ => {}
}
}
let mut result = String::from("");
if modified > 0 {
result = format!("{}+-", modified)
}
if added > 0 {
result = format!("{} {}+", result, added);
}
if deleted > 0 {
result = format!("{} {}-", result, deleted);
}
Ok(result.trim().into())
}
}
async fn get_packed_size(dir: &str) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.arg("count-objects")
.arg("-vH")
.output()
.await
.expect("Failed to execute git.");
let output = String::from_utf8_lossy(&output.stdout);
let lines = output.to_string();
let size_line = lines
.split('\n')
.find(|line| line.starts_with("size-pack:"));
let repo_size = match size_line {
None => "??",
Some(size_str) => &(size_str[11..]),
};
let output = Command::new("git")
.arg("-C")
.arg(dir)
.arg("ls-files")
.output()
.await
.expect("Failed to execute git.");
// To check if command executed successfully or not
let error = &output.stderr;
if error.is_empty() {
let output = String::from_utf8_lossy(&output.stdout);
let lines = output.to_string();
let files_list = lines.split('\n');
let mut files_count: u128 = 0;
for _file in files_list {
files_count += 1;
}
files_count -= 1; // As splitting giving one line extra(blank).
let res = repo_size.to_owned() + (" (") + &(files_count.to_string()) + (" files)");
Ok(res)
} else {
let res = repo_size;
Ok(res.into())
}
}
fn get_date_of_last_commit(git_history: &[String]) -> Result<String> {
let last_commit = git_history.first();
let output = match last_commit {
Some(date) => date.split('\t').collect::<Vec<_>>()[0].to_string(),
None => "??".into(),
};
Ok(output)
}
fn get_creation_date(git_history: &[String]) -> Result<String> {
let first_commit = git_history.last();
let output = match first_commit {
Some(creation_time) => creation_time.split('\t').collect::<Vec<_>>()[0].to_string(),
None => "??".into(),
};
Ok(output)
}
async fn get_project_license(dir: &str) -> Result<String> {
fn is_license_file<S: AsRef<str>>(file_name: S) -> bool {
LICENSE_FILES
.iter()
.any(|&name| file_name.as_ref().starts_with(name))
}
let detector = Detector::new()?;
let mut output = fs::read_dir(dir)
.map_err(|_| Error::ReadDirectory)?
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.filter(|entry| {
entry.is_file()
&& entry
.file_name()
.map(OsStr::to_string_lossy)
.map(is_license_file)
.unwrap_or_default()
})
.filter_map(|entry| {
let contents = fs::read_to_string(entry).unwrap_or_default();
detector.analyze(&contents)
})
.collect::<Vec<_>>();
output.sort();
output.dedup();
let output = output.join(", ");
if output == "" {
Ok("??".into())
} else {
Ok(output)
}
}
fn get_ascii(&self) -> &str {
let language = if let Language::Unknown = self.custom_logo {
&self.dominant_language
} else {
&self.custom_logo
};
language.get_ascii_art()
}
fn colors(&self) -> Vec<Color> {
let language = if let Language::Unknown = self.custom_logo {
&self.dominant_language
} else {
&self.custom_logo
};
let colors = language.get_colors();
let colors: Vec<Color> = colors
.iter()
.enumerate()
.map(|(index, default_color)| {
if let Some(color_num) = self.custom_colors.get(index) {
if let Some(color) = Info::num_to_color(color_num) {
return color;
}
}
*default_color
})
.collect();
colors
}
fn num_to_color(num: &str) -> Option<Color> {
let color = match num {
"0" => Color::Black,
"1" => Color::Red,
"2" => Color::Green,
"3" => Color::Yellow,
"4" => Color::Blue,
"5" => Color::Magenta,
"6" => Color::Cyan,
"7" => Color::White,
"8" => Color::BrightBlack,
"9" => Color::BrightRed,
"10" => Color::BrightGreen,
"11" => Color::BrightYellow,
"12" => Color::BrightBlue,
"13" => Color::BrightMagenta,
"14" => Color::BrightCyan,
"15" => Color::BrightWhite,
_ => return None,
};
Some(color)
}
/// Returns a formatted info label with the desired color and boldness
fn get_formatted_info_label(&self, label: &str, color: Color) -> ColoredString {
let mut formatted_label = label.color(color);
if self.bold_enabled {
formatted_label = formatted_label.bold();
}
formatted_label
}
}
fn write_buf<T: std::fmt::Display>(
buffer: &mut String,
title: &ColoredString,
content: T,
) -> std::fmt::Result {
writeln!(buffer, "{}{}", title, content)
}