use std::ffi::OsStr;
use std::fmt::{Display, Formatter};
use std::io::BufRead;
use std::path::PathBuf;
use anyhow::{anyhow, Context};
use lazy_static::lazy_static;
use rayon::prelude::*;
use regex::Regex;
use simple_cmd::{CommandBuilder, Vec8ToString};
use which::which;
use crate::{Author, CommitArgs, CommitDetail, CommitHash, CommitStats, Detail, Repo};
lazy_static! {
static ref SHORT_STATS_RE: Regex = regex::Regex::new("(?<files>[\\d]+) files? changed(, (?<insertions>[\\d]+) insertions?\\(\\+\\))?(, (?<deletions>[\\d]+) deletions?\\(\\-\\))?$").unwrap();
static ref NUMSTATS_RE: Regex = regex::Regex::new("^(?<additions>[\\d]+)\\s+(?<deletions>[\\d]+)\\s+(?<filename>[^\n]+)").unwrap();
static ref SIZE_RE: Regex = regex::RegexBuilder::new(r#"^size-pack:\s*(?<size>[\d]+)$"#).multi_line(true).build().unwrap();
}
impl Repo {
pub fn new<S: AsRef<OsStr> + ?Sized>(s: &S) -> Self {
Repo { inner: PathBuf::from(s) }
}
pub fn to_str(&self) -> Option<&str> {
self.inner.to_str()
}
pub fn fetch(&self) -> anyhow::Result<()> {
self.git()?
.arg("fetch")
.build()
.output()
.map(|_| ())
.context("Failed to fetch remote")
}
pub fn fetch_all(&self) -> anyhow::Result<()> {
self.git()?
.args([
"fetch", "--all",
])
.build()
.output()
.map(|_| ())
.context("Failed to fetch remotes")
}
pub fn list_commits(&self, options: CommitArgs) -> anyhow::Result<Vec<CommitHash>> {
options.validate()?;
let mut command = self.git()?.arg("log");
command = command.with_args(options).with_arg("--reverse");
let output = command.build().output()?;
Ok(output
.stdout
.lines()
.filter_map(|line| if let Ok(line) = line { Some(CommitHash(line)) } else { None })
.collect::<Vec<_>>())
}
pub fn first_commit(&self) -> anyhow::Result<Option<CommitDetail>> {
let command = self.git()?.with_args(&[
"rev-list",
"--max-parents=0",
"HEAD",
]);
let output = command.build().output()?;
if let Some(commit) = output.stdout.as_str().map(|line| CommitHash(line.trim().to_string())) {
Ok(Some(self.commit_stats(commit)?))
} else {
Ok(None)
}
}
pub fn last_commit(&self) -> anyhow::Result<Option<CommitDetail>> {
let command = self.git()?.with_args(&[
"rev-list", "-n", "1", "HEAD",
]);
let output = command.build().output()?;
if let Some(commit) = output.stdout.as_str().map(|line| CommitHash(line.trim().to_string())) {
Ok(Some(self.commit_stats(commit)?))
} else {
Ok(None)
}
}
pub fn size(&self) -> anyhow::Result<u64> {
let command = self.git()?.with_args(&[
"count-objects",
"-v",
]);
let output = command.build().output()?;
let string = output
.stdout
.as_str()
.ok_or(anyhow!("failed to find repository size"))?
.trim();
if let Some(find) = SIZE_RE.captures(string) {
let size_string = find.name("size").unwrap().as_str();
let size: u64 = size_string.parse::<u64>()?;
Ok(size)
} else {
Err(anyhow::Error::msg("failed to find repository size"))
}
}
pub fn commits_count(&self) -> anyhow::Result<usize> {
let command = self.git()?.with_args(&[
"rev-list", "--count", "--all",
]);
let output = command.build().output()?;
let string = output.stdout.lines().nth(0).ok_or(anyhow!("failed to get total commits"))??;
Ok(string.parse::<usize>()?)
}
pub fn details(&self) -> anyhow::Result<Detail> {
let size = self.size()?;
let first_commit = self.first_commit()?;
let last_commit = self.last_commit()?;
let commits_count = self.commits_count()?;
Ok(Detail {
size,
commits_count,
first_commit: first_commit.map(|c| c.author_timestamp),
last_commit: last_commit.map(|c| c.author_timestamp),
})
}
pub fn commits_stats(&self, commits: &Vec<CommitHash>) -> anyhow::Result<Vec<CommitDetail>> {
commits
.into_par_iter()
.map(|commit| self.commit_stats(commit.to_owned()))
.collect()
}
pub fn commit_stats(&self, commit: CommitHash) -> anyhow::Result<CommitDetail> {
let mut command = self.git()?.with_debug(false);
let hash: &str = (&commit).into();
command = command
.arg("show")
.arg("--shortstat")
.arg("--pretty=\"format:%H\n%aN\n%aE\n%at\n\"")
.arg(hash);
let result = command.build().output()?;
let output = result.stdout;
let lines = output.lines().map(|f| f.unwrap()).collect::<Vec<String>>();
let size = lines.len();
let mut commit_hash: Option<String> = None;
let mut author_name: Option<String> = None;
let mut author_email: Option<String> = None;
let mut author_date: Option<i64> = None;
for index in 0..size {
let line = &lines[index];
match index {
0 => commit_hash = Some(line.to_string()),
1 => author_name = Some(line.to_string()),
2 => author_email = Some(line.to_string()),
3 => {
let timestamp = line.parse::<i64>().expect("invalid timestamp");
author_date = Some(timestamp);
}
_ => {
}
}
}
let mut files: u32 = 0;
let mut insertions: u32 = 0;
let mut deletions: u32 = 0;
if let Some(find) = SHORT_STATS_RE.captures(lines.last().ok_or(anyhow!("failed to find last line"))?.as_str()) {
files = find.name("files").map_or(0, |f| f.as_str().parse::<u32>().unwrap_or(0));
insertions = find.name("insertions").map_or(0, |f| f.as_str().parse::<u32>().unwrap_or(0));
deletions = find.name("deletions").map_or(0, |f| f.as_str().parse::<u32>().unwrap_or(0));
}
if commit_hash.is_none() {
return Err(anyhow!("commit hash not found"));
} else if author_name.is_none() {
return Err(anyhow!("author name not found"));
} else if author_email.is_none() {
return Err(anyhow!("author email not found"));
} else if author_date.is_none() {
return Err(anyhow!("author datetime not found"));
}
let stats = CommitStats {
files_changed: files,
lines_added: insertions,
lines_deleted: deletions,
};
let commit = CommitDetail {
hash: commit,
author: Author::new(author_name.unwrap()).with_email_opt(author_email.as_deref()),
author_timestamp: author_date.unwrap(),
stats,
};
Ok(commit)
}
fn git(&self) -> anyhow::Result<CommandBuilder> {
let git = which("git")?;
Ok(CommandBuilder::new(git).with_debug(true).with_arg("-C").with_arg(&self.inner))
}
}
impl<'a, T: ?Sized + AsRef<OsStr>> From<&'a T> for Repo {
fn from(s: &'a T) -> Self {
Repo::new(s)
}
}
impl Display for Repo {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.inner)
}
}