use std::path::Path;
use std::process::Command;
use serde::Serialize;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CommitInfo {
pub hash: String,
pub short: String,
pub author_name: String,
pub author_email: String,
pub date: String,
pub subject: String,
}
const FS: char = '\u{1f}';
const RS: char = '\u{1e}';
pub fn is_repo(root: &Path) -> bool {
run(root, &["rev-parse", "--is-inside-work-tree"])
.map(|o| o.trim() == "true")
.unwrap_or(false)
}
pub fn log(root: &Path, pathspec: Option<&str>, limit: Option<usize>) -> Result<Vec<CommitInfo>> {
let format = format!("--format=%H{FS}%h{FS}%an{FS}%ae{FS}%aI{FS}%s{RS}");
let mut args: Vec<String> = vec![
"--no-pager".into(),
"log".into(),
format,
"--no-color".into(),
];
if let Some(l) = limit {
args.push("-n".into());
args.push(l.to_string());
}
if let Some(p) = pathspec {
args.push("--".into());
args.push(p.to_string());
}
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let out = run(root, &refs)?;
Ok(parse_log(&out))
}
pub fn file_at_commit(root: &Path, rev: &str, relpath: &str) -> Result<Option<String>> {
let spec = format!("{rev}:{}", relpath.replace('\\', "/"));
match run(root, &["--no-pager", "show", &spec]) {
Ok(s) => Ok(Some(s)),
Err(Error::Message(_)) => Ok(None),
Err(e) => Err(e),
}
}
pub fn last_change(root: &Path, relpath: &str) -> Result<Option<CommitInfo>> {
Ok(log(root, Some(relpath), Some(1))?.into_iter().next())
}
pub fn blob_hash(bytes: &[u8]) -> String {
use sha1::{Digest, Sha1};
let mut h = Sha1::new();
h.update(format!("blob {}\0", bytes.len()).as_bytes());
h.update(bytes);
format!("{:x}", h.finalize())
}
pub fn tracked_blobs(root: &Path) -> Result<Vec<(String, String)>> {
let out = run(root, &["ls-files", "-s"])?;
let mut blobs = Vec::new();
for line in out.lines() {
if let Some((meta, path)) = line.split_once('\t') {
let mut cols = meta.split_whitespace();
let _mode = cols.next();
if let Some(hash) = cols.next() {
blobs.push((hash.to_string(), path.to_string()));
}
}
}
Ok(blobs)
}
pub fn authors(root: &Path) -> Result<Vec<(String, String)>> {
let out = run(
root,
&["--no-pager", "log", &format!("--format=%an{FS}%ae")],
)?;
let mut seen = std::collections::HashSet::new();
let mut authors = Vec::new();
for line in out.lines() {
if let Some((name, email)) = line.split_once(FS) {
if seen.insert(email.to_string()) {
authors.push((name.to_string(), email.to_string()));
}
}
}
Ok(authors)
}
pub fn config_identity(root: &Path) -> Option<String> {
let get = |key: &str| {
run(root, &["config", key])
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
match (get("user.name"), get("user.email")) {
(Some(name), Some(email)) => Some(format!("{name} <{email}>")),
(Some(name), None) => Some(name),
(None, Some(email)) => Some(email),
(None, None) => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GraphCommit {
pub hash: String,
pub short: String,
pub parents: Vec<String>,
pub refs: Vec<String>,
pub author_name: String,
pub date: String,
pub subject: String,
pub board: bool,
}
pub fn graph(root: &Path, limit: Option<usize>) -> Result<Vec<GraphCommit>> {
let board: std::collections::HashSet<String> = run(
root,
&["--no-pager", "log", "--all", "--format=%H", "--", ".wipe"],
)
.unwrap_or_default()
.lines()
.map(|s| s.trim().to_string())
.collect();
let format = format!("--format=%H{FS}%h{FS}%P{FS}%D{FS}%an{FS}%aI{FS}%s{RS}");
let mut args: Vec<String> = vec![
"--no-pager".into(),
"log".into(),
"--all".into(),
"--date-order".into(),
format,
"--no-color".into(),
];
if let Some(l) = limit {
args.push("-n".into());
args.push(l.to_string());
}
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let out = run(root, &refs)?;
Ok(out
.split(RS)
.map(str::trim)
.filter(|r| !r.is_empty())
.filter_map(|record| {
let mut f = record.split(FS);
let hash = f.next()?.to_string();
let short = f.next()?.to_string();
let parents = f
.next()?
.split_whitespace()
.map(|s| s.to_string())
.collect();
let refs = f
.next()
.unwrap_or("")
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let author_name = f.next().unwrap_or("").to_string();
let date = f.next().unwrap_or("").to_string();
let subject = f.next().unwrap_or("").to_string();
let board = board.contains(&hash);
Some(GraphCommit {
hash,
short,
parents,
refs,
author_name,
date,
subject,
board,
})
})
.collect())
}
fn parse_log(out: &str) -> Vec<CommitInfo> {
out.split(RS)
.map(str::trim)
.filter(|r| !r.is_empty())
.filter_map(|record| {
let mut f = record.split(FS);
Some(CommitInfo {
hash: f.next()?.to_string(),
short: f.next()?.to_string(),
author_name: f.next()?.to_string(),
author_email: f.next()?.to_string(),
date: f.next()?.to_string(),
subject: f.next().unwrap_or("").to_string(),
})
})
.collect()
}
fn plain(root: &Path) -> std::path::PathBuf {
let s = root.to_string_lossy();
match s.strip_prefix(r"\\?\") {
Some(rest) => std::path::PathBuf::from(rest),
None => root.to_path_buf(),
}
}
fn run(root: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.arg("-C")
.arg(plain(root))
.args(args)
.output()
.map_err(|e| Error::msg(format!("failed to run git: {e}")))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(Error::msg(
String::from_utf8_lossy(&out.stderr).trim().to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git(root: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
}
#[test]
fn log_and_show_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "t@example.com"]);
git(root, &["config", "user.name", "Tester"]);
std::fs::write(root.join("a.txt"), "v1\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", "first commit"]);
assert!(is_repo(root));
let history = log(root, None, None).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].subject, "first commit");
assert_eq!(history[0].author_email, "t@example.com");
let head = &history[0].hash;
let content = file_at_commit(root, head, "a.txt").unwrap();
assert_eq!(content.as_deref(), Some("v1\n"));
assert_eq!(file_at_commit(root, head, "missing.txt").unwrap(), None);
}
#[test]
fn non_repo_reports_false() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_repo(dir.path()));
}
}