use anyhow::Result;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use crate::channel::Channel;
use crate::commands::hook;
use crate::config::Registry;
use crate::output;
use crate::setup;
pub fn run(deep: bool, yes: bool) -> Result<()> {
output::print_header(if deep {
"dev-prune Deep Uninstaller (Full Purge)"
} else {
"dev-prune Uninstaller"
});
let registry = Registry::load().ok();
if deep && !yes {
use std::io::{IsTerminal, Write};
let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
output::print_warning(&format!(
"This deletes the global config directory (including prune history) and \
removes `.devprune.json` from {repo_count} registered repositories."
));
if !std::io::stdin().is_terminal() {
anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
}
eprint!("Continue? [y/N]: ");
std::io::stderr().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
output::print_info("Deep uninstall cancelled.");
return Ok(());
}
}
let mut left_behind: Vec<String> = Vec::new();
let mut pending_files: Vec<PathBuf> = Vec::new();
let mut pending_dirs: Vec<PathBuf> = Vec::new();
let hands_off = setup::no_auto_setup_requested();
if hands_off {
output::print_info(&format!(
"{} is set — leaving the scheduler and agent skills alone.",
setup::ENV_NO_AUTO_SETUP
));
} else {
output::print_info("Removing background daemon scheduler...");
if let Err(e) = crate::daemon::uninstall_daemon() {
output::print_error(&format!("Background scheduler: {e:#}"));
left_behind.push("the background scheduler".to_string());
}
}
output::print_info("Removing global Git auto-registration hooks...");
if let Err(e) = hook::run_uninstall() {
output::print_error(&format!("Git hooks: {e:#}"));
left_behind.push("the global Git hooks".to_string());
}
crate::commands::icon::unregister_file_type();
let skill_roots = if hands_off {
Vec::new()
} else {
setup::agent_skill_roots()
};
for root in skill_roots {
if !root.exists() {
continue;
}
match fs::remove_dir_all(&root) {
Ok(()) => output::print_info(&format!(
"Removed the agent skill at {}.",
output::clean_path(&root)
)),
Err(e) => {
output::print_error(&format!(
"Could not remove {}: {e}",
output::clean_path(&root)
));
left_behind.push("the AI agent skill".to_string());
}
}
}
if let Ok(bin_dir) = setup::managed_bin_dir() {
match crate::pathenv::remove_reachability(&bin_dir) {
Ok(true) => output::print_info("Removed dev-prune from your PATH."),
Ok(false) => {}
Err(e) => {
output::print_error(&format!("Could not update your PATH: {e:#}"));
left_behind.push("the PATH entry".to_string());
}
}
}
let channel = Channel::detect();
remove_binaries(
deep,
channel.owns_its_files(),
&mut left_behind,
&mut pending_files,
&mut pending_dirs,
);
let mut manager_hints: Vec<Channel> = Vec::new();
if channel.owns_its_files() {
manager_hints.push(channel);
}
sweep_stray_copies(
yes,
&mut manager_hints,
&mut left_behind,
&mut pending_files,
);
if deep {
if let Some(reg) = registry {
for repo_path in reg.repositories.keys() {
let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
if cfg_file.exists() {
let _ = fs::remove_file(cfg_file);
}
}
}
if let Ok(config_dir) = Registry::config_dir()
&& config_dir.exists()
{
match fs::remove_dir_all(&config_dir) {
Ok(()) => output::print_info("Removed global configuration directory."),
Err(e) => {
let running_inside =
std::env::current_exe().is_ok_and(|exe| exe.starts_with(&config_dir));
if cfg!(windows) && running_inside {
pending_dirs.push(config_dir);
} else {
output::print_error(&format!(
"Could not remove {}: {e}",
output::clean_path(&config_dir)
));
left_behind.push("the global configuration directory".to_string());
}
}
}
}
} else {
setup::suppress_next_auto_setup();
}
let leftover = spawn_deletion_helper(&pending_files, &pending_dirs);
if !pending_files.is_empty() || !pending_dirs.is_empty() {
if leftover.len() < pending_files.len() + pending_dirs.len() {
output::print_info(
"The running binary cannot delete itself — the rest is removed \
automatically a few seconds after this command exits.",
);
}
if !leftover.is_empty() {
report_manual_removal(&leftover);
left_behind.push("the binaries".to_string());
}
}
println!();
if left_behind.is_empty() {
output::print_success(if deep {
"Deep uninstall complete: program, integrations, configuration and registry removed."
} else {
"Uninstall complete: program and integrations removed. Configuration and \
prune history preserved for a future reinstall."
});
}
for hint in &manager_hints {
let Some(command) = hint.uninstall_command() else {
continue;
};
output::print_info(&format!(
"{} still lists dev-prune as installed — finish with `{command}` to clear \
its records.",
hint.label()
));
}
output::print_info(&format!("Reinstall any time with: {}", reinstall_hint()));
if !left_behind.is_empty() {
anyhow::bail!("Uninstall finished, but {} is still installed.", {
left_behind.join(" and ")
});
}
Ok(())
}
fn remove_binaries(
deep: bool,
manager_owned: bool,
left_behind: &mut Vec<String>,
pending_files: &mut Vec<PathBuf>,
pending_dirs: &mut Vec<PathBuf>,
) {
let mut candidates: Vec<PathBuf> = Vec::new();
let managed_bin_dir = setup::managed_bin_dir().ok();
if let Some(bin_dir) = &managed_bin_dir {
for stem in ["dev-prune", "devp"] {
candidates.push(bin_dir.join(exe_name(stem)));
}
#[cfg(windows)]
candidates.push(bin_dir.join(crate::constants::WINDOWS_HIDDEN_BIN));
}
if let Ok(current) = std::env::current_exe() {
if is_dev_build(¤t) {
output::print_info(
"This is a development build — leaving the `target/` binaries alone.",
);
} else if manager_owned {
} else if let Some(parent) = current.parent() {
for stem in ["dev-prune", "devp"] {
let twin = parent.join(exe_name(stem));
if !candidates.contains(&twin) {
candidates.push(twin);
}
}
}
}
let mut removed_any = false;
for exe in candidates {
if !exe.is_file() {
continue;
}
match fs::remove_file(&exe) {
Ok(()) => removed_any = true,
Err(e) => {
if cfg!(windows) && is_in_use_error(&e) {
pending_files.push(exe);
} else {
output::print_error(&format!(
"Could not remove {}: {e}",
output::clean_path(&exe)
));
left_behind.push("the binaries".to_string());
}
}
}
}
if removed_any {
output::print_info("Removed the dev-prune binaries.");
}
if !deep
&& let Some(bin_dir) = managed_bin_dir
&& bin_dir.is_dir()
&& fs::remove_dir(&bin_dir).is_err()
&& !pending_files.is_empty()
{
pending_dirs.push(bin_dir);
}
}
struct StrayCopy {
path: PathBuf,
channel: Channel,
}
fn sweep_stray_copies(
yes: bool,
manager_hints: &mut Vec<Channel>,
left_behind: &mut Vec<String>,
pending_files: &mut Vec<PathBuf>,
) {
let already_pending: HashSet<String> = pending_files.iter().map(|p| canon_key(p)).collect();
let strays: Vec<StrayCopy> = find_stray_copies()
.into_iter()
.filter(|s| !already_pending.contains(&canon_key(&s.path)))
.collect();
if strays.is_empty() {
return;
}
println!();
output::print_warning(&format!(
"Found {} more cop{} of dev-prune, from other install channels:",
strays.len(),
if strays.len() == 1 { "y" } else { "ies" }
));
for stray in &strays {
if stray.channel.owns_its_files() {
println!(
" {} (installed with {})",
output::clean_path(&stray.path),
stray.channel.label()
);
} else {
println!(" {}", output::clean_path(&stray.path));
}
}
if !confirm_sweep(yes) {
output::print_info(
"Left in place. Remove them yourself, or re-run `devp uninstall` any time.",
);
return;
}
let mut removed = 0usize;
for stray in strays {
if stray.channel.owns_its_files() && !manager_hints.contains(&stray.channel) {
manager_hints.push(stray.channel);
}
if stray.channel.replaces_its_directory() {
continue;
}
match fs::remove_file(&stray.path) {
Ok(()) => removed += 1,
Err(e) => {
if cfg!(windows) && is_in_use_error(&e) {
pending_files.push(stray.path);
} else {
output::print_error(&format!(
"Could not remove {}: {e}",
output::clean_path(&stray.path)
));
left_behind.push("a stray copy".to_string());
}
}
}
}
if removed > 0 {
output::print_info(&format!(
"Removed {removed} stray cop{}.",
if removed == 1 { "y" } else { "ies" }
));
}
}
fn confirm_sweep(yes: bool) -> bool {
use std::io::{IsTerminal, Write};
if yes {
return true;
}
if !std::io::stdin().is_terminal() {
output::print_info("Not running in a terminal — pass `--yes` to remove these too.");
return false;
}
eprint!("Remove them all? [y/N]: ");
if std::io::stderr().flush().is_err() {
return false;
}
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
fn find_stray_copies() -> Vec<StrayCopy> {
let managed = setup::managed_bin_dir().ok().map(|d| canon_key(&d));
let managed_exe = setup::managed_exe_path().ok();
let names = sweep_names();
let mut seen_dirs: HashSet<String> = HashSet::new();
let mut seen_files: HashSet<String> = HashSet::new();
let mut found = Vec::new();
for dir in sweep_dirs() {
let dir_key = canon_key(&dir);
if !seen_dirs.insert(dir_key.clone()) {
continue;
}
if managed.as_deref() == Some(dir_key.as_str()) {
continue;
}
for name in &names {
let candidate = dir.join(name);
let Ok(meta) = fs::symlink_metadata(&candidate) else {
continue;
};
if meta.is_dir() || is_dev_build(&candidate) {
continue;
}
if !seen_files.insert(canon_key(&candidate)) {
continue;
}
let channel = Channel::detect_at(&candidate, managed_exe.as_deref());
found.push(StrayCopy {
path: candidate,
channel,
});
}
}
found
}
fn sweep_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if let Some(path_var) = std::env::var_os("PATH") {
dirs.extend(std::env::split_paths(&path_var));
}
if setup::no_auto_setup_requested() {
if let Ok(exe) = std::env::current_exe()
&& let Some(parent) = exe.parent()
{
dirs.push(parent.to_path_buf());
}
return dirs;
}
dirs.extend(crate::channel::install_dirs(dirs::home_dir().as_deref()));
if cfg!(windows) {
if let Some(appdata) = dirs::config_dir()
&& let Ok(entries) = fs::read_dir(appdata.join("Python"))
{
for entry in entries.flatten() {
let scripts = entry.path().join("Scripts");
if scripts.is_dir() {
dirs.push(scripts);
}
}
}
}
if let Ok(exe) = std::env::current_exe()
&& let Some(parent) = exe.parent()
{
dirs.push(parent.to_path_buf());
}
dirs
}
fn sweep_names() -> Vec<String> {
let stems = ["dev-prune", "devp"];
if cfg!(windows) {
let mut names: Vec<String> = Vec::new();
for stem in stems {
for ext in ["exe", "cmd", "ps1", "bat"] {
names.push(format!("{stem}.{ext}"));
}
names.push(stem.to_string());
}
names
} else {
stems.iter().map(|s| s.to_string()).collect()
}
}
fn canon_key(path: &Path) -> String {
let key = path.to_string_lossy().replace('\\', "/");
let key = key.trim_end_matches('/').to_string();
if cfg!(windows) {
key.to_lowercase()
} else {
key
}
}
fn exe_name(stem: &str) -> String {
if cfg!(windows) {
format!("{stem}.exe")
} else {
stem.to_string()
}
}
fn is_in_use_error(e: &std::io::Error) -> bool {
matches!(e.raw_os_error(), Some(5) | Some(32))
}
fn is_dev_build(exe: &Path) -> bool {
let path = exe.to_string_lossy().replace('\\', "/");
path.contains("/target/debug/") || path.contains("/target/release/")
}
fn reinstall_hint() -> String {
if cfg!(windows) {
format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL)
} else {
format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL)
}
}
fn report_manual_removal(paths: &[PathBuf]) {
output::print_error(&format!(
"{} item(s) are still in use and could not be scheduled for removal.",
paths.len()
));
for path in paths {
let meta = fs::symlink_metadata(path).ok();
let kind = match meta.as_ref() {
Some(m) if m.is_dir() => "directory".to_string(),
Some(m) => format!("file, {}", output::format_bytes(m.len())),
None => "already gone".to_string(),
};
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| output::clean_path(path));
let parent = path
.parent()
.map(output::clean_path)
.unwrap_or_else(|| "—".to_string());
println!(" {name} ({kind})");
println!(" in {parent}");
}
println!("\n Remove them yourself with:");
for path in paths {
println!(
" Remove-Item -LiteralPath '{}' -Recurse -Force",
path.display().to_string().replace('\'', "''")
);
}
}
#[cfg(windows)]
fn ps_quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "''"))
}
#[cfg(windows)]
fn spawn_deletion_helper(files: &[PathBuf], dirs: &[PathBuf]) -> Vec<PathBuf> {
if files.is_empty() && dirs.is_empty() {
return Vec::new();
}
if spawn_powershell_helper(files, dirs) {
return Vec::new();
}
let has_percent = |p: &&PathBuf| p.to_string_lossy().contains('%');
let left_behind: Vec<PathBuf> = files
.iter()
.chain(dirs.iter())
.filter(has_percent)
.cloned()
.collect();
let safe_files: Vec<PathBuf> = files.iter().filter(|p| !has_percent(p)).cloned().collect();
let safe_dirs: Vec<PathBuf> = dirs.iter().filter(|p| !has_percent(p)).cloned().collect();
if (safe_files.is_empty() && safe_dirs.is_empty()) || spawn_cmd_helper(&safe_files, &safe_dirs)
{
left_behind
} else {
files.iter().chain(dirs.iter()).cloned().collect()
}
}
#[cfg(windows)]
fn spawn_powershell_helper(files: &[PathBuf], dirs: &[PathBuf]) -> bool {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut attempt = String::new();
for file in files {
attempt.push_str(&format!(
"Remove-Item -LiteralPath {} -Force -ErrorAction SilentlyContinue; ",
ps_quote(file)
));
}
for dir in dirs {
attempt.push_str(&format!(
"Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction SilentlyContinue; ",
ps_quote(dir)
));
}
let mut script = String::new();
for _ in 0..3 {
script.push_str("Start-Sleep -Seconds 2; ");
script.push_str(&attempt);
}
for program in [
crate::spawn::system32(r"WindowsPowerShell\v1.0\powershell.exe"),
String::from("pwsh.exe"),
String::from("pwsh-preview.exe"),
String::from("powershell.exe"),
] {
let spawned = std::process::Command::new(&program)
.args(["-NoProfile", "-NonInteractive", "-Command"])
.arg(&script)
.creation_flags(CREATE_NO_WINDOW)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok();
if spawned {
return true;
}
}
false
}
#[cfg(windows)]
fn spawn_cmd_helper(files: &[PathBuf], dirs: &[PathBuf]) -> bool {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut attempt = String::new();
for file in files {
attempt.push_str(&format!(" & del /F /Q \"{}\"", file.display()));
}
for dir in dirs {
attempt.push_str(&format!(" & rmdir /S /Q \"{}\"", dir.display()));
}
let mut script = String::new();
for _ in 0..3 {
script.push_str("ping -n 3 127.0.0.1 >nul");
script.push_str(&attempt);
script.push_str(" & ");
}
script.push_str("exit");
std::process::Command::new(crate::spawn::system32("cmd.exe"))
.raw_arg(format!("/C {script}"))
.creation_flags(CREATE_NO_WINDOW)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok()
}
#[cfg(not(windows))]
fn spawn_deletion_helper(_files: &[PathBuf], _dirs: &[PathBuf]) -> Vec<PathBuf> {
Vec::new()
}