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// On Windows a running executable cannot delete itself, so whatever is still in use is
22// handed to a detached PowerShell (or `cmd.exe`) helper that waits for this process to
23// exit and then deletes it. That is scheduled work, not failure — the command
24// reports it and exits `0`.
25
26use anyhow::Result;
27use std::collections::HashSet;
28use std::fs;
29use std::path::{Path, PathBuf};
30
31use crate::channel::Channel;
32use crate::commands::hook;
33use crate::config::Registry;
34use crate::output;
35use crate::setup;
36
37pub fn run(deep: bool, yes: bool) -> Result<()> {
38    output::print_header(if deep {
39        "dev-prune Deep Uninstaller (Full Purge)"
40    } else {
41        "dev-prune Uninstaller"
42    });
43
44    let registry = Registry::load().ok();
45
46    // A deep uninstall deletes files inside the user's own repositories and destroys
47    // the prune history. That is not something to do on a mistyped flag.
48    if deep && !yes {
49        use std::io::{IsTerminal, Write};
50        let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
51        output::print_warning(&format!(
52            "This deletes the global config directory (including prune history) and \
53             removes `.devprune.json` from {repo_count} registered repositories."
54        ));
55        if !std::io::stdin().is_terminal() {
56            anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
57        }
58        // stderr, like every other confirmation: with stdout piped the question would
59        // vanish into the pipe and the command would appear to hang.
60        eprint!("Continue? [y/N]: ");
61        std::io::stderr().flush()?;
62        let mut input = String::new();
63        std::io::stdin().read_line(&mut input)?;
64        if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
65            output::print_info("Deep uninstall cancelled.");
66            return Ok(());
67        }
68    }
69
70    // Each step keeps going when another fails — a scheduler that refuses to uninstall
71    // must not stop the hooks being removed — but none of them is silent about it. What
72    // could not be removed is reported, and its presence makes the exit code `1`.
73    let mut left_behind: Vec<String> = Vec::new();
74    // Files and directories that are in use right now (Windows keeps a running image
75    // locked, under every one of its hard-linked names). Deleted by a detached helper
76    // the moment this process exits.
77    let mut pending_files: Vec<PathBuf> = Vec::new();
78    let mut pending_dirs: Vec<PathBuf> = Vec::new();
79
80    // `DEV_PRUNE_NO_AUTO_SETUP` means "dev-prune manages nothing on this machine" — and
81    // that has to cut both ways. If the variable stopped setup from registering a
82    // scheduler or writing into agent skill directories, then uninstall must not reach
83    // for them either: whatever is there was put there by hand (or by another install
84    // this process knows nothing about), and hands-off means hands-off. It is also what
85    // lets the test suite run this command against a real machine.
86    let hands_off = setup::no_auto_setup_requested();
87
88    // 1. Background scheduler.
89    if hands_off {
90        output::print_info(&format!(
91            "{} is set — leaving the scheduler and agent skills alone.",
92            setup::ENV_NO_AUTO_SETUP
93        ));
94    } else {
95        output::print_info("Removing background daemon scheduler...");
96        if let Err(e) = crate::daemon::uninstall_daemon() {
97            output::print_error(&format!("Background scheduler: {e:#}"));
98            left_behind.push("the background scheduler".to_string());
99        }
100    }
101
102    // 2. Global Git hooks.
103    output::print_info("Removing global Git auto-registration hooks...");
104    if let Err(e) = hook::run_uninstall() {
105        output::print_error(&format!("Git hooks: {e:#}"));
106        left_behind.push("the global Git hooks".to_string());
107    }
108
109    // 3. The `*.devprune.json` file type, out of the desktop database.
110    crate::commands::icon::unregister_file_type();
111
112    // 4. The skill installed into AI agents' own directories. Only directories named
113    // for this tool are touched — `~/.claude/skills/dev-prune/`, never a sibling — and
114    // none at all under hands-off, for the same reason as the scheduler above.
115    let skill_roots = if hands_off {
116        Vec::new()
117    } else {
118        setup::agent_skill_roots()
119    };
120    for root in skill_roots {
121        if !root.exists() {
122            continue;
123        }
124        match fs::remove_dir_all(&root) {
125            Ok(()) => output::print_info(&format!(
126                "Removed the agent skill at {}.",
127                output::clean_path(&root)
128            )),
129            Err(e) => {
130                output::print_error(&format!(
131                    "Could not remove {}: {e}",
132                    output::clean_path(&root)
133                ));
134                left_behind.push("the AI agent skill".to_string());
135            }
136        }
137    }
138
139    // 5. Reachability: the user-PATH entry on Windows, the `~/.local/bin` links
140    // elsewhere. Before the binaries go, so no window exists where PATH names a
141    // directory whose contents are gone.
142    if let Ok(bin_dir) = setup::managed_bin_dir() {
143        match crate::pathenv::remove_reachability(&bin_dir) {
144            Ok(true) => output::print_info("Removed dev-prune from your PATH."),
145            Ok(false) => {}
146            Err(e) => {
147                output::print_error(&format!("Could not update your PATH: {e:#}"));
148                left_behind.push("the PATH entry".to_string());
149            }
150        }
151    }
152
153    // 6. The binaries themselves.
154    let channel = Channel::detect();
155    remove_binaries(
156        deep,
157        channel.owns_its_files(),
158        &mut left_behind,
159        &mut pending_files,
160        &mut pending_dirs,
161    );
162
163    // 7. Every other copy on the machine. A machine that has tried more than one
164    // install channel has more than one binary, and the ones not currently first on
165    // PATH would quietly *become* the installation the moment the managed pair above
166    // is gone.
167    let mut manager_hints: Vec<Channel> = Vec::new();
168    if channel.owns_its_files() {
169        manager_hints.push(channel);
170    }
171    sweep_stray_copies(
172        yes,
173        &mut manager_hints,
174        &mut left_behind,
175        &mut pending_files,
176    );
177
178    if deep {
179        // Per-repo configs, then the config directory itself.
180        if let Some(reg) = registry {
181            for repo_path in reg.repositories.keys() {
182                let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
183                if cfg_file.exists() {
184                    let _ = fs::remove_file(cfg_file);
185                }
186            }
187        }
188
189        if let Ok(config_dir) = Registry::config_dir()
190            && config_dir.exists()
191        {
192            match fs::remove_dir_all(&config_dir) {
193                Ok(()) => output::print_info("Removed global configuration directory."),
194                Err(e) => {
195                    // Routine on Windows: the managed copy under `<config>/bin` is
196                    // often the very binary running this command, and a running
197                    // executable cannot be deleted. That case is finished by the
198                    // helper; anything else really is left behind.
199                    let running_inside =
200                        std::env::current_exe().is_ok_and(|exe| exe.starts_with(&config_dir));
201                    if cfg!(windows) && running_inside {
202                        pending_dirs.push(config_dir);
203                    } else {
204                        output::print_error(&format!(
205                            "Could not remove {}: {e}",
206                            output::clean_path(&config_dir)
207                        ));
208                        left_behind.push("the global configuration directory".to_string());
209                    }
210                }
211            }
212        }
213    } else {
214        // Stamp the current version so a surviving copy (a package-manager install, a
215        // dev build) does not reinstall, on its very next command, everything this
216        // command was run to remove.
217        setup::suppress_next_auto_setup();
218    }
219
220    // 8. One detached helper for everything that is in use right now. PowerShell is
221    // preferred: its single-quoted string literals are fully literal, so a path
222    // carrying `%` survives, where `cmd.exe` would expand it and a `/C` command line
223    // has no way to escape one. `cmd.exe` remains the fallback for a machine without
224    // PowerShell, and whatever neither could take is listed for manual removal.
225    let leftover = spawn_deletion_helper(&pending_files, &pending_dirs);
226    if !pending_files.is_empty() || !pending_dirs.is_empty() {
227        if leftover.len() < pending_files.len() + pending_dirs.len() {
228            output::print_info(
229                "The running binary cannot delete itself — the rest is removed \
230                 automatically a few seconds after this command exits.",
231            );
232        }
233        if !leftover.is_empty() {
234            report_manual_removal(&leftover);
235            left_behind.push("the binaries".to_string());
236        }
237    }
238
239    println!();
240    if left_behind.is_empty() {
241        output::print_success(if deep {
242            "Deep uninstall complete: program, integrations, configuration and registry removed."
243        } else {
244            "Uninstall complete: program and integrations removed. Configuration and \
245             prune history preserved for a future reinstall."
246        });
247    }
248    for hint in &manager_hints {
249        let Some(command) = hint.uninstall_command() else {
250            continue;
251        };
252        output::print_info(&format!(
253            "{} still lists dev-prune as installed — finish with `{command}` to clear \
254             its records.",
255            hint.label()
256        ));
257    }
258    output::print_info(&format!("Reinstall any time with: {}", reinstall_hint()));
259
260    if !left_behind.is_empty() {
261        anyhow::bail!("Uninstall finished, but {} is still installed.", {
262            left_behind.join(" and ")
263        });
264    }
265
266    Ok(())
267}
268
269/// Delete the managed pair, and the pair beside the running executable.
270///
271/// Skips a development build outright — deleting `target/debug/dev-prune` because a
272/// test or a contributor ran `uninstall` would destroy the build being worked on — and
273/// skips the copy beside a package-manager-owned executable, which the sweep offers to
274/// delete *with confirmation* rather than silently, because pulling files out from
275/// under a manager leaves its records dangling until its own uninstall command runs.
276fn remove_binaries(
277    deep: bool,
278    manager_owned: bool,
279    left_behind: &mut Vec<String>,
280    pending_files: &mut Vec<PathBuf>,
281    pending_dirs: &mut Vec<PathBuf>,
282) {
283    let mut candidates: Vec<PathBuf> = Vec::new();
284    let managed_bin_dir = setup::managed_bin_dir().ok();
285
286    if let Some(bin_dir) = &managed_bin_dir {
287        for stem in ["dev-prune", "devp"] {
288            candidates.push(bin_dir.join(exe_name(stem)));
289        }
290        // The windowless scheduler twin, generated beside the managed binary on Windows
291        // and nowhere else. `WINDOWS_HIDDEN_BIN` already carries its `.exe`.
292        #[cfg(windows)]
293        candidates.push(bin_dir.join(crate::constants::WINDOWS_HIDDEN_BIN));
294    }
295
296    if let Ok(current) = std::env::current_exe() {
297        if is_dev_build(&current) {
298            output::print_info(
299                "This is a development build — leaving the `target/` binaries alone.",
300            );
301        } else if manager_owned {
302            // The caller prints the manager's own uninstall command.
303        } else if let Some(parent) = current.parent() {
304            for stem in ["dev-prune", "devp"] {
305                let twin = parent.join(exe_name(stem));
306                if !candidates.contains(&twin) {
307                    candidates.push(twin);
308                }
309            }
310        }
311    }
312
313    let mut removed_any = false;
314    for exe in candidates {
315        if !exe.is_file() {
316            continue;
317        }
318        match fs::remove_file(&exe) {
319            Ok(()) => removed_any = true,
320            Err(e) => {
321                if cfg!(windows) && is_in_use_error(&e) {
322                    pending_files.push(exe);
323                } else {
324                    output::print_error(&format!(
325                        "Could not remove {}: {e}",
326                        output::clean_path(&exe)
327                    ));
328                    left_behind.push("the binaries".to_string());
329                }
330            }
331        }
332    }
333    if removed_any {
334        output::print_info("Removed the dev-prune binaries.");
335    }
336
337    // The install receipt describes the binary in this directory and nothing else, so it
338    // goes when that binary goes. Left behind it would outlive its subject, and it would
339    // also keep the directory below from ever being empty.
340    if let Some(bin_dir) = &managed_bin_dir {
341        let _ = fs::remove_file(bin_dir.join(crate::constants::INSTALL_RECEIPT_FILE));
342    }
343
344    // The managed `bin` directory should not outlive its contents. On a deep uninstall
345    // the whole config directory goes anyway; on a light one, remove it once empty, or
346    // let the helper do it after the pending deletions.
347    if !deep
348        && let Some(bin_dir) = managed_bin_dir
349        && bin_dir.is_dir()
350        && fs::remove_dir(&bin_dir).is_err()
351        && !pending_files.is_empty()
352    {
353        pending_dirs.push(bin_dir);
354    }
355}
356
357/// One copy of dev-prune found somewhere other than the managed directory.
358struct StrayCopy {
359    path: PathBuf,
360    channel: Channel,
361}
362
363/// Find every other copy of the pair, show the list, and — with the user's yes —
364/// delete them all.
365///
366/// Discovery covers every directory on this process's PATH plus the well-known install
367/// directories that are often *not* on it any more: `~/.cargo/bin`, `~/.local/bin`
368/// (uv, pipx and the XDG convention), npm's global directory, pip's per-user `Scripts`
369/// directories, and whatever directory the running executable lives in. Only files
370/// carrying the pair's own names are ever considered, so nothing else in those
371/// directories can be touched.
372///
373/// Deletion is opt-in: the list is printed and confirmed first (`--yes` counts as
374/// confirmation; a non-terminal without it leaves everything in place). A declined
375/// prompt is a decision, not a failure — it does not change the exit code.
376fn sweep_stray_copies(
377    yes: bool,
378    manager_hints: &mut Vec<Channel>,
379    left_behind: &mut Vec<String>,
380    pending_files: &mut Vec<PathBuf>,
381) {
382    // Anything already queued for the deletion helper still exists on disk right now;
383    // finding it again here would list it as a stray and queue it twice.
384    let already_pending: HashSet<String> = pending_files.iter().map(|p| canon_key(p)).collect();
385    let strays: Vec<StrayCopy> = find_stray_copies()
386        .into_iter()
387        .filter(|s| !already_pending.contains(&canon_key(&s.path)))
388        .collect();
389    if strays.is_empty() {
390        return;
391    }
392
393    println!();
394    output::print_warning(&format!(
395        "Found {} more cop{} of dev-prune, from other install channels:",
396        strays.len(),
397        if strays.len() == 1 { "y" } else { "ies" }
398    ));
399    for stray in &strays {
400        if stray.channel.owns_its_files() {
401            println!(
402                "   {}  (installed with {})",
403                output::clean_path(&stray.path),
404                stray.channel.label()
405            );
406        } else {
407            println!("   {}", output::clean_path(&stray.path));
408        }
409    }
410
411    if !confirm_sweep(yes) {
412        output::print_info(
413            "Left in place. Remove them yourself, or re-run `devp uninstall` any time.",
414        );
415        return;
416    }
417
418    let mut removed = 0usize;
419    for stray in strays {
420        if stray.channel.owns_its_files() && !manager_hints.contains(&stray.channel) {
421            manager_hints.push(stray.channel);
422        }
423        // WinGet, Scoop and Homebrew each keep their package in a versioned directory
424        // they own end to end. Deleting the binary out of one leaves the manager certain
425        // the package is still installed and its own uninstall with nothing to remove —
426        // a state the user cannot get out of without editing the manager's database. So
427        // that copy is named, not deleted, and the command that really removes it is
428        // printed with the rest of the hints.
429        if stray.channel.replaces_its_directory() {
430            continue;
431        }
432        match fs::remove_file(&stray.path) {
433            Ok(()) => removed += 1,
434            Err(e) => {
435                // The running executable itself is often in this list. Windows keeps
436                // it locked; the detached helper finishes the job.
437                if cfg!(windows) && is_in_use_error(&e) {
438                    pending_files.push(stray.path);
439                } else {
440                    output::print_error(&format!(
441                        "Could not remove {}: {e}",
442                        output::clean_path(&stray.path)
443                    ));
444                    left_behind.push("a stray copy".to_string());
445                }
446            }
447        }
448    }
449    if removed > 0 {
450        output::print_info(&format!(
451            "Removed {removed} stray cop{}.",
452            if removed == 1 { "y" } else { "ies" }
453        ));
454    }
455}
456
457/// Ask before the sweep deletes anything. `--yes` answers for the user; a pipe or a
458/// script without it gets a "no" plus the flag to pass next time.
459fn confirm_sweep(yes: bool) -> bool {
460    use std::io::{IsTerminal, Write};
461    if yes {
462        return true;
463    }
464    if !std::io::stdin().is_terminal() {
465        output::print_info("Not running in a terminal — pass `--yes` to remove these too.");
466        return false;
467    }
468    // Default no, like every other deletion prompt in this tool: these files live in
469    // directories dev-prune does not manage, and a reflexive Enter should never be
470    // what deletes them. The question goes to stderr so a piped stdout cannot eat it.
471    eprint!("Remove them all? [y/N]: ");
472    if std::io::stderr().flush().is_err() {
473        return false;
474    }
475    let mut input = String::new();
476    if std::io::stdin().read_line(&mut input).is_err() {
477        return false;
478    }
479    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
480}
481
482/// Every dev-prune/devp file in the sweep directories, except the managed pair (the
483/// caller already removed it), development builds, and directories, deduplicated.
484///
485/// A dangling symlink still counts — it is exactly the kind of leftover the sweep
486/// exists to clean up — which is why this checks `symlink_metadata`, not `is_file`.
487fn find_stray_copies() -> Vec<StrayCopy> {
488    let managed = setup::managed_bin_dir().ok().map(|d| canon_key(&d));
489    let managed_exe = setup::managed_exe_path().ok();
490    let names = sweep_names();
491    let mut seen_dirs: HashSet<String> = HashSet::new();
492    let mut seen_files: HashSet<String> = HashSet::new();
493    let mut found = Vec::new();
494
495    for dir in sweep_dirs() {
496        let dir_key = canon_key(&dir);
497        if !seen_dirs.insert(dir_key.clone()) {
498            continue;
499        }
500        if managed.as_deref() == Some(dir_key.as_str()) {
501            continue;
502        }
503        for name in &names {
504            let candidate = dir.join(name);
505            let Ok(meta) = fs::symlink_metadata(&candidate) else {
506                continue;
507            };
508            if meta.is_dir() || is_dev_build(&candidate) {
509                continue;
510            }
511            if !seen_files.insert(canon_key(&candidate)) {
512                continue;
513            }
514            let channel = Channel::detect_at(&candidate, managed_exe.as_deref());
515            found.push(StrayCopy {
516                path: candidate,
517                channel,
518            });
519        }
520    }
521    found
522}
523
524/// The directories worth looking in: everything on PATH, plus the install directories
525/// each supported channel writes to — which stop being on PATH the moment a venv
526/// deactivates or a profile line is removed, without the files going anywhere.
527fn sweep_dirs() -> Vec<PathBuf> {
528    let mut dirs: Vec<PathBuf> = Vec::new();
529    if let Some(path_var) = std::env::var_os("PATH") {
530        dirs.extend(std::env::split_paths(&path_var));
531    }
532    // Under hands-off the sweep stays inside directories the caller's own environment
533    // names. `PATH` is the caller's to shape; the home-derived extras below are this
534    // code guessing at install locations, which is exactly the reaching-around that
535    // `DEV_PRUNE_NO_AUTO_SETUP` turns off — and what keeps the test suite out of the
536    // developer's real `~/.cargo/bin`.
537    if setup::no_auto_setup_requested() {
538        if let Ok(exe) = std::env::current_exe()
539            && let Some(parent) = exe.parent()
540        {
541            dirs.push(parent.to_path_buf());
542        }
543        return dirs;
544    }
545    dirs.extend(crate::channel::install_dirs(dirs::home_dir().as_deref()));
546    if cfg!(windows) {
547        // `config_dir` is %APPDATA% — pip's per-user scripts live under it, one
548        // directory per interpreter version, so they have to be enumerated rather than
549        // named.
550        if let Some(appdata) = dirs::config_dir()
551            && let Ok(entries) = fs::read_dir(appdata.join("Python"))
552        {
553            for entry in entries.flatten() {
554                let scripts = entry.path().join("Scripts");
555                if scripts.is_dir() {
556                    dirs.push(scripts);
557                }
558            }
559        }
560    }
561    if let Ok(exe) = std::env::current_exe()
562        && let Some(parent) = exe.parent()
563    {
564        dirs.push(parent.to_path_buf());
565    }
566    dirs
567}
568
569/// The file names one of the pair can appear under. On Windows that is more than the
570/// two `.exe`s: npm writes `.cmd` and `.ps1` shims plus an extensionless sh shim for
571/// Git Bash, and each is a separate file to delete.
572fn sweep_names() -> Vec<String> {
573    let stems = ["dev-prune", "devp"];
574    if cfg!(windows) {
575        let mut names: Vec<String> = Vec::new();
576        for stem in stems {
577            for ext in ["exe", "cmd", "ps1", "bat"] {
578                names.push(format!("{stem}.{ext}"));
579            }
580            names.push(stem.to_string());
581        }
582        names
583    } else {
584        stems.iter().map(|s| s.to_string()).collect()
585    }
586}
587
588/// One canonical string per path, so `C:\X\Bin\` and `c:\x\bin` count once.
589fn canon_key(path: &Path) -> String {
590    let key = path.to_string_lossy().replace('\\', "/");
591    let key = key.trim_end_matches('/').to_string();
592    if cfg!(windows) {
593        key.to_lowercase()
594    } else {
595        key
596    }
597}
598
599/// The on-disk file name for one of the pair, on this platform.
600fn exe_name(stem: &str) -> String {
601    if cfg!(windows) {
602        format!("{stem}.exe")
603    } else {
604        stem.to_string()
605    }
606}
607
608/// Whether a deletion failure means "in use right now" — the one case the detached
609/// helper can finish. 5 is ERROR_ACCESS_DENIED, which is what deleting the running
610/// image reports; 32 is ERROR_SHARING_VIOLATION. Anything else (read-only media, a
611/// policy block) the helper would only inherit, so it is reported instead of queued.
612fn is_in_use_error(e: &std::io::Error) -> bool {
613    matches!(e.raw_os_error(), Some(5) | Some(32))
614}
615
616/// Whether this executable is running out of a Cargo build directory.
617fn is_dev_build(exe: &Path) -> bool {
618    let path = exe.to_string_lossy().replace('\\', "/");
619    path.contains("/target/debug/") || path.contains("/target/release/")
620}
621
622/// The install one-liner for this platform, for the goodbye message.
623fn reinstall_hint() -> String {
624    if cfg!(windows) {
625        format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL)
626    } else {
627        format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL)
628    }
629}
630
631/// List what could not be scheduled, with enough detail to act on, plus the command
632/// that removes it.
633///
634/// The stray-copy sweep lists every path before it deletes anything; this is the same
635/// courtesy for the residue. "Some files could not be removed" leaves someone hunting
636/// through Program Files for a name they were never told, so each line carries the
637/// name, the directory it sits in, what kind of thing it is and how big it is.
638fn report_manual_removal(paths: &[PathBuf]) {
639    output::print_error(&format!(
640        "{} item(s) are still in use and could not be scheduled for removal.",
641        paths.len()
642    ));
643    for path in paths {
644        let meta = fs::symlink_metadata(path).ok();
645        let kind = match meta.as_ref() {
646            Some(m) if m.is_dir() => "directory".to_string(),
647            Some(m) => format!("file, {}", output::format_bytes(m.len())),
648            None => "already gone".to_string(),
649        };
650        let name = path
651            .file_name()
652            .map(|n| n.to_string_lossy().into_owned())
653            .unwrap_or_else(|| output::clean_path(path));
654        let parent = path
655            .parent()
656            .map(output::clean_path)
657            .unwrap_or_else(|| "—".to_string());
658        println!("    {name}  ({kind})");
659        println!("      in {parent}");
660    }
661    println!("\n  Remove them yourself with:");
662    for path in paths {
663        // `-LiteralPath` and single quotes, because these are exactly the paths whose
664        // `%` the fallback could not survive — the command printed here has to be one
665        // that can be pasted verbatim.
666        println!(
667            "    Remove-Item -LiteralPath '{}' -Recurse -Force",
668            path.display().to_string().replace('\'', "''")
669        );
670    }
671}
672
673/// Quote a path as a PowerShell single-quoted string literal.
674///
675/// Inside single quotes PowerShell expands nothing at all — not `$var`, not a backtick
676/// escape, and crucially not `%VAR%`. The only character with meaning is the closing
677/// quote, and doubling it is the documented way to write a literal one. That makes this
678/// a complete escape rule for an arbitrary path, which is exactly what `cmd /C` could
679/// not offer.
680#[cfg(windows)]
681fn ps_quote(path: &Path) -> String {
682    format!("'{}'", path.display().to_string().replace('\'', "''"))
683}
684
685/// Schedule the in-use files for deletion after this process exits.
686///
687/// Returns the paths that could not be handed over, which is empty in the normal case.
688///
689/// PowerShell rather than `cmd.exe`, because `cmd` expands `%VAR%` even inside double
690/// quotes and a `/C` command line has no escape for a literal `%`. A path carrying one
691/// therefore could not be passed at all: it used to be reported and left on disk. The
692/// `cmd` route survives only as the fallback for a machine where PowerShell cannot be
693/// launched, and there the old restriction still applies.
694#[cfg(windows)]
695fn spawn_deletion_helper(files: &[PathBuf], dirs: &[PathBuf]) -> Vec<PathBuf> {
696    if files.is_empty() && dirs.is_empty() {
697        return Vec::new();
698    }
699    if spawn_powershell_helper(files, dirs) {
700        return Vec::new();
701    }
702
703    // Fallback. `cmd` cannot be given a literal `%`, so those paths stay behind and are
704    // returned for the caller to report.
705    let has_percent = |p: &&PathBuf| p.to_string_lossy().contains('%');
706    let left_behind: Vec<PathBuf> = files
707        .iter()
708        .chain(dirs.iter())
709        .filter(has_percent)
710        .cloned()
711        .collect();
712    let safe_files: Vec<PathBuf> = files.iter().filter(|p| !has_percent(p)).cloned().collect();
713    let safe_dirs: Vec<PathBuf> = dirs.iter().filter(|p| !has_percent(p)).cloned().collect();
714
715    if (safe_files.is_empty() && safe_dirs.is_empty()) || spawn_cmd_helper(&safe_files, &safe_dirs)
716    {
717        left_behind
718    } else {
719        files.iter().chain(dirs.iter()).cloned().collect()
720    }
721}
722
723/// The PowerShell form of the retry loop. `true` if the helper was launched.
724#[cfg(windows)]
725fn spawn_powershell_helper(files: &[PathBuf], dirs: &[PathBuf]) -> bool {
726    use std::os::windows::process::CommandExt;
727    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
728
729    let mut attempt = String::new();
730    for file in files {
731        attempt.push_str(&format!(
732            "Remove-Item -LiteralPath {} -Force -ErrorAction SilentlyContinue; ",
733            ps_quote(file)
734        ));
735    }
736    for dir in dirs {
737        attempt.push_str(&format!(
738            "Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction SilentlyContinue; ",
739            ps_quote(dir)
740        ));
741    }
742
743    // Three attempts, two seconds apart — the same reasoning as the `cmd` loop below.
744    let mut script = String::new();
745    for _ in 0..3 {
746        script.push_str("Start-Sleep -Seconds 2; ");
747        script.push_str(&attempt);
748    }
749
750    // Windows PowerShell 5.1 ships with every supported Windows and lives at a fixed
751    // place, so it is tried by absolute path first. The rest cover the machines where
752    // it does not answer — Nano Server, an image built without the Windows PowerShell
753    // feature, or a policy that blocks the inbox copy while permitting PowerShell 7 —
754    // and those are found through `PATH`, because 7.x installs beside its own major
755    // version rather than into `System32`.
756    for program in [
757        crate::spawn::system32(r"WindowsPowerShell\v1.0\powershell.exe"),
758        String::from("pwsh.exe"),
759        String::from("pwsh-preview.exe"),
760        String::from("powershell.exe"),
761    ] {
762        let spawned = std::process::Command::new(&program)
763            .args(["-NoProfile", "-NonInteractive", "-Command"])
764            .arg(&script)
765            .creation_flags(CREATE_NO_WINDOW)
766            .stdin(std::process::Stdio::null())
767            .stdout(std::process::Stdio::null())
768            .stderr(std::process::Stdio::null())
769            .spawn()
770            .is_ok();
771        if spawned {
772            return true;
773        }
774    }
775    false
776}
777
778/// The original `cmd.exe` form, kept as the fallback. Callers must have filtered out
779/// any path containing `%` before calling this.
780#[cfg(windows)]
781fn spawn_cmd_helper(files: &[PathBuf], dirs: &[PathBuf]) -> bool {
782    use std::os::windows::process::CommandExt;
783    // Not in windows-sys's prelude of imported constants anywhere else in this crate;
784    // documented value of CREATE_NO_WINDOW.
785    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
786
787    // Three attempts, two seconds apart. One would cover the normal case — this
788    // process exits the moment the command returns, releasing the image lock — but a
789    // slow exit, an antivirus scan hooked on process teardown, or the user running
790    // `devp` again inside the first window would otherwise leave the binary behind
791    // with nothing ever retrying. `cmd /C` cannot use labels, so the loop is unrolled.
792    let mut attempt = String::new();
793    for file in files {
794        attempt.push_str(&format!(" & del /F /Q \"{}\"", file.display()));
795    }
796    for dir in dirs {
797        attempt.push_str(&format!(" & rmdir /S /Q \"{}\"", dir.display()));
798    }
799    let mut script = String::new();
800    for _ in 0..3 {
801        script.push_str("ping -n 3 127.0.0.1 >nul");
802        script.push_str(&attempt);
803        script.push_str(" & ");
804    }
805    script.push_str("exit");
806
807    std::process::Command::new(crate::spawn::system32("cmd.exe"))
808        // `raw_arg`, because std's quoting would wrap the whole script in quotes and
809        // `cmd /C` would then treat it as one file name rather than a command line.
810        .raw_arg(format!("/C {script}"))
811        .creation_flags(CREATE_NO_WINDOW)
812        .stdin(std::process::Stdio::null())
813        .stdout(std::process::Stdio::null())
814        .stderr(std::process::Stdio::null())
815        .spawn()
816        .is_ok()
817}
818
819/// On Unix an open file can be unlinked, so nothing ever needs scheduling; this exists
820/// so the call site compiles unconditionally and is unreachable in practice.
821#[cfg(not(windows))]
822fn spawn_deletion_helper(_files: &[PathBuf], _dirs: &[PathBuf]) -> Vec<PathBuf> {
823    Vec::new()
824}