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};
use crate::call;
use crate::errors::GitCallError;
const GIT_CONFIG_TIMEOUT_SECS: u64 = 20;
pub(crate) 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(crate) fn path_to_bytes(path: &Path) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().to_vec()
}
#[cfg(not(unix))]
{
path.to_string_lossy().replace('\\', "/").into_bytes()
}
}
pub async fn get_head_commit(repo_path: &Path, branch: &str) -> Result<String, GitCallError> {
let mut git = Command::new("git");
git.arg("-C").arg(repo_path).args([
"show-ref",
"--verify",
"--hash",
&format!("refs/heads/{branch}"),
]);
let output = call::run("show-ref", &mut git).await?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
pub async fn reset_hard(repo_path: &Path) -> Result<()> {
let root = repo_path
.canonicalize()
.with_context(|| format!("resolving {}", repo_path.display()))?;
let mut safe_directory = OsString::from("safe.directory=");
safe_directory.push(&root);
let output = Command::new("git")
.arg("-c")
.arg(&safe_directory)
.args([
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=/dev/null",
])
.arg("-C")
.arg(&root)
.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 disable_quotepath(repo_path: &Path) -> Result<(), GitCallError> {
let mut git_dir = OsString::from("--git-dir=");
git_dir.push(repo_path);
let mut git = Command::new("git");
git.arg(&git_dir)
.args(["config", "core.quotepath", "off"])
.kill_on_drop(true);
let after = Duration::from_secs(GIT_CONFIG_TIMEOUT_SECS);
tokio::time::timeout(after, call::run("config", &mut git))
.await
.map_err(|_| GitCallError::TimedOut {
command: "config",
after,
})??;
Ok(())
}
pub async fn get_last_commit_info(
repo_path: &Path,
filename: &Path,
) -> Result<Option<CommitInfo>, GitCallError> {
let mut git = Command::new("git");
git.current_dir(repo_path)
.args(["log", "--max-count", "1", "--format=%H%n%ce%n%cI", "--"])
.arg(filename);
let output = call::run("log", &mut git).await?;
parse_last_commit(&output.stdout)
}
fn parse_last_commit(stdout: &[u8]) -> Result<Option<CommitInfo>, GitCallError> {
let malformed = |reason: String| GitCallError::Malformed {
command: "log",
reason,
};
let stdout = String::from_utf8_lossy(stdout);
let stdout = stdout.trim();
if stdout.is_empty() {
return Ok(None);
}
let mut lines = stdout.lines();
let (Some(sha), Some(author_email), Some(date)) = (lines.next(), lines.next(), lines.next())
else {
return Err(malformed(format!("expected three lines, got {stdout:?}")));
};
let date = DateTime::parse_from_rfc3339(date)
.map_err(|error| malformed(format!("committer date {date:?}: {error}")))?;
Ok(Some(CommitInfo {
sha: sha.to_owned(),
author_email: author_email.to_owned(),
date,
}))
}
pub async fn get_line_author(
repo_path: &Path,
filename: &Path,
line: usize,
rev: &str,
) -> Result<Option<CommitInfo>, GitCallError> {
let mut git = Command::new("git");
git.current_dir(repo_path)
.args([
"blame",
"-L",
&format!("{line},+1"),
"-l",
"-p",
"-M",
"-C",
"-C",
rev,
"--",
])
.arg(filename);
let output = call::run("blame", &mut git).await?;
parse_blame(&output.stdout)
}
fn parse_blame(stdout: &[u8]) -> Result<Option<CommitInfo>, GitCallError> {
let malformed = |reason: String| GitCallError::Malformed {
command: "blame",
reason,
};
let stdout = String::from_utf8_lossy(stdout);
if stdout.trim().is_empty() {
return Ok(None);
}
let mut lines = stdout.split('\n');
let sha = lines
.next()
.and_then(|header| header.split(' ').next())
.unwrap_or_default();
let mut author_email = "";
let mut committer_time = None;
for line in lines.take_while(|line| !line.starts_with('\t')) {
if let Some(value) = line.strip_prefix("author-mail <") {
author_email = value.split_once('>').map_or("", |(email, _)| email);
} else if let Some(value) = line.strip_prefix("committer-time ") {
committer_time = Some(value);
}
}
let date = match committer_time {
None => DateTime::UNIX_EPOCH,
Some(seconds) => seconds
.parse()
.ok()
.and_then(|seconds| DateTime::from_timestamp(seconds, 0))
.ok_or_else(|| malformed(format!("committer time {seconds:?}")))?,
};
Ok(Some(CommitInfo {
sha: sha.to_owned(),
author_email: author_email.to_owned(),
date: date.fixed_offset(),
}))
}
pub async fn get_modified_filenames(
repo_path: &Path,
commit_sha: &str,
) -> Result<Vec<PathBuf>, GitCallError> {
let mut git = Command::new("git");
git.current_dir(repo_path).args([
"diff",
"--name-only",
"--end-of-options",
&format!("{commit_sha}..HEAD"),
]);
let output = call::run("diff", &mut git).await?;
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, GitCallError> {
let mut git = Command::new("git");
git.current_dir(repo_path).args([
"merge-base",
"--is-ancestor",
"--end-of-options",
commit_sha,
&format!("refs/heads/{branch}"),
]);
match call::run("merge-base", &mut git).await {
Ok(_) => Ok(true),
Err(GitCallError::Refused { status, .. }) if status.code() == Some(1) => Ok(false),
Err(error) => Err(error),
}
}
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))
}
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_line_author_is_read_from_the_headers_before_the_line() {
let output = b"\
abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
author Test\n\
author-mail <dev@example.com>\n\
committer-time 1700000000\n\
filename file.txt\n\
\tauthor-mail <code@example.com>\n";
let info = parse_blame(output).unwrap().unwrap();
assert_eq!(info.sha, "abc1234567890abc1234567890abc1234567890ab");
assert_eq!(info.author_email, "dev@example.com");
assert_eq!(info.date.timestamp(), 1_700_000_000);
}
#[test]
fn a_blame_without_a_committer_time_is_dated_at_the_epoch() {
let output = b"abc 1 1 1\nauthor-mail <dev@example.com>\n\tcode\n";
let info = parse_blame(output).unwrap().unwrap();
assert_eq!(info.date, DateTime::UNIX_EPOCH.fixed_offset());
}
#[test]
fn a_blame_with_an_unreadable_committer_time_is_malformed() {
for time in ["", "soon", "99999999999999"] {
let output = format!("abc 1 1 1\ncommitter-time {time}\n\tcode\n");
assert!(matches!(
parse_blame(output.as_bytes()),
Err(GitCallError::Malformed {
command: "blame",
..
})
));
}
}
#[test]
fn an_empty_blame_has_no_line_author() {
assert!(parse_blame(b"").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());
}
fn git(repo: &Path, args: &[&str]) -> String {
let output = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args([
"-c",
"user.email=dev@fluidattacks.com",
"-c",
"user.name=dev",
])
.args(args)
.output()
.unwrap();
assert!(output.status.success());
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
#[tokio::test]
async fn a_warning_from_git_does_not_hide_its_answer() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("GIT_CONFIG_GLOBAL", dir.path().join("gitconfig"));
let repo = dir.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("f.txt"), b"first").unwrap();
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-q", "-m", "first"]);
let first = git(&repo, &["rev-parse", "HEAD"]);
std::fs::write(repo.join("g.txt"), b"second").unwrap();
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-q", "-m", "second"]);
git(&repo, &["update-ref", "refs/heads/HEAD", "HEAD"]);
git(
&repo,
&["update-ref", &format!("refs/heads/{first}"), &first],
);
let last = get_last_commit_info(&repo, Path::new("f.txt"))
.await
.unwrap();
assert_eq!(last.map(|info| info.sha), Some(first.clone()));
let author = get_line_author(&repo, Path::new("f.txt"), 1, "HEAD")
.await
.unwrap();
assert_eq!(author.map(|info| info.sha), Some(first.clone()));
let modified = get_modified_filenames(&repo, &first).await.unwrap();
assert_eq!(modified, vec![PathBuf::from("g.txt")]);
assert!(is_commit_in_branch(&repo, "main", &first).await.unwrap());
}
fn two_branches(repo: &Path) -> (String, String) {
std::fs::create_dir_all(repo).unwrap();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["commit", "-q", "--allow-empty", "-m", "on main"]);
let on_main = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["checkout", "-q", "-b", "main-old"]);
git(
repo,
&["commit", "-q", "--allow-empty", "-m", "on main-old"],
);
let on_main_old = git(repo, &["rev-parse", "HEAD"]);
(on_main, on_main_old)
}
#[tokio::test]
async fn a_commit_is_in_the_branch_it_was_made_on_and_not_in_one_named_alike() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let (on_main, on_main_old) = two_branches(&repo);
assert!(is_commit_in_branch(&repo, "main", &on_main).await.unwrap());
assert!(!is_commit_in_branch(&repo, "main", &on_main_old)
.await
.unwrap());
}
#[tokio::test]
async fn a_commit_or_a_branch_the_repository_lacks_is_refused() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let (on_main, _) = two_branches(&repo);
for (branch, commit) in [("main", "0".repeat(40)), ("gone", on_main)] {
let outcome = is_commit_in_branch(&repo, branch, &commit).await;
assert!(matches!(
outcome,
Err(GitCallError::Refused { command: "merge-base", status, .. })
if status.code() == Some(128)
));
}
}
#[tokio::test]
async fn a_branch_is_read_by_its_exact_name_and_never_as_a_revision() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let (_, on_main_old) = two_branches(&repo);
assert_eq!(
get_head_commit(&repo, "main-old").await.unwrap(),
on_main_old
);
for revision in ["main-old^", "main-old~1", "main..main-old"] {
assert!(matches!(
get_head_commit(&repo, revision).await,
Err(GitCallError::Refused {
command: "show-ref",
..
})
));
}
}
#[tokio::test]
async fn a_diff_from_a_commit_the_repository_lacks_is_refused() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
two_branches(&repo);
let outcome = get_modified_filenames(&repo, &"0".repeat(40)).await;
assert!(matches!(
outcome,
Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
));
}
#[test]
fn a_last_commit_is_read_from_its_three_lines() {
let info = parse_last_commit(b"abc\ndev@example.com\n2024-01-01T10:00:00-05:00\n")
.unwrap()
.unwrap();
assert_eq!(info.sha, "abc");
assert_eq!(info.author_email, "dev@example.com");
assert_eq!(info.date.to_rfc3339(), "2024-01-01T10:00:00-05:00");
}
#[test]
fn a_file_without_commits_has_no_last_commit() {
assert!(parse_last_commit(b"").unwrap().is_none());
}
#[test]
fn a_log_that_is_cut_short_or_misdated_is_malformed() {
for output in [&b"abc\n"[..], &b"abc\ndev@example.com\nnot-a-date\n"[..]] {
assert!(matches!(
parse_last_commit(output),
Err(GitCallError::Malformed { command: "log", .. })
));
}
}
#[tokio::test]
async fn a_log_of_a_repository_without_commits_is_refused() {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "-q", "-b", "main"]);
let outcome = get_last_commit_info(dir.path(), Path::new("f.txt")).await;
assert!(matches!(
outcome,
Err(GitCallError::Refused { command: "log", status, .. }) if status.code() == Some(128)
));
}
#[tokio::test]
async fn a_commit_that_looks_like_an_option_is_not_read_as_one() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
two_branches(&repo);
let leak = dir.path().join("leak");
let outcome = get_modified_filenames(&repo, &format!("--output={}", leak.display())).await;
assert!(matches!(
outcome,
Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
));
assert!(!dir.path().join("leak..HEAD").exists());
}
#[tokio::test]
async fn a_modified_name_is_cut_only_at_a_newline() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let (on_main, _) = two_branches(&repo);
git(&repo, &["config", "core.quotepath", "off"]);
std::fs::write(repo.join("a\u{85}b.txt"), b"content").unwrap();
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-q", "-m", "next line in a name"]);
let modified = get_modified_filenames(&repo, &on_main).await.unwrap();
assert_eq!(modified, vec![PathBuf::from("a\u{85}b.txt")]);
}
#[tokio::test]
async fn a_repository_path_that_does_not_exist_does_not_start() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing");
let in_branch = is_commit_in_branch(&missing, "main", "HEAD").await;
let modified = get_modified_filenames(&missing, "HEAD").await;
let last_commit = get_last_commit_info(&missing, Path::new("f.txt")).await;
let line_author = get_line_author(&missing, Path::new("f.txt"), 1, "HEAD").await;
assert!(matches!(
in_branch,
Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
));
assert!(matches!(
modified,
Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
));
assert!(matches!(
last_commit,
Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
));
assert!(matches!(
line_author,
Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
));
}
#[tokio::test]
async fn a_blame_at_a_revision_the_repository_lacks_is_refused() {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "-q", "-b", "main"]);
git(
dir.path(),
&["commit", "-q", "--allow-empty", "-m", "first"],
);
let outcome = get_line_author(dir.path(), Path::new("f.txt"), 1, &"0".repeat(40)).await;
assert!(matches!(
outcome,
Err(GitCallError::Refused { command: "blame", status, .. }) if status.code() == Some(128)
));
}
#[tokio::test]
async fn quotepath_is_turned_off_in_the_git_dir_given() {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "-q", "-b", "main"]);
disable_quotepath(&dir.path().join(".git")).await.unwrap();
assert_eq!(git(dir.path(), &["config", "core.quotepath"]), "off");
}
#[tokio::test]
async fn quotepath_in_a_folder_that_is_not_a_git_dir_is_refused() {
let dir = tempfile::tempdir().unwrap();
let outcome = disable_quotepath(dir.path()).await;
assert!(matches!(
outcome,
Err(GitCallError::Refused { command: "config", status, .. }) if status.code() == Some(128)
));
}
}