use std::path::{Path, PathBuf};
use crate::fs::boundary::BoundedDir;
use crate::lockfile::LockEntry;
use super::error::TreeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DestClass {
Missing,
PresentDeclared,
PresentDirty,
PresentInProgress,
PresentUndeclared,
}
#[must_use]
pub fn git_in_progress_at(dest: &Path) -> bool {
let git = dest.join(".git");
if !git.exists() {
return false;
}
git.join("rebase-merge").exists()
|| git.join("rebase-apply").exists()
|| git.join("MERGE_HEAD").exists()
|| git.join("CHERRY_PICK_HEAD").exists()
|| git.join("REVERT_HEAD").exists()
|| git.join("BISECT_LOG").exists()
}
#[must_use]
fn git_status_dirty(dest: &Path) -> bool {
let output = std::process::Command::new("git")
.arg("-C")
.arg(dest)
.arg("status")
.arg("--porcelain")
.arg("--ignored=no")
.output();
match output {
Ok(out) if out.status.success() => !out.stdout.is_empty(),
_ => false,
}
}
#[must_use]
pub fn classify_dest(
dest: &Path,
declared_in_manifest: bool,
_lockfile_entry: Option<&LockEntry>,
) -> DestClass {
if let (Some(parent), Some(name)) = (dest.parent(), dest.file_name()) {
if !parent.as_os_str().is_empty() {
if BoundedDir::open(parent, Path::new(name)).is_err() {
return DestClass::Missing;
}
}
}
if !dest.exists() {
return DestClass::Missing;
}
if !dest.join(".git").exists() {
return DestClass::Missing;
}
if git_in_progress_at(dest) {
return DestClass::PresentInProgress;
}
if !declared_in_manifest {
return DestClass::PresentUndeclared;
}
if git_status_dirty(dest) {
return DestClass::PresentDirty;
}
DestClass::PresentDeclared
}
pub fn aggregate_untracked<I, P>(classifications: I) -> Result<(), TreeError>
where
I: IntoIterator<Item = (P, DestClass)>,
P: Into<PathBuf>,
{
let untracked: Vec<PathBuf> = classifications
.into_iter()
.filter_map(|(p, c)| if c == DestClass::PresentUndeclared { Some(p.into()) } else { None })
.collect();
if untracked.is_empty() {
Ok(())
} else {
Err(TreeError::UntrackedGitRepos { paths: untracked })
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn make_git_dir(parent: &Path, name: &str) -> PathBuf {
let dest = parent.join(name);
fs::create_dir_all(dest.join(".git")).unwrap();
dest
}
#[test]
fn test_classify_dest_missing() {
let parent = tempdir().unwrap();
let dest = parent.path().join("absent");
assert_eq!(classify_dest(&dest, true, None), DestClass::Missing);
}
#[test]
fn test_classify_dest_missing_when_no_dot_git() {
let parent = tempdir().unwrap();
let dest = parent.path().join("plain-dir");
fs::create_dir(&dest).unwrap();
assert_eq!(classify_dest(&dest, true, None), DestClass::Missing);
}
#[test]
fn test_classify_dest_present_declared() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
let init =
std::process::Command::new("git").arg("-C").arg(&dest).arg("init").arg("-q").status();
if init.is_err() || !init.unwrap().success() {
}
assert_eq!(classify_dest(&dest, true, None), DestClass::PresentDeclared);
}
#[test]
fn test_classify_dest_present_dirty() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
let init =
std::process::Command::new("git").arg("-C").arg(&dest).arg("init").arg("-q").status();
if init.is_err() || !init.map(|s| s.success()).unwrap_or(false) {
return;
}
fs::write(dest.join("dirty.txt"), b"hello").unwrap();
assert_eq!(classify_dest(&dest, true, None), DestClass::PresentDirty);
}
#[test]
fn test_classify_dest_present_in_progress_rebase() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
fs::create_dir_all(dest.join(".git/rebase-merge")).unwrap();
assert_eq!(classify_dest(&dest, true, None), DestClass::PresentInProgress);
}
#[test]
fn test_classify_dest_present_in_progress_rebase_apply() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
fs::create_dir_all(dest.join(".git/rebase-apply")).unwrap();
assert_eq!(classify_dest(&dest, true, None), DestClass::PresentInProgress);
}
#[test]
fn test_classify_dest_present_in_progress_merge() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
fs::write(dest.join(".git/MERGE_HEAD"), b"deadbeef").unwrap();
assert_eq!(classify_dest(&dest, true, None), DestClass::PresentInProgress);
}
#[test]
fn test_classify_dest_present_in_progress_cherry_pick() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
fs::write(dest.join(".git/CHERRY_PICK_HEAD"), b"deadbeef").unwrap();
assert_eq!(classify_dest(&dest, true, None), DestClass::PresentInProgress);
}
#[test]
fn test_classify_dest_present_in_progress_revert() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
fs::write(dest.join(".git/REVERT_HEAD"), b"deadbeef").unwrap();
assert_eq!(classify_dest(&dest, true, None), DestClass::PresentInProgress);
}
#[test]
fn test_classify_dest_present_in_progress_bisect() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
fs::write(dest.join(".git/BISECT_LOG"), b"start").unwrap();
assert_eq!(classify_dest(&dest, true, None), DestClass::PresentInProgress);
}
#[test]
fn test_classify_dest_present_undeclared() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
assert_eq!(classify_dest(&dest, false, None), DestClass::PresentUndeclared);
}
#[test]
fn test_git_in_progress_at_returns_false_on_clean_repo() {
let parent = tempdir().unwrap();
let dest = make_git_dir(parent.path(), "child");
assert!(!git_in_progress_at(&dest));
}
#[test]
fn test_git_in_progress_at_returns_false_on_non_repo() {
let parent = tempdir().unwrap();
let dest = parent.path().join("not-a-repo");
fs::create_dir(&dest).unwrap();
assert!(!git_in_progress_at(&dest));
}
#[test]
fn test_phase1_aggregates_untracked_into_error() {
let inputs: Vec<(PathBuf, DestClass)> = vec![
(PathBuf::from("alpha"), DestClass::PresentUndeclared),
(PathBuf::from("beta"), DestClass::PresentDeclared),
(PathBuf::from("gamma"), DestClass::PresentUndeclared),
(PathBuf::from("delta"), DestClass::Missing),
(PathBuf::from("epsilon"), DestClass::PresentUndeclared),
];
let err =
aggregate_untracked(inputs).expect_err("aggregation must fail when any undeclared");
match err {
TreeError::UntrackedGitRepos { paths } => {
assert_eq!(
paths,
vec![PathBuf::from("alpha"), PathBuf::from("gamma"), PathBuf::from("epsilon"),],
"aggregator must enumerate ALL undeclared paths in input order",
);
}
other => panic!("expected UntrackedGitRepos, got {other:?}"),
}
}
#[test]
fn test_phase1_aggregate_ok_when_no_undeclared() {
let inputs: Vec<(PathBuf, DestClass)> = vec![
(PathBuf::from("alpha"), DestClass::Missing),
(PathBuf::from("beta"), DestClass::PresentDeclared),
];
assert!(aggregate_untracked(inputs).is_ok());
}
#[test]
fn test_phase1_aggregate_ok_on_empty_input() {
let inputs: Vec<(PathBuf, DestClass)> = Vec::new();
assert!(aggregate_untracked(inputs).is_ok());
}
}