use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use crate::cook::execution::mapreduce::coordination::executor::OrphanedWorktree;
pub fn resolve_registry_base_path(home_dir: &Path, repo_name: &str) -> PathBuf {
home_dir
.join(".prodigy")
.join("orphaned_worktrees")
.join(repo_name)
}
pub fn find_registry_files(registry_path: &Path) -> Result<Vec<PathBuf>> {
if !registry_path.exists() {
return Ok(Vec::new());
}
let entries = std::fs::read_dir(registry_path)
.context("Failed to read orphaned worktrees registry directory")?;
let files: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("json"))
.collect();
Ok(files)
}
pub fn format_orphaned_worktree(orphaned: &OrphanedWorktree) -> String {
format!(
" - {} (agent: {}, item: {}, error: {})",
orphaned.path.display(),
orphaned.agent_id,
orphaned.item_id,
orphaned.error
)
}
pub async fn run_worktree_clean_orphaned(
job_id: Option<String>,
dry_run: bool,
force: bool,
) -> Result<()> {
let home_dir =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Unable to determine home directory"))?;
let repo_path = std::env::current_dir()?;
let repo_name = repo_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let registry_path = resolve_registry_base_path(&home_dir, repo_name);
if !registry_path.exists() {
println!("No orphaned worktrees registry found.");
return Ok(());
}
let registry_file = find_target_registry_file(®istry_path, job_id, force)?;
if !registry_file.exists() {
println!("No orphaned worktrees found for the specified job.");
return Ok(());
}
let orphaned_worktrees = load_orphaned_worktrees(®istry_file)?;
if orphaned_worktrees.is_empty() {
println!("No orphaned worktrees in registry.");
return Ok(());
}
display_orphaned_worktrees(&orphaned_worktrees);
if dry_run {
println!(
"\nDry run: would clean {} worktree(s)",
orphaned_worktrees.len()
);
return Ok(());
}
if !force && !confirm_cleanup()? {
println!("Cleanup cancelled.");
return Ok(());
}
let (cleaned, failed) = cleanup_orphaned_worktrees(&orphaned_worktrees).await;
if failed == 0 {
std::fs::remove_file(®istry_file)?;
println!("\n✅ Cleaned {} orphaned worktree(s)", cleaned);
} else {
println!("\n⚠️ Cleaned {} worktree(s), {} failed", cleaned, failed);
}
Ok(())
}
fn find_target_registry_file(
registry_path: &Path,
job_id: Option<String>,
force: bool,
) -> Result<PathBuf> {
if let Some(ref jid) = job_id {
Ok(registry_path.join(format!("{}.json", jid)))
} else {
let mut files = find_registry_files(registry_path)?;
if files.is_empty() {
println!("No orphaned worktrees found.");
return Ok(PathBuf::new());
}
if files.len() > 1 && !force {
println!("Multiple job registries found. Please specify a job_id:");
for file in &files {
if let Some(name) = file.file_stem().and_then(|s| s.to_str()) {
println!(" - {}", name);
}
}
return Ok(PathBuf::new());
}
Ok(files.remove(0))
}
}
fn load_orphaned_worktrees(registry_file: &Path) -> Result<Vec<OrphanedWorktree>> {
let content = std::fs::read_to_string(registry_file)
.context("Failed to read orphaned worktrees registry")?;
let orphaned_worktrees: Vec<OrphanedWorktree> =
serde_json::from_str(&content).context("Failed to parse orphaned worktrees registry")?;
Ok(orphaned_worktrees)
}
fn display_orphaned_worktrees(orphaned_worktrees: &[OrphanedWorktree]) {
println!("Found {} orphaned worktree(s):", orphaned_worktrees.len());
for orphaned in orphaned_worktrees {
println!("{}", format_orphaned_worktree(orphaned));
}
}
fn confirm_cleanup() -> Result<bool> {
println!("\nProceed with cleanup? [y/N]");
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.context("Failed to read user input")?;
Ok(input.trim().eq_ignore_ascii_case("y"))
}
async fn cleanup_orphaned_worktrees(orphaned_worktrees: &[OrphanedWorktree]) -> (usize, usize) {
let mut cleaned = 0;
let mut failed = 0;
for orphaned in orphaned_worktrees {
if orphaned.path.exists() {
match std::fs::remove_dir_all(&orphaned.path) {
Ok(_) => {
println!("✅ Cleaned: {}", orphaned.path.display());
cleaned += 1;
}
Err(e) => {
eprintln!("❌ Failed to clean {}: {}", orphaned.path.display(), e);
failed += 1;
}
}
} else {
println!("⚠️ Already removed: {}", orphaned.path.display());
cleaned += 1;
}
}
(cleaned, failed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_registry_base_path() {
let home = PathBuf::from("/home/user");
let path = resolve_registry_base_path(&home, "myrepo");
assert_eq!(
path,
PathBuf::from("/home/user/.prodigy/orphaned_worktrees/myrepo")
);
}
#[test]
fn test_format_orphaned_worktree() {
let orphaned = OrphanedWorktree {
path: PathBuf::from("/tmp/worktree"),
agent_id: "agent-1".to_string(),
item_id: "item-1".to_string(),
failed_at: chrono::Utc::now(),
error: "permission denied".to_string(),
};
let formatted = format_orphaned_worktree(&orphaned);
assert!(formatted.contains("agent-1"));
assert!(formatted.contains("item-1"));
assert!(formatted.contains("permission denied"));
}
#[test]
fn test_find_registry_files_empty_dir() {
use std::fs;
let temp_dir = std::env::temp_dir().join("test_registry_empty");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let files = find_registry_files(&temp_dir).unwrap();
assert_eq!(files.len(), 0);
fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_find_registry_files_nonexistent() {
let nonexistent = PathBuf::from("/nonexistent/path/registry");
let files = find_registry_files(&nonexistent).unwrap();
assert_eq!(files.len(), 0);
}
}