Skip to main content

dev_prune/commands/
uninstall.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune uninstall`.
5//
6// Two modes, and both of them remove the program itself — an uninstall that leaves a
7// fully working binary on PATH is not an uninstall, it is a settings change:
8//
9// - Light (default): removes the scheduler, the Git hooks, the file-type icons, the
10//   agent skill, the PATH entry and the binaries. The config directory — registry,
11//   prune history, settings — is kept, so a later reinstall picks up where it left off.
12// - Deep (`--deep`): all of the above, plus `.devprune.json` in every registered
13//   repository and the config directory itself.
14//
15// Both modes also sweep for *other* copies of the pair — a machine that has tried
16// `pip install`, `cargo install` and the shell installer over time has binaries and
17// shims in `~/.cargo/bin`, `~/.local/bin`, npm's global directory, a venv's `Scripts`
18// — and offers to delete every one it finds, so "uninstall" means the command stops
19// resolving everywhere, not just in the managed directory.
20//
21// A copy a package manager installed is removed by *that manager's* uninstall, never
22// by deleting the file: with the file gone, `cargo uninstall dev-prune` exits 101 with
23// `corrupt metadata, ... does not exist when it should` and leaves its ledger entry
24// standing, so the command printed as the remedy can no longer succeed.
25//
26// On Windows a running executable cannot delete itself, so whatever is still in use is
27// handed to a detached PowerShell (or `cmd.exe`) helper that waits for this process to
28// exit and then deletes it. The manager commands go the same way for the same reason,
29// one step further along: while its binary is executing, `cargo uninstall` fails with
30// `Access is denied` and *also* keeps the entry, and renaming the file aside first only
31// trades that failure for the `corrupt metadata` one. Exit first, then uninstall, is
32// the only order that clears the record. That is scheduled work, not failure — the
33// command reports it and exits `0`.
34
35use anyhow::{Context, Result};
36use std::collections::HashSet;
37use std::fs;
38use std::path::{Path, PathBuf};
39
40use crate::channel::Channel;
41use crate::commands::hook;
42use crate::config::Registry;
43use crate::output;
44use crate::setup;
45
46pub fn run(deep: bool, yes: bool) -> Result<()> {
47    output::print_header(if deep {
48        "dev-prune Deep Uninstaller (Full Purge)"
49    } else {
50        "dev-prune Uninstaller"
51    });
52
53    let registry = Registry::load().ok();
54
55    // A deep uninstall deletes files inside the user's own repositories and destroys
56    // the prune history. That is not something to do on a mistyped flag.
57    if deep && !yes {
58        use std::io::{IsTerminal, Write};
59        let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
60        output::print_warning(&format!(
61            "This deletes the global config directory (including prune history) and \
62             removes `.devprune.json` from {repo_count} registered repositories."
63        ));
64        if !std::io::stdin().is_terminal() {
65            anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
66        }
67        // stderr, like every other confirmation: with stdout piped the question would
68        // vanish into the pipe and the command would appear to hang.
69        eprint!("Continue? [y/N]: ");
70        std::io::stderr().flush()?;
71        let mut input = String::new();
72        std::io::stdin().read_line(&mut input)?;
73        if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
74            output::print_info("Deep uninstall cancelled.");
75            return Ok(());
76        }
77    }
78
79    // Each step keeps going when another fails — a scheduler that refuses to uninstall
80    // must not stop the hooks being removed — but none of them is silent about it. What
81    // could not be removed is reported, and its presence makes the exit code `1`.
82    let mut left_behind: Vec<String> = Vec::new();
83    // Files and directories that are in use right now (Windows keeps a running image
84    // locked, under every one of its hard-linked names). Deleted by a detached helper
85    // the moment this process exits.
86    let mut pending_files: Vec<PathBuf> = Vec::new();
87    let mut pending_dirs: Vec<PathBuf> = Vec::new();
88
89    // `DEV_PRUNE_NO_AUTO_SETUP` means "dev-prune manages nothing on this machine" — and
90    // that has to cut both ways. If the variable stopped setup from registering a
91    // scheduler or writing into agent skill directories, then uninstall must not reach
92    // for them either: whatever is there was put there by hand (or by another install
93    // this process knows nothing about), and hands-off means hands-off. It is also what
94    // lets the test suite run this command against a real machine.
95    let hands_off = setup::no_auto_setup_requested();
96
97    // 1. Background scheduler.
98    if hands_off {
99        output::print_info(&format!(
100            "{} is set — leaving the scheduler and agent skills alone.",
101            setup::ENV_NO_AUTO_SETUP
102        ));
103    } else {
104        output::print_info("Removing background daemon scheduler...");
105        if let Err(e) = crate::daemon::uninstall_daemon() {
106            output::print_error(&format!("Background scheduler: {e:#}"));
107            left_behind.push("the background scheduler".to_string());
108        }
109    }
110
111    // 2. Global Git hooks.
112    output::print_info("Removing global Git auto-registration hooks...");
113    if let Err(e) = hook::run_uninstall() {
114        output::print_error(&format!("Git hooks: {e:#}"));
115        left_behind.push("the global Git hooks".to_string());
116    }
117
118    // 3. The `*.devprune.json` file type, out of the desktop database.
119    crate::commands::icon::unregister_file_type();
120
121    // 4. The skill installed into AI agents' own directories. Only directories named
122    // for this tool are touched — `~/.claude/skills/dev-prune/`, never a sibling — and
123    // none at all under hands-off, for the same reason as the scheduler above.
124    let skill_roots = if hands_off {
125        Vec::new()
126    } else {
127        setup::agent_skill_roots()
128    };
129    for root in skill_roots {
130        if !root.exists() {
131            continue;
132        }
133        match fs::remove_dir_all(&root) {
134            Ok(()) => output::print_info(&format!(
135                "Removed the agent skill at {}.",
136                output::clean_path(&root)
137            )),
138            Err(e) => {
139                output::print_error(&format!(
140                    "Could not remove {}: {e}",
141                    output::clean_path(&root)
142                ));
143                left_behind.push("the AI agent skill".to_string());
144            }
145        }
146    }
147
148    // 5. Reachability: the user-PATH entry on Windows, the `~/.local/bin` links
149    // elsewhere. Before the binaries go, so no window exists where PATH names a
150    // directory whose contents are gone.
151    if let Ok(bin_dir) = setup::managed_bin_dir() {
152        match crate::pathenv::remove_reachability(&bin_dir) {
153            Ok(true) => output::print_info("Removed dev-prune from your PATH."),
154            Ok(false) => {}
155            Err(e) => {
156                output::print_error(&format!("Could not update your PATH: {e:#}"));
157                left_behind.push("the PATH entry".to_string());
158            }
159        }
160    }
161
162    // 6. The binaries themselves.
163    let channel = Channel::detect();
164    remove_binaries(
165        deep,
166        channel.owns_its_files(),
167        &mut left_behind,
168        &mut pending_files,
169        &mut pending_dirs,
170    );
171
172    // 7. Every other copy on the machine. A machine that has tried more than one
173    // install channel has more than one binary, and the ones not currently first on
174    // PATH would quietly *become* the installation the moment the managed pair above
175    // is gone.
176    //
177    // The channel this binary came from needs no separate handling: the sweep looks in
178    // the running executable's own directory, so a manager-owned copy is found there
179    // like any other and removed the same way.
180    let mut manager_hints: Vec<Channel> = Vec::new();
181    let mut pending_commands: Vec<Channel> = Vec::new();
182    sweep_stray_copies(
183        yes,
184        &mut manager_hints,
185        &mut left_behind,
186        &mut pending_files,
187        &mut pending_commands,
188    );
189
190    if deep {
191        // Per-repo configs, then the config directory itself.
192        //
193        // Only the personal file. `project.devprune.json` is a tracked file somebody
194        // committed, and uninstalling a tool from one machine is not a mandate to delete
195        // a file from a shared repository -- the deletion would show up in `git status`
196        // on a branch the user never meant to touch, and reach their colleagues on the
197        // next push.
198        if let Some(reg) = registry {
199            for repo_path in reg.repositories.keys() {
200                let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
201                if cfg_file.exists() {
202                    let _ = fs::remove_file(cfg_file);
203                }
204            }
205        }
206
207        if let Ok(config_dir) = Registry::config_dir()
208            && config_dir.exists()
209        {
210            match fs::remove_dir_all(&config_dir) {
211                Ok(()) => output::print_info("Removed global configuration directory."),
212                Err(e) => {
213                    // Routine on Windows: the managed copy under `<config>/bin` is
214                    // often the very binary running this command, and a running
215                    // executable cannot be deleted. That case is finished by the
216                    // helper; anything else really is left behind.
217                    let running_inside =
218                        std::env::current_exe().is_ok_and(|exe| exe.starts_with(&config_dir));
219                    if cfg!(windows) && running_inside {
220                        pending_dirs.push(config_dir);
221                    } else {
222                        output::print_error(&format!(
223                            "Could not remove {}: {e}",
224                            output::clean_path(&config_dir)
225                        ));
226                        left_behind.push("the global configuration directory".to_string());
227                    }
228                }
229            }
230        }
231    } else {
232        // Stamp the current version so a surviving copy (a package-manager install, a
233        // dev build) does not reinstall, on its very next command, everything this
234        // command was run to remove.
235        setup::suppress_next_auto_setup();
236    }
237
238    // 8. One detached helper for everything that is in use right now. PowerShell is
239    // preferred: its single-quoted string literals are fully literal, so a path
240    // carrying `%` survives, where `cmd.exe` would expand it and a `/C` command line
241    // has no way to escape one. `cmd.exe` remains the fallback for a machine without
242    // PowerShell, and whatever neither could take is listed for manual removal.
243    let scheduled = pending_files.len() + pending_dirs.len() + pending_commands.len();
244    let (leftover, commands_scheduled) =
245        spawn_deletion_helper(&pending_files, &pending_dirs, &pending_commands);
246    if scheduled > 0 {
247        if leftover.len() + pending_commands.len() < scheduled || commands_scheduled {
248            output::print_info(
249                "The running binary cannot remove itself — the rest is finished \
250                 automatically a few seconds after this command exits.",
251            );
252        }
253        if !leftover.is_empty() {
254            report_manual_removal(&leftover);
255            left_behind.push("the binaries".to_string());
256        }
257        if !commands_scheduled {
258            // Nothing will run those uninstalls after this process exits, so the user
259            // has to. Put their managers back on the list printed below.
260            for channel in &pending_commands {
261                if !manager_hints.contains(channel) {
262                    manager_hints.push(*channel);
263                }
264            }
265        }
266    }
267
268    println!();
269    if left_behind.is_empty() {
270        output::print_success(if deep {
271            "Deep uninstall complete: program, integrations, configuration and registry removed."
272        } else {
273            "Uninstall complete: program and integrations removed. Configuration and \
274             prune history preserved for a future reinstall."
275        });
276    }
277    for hint in &manager_hints {
278        let Some(command) = hint.uninstall_command() else {
279            continue;
280        };
281        output::print_info(&format!(
282            "{} still lists dev-prune as installed — finish with `{command}` to clear \
283             its records.",
284            hint.label()
285        ));
286    }
287    output::print_info(&format!("Reinstall any time with: {}", reinstall_hint()));
288
289    if !left_behind.is_empty() {
290        anyhow::bail!("Uninstall finished, but {} is still installed.", {
291            left_behind.join(" and ")
292        });
293    }
294
295    Ok(())
296}
297
298/// Delete the managed pair, and the pair beside the running executable.
299///
300/// Skips a development build outright — deleting `target/debug/dev-prune` because a
301/// test or a contributor ran `uninstall` would destroy the build being worked on — and
302/// skips the copy beside a package-manager-owned executable, which the sweep offers to
303/// delete *with confirmation* rather than silently, because pulling files out from
304/// under a manager leaves its records dangling until its own uninstall command runs.
305fn remove_binaries(
306    deep: bool,
307    manager_owned: bool,
308    left_behind: &mut Vec<String>,
309    pending_files: &mut Vec<PathBuf>,
310    pending_dirs: &mut Vec<PathBuf>,
311) {
312    let mut candidates: Vec<PathBuf> = Vec::new();
313    let managed_bin_dir = setup::managed_bin_dir().ok();
314
315    if let Some(bin_dir) = &managed_bin_dir {
316        for stem in ["dev-prune", "devp"] {
317            candidates.push(bin_dir.join(exe_name(stem)));
318        }
319        // The windowless scheduler twin, generated beside the managed binary on Windows
320        // and nowhere else. `WINDOWS_HIDDEN_BIN` already carries its `.exe`.
321        #[cfg(windows)]
322        candidates.push(bin_dir.join(crate::constants::WINDOWS_HIDDEN_BIN));
323    }
324
325    if let Ok(current) = std::env::current_exe() {
326        if is_dev_build(&current) {
327            output::print_info(
328                "This is a development build — leaving the `target/` binaries alone.",
329            );
330        } else if manager_owned {
331            // The sweep below removes it, by running the manager's own uninstall.
332            // Deleting it here would destroy that command's precondition.
333        } else if let Some(parent) = current.parent() {
334            for stem in ["dev-prune", "devp"] {
335                let twin = parent.join(exe_name(stem));
336                if !candidates.contains(&twin) {
337                    candidates.push(twin);
338                }
339            }
340        }
341    }
342
343    let mut removed_any = false;
344    for exe in candidates {
345        if !exe.is_file() {
346            continue;
347        }
348        match fs::remove_file(&exe) {
349            Ok(()) => removed_any = true,
350            Err(e) => {
351                if cfg!(windows) && is_in_use_error(&e) {
352                    pending_files.push(exe);
353                } else {
354                    output::print_error(&format!(
355                        "Could not remove {}: {e}",
356                        output::clean_path(&exe)
357                    ));
358                    left_behind.push("the binaries".to_string());
359                }
360            }
361        }
362    }
363    if removed_any {
364        output::print_info("Removed the dev-prune binaries.");
365    }
366
367    // The install receipt describes the binary in this directory and nothing else, so it
368    // goes when that binary goes. Left behind it would outlive its subject, and it would
369    // also keep the directory below from ever being empty.
370    if let Some(bin_dir) = &managed_bin_dir {
371        let _ = fs::remove_file(bin_dir.join(crate::constants::INSTALL_RECEIPT_FILE));
372    }
373
374    // The managed `bin` directory should not outlive its contents. On a deep uninstall
375    // the whole config directory goes anyway; on a light one, remove it once empty, or
376    // let the helper do it after the pending deletions.
377    if !deep
378        && let Some(bin_dir) = managed_bin_dir
379        && bin_dir.is_dir()
380        && fs::remove_dir(&bin_dir).is_err()
381        && !pending_files.is_empty()
382    {
383        pending_dirs.push(bin_dir);
384    }
385}
386
387/// One copy of dev-prune found somewhere other than the managed directory.
388pub(crate) struct StrayCopy {
389    pub(crate) path: PathBuf,
390    pub(crate) channel: Channel,
391}
392
393/// Find every other copy of the pair, show the list, and — with the user's yes —
394/// delete them all.
395///
396/// Discovery covers every directory on this process's PATH plus the well-known install
397/// directories that are often *not* on it any more: `~/.cargo/bin`, `~/.local/bin`
398/// (uv, pipx and the XDG convention), npm's global directory, pip's per-user `Scripts`
399/// directories, and whatever directory the running executable lives in. Only files
400/// carrying the pair's own names are ever considered, so nothing else in those
401/// directories can be touched.
402///
403/// Deletion is opt-in: the list is printed and confirmed first (`--yes` counts as
404/// confirmation; a non-terminal without it leaves everything in place). A declined
405/// prompt is a decision, not a failure — it does not change the exit code.
406///
407/// What removal *means* depends on who owns the file, and there are three answers, not
408/// two. A copy in a location nothing claims is deleted outright, because the file is the
409/// whole install. A copy a manager installed is removed by that manager's own uninstall
410/// — see [`Channel::uninstall_argv`] for why deleting it directly is worse than leaving
411/// it. And a copy inside a manager dev-prune knows by name but cannot drive is named and
412/// left, which is the same reasoning with no command at the end of it.
413fn sweep_stray_copies(
414    yes: bool,
415    manager_hints: &mut Vec<Channel>,
416    left_behind: &mut Vec<String>,
417    pending_files: &mut Vec<PathBuf>,
418    pending_commands: &mut Vec<Channel>,
419) {
420    // Anything already queued for the deletion helper still exists on disk right now;
421    // finding it again here would list it as a stray and queue it twice.
422    let already_pending: HashSet<String> = pending_files.iter().map(|p| canon_key(p)).collect();
423    let strays: Vec<StrayCopy> = find_stray_copies()
424        .into_iter()
425        .filter(|s| !already_pending.contains(&canon_key(&s.path)))
426        .collect();
427    if strays.is_empty() {
428        return;
429    }
430
431    println!();
432    output::print_warning(&format!(
433        "Found {} more cop{} of dev-prune, from other install channels:",
434        strays.len(),
435        if strays.len() == 1 { "y" } else { "ies" }
436    ));
437    for stray in &strays {
438        if stray.channel.owns_its_files() {
439            println!(
440                "   {}  (installed with {})",
441                output::clean_path(&stray.path),
442                stray.channel.label()
443            );
444        } else {
445            println!("   {}", output::clean_path(&stray.path));
446        }
447    }
448
449    // Every manager whose copy turned up. Entries come off this list as each
450    // manager's own uninstall runs or is scheduled; whatever is left over is printed
451    // at the end for the user to finish by hand.
452    for stray in &strays {
453        if stray.channel.owns_its_files() && !manager_hints.contains(&stray.channel) {
454            manager_hints.push(stray.channel);
455        }
456    }
457
458    if !confirm_sweep(yes) {
459        output::print_info(
460            "Left in place. Remove them yourself, or re-run `devp uninstall` any time.",
461        );
462        return;
463    }
464
465    let running = std::env::current_exe().ok().map(|p| canon_key(&p));
466    let mut removed = 0usize;
467    for (channel, paths) in group_by_channel(strays) {
468        let Some(argv) = channel.uninstall_argv() else {
469            // Two channels have no command, and only one of them may be deleted. A
470            // `Foreign` copy is inside somebody's package tree; removing the file would
471            // leave that manager listing a binary that is gone, with no `uninstall_argv`
472            // to repair it afterwards.
473            if !channel.may_delete_directly() {
474                let who = channel.label();
475                output::print_warning(&format!(
476                    "{who} installed {} of the copies above, and dev-prune does not know \
477                     {who}'s uninstall command. Left in place: deleting the file would \
478                     leave {who} listing a binary that is gone.",
479                    paths.len()
480                ));
481                continue;
482            }
483            // Nobody holds a record of these, so the file is the whole install.
484            for path in paths {
485                match fs::remove_file(&path) {
486                    Ok(()) => removed += 1,
487                    Err(e) => {
488                        // The running executable itself is often in this list. Windows
489                        // keeps it locked; the detached helper finishes the job.
490                        if cfg!(windows) && is_in_use_error(&e) {
491                            pending_files.push(path);
492                        } else {
493                            output::print_error(&format!(
494                                "Could not remove {}: {e}",
495                                output::clean_path(&path)
496                            ));
497                            left_behind.push("a stray copy".to_string());
498                        }
499                    }
500                }
501            }
502            continue;
503        };
504
505        // Without the manager on PATH there is no way to clear its record, and deleting
506        // the file would make the record unclearable. Leaving it is the only move that
507        // keeps the machine recoverable.
508        if !crate::adapters::binary_available(&argv[0]) {
509            output::print_warning(&format!(
510                "{} is not on PATH, so its copy was left in place.",
511                channel.label()
512            ));
513            continue;
514        }
515
516        // Windows will not let a manager delete a binary that is executing, and there
517        // is no way to get around it from inside that binary — see the note at the top
518        // of this file. Scheduled, not skipped.
519        if cfg!(windows) && paths.iter().any(|p| Some(canon_key(p)) == running) {
520            pending_commands.push(channel);
521            manager_hints.retain(|c| *c != channel);
522            continue;
523        }
524
525        match run_manager_uninstall(&argv) {
526            Ok(()) => {
527                manager_hints.retain(|c| *c != channel);
528                removed += paths.len();
529                // Whatever the manager did not take is safe to delete now: its record
530                // is clear, so the file is no longer part of an install.
531                for path in paths {
532                    if fs::symlink_metadata(&path).is_ok() {
533                        let _ = fs::remove_file(&path);
534                    }
535                }
536            }
537            Err(e) => {
538                output::print_error(&format!("`{}` failed: {e:#}", argv.join(" ")));
539                left_behind.push(format!("the {} copy", channel.label()));
540            }
541        }
542    }
543    if removed > 0 {
544        output::print_info(&format!(
545            "Removed {removed} stray cop{}.",
546            if removed == 1 { "y" } else { "ies" }
547        ));
548    }
549}
550
551/// Group the strays by the manager that owns them, keeping discovery order.
552///
553/// One manager is told once, however many of its files turned up. `~/.cargo/bin` holds
554/// both `dev-prune` and `devp`, and a second `cargo uninstall dev-prune` exits 101 with
555/// "package ID specification did not match any packages" — a failure to report, from a
556/// command that had in fact already worked.
557pub(crate) fn group_by_channel(strays: Vec<StrayCopy>) -> Vec<(Channel, Vec<PathBuf>)> {
558    let mut groups: Vec<(Channel, Vec<PathBuf>)> = Vec::new();
559    for stray in strays {
560        match groups.iter_mut().find(|(c, _)| *c == stray.channel) {
561            Some((_, paths)) => paths.push(stray.path),
562            None => groups.push((stray.channel, vec![stray.path])),
563        }
564    }
565    groups
566}
567
568/// Run a package manager's own uninstall, wired to this terminal so its progress and
569/// its errors are the user's to read.
570fn run_manager_uninstall(argv: &[String]) -> Result<()> {
571    output::print_info(&format!("Running: {}", argv.join(" ")));
572    let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
573        .args(&argv[1..])
574        .status()
575        .with_context(|| format!("could not start `{}`", argv[0]))?;
576    if !status.success() {
577        anyhow::bail!("exited with {status}");
578    }
579    Ok(())
580}
581
582/// Ask before the sweep deletes anything. `--yes` answers for the user; a pipe or a
583/// script without it gets a "no" plus the flag to pass next time.
584fn confirm_sweep(yes: bool) -> bool {
585    use std::io::{IsTerminal, Write};
586    if yes {
587        return true;
588    }
589    if !std::io::stdin().is_terminal() {
590        output::print_info("Not running in a terminal — pass `--yes` to remove these too.");
591        return false;
592    }
593    // Default no, like every other deletion prompt in this tool: these files live in
594    // directories dev-prune does not manage, and a reflexive Enter should never be
595    // what deletes them. The question goes to stderr so a piped stdout cannot eat it.
596    eprint!("Remove them all? [y/N]: ");
597    if std::io::stderr().flush().is_err() {
598        return false;
599    }
600    let mut input = String::new();
601    if std::io::stdin().read_line(&mut input).is_err() {
602        return false;
603    }
604    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
605}
606
607/// Every dev-prune/devp file in the sweep directories, except the managed pair (the
608/// caller already removed it), development builds, and directories, deduplicated.
609///
610/// A dangling symlink still counts — it is exactly the kind of leftover the sweep
611/// exists to clean up — which is why this checks `symlink_metadata`, not `is_file`.
612pub(crate) fn find_stray_copies() -> Vec<StrayCopy> {
613    let managed = setup::managed_bin_dir().ok().map(|d| canon_key(&d));
614    let managed_exe = setup::managed_exe_path().ok();
615    let names = sweep_names();
616    let mut seen_dirs: HashSet<String> = HashSet::new();
617    let mut seen_files: HashSet<String> = HashSet::new();
618    let mut found = Vec::new();
619
620    for dir in sweep_dirs() {
621        let dir_key = canon_key(&dir);
622        if !seen_dirs.insert(dir_key.clone()) {
623            continue;
624        }
625        if managed.as_deref() == Some(dir_key.as_str()) {
626            continue;
627        }
628        for name in &names {
629            let candidate = dir.join(name);
630            let Ok(meta) = fs::symlink_metadata(&candidate) else {
631                continue;
632            };
633            if meta.is_dir() || is_dev_build(&candidate) {
634                continue;
635            }
636            if !seen_files.insert(canon_key(&candidate)) {
637                continue;
638            }
639            let channel = Channel::detect_at(&candidate, managed_exe.as_deref());
640            found.push(StrayCopy {
641                path: candidate,
642                channel,
643            });
644        }
645    }
646    found
647}
648
649/// The directories worth looking in: everything on PATH, plus the install directories
650/// each supported channel writes to — which stop being on PATH the moment a venv
651/// deactivates or a profile line is removed, without the files going anywhere.
652fn sweep_dirs() -> Vec<PathBuf> {
653    let mut dirs: Vec<PathBuf> = Vec::new();
654    if let Some(path_var) = std::env::var_os("PATH") {
655        dirs.extend(std::env::split_paths(&path_var));
656    }
657    // Under hands-off the sweep stays inside directories the caller's own environment
658    // names. `PATH` is the caller's to shape; the home-derived extras below are this
659    // code guessing at install locations, which is exactly the reaching-around that
660    // `DEV_PRUNE_NO_AUTO_SETUP` turns off — and what keeps the test suite out of the
661    // developer's real `~/.cargo/bin`.
662    if setup::no_auto_setup_requested() {
663        if let Ok(exe) = std::env::current_exe()
664            && let Some(parent) = exe.parent()
665        {
666            dirs.push(parent.to_path_buf());
667        }
668        return dirs;
669    }
670    dirs.extend(crate::channel::install_dirs(dirs::home_dir().as_deref()));
671    if cfg!(windows) {
672        // `config_dir` is %APPDATA% — pip's per-user scripts live under it, one
673        // directory per interpreter version, so they have to be enumerated rather than
674        // named.
675        if let Some(appdata) = dirs::config_dir()
676            && let Ok(entries) = fs::read_dir(appdata.join("Python"))
677        {
678            for entry in entries.flatten() {
679                let scripts = entry.path().join("Scripts");
680                if scripts.is_dir() {
681                    dirs.push(scripts);
682                }
683            }
684        }
685    }
686    if let Ok(exe) = std::env::current_exe()
687        && let Some(parent) = exe.parent()
688    {
689        dirs.push(parent.to_path_buf());
690    }
691    dirs
692}
693
694/// The file names one of the pair can appear under. On Windows that is more than the
695/// two `.exe`s: npm writes `.cmd` and `.ps1` shims plus an extensionless sh shim for
696/// Git Bash, and each is a separate file to delete.
697fn sweep_names() -> Vec<String> {
698    let stems = ["dev-prune", "devp"];
699    if cfg!(windows) {
700        let mut names: Vec<String> = Vec::new();
701        for stem in stems {
702            // `exe.old` is dev-prune's own debris: an update renames the running binary
703            // aside so the channel can write a fresh one at the real name, and the
704            // delete that follows is best-effort because the file is still the running
705            // image. The next update sweeps it -- but somebody who updates once and then
706            // uninstalls never has a next update, and the orphan outlives the install.
707            for ext in ["exe", "cmd", "ps1", "bat", "exe.old"] {
708                names.push(format!("{stem}.{ext}"));
709            }
710            names.push(stem.to_string());
711        }
712        names
713    } else {
714        stems.iter().map(|s| s.to_string()).collect()
715    }
716}
717
718/// One canonical string per path, so `C:\X\Bin\` and `c:\x\bin` count once.
719pub(crate) fn canon_key(path: &Path) -> String {
720    let key = path.to_string_lossy().replace('\\', "/");
721    let key = key.trim_end_matches('/').to_string();
722    if cfg!(windows) {
723        key.to_lowercase()
724    } else {
725        key
726    }
727}
728
729/// The on-disk file name for one of the pair, on this platform.
730fn exe_name(stem: &str) -> String {
731    if cfg!(windows) {
732        format!("{stem}.exe")
733    } else {
734        stem.to_string()
735    }
736}
737
738/// Whether a deletion failure means "in use right now" — the one case the detached
739/// helper can finish. 5 is ERROR_ACCESS_DENIED, which is what deleting the running
740/// image reports; 32 is ERROR_SHARING_VIOLATION. Anything else (read-only media, a
741/// policy block) the helper would only inherit, so it is reported instead of queued.
742fn is_in_use_error(e: &std::io::Error) -> bool {
743    matches!(e.raw_os_error(), Some(5) | Some(32))
744}
745
746/// Whether this executable is running out of a Cargo build directory.
747fn is_dev_build(exe: &Path) -> bool {
748    let path = exe.to_string_lossy().replace('\\', "/");
749    path.contains("/target/debug/") || path.contains("/target/release/")
750}
751
752/// The install one-liner for this platform, for the goodbye message.
753fn reinstall_hint() -> String {
754    if cfg!(windows) {
755        format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL)
756    } else {
757        format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL)
758    }
759}
760
761/// List what could not be scheduled, with enough detail to act on, plus the command
762/// that removes it.
763///
764/// The stray-copy sweep lists every path before it deletes anything; this is the same
765/// courtesy for the residue. "Some files could not be removed" leaves someone hunting
766/// through Program Files for a name they were never told, so each line carries the
767/// name, the directory it sits in, what kind of thing it is and how big it is.
768fn report_manual_removal(paths: &[PathBuf]) {
769    output::print_error(&format!(
770        "{} item(s) are still in use and could not be scheduled for removal.",
771        paths.len()
772    ));
773    for path in paths {
774        let meta = fs::symlink_metadata(path).ok();
775        let kind = match meta.as_ref() {
776            Some(m) if m.is_dir() => "directory".to_string(),
777            Some(m) => format!("file, {}", output::format_bytes(m.len())),
778            None => "already gone".to_string(),
779        };
780        let name = path
781            .file_name()
782            .map(|n| n.to_string_lossy().into_owned())
783            .unwrap_or_else(|| output::clean_path(path));
784        let parent = path
785            .parent()
786            .map(output::clean_path)
787            .unwrap_or_else(|| "—".to_string());
788        println!("    {name}  ({kind})");
789        println!("      in {parent}");
790    }
791    println!("\n  Remove them yourself with:");
792    for path in paths {
793        // `-LiteralPath` and single quotes, because these are exactly the paths whose
794        // `%` the fallback could not survive — the command printed here has to be one
795        // that can be pasted verbatim.
796        println!(
797            "    Remove-Item -LiteralPath '{}' -Recurse -Force",
798            path.display().to_string().replace('\'', "''")
799        );
800    }
801}
802
803/// Quote a path as a PowerShell single-quoted string literal.
804///
805/// Inside single quotes PowerShell expands nothing at all — not `$var`, not a backtick
806/// escape, and crucially not `%VAR%`. The only character with meaning is the closing
807/// quote, and doubling it is the documented way to write a literal one. That makes this
808/// a complete escape rule for an arbitrary path, which is exactly what `cmd /C` could
809/// not offer.
810#[cfg(windows)]
811fn ps_quote(path: &Path) -> String {
812    format!("'{}'", path.display().to_string().replace('\'', "''"))
813}
814
815/// Schedule the work that cannot happen until this process exits: deleting the files
816/// Windows has locked, and running the uninstalls of the managers that own them.
817///
818/// Returns the paths that could not be handed over — empty in the normal case — and
819/// whether the manager commands were handed over at all. A caller that gets `false`
820/// must tell the user to run them, because nothing else now will.
821///
822/// PowerShell rather than `cmd.exe`, because `cmd` expands `%VAR%` even inside double
823/// quotes and a `/C` command line has no escape for a literal `%`. A path carrying one
824/// therefore could not be passed at all: it used to be reported and left on disk. The
825/// `cmd` route survives only as the fallback for a machine where PowerShell cannot be
826/// launched, and there the old restriction still applies.
827#[cfg(windows)]
828fn spawn_deletion_helper(
829    files: &[PathBuf],
830    dirs: &[PathBuf],
831    channels: &[Channel],
832) -> (Vec<PathBuf>, bool) {
833    if files.is_empty() && dirs.is_empty() && channels.is_empty() {
834        return (Vec::new(), true);
835    }
836    if spawn_powershell_helper(files, dirs, channels) {
837        return (Vec::new(), true);
838    }
839
840    // Fallback. `cmd` cannot be given a literal `%`, so those paths stay behind and are
841    // returned for the caller to report.
842    let has_percent = |p: &&PathBuf| p.to_string_lossy().contains('%');
843    let left_behind: Vec<PathBuf> = files
844        .iter()
845        .chain(dirs.iter())
846        .filter(has_percent)
847        .cloned()
848        .collect();
849    let safe_files: Vec<PathBuf> = files.iter().filter(|p| !has_percent(p)).cloned().collect();
850    let safe_dirs: Vec<PathBuf> = dirs.iter().filter(|p| !has_percent(p)).cloned().collect();
851
852    // The manager commands are fixed argv of ASCII words, so `%` is not a question for
853    // them; they ride along whenever `cmd` starts at all.
854    if (safe_files.is_empty() && safe_dirs.is_empty() && channels.is_empty())
855        || spawn_cmd_helper(&safe_files, &safe_dirs, channels)
856    {
857        (left_behind, true)
858    } else {
859        (files.iter().chain(dirs.iter()).cloned().collect(), false)
860    }
861}
862
863/// The manager uninstalls, as one PowerShell statement each.
864///
865/// `&` because these are native commands, not cmdlets, and the call operator is what
866/// makes PowerShell run a quoted string as one. Output goes nowhere: the helper has no
867/// console to write to, and a failure here is already covered by the retries.
868#[cfg(windows)]
869fn ps_manager_commands(channels: &[Channel]) -> String {
870    let mut out = String::new();
871    for channel in channels {
872        let Some(argv) = channel.uninstall_argv() else {
873            continue;
874        };
875        out.push_str("& ");
876        for token in &argv {
877            out.push_str(&ps_quote(Path::new(token)));
878            out.push(' ');
879        }
880        out.push_str("*> $null; ");
881    }
882    out
883}
884
885/// The PowerShell form of the retry loop. `true` if the helper was launched.
886#[cfg(windows)]
887fn spawn_powershell_helper(files: &[PathBuf], dirs: &[PathBuf], channels: &[Channel]) -> bool {
888    use std::os::windows::process::CommandExt;
889    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
890
891    let mut attempt = String::new();
892    for file in files {
893        attempt.push_str(&format!(
894            "Remove-Item -LiteralPath {} -Force -ErrorAction SilentlyContinue; ",
895            ps_quote(file)
896        ));
897    }
898    for dir in dirs {
899        attempt.push_str(&format!(
900            "Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction SilentlyContinue; ",
901            ps_quote(dir)
902        ));
903    }
904    // Last in the round: a manager whose binary is still locked would fail with `Access
905    // is denied` and keep its record, which is the whole reason this is deferred, so it
906    // gets every attempt the deletions get rather than one shot at the end.
907    attempt.push_str(&ps_manager_commands(channels));
908
909    // Three attempts, two seconds apart — the same reasoning as the `cmd` loop below.
910    let mut script = String::new();
911    for _ in 0..3 {
912        script.push_str("Start-Sleep -Seconds 2; ");
913        script.push_str(&attempt);
914    }
915
916    // Windows PowerShell 5.1 ships with every supported Windows and lives at a fixed
917    // place, so it is tried by absolute path first. The rest cover the machines where
918    // it does not answer — Nano Server, an image built without the Windows PowerShell
919    // feature, or a policy that blocks the inbox copy while permitting PowerShell 7 —
920    // and those are found through `PATH`, because 7.x installs beside its own major
921    // version rather than into `System32`.
922    for program in [
923        crate::spawn::system32(r"WindowsPowerShell\v1.0\powershell.exe"),
924        String::from("pwsh.exe"),
925        String::from("pwsh-preview.exe"),
926        String::from("powershell.exe"),
927    ] {
928        let spawned = std::process::Command::new(&program)
929            .args(["-NoProfile", "-NonInteractive", "-Command"])
930            .arg(&script)
931            .creation_flags(CREATE_NO_WINDOW)
932            .stdin(std::process::Stdio::null())
933            .stdout(std::process::Stdio::null())
934            .stderr(std::process::Stdio::null())
935            .spawn()
936            .is_ok();
937        if spawned {
938            return true;
939        }
940    }
941    false
942}
943
944/// The original `cmd.exe` form, kept as the fallback. Callers must have filtered out
945/// any path containing `%` before calling this.
946#[cfg(windows)]
947fn spawn_cmd_helper(files: &[PathBuf], dirs: &[PathBuf], channels: &[Channel]) -> bool {
948    use std::os::windows::process::CommandExt;
949    // Not in windows-sys's prelude of imported constants anywhere else in this crate;
950    // documented value of CREATE_NO_WINDOW.
951    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
952
953    // Three attempts, two seconds apart. One would cover the normal case — this
954    // process exits the moment the command returns, releasing the image lock — but a
955    // slow exit, an antivirus scan hooked on process teardown, or the user running
956    // `devp` again inside the first window would otherwise leave the binary behind
957    // with nothing ever retrying. `cmd /C` cannot use labels, so the loop is unrolled.
958    let mut attempt = String::new();
959    for file in files {
960        attempt.push_str(&format!(" & del /F /Q \"{}\"", file.display()));
961    }
962    for dir in dirs {
963        attempt.push_str(&format!(" & rmdir /S /Q \"{}\"", dir.display()));
964    }
965    for channel in channels {
966        if let Some(argv) = channel.uninstall_argv() {
967            attempt.push_str(&format!(" & {} >nul 2>&1", argv.join(" ")));
968        }
969    }
970    let mut script = String::new();
971    for _ in 0..3 {
972        script.push_str("ping -n 3 127.0.0.1 >nul");
973        script.push_str(&attempt);
974        script.push_str(" & ");
975    }
976    script.push_str("exit");
977
978    std::process::Command::new(crate::spawn::system32("cmd.exe"))
979        // `raw_arg`, because std's quoting would wrap the whole script in quotes and
980        // `cmd /C` would then treat it as one file name rather than a command line.
981        .raw_arg(format!("/C {script}"))
982        .creation_flags(CREATE_NO_WINDOW)
983        .stdin(std::process::Stdio::null())
984        .stdout(std::process::Stdio::null())
985        .stderr(std::process::Stdio::null())
986        .spawn()
987        .is_ok()
988}
989
990/// Hand one manager's uninstall to the helper that runs after this process exits.
991///
992/// `false` if nothing could be scheduled, in which case the caller has to tell the user
993/// to run the command themselves. Windows only: everywhere else a manager can uninstall
994/// a binary that is running, so there is nothing to defer.
995#[cfg(windows)]
996pub(crate) fn schedule_manager_uninstall(channel: Channel) -> bool {
997    spawn_deletion_helper(&[], &[], &[channel]).1
998}
999
1000/// On Unix an open file can be unlinked and a package manager can uninstall a binary
1001/// that is running, so nothing ever needs scheduling; this exists so the call site
1002/// compiles unconditionally and is unreachable in practice.
1003#[cfg(not(windows))]
1004fn spawn_deletion_helper(
1005    _files: &[PathBuf],
1006    _dirs: &[PathBuf],
1007    _channels: &[Channel],
1008) -> (Vec<PathBuf>, bool) {
1009    (Vec::new(), true)
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015
1016    /// The sweep has to know the name the update leaves behind, or dev-prune's own
1017    /// debris outlives the uninstall that reported it gone.
1018    #[test]
1019    #[cfg(windows)]
1020    fn the_sweep_looks_for_the_file_an_update_renames_aside() {
1021        let names = sweep_names();
1022        assert!(names.contains(&"devp.exe.old".to_string()), "{names:?}");
1023        assert!(
1024            names.contains(&"dev-prune.exe.old".to_string()),
1025            "{names:?}"
1026        );
1027    }
1028
1029    /// Only Windows needs the rename-aside, so only Windows grows the extra name.
1030    #[test]
1031    #[cfg(not(windows))]
1032    fn elsewhere_the_sweep_is_just_the_two_stems() {
1033        assert_eq!(sweep_names(), ["dev-prune", "devp"]);
1034    }
1035}