use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlameLine {
pub path: String,
pub line: u32,
pub commit: String,
pub author: String,
pub author_time: u64,
pub summary: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Commit {
pub commit: String,
pub author: String,
pub author_time: u64,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangedFile {
pub path: String,
pub status: String,
}
fn git(root: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.map_err(|e| Error::other(format!("failed to run git: {e}")))?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(Error::other(format!(
"git {} failed: {}",
args.join(" "),
err.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn is_repo(root: &Path) -> bool {
git(root, &["rev-parse", "--is-inside-work-tree"])
.map(|s| s.trim() == "true")
.unwrap_or(false)
}
pub fn head(root: &Path) -> Option<(String, String)> {
let sha = git(root, &["rev-parse", "HEAD"]).ok()?.trim().to_string();
let branch = git(root, &["rev-parse", "--abbrev-ref", "HEAD"])
.ok()
.map(|s| s.trim().to_string())
.unwrap_or_default();
Some((sha, branch))
}
pub fn blame(root: &Path, rel_path: &str, line: u32) -> Result<BlameLine> {
let range = format!("{line},{line}");
let out = git(
root,
&["blame", "-L", &range, "--porcelain", "--", rel_path],
)?;
parse_blame(&out, rel_path, line)
.ok_or_else(|| Error::other(format!("could not blame {rel_path}:{line}")))
}
fn parse_blame(out: &str, rel_path: &str, line: u32) -> Option<BlameLine> {
let mut commit = String::new();
let mut author = String::new();
let mut author_time = 0u64;
let mut summary = String::new();
let mut content = String::new();
for (i, l) in out.lines().enumerate() {
if i == 0 {
commit = l.split_whitespace().next().unwrap_or("").to_string();
} else if let Some(rest) = l.strip_prefix("author ") {
author = rest.to_string();
} else if let Some(rest) = l.strip_prefix("author-time ") {
author_time = rest.trim().parse().unwrap_or(0);
} else if let Some(rest) = l.strip_prefix("summary ") {
summary = rest.to_string();
} else if let Some(rest) = l.strip_prefix('\t') {
content = rest.to_string();
}
}
if commit.is_empty() {
return None;
}
Some(BlameLine {
path: rel_path.to_string(),
line,
commit: short_sha(&commit),
author,
author_time,
summary,
content,
})
}
pub fn line_history(
root: &Path,
rel_path: &str,
start: u32,
end: u32,
limit: usize,
) -> Result<Vec<Commit>> {
let lspec = format!("{start},{end}:{rel_path}");
let out = git(
root,
&[
"log",
"-L",
&lspec,
"--no-patch",
&format!("--max-count={limit}"),
"--format=%H%x09%an%x09%at%x09%s",
],
)?;
Ok(parse_commits(&out))
}
pub fn file_history(root: &Path, rel_path: &str, limit: usize) -> Result<Vec<Commit>> {
let out = git(
root,
&[
"log",
&format!("--max-count={limit}"),
"--format=%H%x09%an%x09%at%x09%s",
"--",
rel_path,
],
)?;
Ok(parse_commits(&out))
}
fn parse_commits(out: &str) -> Vec<Commit> {
let mut commits = Vec::new();
for l in out.lines() {
let parts: Vec<&str> = l.splitn(4, '\t').collect();
if parts.len() == 4 {
commits.push(Commit {
commit: short_sha(parts[0]),
author: parts[1].to_string(),
author_time: parts[2].trim().parse().unwrap_or(0),
summary: parts[3].to_string(),
});
}
}
commits
}
pub fn changed_since(root: &Path, rev: &str) -> Result<Vec<ChangedFile>> {
let out = git(root, &["diff", "--name-status", rev, "--"])?;
let mut files = Vec::new();
for l in out.lines() {
let mut it = l.split('\t');
let status = it.next().unwrap_or("").to_string();
let path = it.next_back().unwrap_or("").to_string();
if !path.is_empty() {
files.push(ChangedFile {
path,
status: status.chars().next().map(String::from).unwrap_or_default(),
});
}
}
Ok(files)
}
fn short_sha(sha: &str) -> String {
sha.chars().take(12).collect()
}