use anyhow::{Context, Result};
use dracon_git::{
types::{DiffFile, FileStatus},
GitService,
};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub(crate) async fn git_diff_head_files(repo: &Path) -> Result<HashSet<PathBuf>> {
let r = repo.to_path_buf();
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::task::spawn_blocking(move || -> anyhow::Result<HashSet<PathBuf>> {
let output = crate::git::git_cmd()
.current_dir(&r)
.args(["diff", "HEAD", "--name-only", "-z"])
.output()?;
if !output.status.success() {
anyhow::bail!("git diff HEAD exited with {}", output.status);
}
let files: HashSet<PathBuf> = String::from_utf8_lossy(&output.stdout)
.split('\0')
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
Ok(files)
}),
)
.await;
let inner = match outcome {
Ok(inner) => inner,
Err(_) => return Err(anyhow::anyhow!("git diff HEAD timed out")),
};
match inner {
Ok(Ok(files)) => Ok(files),
Ok(Err(e)) => Err(anyhow::anyhow!("git diff HEAD task failed: {}", e)),
Err(e) => Err(anyhow::anyhow!("git diff HEAD task failed: {}", e)),
}
}
pub(crate) fn parse_name_status_line(line: &str) -> Option<(PathBuf, FileStatus)> {
let mut parts = line.split('\t');
let status_raw = parts.next()?.trim();
if status_raw.is_empty() {
return None;
}
let status_char = status_raw.chars().next()?;
let (path, status) = match status_char {
'M' => (parts.next()?, FileStatus::Modified),
'A' => (parts.next()?, FileStatus::Added),
'D' => (parts.next()?, FileStatus::Deleted),
'T' => (parts.next()?, FileStatus::TypeChange),
'R' => {
let _old = parts.next()?;
let new = parts.next()?;
(new, FileStatus::Renamed)
}
_ => return None,
};
Some((PathBuf::from(path.trim()), status))
}
pub(crate) async fn git_name_status_entries(
repo: &Path,
args: &[&str],
) -> Result<Vec<(PathBuf, FileStatus)>> {
let output = crate::git::tokio_git_cmd()
.args(args)
.current_dir(repo)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await
.with_context(|| format!("failed to run git {:?} in {}", args, repo.display()))?;
if !output.status.success() {
return Ok(Vec::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.filter_map(parse_name_status_line)
.collect::<Vec<_>>())
}
#[cfg(test)]
pub(crate) fn fallback_status_rank(status: &FileStatus) -> u8 {
match status {
FileStatus::Deleted => 5,
FileStatus::Renamed => 4,
FileStatus::TypeChange => 3,
FileStatus::Added => 2,
FileStatus::Modified => 1,
FileStatus::Unknown => 0,
_ => 0,
}
}
pub(crate) async fn cli_diff_entries(repo: &Path) -> Result<Vec<DiffFile>> {
let output = crate::git::tokio_git_cmd()
.args(["diff", "--name-status", "HEAD"])
.current_dir(repo)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
for line in stdout.lines() {
if let Some((path, status)) = parse_name_status_line(line) {
entries.push(DiffFile::new(path, status));
}
}
Ok(entries)
}
pub(crate) async fn untracked_entries(repo: &Path) -> Result<Vec<DiffFile>> {
let output = crate::git::tokio_git_cmd()
.args(["ls-files", "--others", "--exclude-standard", "-z"])
.current_dir(repo)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.split('\0')
.filter(|s| !s.is_empty())
.map(|p| DiffFile::new(PathBuf::from(p), FileStatus::Added))
.collect())
}
pub(crate) async fn repo_diff_entries(repo: &Path) -> Result<Vec<DiffFile>> {
let svc = GitService::new(repo)?;
let status = svc.get_status().await?;
if status.is_clean {
return Ok(Vec::new());
}
let diff = cli_diff_entries(repo).await?;
if !diff.is_empty() {
let untracked = untracked_entries(repo).await.unwrap_or_default();
if untracked.is_empty() {
return Ok(diff);
}
let mut combined = diff;
combined.extend(untracked);
return Ok(combined);
}
let untracked = untracked_entries(repo).await.unwrap_or_default();
if !untracked.is_empty() {
return Ok(untracked);
}
Ok(Vec::new())
}
pub(crate) async fn staged_paths(repo: &Path) -> Result<HashSet<PathBuf>> {
let output = crate::git::tokio_git_cmd()
.args(["diff", "--cached", "--name-only", "-z"])
.current_dir(repo)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.split('\0')
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect())
}