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