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();
}
let scheduled = pending_files.len() + pending_dirs.len() + pending_commands.len();
let (leftover, commands_scheduled) =
spawn_deletion_helper(&pending_files, &pending_dirs, &pending_commands);
if scheduled > 0 {
if leftover.len() + pending_commands.len() < scheduled || commands_scheduled {
output::print_info(
"The running binary cannot remove itself — the rest is finished \
automatically a few seconds after this command exits.",
);
}
if !leftover.is_empty() {
report_manual_removal(&leftover);
left_behind.push("the binaries".to_string());
}
if !commands_scheduled {
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_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 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"];
if cfg!(windows) {
let mut names: Vec<String> = Vec::new();
for stem in stems {
for ext in ["exe", "cmd", "ps1", "bat", "exe.old"] {
names.push(format!("{stem}.{ext}"));
}
names.push(stem.to_string());
}
names
} else {
stems.iter().map(|s| s.to_string()).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))
}
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],
channels: &[Channel],
) -> (Vec<PathBuf>, bool) {
if files.is_empty() && dirs.is_empty() && channels.is_empty() {
return (Vec::new(), true);
}
if spawn_powershell_helper(files, dirs, channels) {
return (Vec::new(), true);
}
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() && channels.is_empty())
|| spawn_cmd_helper(&safe_files, &safe_dirs, channels)
{
(left_behind, true)
} else {
(files.iter().chain(dirs.iter()).cloned().collect(), false)
}
}
#[cfg(windows)]
fn ps_manager_commands(channels: &[Channel]) -> String {
let mut out = String::new();
for channel in channels {
let Some(argv) = channel.uninstall_argv() else {
continue;
};
out.push_str("& ");
for token in &argv {
out.push_str(&ps_quote(Path::new(token)));
out.push(' ');
}
out.push_str("*> $null; ");
}
out
}
#[cfg(windows)]
fn spawn_powershell_helper(files: &[PathBuf], dirs: &[PathBuf], channels: &[Channel]) -> 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)
));
}
attempt.push_str(&ps_manager_commands(channels));
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], channels: &[Channel]) -> 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()));
}
for channel in channels {
if let Some(argv) = channel.uninstall_argv() {
attempt.push_str(&format!(" & {} >nul 2>&1", argv.join(" ")));
}
}
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(windows)]
pub(crate) fn schedule_manager_uninstall(channel: Channel) -> bool {
spawn_deletion_helper(&[], &[], &[channel]).1
}
#[cfg(not(windows))]
fn spawn_deletion_helper(
_files: &[PathBuf],
_dirs: &[PathBuf],
_channels: &[Channel],
) -> (Vec<PathBuf>, bool) {
(Vec::new(), true)
}
#[cfg(test)]
mod tests {
use super::*;
#[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_just_the_two_stems() {
assert_eq!(sweep_names(), ["dev-prune", "devp"]);
}
}