use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::str;
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, FixedOffset};
pub const GIT_EXEC: &str = "git";
#[derive(Debug, Clone)]
pub struct GitRepo {
pub top_level_abs_path: String,
pub files: GitMap,
}
pub type GitMap = HashMap<String, GitInfo>;
#[derive(Debug, Clone)]
pub struct GitInfo {
pub hash: String,
pub abbreviated_hash: String,
pub subject: String,
pub author_name: String,
pub author_email: String,
pub author_date: DateTime<FixedOffset>,
pub commit_date: DateTime<FixedOffset>,
pub body: String,
}
pub struct Options {
pub repository: String,
pub revision: String,
}
fn git(args: &[&str]) -> Result<String> {
let output = Command::new(GIT_EXEC)
.args(args)
.output()
.with_context(|| format!("failed to run git with args {:?}", args))?;
if !output.status.success() {
return Err(anyhow!(
"{}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn to_git_info(entry: &str) -> Result<GitInfo> {
let mut items: Vec<&str> = entry.split('\x1f').collect();
if items.len() == 7 {
items.push("");
}
if items.len() != 8 {
return Err(anyhow!("unexpected number of fields in entry: {:?}", items));
}
let author_date = DateTime::parse_from_str(items[5], "%Y-%m-%d %H:%M:%S %z")
.with_context(|| format!("parsing author date: {}", items[5]))?;
let commit_date = DateTime::parse_from_str(items[6], "%Y-%m-%d %H:%M:%S %z")
.with_context(|| format!("parsing commit date: {}", items[6]))?;
Ok(GitInfo {
hash: items[0].to_string(),
abbreviated_hash: items[1].to_string(),
subject: items[2].to_string(),
author_name: items[3].to_string(),
author_email: items[4].to_string(),
author_date,
commit_date,
body: items[7].trim().to_string(),
})
}
pub fn map(opts: Options) -> Result<GitRepo> {
let mut files: GitMap = HashMap::new();
let repo_path = Path::new(&opts.repository)
.canonicalize()
.with_context(|| format!("resolving repository path: {}", opts.repository))?;
let rev_parse_args = ["-C", &opts.repository, "rev-parse", "--show-cdup"];
let cd_up = git(&rev_parse_args)?.trim().to_string();
let top_level_path = {
let joined = repo_path.join(cd_up);
joined
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/")
};
let git_log_format = format!(
"--name-only --no-merges --format=format:\x1e%H\x1f%h\x1f%s\x1f%aN\x1f%aE\x1f%ai\x1f%ci\x1f%b\x1d {}",
opts.revision
);
let log_fields: Vec<&str> = git_log_format.split_whitespace().collect();
let mut args = vec![
"-c",
"diff.renames=0",
"-c",
"log.showSignature=0",
"-C",
&opts.repository,
"log",
];
args.extend(log_fields);
let log_output = git(&args)?;
let entries_str = log_output.trim_matches(|c| c == '\n' || c == '\x1e' || c == '\'');
if entries_str.is_empty() {
return Ok(GitRepo {
top_level_abs_path: top_level_path,
files,
});
}
for entry in entries_str.split('\x1e') {
let parts: Vec<&str> = entry.split('\x1d').collect();
if parts.len() < 2 {
continue;
}
let git_info = to_git_info(parts[0])
.with_context(|| format!("parsing git info from entry: {:?}", parts[0]))?;
for filename in parts[1].split('\n') {
let filename = filename.trim();
if filename.is_empty() {
continue;
}
files
.entry(filename.to_string())
.or_insert_with(|| git_info.clone());
}
}
Ok(GitRepo {
top_level_abs_path: top_level_path,
files,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_repo() {
let opts = Options {
repository: ".".to_string(),
revision: "HEAD".to_string(),
};
let repo = map(opts).expect("failed to map repo");
println!("Top level path: {}", repo.top_level_abs_path);
println!("Found {} files", repo.files.len());
}
}