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(¤t) {
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}