use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::process::Command;
use anyhow::{anyhow, Context, Result};
use super::commands::is_transient_error;
use crate::vcs::shared::run_vcs_with_retry;
#[derive(Debug, Clone)]
pub struct ChangedFile {
pub path: String,
pub old_path: Option<String>,
}
fn detect_unstaged_renames(
repo_path: &Path,
deleted_files: &[String],
untracked_files: &[String],
) -> Result<Vec<(String, String)>> {
use std::io::Write;
if deleted_files.is_empty() || untracked_files.is_empty() {
return Ok(Vec::new());
}
let git_dir_output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(repo_path)
.output()
.context("Failed to find .git directory")?;
if !git_dir_output.status.success() {
return Ok(Vec::new());
}
let git_dir = repo_path.join(
String::from_utf8_lossy(&git_dir_output.stdout).trim(),
);
let index_path = git_dir.join("index");
let mut temp_index = tempfile::NamedTempFile::new().context("Failed to create temp index")?;
if index_path.exists() {
let index_content = std::fs::read(&index_path).context("Failed to read index")?;
temp_index
.write_all(&index_content)
.context("Failed to write temp index")?;
temp_index.flush()?;
}
let temp_index_path = temp_index.path().to_string_lossy().to_string();
let add_output = Command::new("git")
.args(["add", "-N", "--"])
.args(untracked_files)
.env("GIT_INDEX_FILE", &temp_index_path)
.current_dir(repo_path)
.output()
.context("Failed to run git add -N")?;
if !add_output.status.success() {
return Ok(Vec::new());
}
let diff_output = Command::new("git")
.args(["diff", "--name-status", "-M", "HEAD"])
.env("GIT_INDEX_FILE", &temp_index_path)
.current_dir(repo_path)
.output()
.context("Failed to run git diff with temp index")?;
if !diff_output.status.success() {
return Ok(Vec::new());
}
let deleted_set: HashSet<&str> = deleted_files.iter().map(String::as_str).collect();
let untracked_set: HashSet<&str> = untracked_files.iter().map(String::as_str).collect();
let output_str = String::from_utf8_lossy(&diff_output.stdout);
let mut renames = Vec::new();
for line in output_str.lines() {
if let Some(transition) = parse_diff_line(line)
&& let (Some(from), Some(to)) = (&transition.from, &transition.to)
&& from != to
&& deleted_set.contains(from.as_str())
&& untracked_set.contains(to.as_str())
{
renames.push((from.clone(), to.clone()));
}
}
Ok(renames)
}
pub fn get_all_changed_files(repo_path: &Path, merge_base: &str) -> Result<Vec<ChangedFile>> {
let mut files: HashMap<String, Option<String>> = HashMap::new();
let mut worktree_deleted: Vec<String> = Vec::new();
let mut untracked: Vec<String> = Vec::new();
if !merge_base.is_empty()
&& let Ok(transitions) = get_diff_transitions(repo_path, merge_base, "HEAD")
{
for t in transitions {
match (t.to, t.from) {
(Some(to), Some(from)) if to != from => {
files.insert(to, Some(from));
}
(Some(to), _) => {
files.insert(to, None);
}
(None, Some(from)) => {
files.insert(from, None);
}
(None, None) => {}
}
}
}
let status_output = run_vcs_with_retry(
"git", repo_path,
&["status", "--porcelain=v1", "-uall"],
is_transient_error,
)?;
if status_output.status.success() {
let status_str = String::from_utf8_lossy(&status_output.stdout);
for line in status_str.lines() {
if line.len() < 3 {
continue;
}
let status_codes = &line[..2];
let path_part = line[3..].to_string();
if status_codes.as_bytes()[1] == b'D' {
worktree_deleted.push(path_part.clone());
} else if status_codes == "??" {
untracked.push(path_part.clone());
}
let (path, old_path) = if path_part.contains(" -> ") {
let parts: Vec<&str> = path_part.split(" -> ").collect();
(parts[1].to_string(), Some(parts[0].to_string()))
} else {
(path_part, None)
};
files.entry(path).or_insert(old_path);
}
}
if !worktree_deleted.is_empty()
&& !untracked.is_empty()
&& let Ok(renames) = detect_unstaged_renames(repo_path, &worktree_deleted, &untracked)
{
for (old_path, new_path) in renames {
files.insert(new_path, Some(old_path.clone()));
files.remove(&old_path);
}
}
let mut result: Vec<ChangedFile> = files
.into_iter()
.map(|(path, old_path)| ChangedFile { path, old_path })
.collect();
result.sort_by(|a, b| a.path.cmp(&b.path));
Ok(result)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct FileTransition {
pub(super) from: Option<String>,
pub(super) to: Option<String>,
}
impl FileTransition {
#[cfg(test)]
pub(super) fn current_path(&self) -> Option<&str> {
self.to.as_deref().or(self.from.as_deref())
}
}
pub(super) fn parse_diff_line(line: &str) -> Option<FileTransition> {
let parts: Vec<&str> = line.split('\t').collect();
match parts.as_slice() {
[status, path] if status.starts_with('A') => Some(FileTransition {
from: None,
to: Some(path.to_string()),
}),
[status, path] if status.starts_with('D') => Some(FileTransition {
from: Some(path.to_string()),
to: None,
}),
[status, path] if status.starts_with('M') => Some(FileTransition {
from: Some(path.to_string()),
to: Some(path.to_string()),
}),
[status, old_path, new_path] if status.starts_with('R') => Some(FileTransition {
from: Some(old_path.to_string()),
to: Some(new_path.to_string()),
}),
_ => None,
}
}
pub(super) fn get_diff_transitions(repo_path: &Path, from: &str, to: &str) -> Result<Vec<FileTransition>> {
let output = run_vcs_with_retry(
"git", repo_path,
&["diff", "--name-status", "-M", from, to],
is_transient_error,
)?;
if !output.status.success() {
return Err(anyhow!(
"git diff failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let output_str = String::from_utf8_lossy(&output.stdout);
let transitions: Vec<FileTransition> = output_str
.lines()
.filter_map(parse_diff_line)
.collect();
Ok(transitions)
}
pub(super) fn find_rename_source(repo_path: &Path, file_path: &str, merge_base: &str) -> Option<String> {
if merge_base.is_empty() {
return None;
}
let transitions = get_diff_transitions(repo_path, merge_base, "HEAD").ok()?;
transitions.into_iter().find_map(|t| {
if t.to.as_deref() == Some(file_path) && t.from.as_deref() != Some(file_path) {
t.from
} else {
None
}
})
}