use std::path::{Path, PathBuf};
use crate::fs::boundary::BoundedDir;
use crate::manifest::event::Event;
use super::dest_class::git_in_progress_at;
use super::error::{DirtyTreeRefusalKind, TreeError};
use super::quarantine::{snapshot_then_rm, QuarantineConfig};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsentResult {
Clean,
DirtyTree,
DirtyTreeWithIgnored,
GitInProgress,
SubMetaWithDirtyChildren,
}
impl ConsentResult {
fn refusal_kind(self) -> Option<DirtyTreeRefusalKind> {
match self {
ConsentResult::Clean => None,
ConsentResult::DirtyTree => Some(DirtyTreeRefusalKind::DirtyTree),
ConsentResult::DirtyTreeWithIgnored => Some(DirtyTreeRefusalKind::DirtyTreeWithIgnored),
ConsentResult::GitInProgress => Some(DirtyTreeRefusalKind::GitInProgress),
ConsentResult::SubMetaWithDirtyChildren => {
Some(DirtyTreeRefusalKind::SubMetaWithDirtyChildren)
}
}
}
}
fn classify_status(dest: &Path) -> StatusVerdict {
let porcelain = std::process::Command::new("git")
.arg("-C")
.arg(dest)
.arg("status")
.arg("--porcelain")
.arg("--ignored=no")
.output();
let porcelain_dirty = matches!(
porcelain,
Ok(ref out) if out.status.success() && !out.stdout.is_empty()
);
if porcelain_dirty {
return StatusVerdict::DirtyTree;
}
let with_ignored = std::process::Command::new("git")
.arg("-C")
.arg(dest)
.arg("status")
.arg("--porcelain")
.arg("--ignored")
.output();
let ignored_dirty = matches!(
with_ignored,
Ok(ref out) if out.status.success() && !out.stdout.is_empty()
);
if ignored_dirty {
StatusVerdict::DirtyTreeWithIgnored
} else {
StatusVerdict::Clean
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StatusVerdict {
Clean,
DirtyTree,
DirtyTreeWithIgnored,
}
#[must_use]
pub fn recursive_consent_walk(dir: &Path) -> ConsentResult {
walk_inner(dir, true)
}
fn walk_inner(dir: &Path, root: bool) -> ConsentResult {
let dir_is_meta = dir.join(".git").exists();
let self_verdict = match self_dir_verdict(dir, dir_is_meta) {
Ok(v) => v,
Err(in_progress) => return in_progress,
};
let _ = root;
match walk_children(dir) {
ChildVerdict::SawGitInProgress => ConsentResult::GitInProgress,
ChildVerdict::AllClean => self_verdict,
ChildVerdict::SawDirty if dir_is_meta => ConsentResult::SubMetaWithDirtyChildren,
ChildVerdict::SawDirty => ConsentResult::DirtyTree,
}
}
fn self_dir_verdict(dir: &Path, dir_is_meta: bool) -> Result<ConsentResult, ConsentResult> {
if !dir_is_meta {
return Ok(ConsentResult::Clean);
}
if git_in_progress_at(dir) {
return Err(ConsentResult::GitInProgress);
}
Ok(match classify_status(dir) {
StatusVerdict::Clean => ConsentResult::Clean,
StatusVerdict::DirtyTree => ConsentResult::DirtyTree,
StatusVerdict::DirtyTreeWithIgnored => ConsentResult::DirtyTreeWithIgnored,
})
}
fn walk_children(dir: &Path) -> ChildVerdict {
let cap_dir = cap_std::fs::Dir::open_ambient_dir(dir, cap_std::ambient_authority()).ok();
let Some(cap_dir) = cap_dir else {
return ChildVerdict::AllClean;
};
let Ok(entries) = cap_dir.entries() else {
return ChildVerdict::AllClean;
};
let mut saw_dirty = false;
for entry in entries.flatten() {
let name = entry.file_name();
let Ok(ft) = entry.file_type() else { continue };
if !ft.is_dir() {
continue;
}
if name == std::ffi::OsStr::new(".git") {
continue;
}
let path = dir.join(&name);
match walk_inner(&path, false) {
ConsentResult::Clean => continue,
ConsentResult::GitInProgress => return ChildVerdict::SawGitInProgress,
_ => saw_dirty = true,
}
}
if saw_dirty {
ChildVerdict::SawDirty
} else {
ChildVerdict::AllClean
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChildVerdict {
AllClean,
SawDirty,
SawGitInProgress,
}
pub fn phase2_prune(
dest: &Path,
force_prune: bool,
force_prune_with_ignored: bool,
audit_log: Option<&Path>,
quarantine: Option<&QuarantineConfig>,
) -> Result<(), TreeError> {
let verdict = recursive_consent_walk(dest);
if should_execute(verdict, force_prune, force_prune_with_ignored) {
if let Some(log_path) = audit_log {
if let Some(kind) = verdict.refusal_kind() {
emit_force_prune_event(log_path, dest, kind, force_prune_with_ignored);
}
}
if let Some(cfg) = quarantine {
return execute_quarantine_prune(dest, cfg);
}
return execute_prune(dest);
}
let kind = verdict
.refusal_kind()
.expect("Clean verdicts always pass should_execute and never reach the refusal arm");
Err(TreeError::DirtyTreeRefusal { path: dest.to_path_buf(), kind })
}
fn execute_quarantine_prune(dest: &Path, cfg: &QuarantineConfig) -> Result<(), TreeError> {
match snapshot_then_rm(dest, cfg) {
Ok(_) => Ok(()),
Err(e) => {
tracing::warn!(
dest = %dest.display(),
error = %e,
"quarantine pipeline aborted; dest left intact"
);
Err(TreeError::DirtyTreeRefusal {
path: dest.to_path_buf(),
kind: DirtyTreeRefusalKind::DirtyTree,
})
}
}
}
fn refusal_kind_tag(kind: DirtyTreeRefusalKind) -> &'static str {
match kind {
DirtyTreeRefusalKind::DirtyTree => "dirty_tree",
DirtyTreeRefusalKind::DirtyTreeWithIgnored => "dirty_tree_with_ignored",
DirtyTreeRefusalKind::GitInProgress => "git_in_progress",
DirtyTreeRefusalKind::SubMetaWithDirtyChildren => "sub_meta_with_dirty_children",
}
}
fn emit_force_prune_event(
log_path: &Path,
dest: &Path,
kind: DirtyTreeRefusalKind,
force_prune_with_ignored: bool,
) {
let event = Event::ForcePruneExecuted {
ts: chrono::Utc::now(),
path: dest.display().to_string(),
kind: refusal_kind_tag(kind).to_string(),
force_prune_with_ignored,
};
if let Err(e) = crate::manifest::append::append_event(log_path, &event) {
tracing::warn!(
audit_log = %log_path.display(),
error = %e,
"failed to append ForcePruneExecuted audit event; prune still executed",
);
}
}
fn should_execute(
verdict: ConsentResult,
force_prune: bool,
force_prune_with_ignored: bool,
) -> bool {
match verdict {
ConsentResult::Clean => true,
ConsentResult::GitInProgress => false,
ConsentResult::DirtyTree | ConsentResult::SubMetaWithDirtyChildren => {
force_prune || force_prune_with_ignored
}
ConsentResult::DirtyTreeWithIgnored => force_prune_with_ignored,
}
}
fn execute_prune(dest: &Path) -> Result<(), TreeError> {
let (parent, name) = match (dest.parent(), dest.file_name()) {
(Some(p), Some(n)) if !p.as_os_str().is_empty() => (p, PathBuf::from(n)),
_ => {
return std_fs_remove_with_refusal(dest);
}
};
if BoundedDir::open(parent, &name).is_err() {
if !dest.exists() {
return Ok(());
}
return Err(TreeError::DirtyTreeRefusal {
path: dest.to_path_buf(),
kind: DirtyTreeRefusalKind::DirtyTree,
});
}
let parent_dir = match cap_std::fs::Dir::open_ambient_dir(parent, cap_std::ambient_authority())
{
Ok(d) => d,
Err(_) => return std_fs_remove_with_refusal(dest),
};
if parent_dir.remove_dir_all(&name).is_err() {
if !dest.exists() {
return Ok(());
}
return std_fs_remove_with_refusal(dest);
}
Ok(())
}
fn std_fs_remove_with_refusal(dest: &Path) -> Result<(), TreeError> {
if !dest.exists() {
return Ok(());
}
if let (Some(parent), Some(name)) = (dest.parent(), dest.file_name()) {
if !parent.as_os_str().is_empty() {
if let Ok(parent_dir) =
cap_std::fs::Dir::open_ambient_dir(parent, cap_std::ambient_authority())
{
return parent_dir.remove_dir_all(name).map_err(|_| TreeError::DirtyTreeRefusal {
path: dest.to_path_buf(),
kind: DirtyTreeRefusalKind::DirtyTree,
});
}
}
}
std::fs::remove_dir_all(dest).map_err(|_| TreeError::DirtyTreeRefusal {
path: dest.to_path_buf(),
kind: DirtyTreeRefusalKind::DirtyTree,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn try_git_init(dir: &Path) -> bool {
let status =
std::process::Command::new("git").arg("-C").arg(dir).arg("init").arg("-q").status();
matches!(status, Ok(s) if s.success())
}
fn try_git_identity(dir: &Path) -> bool {
let a = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["config", "user.email", "test@example.com"])
.status();
let b = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["config", "user.name", "Test"])
.status();
matches!((a, b), (Ok(sa), Ok(sb)) if sa.success() && sb.success())
}
fn try_git_commit_initial(dir: &Path) -> bool {
fs::write(dir.join("README"), b"seed\n").unwrap();
let add =
std::process::Command::new("git").arg("-C").arg(dir).args(["add", "README"]).status();
if !matches!(add, Ok(s) if s.success()) {
return false;
}
let commit = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["commit", "-q", "-m", "init"])
.status();
matches!(commit, Ok(s) if s.success())
}
#[test]
fn test_consent_clean_repo() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("clean");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
assert_eq!(recursive_consent_walk(&dest), ConsentResult::Clean);
}
#[test]
fn test_consent_dirty_tree() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("dirty");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
fs::write(dest.join("scratch.txt"), b"changes").unwrap();
assert_eq!(recursive_consent_walk(&dest), ConsentResult::DirtyTree);
}
#[test]
fn test_consent_dirty_with_ignored() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("ignored-only");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
if !try_git_identity(&dest) || !try_git_commit_initial(&dest) {
return;
}
fs::write(dest.join(".gitignore"), b"target/\n").unwrap();
let add = std::process::Command::new("git")
.arg("-C")
.arg(&dest)
.args(["add", ".gitignore"])
.status();
let commit = std::process::Command::new("git")
.arg("-C")
.arg(&dest)
.args(["commit", "-q", "-m", "ignore"])
.status();
if !matches!(add, Ok(s) if s.success()) || !matches!(commit, Ok(s) if s.success()) {
return;
}
fs::create_dir_all(dest.join("target")).unwrap();
fs::write(dest.join("target/build.out"), b"artefact").unwrap();
assert_eq!(recursive_consent_walk(&dest), ConsentResult::DirtyTreeWithIgnored);
}
#[test]
fn test_consent_git_in_progress() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("midflight");
fs::create_dir_all(dest.join(".git")).unwrap();
fs::write(dest.join(".git/MERGE_HEAD"), b"deadbeef\n").unwrap();
assert_eq!(recursive_consent_walk(&dest), ConsentResult::GitInProgress);
}
#[test]
fn test_consent_sub_meta_dirty() {
let tmp = tempdir().unwrap();
let outer = tmp.path().join("outer");
fs::create_dir(&outer).unwrap();
if !try_git_init(&outer) {
return;
}
let inner = outer.join("inner");
fs::create_dir(&inner).unwrap();
if !try_git_init(&inner) {
return;
}
fs::write(inner.join("scratch.txt"), b"changes").unwrap();
if !try_git_identity(&outer) || !try_git_commit_initial(&outer) {
return;
}
fs::write(outer.join(".gitignore"), b"inner/\n").unwrap();
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&outer)
.args(["add", ".gitignore"])
.status();
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&outer)
.args(["commit", "-q", "-m", "ignore inner"])
.status();
assert_eq!(recursive_consent_walk(&outer), ConsentResult::SubMetaWithDirtyChildren);
}
#[test]
fn test_phase2_prune_clean_succeeds() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("clean");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
let res = phase2_prune(&dest, false, false, None, None);
assert!(res.is_ok(), "clean prune must succeed: {res:?}");
assert!(!dest.exists(), "dest must be removed after a clean prune");
}
#[test]
fn test_phase2_prune_dirty_refuses_without_force() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("dirty");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
fs::write(dest.join("scratch.txt"), b"changes").unwrap();
let res = phase2_prune(&dest, false, false, None, None);
match res {
Err(TreeError::DirtyTreeRefusal { kind: DirtyTreeRefusalKind::DirtyTree, .. }) => {}
other => panic!("expected DirtyTreeRefusal{{DirtyTree}}, got {other:?}"),
}
assert!(dest.exists(), "dest must NOT be removed when refused");
}
#[test]
fn test_phase2_prune_dirty_succeeds_with_force_prune() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("dirty");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
fs::write(dest.join("scratch.txt"), b"changes").unwrap();
let res = phase2_prune(&dest, true, false, None, None);
assert!(res.is_ok(), "force_prune must consume DirtyTree: {res:?}");
assert!(!dest.exists(), "dest must be removed under force_prune");
}
#[test]
fn test_phase2_prune_dirty_with_ignored_refuses_force_prune() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("ignored-only");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
if !try_git_identity(&dest) || !try_git_commit_initial(&dest) {
return;
}
fs::write(dest.join(".gitignore"), b"target/\n").unwrap();
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&dest)
.args(["add", ".gitignore"])
.status();
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&dest)
.args(["commit", "-q", "-m", "ignore"])
.status();
fs::create_dir_all(dest.join("target")).unwrap();
fs::write(dest.join("target/build.out"), b"artefact").unwrap();
let res = phase2_prune(&dest, true, false, None, None);
match res {
Err(TreeError::DirtyTreeRefusal {
kind: DirtyTreeRefusalKind::DirtyTreeWithIgnored,
..
}) => {}
other => panic!("expected DirtyTreeRefusal{{DirtyTreeWithIgnored}}, got {other:?}"),
}
assert!(dest.exists(), "dest must NOT be removed when force_prune alone is insufficient");
}
#[test]
fn test_phase2_prune_dirty_with_ignored_succeeds_with_force_prune_with_ignored() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("ignored-only");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
if !try_git_identity(&dest) || !try_git_commit_initial(&dest) {
return;
}
fs::write(dest.join(".gitignore"), b"target/\n").unwrap();
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&dest)
.args(["add", ".gitignore"])
.status();
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&dest)
.args(["commit", "-q", "-m", "ignore"])
.status();
fs::create_dir_all(dest.join("target")).unwrap();
fs::write(dest.join("target/build.out"), b"artefact").unwrap();
let res = phase2_prune(&dest, true, true, None, None);
assert!(res.is_ok(), "force_prune_with_ignored must consume ignored-only dirt: {res:?}");
assert!(!dest.exists(), "dest must be removed under force_prune_with_ignored");
}
#[test]
fn test_phase2_prune_in_progress_always_refuses() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("midflight");
fs::create_dir_all(dest.join(".git")).unwrap();
fs::write(dest.join(".git/MERGE_HEAD"), b"deadbeef\n").unwrap();
let res = phase2_prune(&dest, true, true, None, None);
match res {
Err(TreeError::DirtyTreeRefusal {
kind: DirtyTreeRefusalKind::GitInProgress, ..
}) => {}
other => panic!("expected DirtyTreeRefusal{{GitInProgress}}, got {other:?}"),
}
assert!(dest.exists(), "dest must NOT be removed during an in-progress git op");
}
#[test]
fn test_should_execute_matrix() {
assert!(should_execute(ConsentResult::Clean, false, false));
assert!(should_execute(ConsentResult::Clean, true, false));
assert!(should_execute(ConsentResult::Clean, true, true));
assert!(!should_execute(ConsentResult::GitInProgress, false, false));
assert!(!should_execute(ConsentResult::GitInProgress, true, false));
assert!(!should_execute(ConsentResult::GitInProgress, true, true));
assert!(!should_execute(ConsentResult::DirtyTree, false, false));
assert!(should_execute(ConsentResult::DirtyTree, true, false));
assert!(should_execute(ConsentResult::DirtyTree, false, true));
assert!(should_execute(ConsentResult::DirtyTree, true, true));
assert!(!should_execute(ConsentResult::DirtyTreeWithIgnored, false, false));
assert!(!should_execute(ConsentResult::DirtyTreeWithIgnored, true, false));
assert!(should_execute(ConsentResult::DirtyTreeWithIgnored, false, true));
assert!(should_execute(ConsentResult::DirtyTreeWithIgnored, true, true));
assert!(!should_execute(ConsentResult::SubMetaWithDirtyChildren, false, false));
assert!(should_execute(ConsentResult::SubMetaWithDirtyChildren, true, false));
assert!(should_execute(ConsentResult::SubMetaWithDirtyChildren, false, true));
assert!(should_execute(ConsentResult::SubMetaWithDirtyChildren, true, true));
}
#[test]
fn test_phase2_prune_emits_audit_log_on_force() {
use crate::manifest::append::read_all;
let tmp = tempdir().unwrap();
let dest = tmp.path().join("dirty");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
fs::write(dest.join("scratch.txt"), b"changes").unwrap();
let log = tmp.path().join(".grex/events.jsonl");
let res = phase2_prune(&dest, true, false, Some(log.as_path()), None);
assert!(res.is_ok(), "force_prune must consume DirtyTree: {res:?}");
assert!(!dest.exists(), "dest must be removed after override");
let events = read_all(&log).expect("audit log readable");
assert_eq!(events.len(), 1, "exactly one audit event must land");
match &events[0] {
Event::ForcePruneExecuted { kind, force_prune_with_ignored, path, .. } => {
assert_eq!(kind, "dirty_tree", "kind tag must be dirty_tree");
assert!(!force_prune_with_ignored, "stronger flag must NOT be in effect here");
assert!(
path.contains("dirty"),
"path must reference the pruned dest, got {path:?}",
);
}
other => panic!("expected ForcePruneExecuted, got {other:?}"),
}
}
#[test]
fn test_phase2_prune_no_audit_when_clean() {
let tmp = tempdir().unwrap();
let dest = tmp.path().join("clean");
fs::create_dir(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
let log = tmp.path().join(".grex/events.jsonl");
let res = phase2_prune(&dest, true, true, Some(log.as_path()), None);
assert!(res.is_ok(), "clean prune must succeed: {res:?}");
assert!(!dest.exists(), "dest must be removed after a clean prune");
assert!(
!log.exists(),
"audit log must NOT be created by a clean prune (was: {})",
log.display(),
);
}
}