use anyhow::{Context, 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();
let mut pending_commands: Vec<Channel> = Vec::new();
sweep_stray_copies(
yes,
&mut manager_hints,
&mut left_behind,
&mut pending_files,
&mut pending_commands,
);
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();
setup::clear_setup_consent();
}
let (residue, still_installed) = finish_locked_removals(&pending_files, &pending_dirs);
if !residue.is_empty() {
report_manual_removal(&residue);
} else if !pending_files.is_empty() || !pending_dirs.is_empty() {
output::print_info(
"The binary running this command cannot delete itself. Its name is free \
again, and Windows removes what is left of the file at the next restart.",
);
}
if still_installed {
left_behind.push("the binaries".to_string());
}
for channel in &pending_commands {
if !manager_hints.contains(channel) {
manager_hints.push(*channel);
}
}
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_WINDOWLESS_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 let Some(bin_dir) = &managed_bin_dir {
let _ = fs::remove_file(bin_dir.join(crate::constants::INSTALL_RECEIPT_FILE));
}
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);
}
}
pub(crate) struct StrayCopy {
pub(crate) path: PathBuf,
pub(crate) channel: Channel,
}
fn sweep_stray_copies(
yes: bool,
manager_hints: &mut Vec<Channel>,
left_behind: &mut Vec<String>,
pending_files: &mut Vec<PathBuf>,
pending_commands: &mut Vec<Channel>,
) {
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));
}
}
for stray in &strays {
if stray.channel.owns_its_files() && !manager_hints.contains(&stray.channel) {
manager_hints.push(stray.channel);
}
}
if !confirm_sweep(yes) {
output::print_info(
"Left in place. Remove them yourself, or re-run `devp uninstall` any time.",
);
return;
}
let running = std::env::current_exe().ok().map(|p| canon_key(&p));
let mut removed = 0usize;
for (channel, paths) in group_by_channel(strays) {
let Some(argv) = channel.uninstall_argv() else {
if !channel.may_delete_directly() {
let who = channel.label();
output::print_warning(&format!(
"{who} installed {} of the copies above, and dev-prune does not know \
{who}'s uninstall command. Left in place: deleting the file would \
leave {who} listing a binary that is gone.",
paths.len()
));
continue;
}
for path in paths {
match fs::remove_file(&path) {
Ok(()) => removed += 1,
Err(e) => {
if cfg!(windows) && is_in_use_error(&e) {
pending_files.push(path);
} else {
output::print_error(&format!(
"Could not remove {}: {e}",
output::clean_path(&path)
));
left_behind.push("a stray copy".to_string());
}
}
}
}
continue;
};
if !crate::adapters::binary_available(&argv[0]) {
output::print_warning(&format!(
"{} is not on PATH, so its copy was left in place.",
channel.label()
));
continue;
}
if cfg!(windows) && paths.iter().any(|p| Some(canon_key(p)) == running) {
pending_commands.push(channel);
manager_hints.retain(|c| *c != channel);
continue;
}
match run_manager_uninstall(&argv) {
Ok(()) => {
manager_hints.retain(|c| *c != channel);
removed += paths.len();
for path in paths {
if fs::symlink_metadata(&path).is_ok() {
let _ = fs::remove_file(&path);
}
}
}
Err(e) => {
output::print_error(&format!("`{}` failed: {e:#}", argv.join(" ")));
left_behind.push(format!("the {} copy", channel.label()));
}
}
}
if removed > 0 {
output::print_info(&format!(
"Removed {removed} stray cop{}.",
if removed == 1 { "y" } else { "ies" }
));
}
}
pub(crate) fn group_by_channel(strays: Vec<StrayCopy>) -> Vec<(Channel, Vec<PathBuf>)> {
let mut groups: Vec<(Channel, Vec<PathBuf>)> = Vec::new();
for stray in strays {
match groups.iter_mut().find(|(c, _)| *c == stray.channel) {
Some((_, paths)) => paths.push(stray.path),
None => groups.push((stray.channel, vec![stray.path])),
}
}
groups
}
fn run_manager_uninstall(argv: &[String]) -> Result<()> {
output::print_info(&format!("Running: {}", argv.join(" ")));
let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
.args(&argv[1..])
.status()
.with_context(|| format!("could not start `{}`", argv[0]))?;
if !status.success() {
anyhow::bail!("exited with {status}");
}
Ok(())
}
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")
}
pub(crate) 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", "devpw"];
if cfg!(windows) {
let mut names: Vec<String> = Vec::new();
for stem in stems {
for ext in ["exe", "cmd", "ps1", "bat", "exe.old", "new"] {
names.push(format!("{stem}.{ext}"));
}
names.push(stem.to_string());
}
names
} else {
stems
.iter()
.flat_map(|s| [s.to_string(), format!("{s}.new")])
.collect()
}
}
pub(crate) 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))
}
pub(crate) fn is_dev_build(exe: &Path) -> bool {
let path = exe.to_string_lossy().replace('\\', "/");
let parts: Vec<&str> = path.split('/').collect();
parts.iter().position(|p| *p == "target").is_some_and(|t| {
parts[t + 1..parts.len().saturating_sub(1)]
.iter()
.any(|p| *p == "debug" || *p == "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_warning(&format!(
"{} item(s) were open in this process and could not be removed from inside it. \
Delete them once this command has exited, or restart — from an elevated shell \
Windows would have taken them at the next boot on its own.",
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)]
enum Retired {
Handled,
Aside(PathBuf),
Stuck(PathBuf),
}
#[cfg(windows)]
fn finish_locked_removals(files: &[PathBuf], dirs: &[PathBuf]) -> (Vec<PathBuf>, bool) {
let mut residue: Vec<PathBuf> = Vec::new();
let mut stuck = false;
for file in files {
match retire_file(file) {
Retired::Handled => {}
Retired::Aside(path) => residue.push(path),
Retired::Stuck(path) => {
residue.push(path);
stuck = true;
}
}
}
for dir in dirs {
stuck |= purge_tree(dir, &mut residue);
}
(residue, stuck)
}
#[cfg(windows)]
fn aside_name(path: &Path) -> PathBuf {
match path.extension() {
Some(ext) => path.with_extension(format!("{}.old", ext.to_string_lossy())),
None => path.with_extension("old"),
}
}
#[cfg(windows)]
fn retire_file(path: &Path) -> Retired {
if fs::remove_file(path).is_ok() || fs::symlink_metadata(path).is_err() {
return Retired::Handled;
}
let aside = aside_name(path);
let Ok(()) = fs::rename(path, &aside) else {
return if delete_on_reboot(path) {
Retired::Handled
} else {
Retired::Stuck(path.to_path_buf())
};
};
if delete_on_reboot(&aside) {
Retired::Handled
} else {
Retired::Aside(aside)
}
}
#[cfg(windows)]
fn purge_tree(dir: &Path, residue: &mut Vec<PathBuf>) -> bool {
if fs::remove_dir_all(dir).is_ok() || fs::symlink_metadata(dir).is_err() {
return false;
}
let Ok(entries) = fs::read_dir(dir) else {
residue.push(dir.to_path_buf());
return false;
};
let mut stuck = false;
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(t) if t.is_symlink() => {
if fs::remove_file(&path).is_err() && fs::remove_dir(&path).is_err() {
residue.push(path);
}
}
Ok(t) if t.is_dir() => stuck |= purge_tree(&path, residue),
_ => match retire_file(&path) {
Retired::Handled => {}
Retired::Aside(left) => residue.push(left),
Retired::Stuck(left) => {
residue.push(left);
stuck = true;
}
},
}
}
if fs::remove_dir(dir).is_ok() {
return stuck;
}
if !delete_on_reboot(dir) {
residue.push(dir.to_path_buf());
}
stuck
}
#[cfg(windows)]
fn delete_on_reboot(path: &Path) -> bool {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_DELAY_UNTIL_REBOOT, MoveFileExW};
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe { MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0 }
}
#[cfg(not(windows))]
fn finish_locked_removals(_files: &[PathBuf], _dirs: &[PathBuf]) -> (Vec<PathBuf>, bool) {
(Vec::new(), false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_profile_directory_under_any_target_subdirectory_is_a_dev_build() {
for dev in [
"/repo/target/debug/dev-prune",
"/repo/target/release/devp",
"/repo/target/x86_64-pc-windows-msvc/release/dev-prune.exe",
"/repo/target/llvm-cov-target/debug/dev-prune",
"C:\\repo\\target\\llvm-cov-target\\debug\\dev-prune.exe",
] {
assert!(is_dev_build(Path::new(dev)), "{dev} was not a dev build");
}
for installed in [
"/home/u/.cargo/bin/dev-prune",
"/opt/release/dev-prune",
"/home/u/target/dev-prune",
"/home/u/target/debug",
] {
assert!(
!is_dev_build(Path::new(installed)),
"{installed} was a dev build"
);
}
}
#[test]
#[cfg(windows)]
fn the_sweep_looks_for_the_file_an_update_renames_aside() {
let names = sweep_names();
assert!(names.contains(&"devp.exe.old".to_string()), "{names:?}");
assert!(
names.contains(&"dev-prune.exe.old".to_string()),
"{names:?}"
);
}
#[test]
#[cfg(not(windows))]
fn elsewhere_the_sweep_is_the_stems_and_their_staging_names() {
assert_eq!(
sweep_names(),
[
"dev-prune",
"dev-prune.new",
"devp",
"devp.new",
"devpw",
"devpw.new"
]
);
}
#[test]
#[cfg(windows)]
fn the_name_a_locked_binary_is_renamed_to_is_one_the_sweep_looks_for() {
let aside = aside_name(Path::new(r"C:\bin\devp.exe"));
assert_eq!(aside.file_name().unwrap(), "devp.exe.old");
assert!(sweep_names().contains(&"devp.exe.old".to_string()));
assert_eq!(
aside_name(Path::new(r"C:\bin\devp")).file_name().unwrap(),
"devp.old"
);
}
#[test]
#[cfg(windows)]
fn an_unlocked_file_is_simply_deleted_and_no_residue_is_reported() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("devp.exe");
std::fs::write(&file, b"binary").unwrap();
let (residue, still_installed) = finish_locked_removals(std::slice::from_ref(&file), &[]);
assert!(residue.is_empty(), "{residue:?}");
assert!(!still_installed);
assert!(!file.exists());
assert!(
!aside_name(&file).exists(),
"nothing should be renamed aside"
);
}
#[test]
#[cfg(windows)]
fn a_tree_with_nothing_locked_in_it_goes_completely() {
let root = tempfile::tempdir().unwrap();
let config = root.path().join("dev-prune");
std::fs::create_dir_all(config.join("bin")).unwrap();
std::fs::write(config.join("bin").join("devp.exe"), b"binary").unwrap();
std::fs::write(config.join("registry.json"), b"{}").unwrap();
let (residue, still_installed) = finish_locked_removals(&[], std::slice::from_ref(&config));
assert!(residue.is_empty(), "{residue:?}");
assert!(!still_installed);
assert!(!config.exists());
}
#[test]
#[cfg(windows)]
fn a_path_that_is_already_gone_is_not_reported() {
let root = tempfile::tempdir().unwrap();
let missing = root.path().join("never-existed");
let (residue, still_installed) =
finish_locked_removals(&[missing.join("devp.exe")], &[missing]);
assert!(residue.is_empty(), "{residue:?}");
assert!(!still_installed);
}
}