use anyhow::{bail, Context, Result};
use chrono::DateTime;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::process::Command;
use tracing::warn;
use super::types::{BlameResult, CommitInfo};
const GIT_CONFIG_TIMEOUT_SECS: u64 = 20;
fn path_from_bytes(bytes: &[u8]) -> PathBuf {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
PathBuf::from(OsStr::from_bytes(bytes))
}
#[cfg(not(unix))]
{
PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
}
}
pub async fn get_head_commit(repo_path: &Path, branch: &str) -> Result<String> {
let reference = format!("refs/heads/{branch}");
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["rev-parse", &reference])
.output()
.await
.context("running git rev-parse")?;
if !output.status.success() {
bail!(
"git rev-parse failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
pub async fn reset_hard(repo_path: &Path) -> Result<()> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["reset", "--hard", "HEAD"])
.output()
.await
.context("running git reset --hard")?;
if !output.status.success() {
bail!(
"git reset --hard failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub async fn set_safe_directory() -> Result<()> {
let output = Command::new("git")
.args(["config", "--global", "--add", "safe.directory", "*"])
.output()
.await
.context("running git config safe.directory")?;
if !output.status.success() {
bail!(
"git config safe.directory failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub async fn disable_quotepath(repo_path: &Path) -> Result<()> {
let mut git_dir = OsString::from("--git-dir=");
git_dir.push(repo_path);
let output = tokio::time::timeout(
Duration::from_secs(GIT_CONFIG_TIMEOUT_SECS),
Command::new("git")
.arg(&git_dir)
.args(["config", "core.quotepath", "off"])
.kill_on_drop(true)
.output(),
)
.await
.context("git config core.quotepath timed out")?
.context("running git config core.quotepath")?;
if !output.status.success() {
bail!(
"git config core.quotepath failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub async fn get_last_commit_info(repo_path: &Path, filename: &Path) -> Result<Option<CommitInfo>> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["log", "--max-count", "1", "--format=%H%n%ce%n%cI", "--"])
.arg(filename)
.output()
.await
.context("running git log")?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() || !stderr.is_empty() {
warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git log did not succeed cleanly");
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
if stdout.is_empty() {
return Ok(None);
}
let lines: Vec<&str> = stdout.lines().collect();
if lines.len() < 3 {
return Ok(None);
}
let date = DateTime::parse_from_rfc3339(lines.get(2).copied().unwrap_or(""))
.context("parsing the committer date of git log")?;
Ok(Some(CommitInfo {
sha: lines.first().copied().unwrap_or("").to_owned(),
author_email: lines.get(1).copied().unwrap_or("").to_owned(),
date,
}))
}
pub async fn get_line_author(
repo_path: &Path,
filename: &Path,
line: usize,
rev: &str,
) -> Result<Option<CommitInfo>> {
let line_spec = format!("{line},+1");
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args([
"blame", "-L", &line_spec, "-l", "-p", "-M", "-C", "-C", rev, "--",
])
.arg(filename)
.output()
.await
.context("running git blame")?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() || !stderr.is_empty() {
warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git blame did not succeed cleanly");
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_blame_porcelain(&stdout)
}
pub async fn get_modified_filenames(repo_path: &Path, commit_sha: &str) -> Result<Vec<PathBuf>> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["diff", "--name-only", &format!("{commit_sha}..HEAD")])
.output()
.await
.context("running git diff --name-only")?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() || !stderr.is_empty() {
warn!(repo_path = %repo_path.display(), commit_sha, %stderr, "git diff did not succeed cleanly");
return Ok(Vec::new());
}
Ok(output
.stdout
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(path_from_bytes)
.collect())
}
pub async fn is_commit_in_branch(repo_path: &Path, branch: &str, commit_sha: &str) -> Result<bool> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["branch", "--contains", commit_sha])
.output()
.await
.context("running git branch --contains")?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() || !stderr.is_empty() {
warn!(repo_path = %repo_path.display(), branch, %stderr, "git branch --contains did not succeed cleanly");
return Ok(false);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().any(|line| branch_name(line) == branch))
}
fn branch_name(line: &str) -> &str {
let line = line.trim();
line.strip_prefix("* ")
.or_else(|| line.strip_prefix("+ "))
.unwrap_or(line)
}
pub async fn show_file_at_rev(
repo_path: &Path,
rev: &str,
file_path: &Path,
) -> Result<Option<String>> {
let mut rev_path = OsString::from(format!("{rev}:"));
rev_path.push(file_path);
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.arg("show")
.arg(&rev_path)
.output()
.await
.context("running git show")?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
}
#[allow(clippy::too_many_arguments)]
pub async fn blame_reverse(
repo_path: &Path,
file_path: &Path,
line: usize,
rev_a: &str,
rev_b: &str,
enable_copy_detection: bool,
) -> Result<Option<BlameResult>> {
let line_spec = format!("{line},+1");
let rev_range = format!("{rev_a}..{rev_b}");
let mut args: Vec<&OsStr> = vec![
OsStr::new("-C"),
repo_path.as_os_str(),
OsStr::new("-c"),
OsStr::new("core.quotepath=off"),
OsStr::new("blame"),
OsStr::new("-p"),
OsStr::new("-M"),
];
if enable_copy_detection {
args.push(OsStr::new("-C"));
}
args.extend([
OsStr::new("-L"),
OsStr::new(&line_spec),
OsStr::new("--reverse"),
OsStr::new(&rev_range),
OsStr::new("--"),
file_path.as_os_str(),
]);
let output = Command::new("git")
.args(&args)
.output()
.await
.context("running git blame --reverse")?;
if !output.status.success() {
warn!(
repo_path = %repo_path.display(),
file_path = %file_path.display(),
line,
rev_range = %rev_range,
stderr = %String::from_utf8_lossy(&output.stderr),
"git blame --reverse failed",
);
return Ok(None);
}
Ok(parse_blame_reverse_porcelain(&output.stdout))
}
pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
let mut sha = String::new();
let mut author_mail = String::new();
let mut committer_time: Option<&str> = None;
for line in output.lines() {
if sha.is_empty() && line.len() >= 40 {
line.split_whitespace()
.next()
.unwrap_or("")
.clone_into(&mut sha);
} else if let Some(val) = line.strip_prefix("author-mail <") {
val.trim_end_matches('>').clone_into(&mut author_mail);
} else if let Some(val) = line.strip_prefix("committer-time ") {
committer_time = Some(val);
}
}
if sha.is_empty() {
return Ok(None);
}
let date = match committer_time {
None => DateTime::UNIX_EPOCH.fixed_offset(),
Some(seconds) => seconds
.parse::<i64>()
.ok()
.and_then(|seconds| DateTime::from_timestamp(seconds, 0))
.context("parsing the committer time of git blame")?
.fixed_offset(),
};
Ok(Some(CommitInfo {
sha,
author_email: author_mail,
date,
}))
}
fn read_octal_escape(
first: u8,
bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
) -> u8 {
let mut code = u32::from(first.saturating_sub(b'0'));
for _ in 0..2 {
let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
break;
};
bytes.next();
code = code
.saturating_mul(8)
.saturating_add(u32::from(digit.saturating_sub(b'0')));
}
u8::try_from(code).unwrap_or(b'?')
}
fn unquote_path(value: &[u8]) -> PathBuf {
let Some(inner) = value
.strip_prefix(b"\"")
.and_then(|rest| rest.strip_suffix(b"\""))
else {
return path_from_bytes(value);
};
let mut out: Vec<u8> = Vec::with_capacity(inner.len());
let mut bytes = inner.iter().copied().peekable();
while let Some(byte) = bytes.next() {
if byte != b'\\' {
out.push(byte);
continue;
}
match bytes.next() {
Some(b'n') => out.push(b'\n'),
Some(b't') => out.push(b'\t'),
Some(b'r') => out.push(b'\r'),
Some(b'a') => out.push(0x07),
Some(b'b') => out.push(0x08),
Some(b'f') => out.push(0x0c),
Some(b'v') => out.push(0x0b),
Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
Some(other) => out.push(other),
None => out.push(b'\\'),
}
}
path_from_bytes(&out)
}
pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
let mut rev = String::new();
let mut line_num: usize = 0;
let mut path = PathBuf::new();
for line in output.split(|byte| *byte == b'\n') {
if rev.is_empty() && line.len() >= 40 {
let header = String::from_utf8_lossy(line);
let parts: Vec<&str> = header.split_whitespace().collect();
if let Some(hash) = parts.first() {
(*hash).clone_into(&mut rev);
}
if let Some(ln) = parts.get(1) {
line_num = ln.parse().unwrap_or(0);
}
} else if let Some(val) = line.strip_prefix(b"filename ") {
path = unquote_path(val);
}
}
if rev.is_empty() {
return None;
}
Some(BlameResult {
rev,
line: line_num,
path,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_branch_checked_out_in_a_worktree_is_still_named() {
assert_eq!(branch_name(" main"), "main");
assert_eq!(branch_name("* main"), "main");
assert_eq!(branch_name("+ main"), "main");
assert_eq!(branch_name(" maintenance"), "maintenance");
assert_eq!(
branch_name("* (HEAD detached at abc1234)"),
"(HEAD detached at abc1234)"
);
}
#[test]
fn test_parse_blame_porcelain_valid() {
let output = "\
abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
author Test\n\
author-mail <dev@example.com>\n\
author-time 1700000000\n\
author-tz +0000\n\
committer Test\n\
committer-mail <dev@example.com>\n\
committer-time 1700000000\n\
committer-tz +0000\n\
summary test commit\n\
filename file.txt\n\
\tcode line\n";
let result = parse_blame_porcelain(output).unwrap().unwrap();
assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
assert_eq!(result.author_email, "dev@example.com");
assert_eq!(result.date.timestamp(), 1_700_000_000);
}
#[test]
fn test_parse_blame_porcelain_without_a_committer_time() {
let output = "\
abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
author-mail <dev@example.com>\n\
filename file.txt\n\
\tcode line\n";
let result = parse_blame_porcelain(output).unwrap().unwrap();
assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
assert_eq!(result.author_email, "dev@example.com");
assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
}
#[test]
fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
let output = "\
abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
author-mail <dev@example.com>\n\
committer-time 99999999999999\n\
filename file.txt\n\
\tcode line\n";
assert!(parse_blame_porcelain(output).is_err());
}
#[test]
fn test_parse_blame_porcelain_empty() {
assert!(parse_blame_porcelain("").unwrap().is_none());
}
#[test]
fn test_parse_blame_reverse_porcelain_valid() {
let output = b"\
abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
author Test\n\
filename renamed.py\n";
let result = parse_blame_reverse_porcelain(output).unwrap();
assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
assert_eq!(result.line, 7);
assert_eq!(result.path, Path::new("renamed.py"));
}
#[test]
fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
let output = b"\
abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
filename \"se\\303\\261al.txt\"\n";
let result = parse_blame_reverse_porcelain(output).unwrap();
assert_eq!(result.path, Path::new("seƱal.txt"));
}
#[test]
#[cfg(unix)]
fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
use std::os::unix::ffi::OsStrExt;
let output = b"\
abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
filename sel\xe1l.txt\n";
let result = parse_blame_reverse_porcelain(output).unwrap();
assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
}
#[test]
fn a_path_git_left_alone_is_taken_as_it_is() {
assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
}
#[test]
fn test_parse_blame_reverse_porcelain_empty() {
assert!(parse_blame_reverse_porcelain(b"").is_none());
}
}