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. dev-prune does not work around
27// that by leaving a shell behind to finish the job after it exits: that shape is the
28// textbook self-delete, and it is what gets an unsigned binary quarantined. It renames
29// the locked image aside instead — which Windows does allow — hands the residue to the
30// session manager for the next boot, and prints whatever survives both.
31//
32// A package manager's own uninstall cannot be rescued the same way. While its binary is
33// executing, `cargo uninstall` fails with `Access is denied` and keeps its ledger entry,
34// and renaming the file aside first only trades that failure for the `corrupt metadata`
35// one, which keeps the entry too. Exit first, then uninstall, is the only order that
36// clears the record, so that command is printed for the user rather than run.
37//
38// Neither is a failure: the command reports both and exits `0`.
39
40use anyhow::{Context, Result};
41use std::collections::HashSet;
42use std::fs;
43use std::path::{Path, PathBuf};
44
45use crate::channel::Channel;
46use crate::commands::hook;
47use crate::config::Registry;
48use crate::output;
49use crate::setup;
50
51pub fn run(deep: bool, yes: bool) -> Result<()> {
52 output::print_header(if deep {
53 "dev-prune Deep Uninstaller (Full Purge)"
54 } else {
55 "dev-prune Uninstaller"
56 });
57
58 let registry = Registry::load().ok();
59
60 // A deep uninstall deletes files inside the user's own repositories and destroys
61 // the prune history. That is not something to do on a mistyped flag.
62 if deep && !yes {
63 use std::io::{IsTerminal, Write};
64 let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
65 output::print_warning(&format!(
66 "This deletes the global config directory (including prune history) and \
67 removes `.devprune.json` from {repo_count} registered repositories."
68 ));
69 if !std::io::stdin().is_terminal() {
70 anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
71 }
72 // stderr, like every other confirmation: with stdout piped the question would
73 // vanish into the pipe and the command would appear to hang.
74 eprint!("Continue? [y/N]: ");
75 std::io::stderr().flush()?;
76 let mut input = String::new();
77 std::io::stdin().read_line(&mut input)?;
78 if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
79 output::print_info("Deep uninstall cancelled.");
80 return Ok(());
81 }
82 }
83
84 // Each step keeps going when another fails — a scheduler that refuses to uninstall
85 // must not stop the hooks being removed — but none of them is silent about it. What
86 // could not be removed is reported, and its presence makes the exit code `1`.
87 let mut left_behind: Vec<String> = Vec::new();
88 // Files and directories that are in use right now (Windows keeps a running image
89 // locked, under every one of its hard-linked names). Deleted by a detached helper
90 // the moment this process exits.
91 let mut pending_files: Vec<PathBuf> = Vec::new();
92 let mut pending_dirs: Vec<PathBuf> = Vec::new();
93
94 // `DEV_PRUNE_NO_AUTO_SETUP` means "dev-prune manages nothing on this machine" — and
95 // that has to cut both ways. If the variable stopped setup from registering a
96 // scheduler or writing into agent skill directories, then uninstall must not reach
97 // for them either: whatever is there was put there by hand (or by another install
98 // this process knows nothing about), and hands-off means hands-off. It is also what
99 // lets the test suite run this command against a real machine.
100 let hands_off = setup::no_auto_setup_requested();
101
102 // 1. Background scheduler.
103 if hands_off {
104 output::print_info(&format!(
105 "{} is set — leaving the scheduler and agent skills alone.",
106 setup::ENV_NO_AUTO_SETUP
107 ));
108 } else {
109 output::print_info("Removing background daemon scheduler...");
110 if let Err(e) = crate::daemon::uninstall_daemon() {
111 output::print_error(&format!("Background scheduler: {e:#}"));
112 left_behind.push("the background scheduler".to_string());
113 }
114 }
115
116 // 2. Global Git hooks.
117 output::print_info("Removing global Git auto-registration hooks...");
118 if let Err(e) = hook::run_uninstall() {
119 output::print_error(&format!("Git hooks: {e:#}"));
120 left_behind.push("the global Git hooks".to_string());
121 }
122
123 // 3. The `*.devprune.json` file type, out of the desktop database.
124 crate::commands::icon::unregister_file_type();
125
126 // 4. The skill installed into AI agents' own directories. Only directories named
127 // for this tool are touched — `~/.claude/skills/dev-prune/`, never a sibling — and
128 // none at all under hands-off, for the same reason as the scheduler above.
129 let skill_roots = if hands_off {
130 Vec::new()
131 } else {
132 setup::agent_skill_roots()
133 };
134 for root in skill_roots {
135 if !root.exists() {
136 continue;
137 }
138 match fs::remove_dir_all(&root) {
139 Ok(()) => output::print_info(&format!(
140 "Removed the agent skill at {}.",
141 output::clean_path(&root)
142 )),
143 Err(e) => {
144 output::print_error(&format!(
145 "Could not remove {}: {e}",
146 output::clean_path(&root)
147 ));
148 left_behind.push("the AI agent skill".to_string());
149 }
150 }
151 }
152
153 // 5. Reachability: the user-PATH entry on Windows, the `~/.local/bin` links
154 // elsewhere. Before the binaries go, so no window exists where PATH names a
155 // directory whose contents are gone.
156 if let Ok(bin_dir) = setup::managed_bin_dir() {
157 match crate::pathenv::remove_reachability(&bin_dir) {
158 Ok(true) => output::print_info("Removed dev-prune from your PATH."),
159 Ok(false) => {}
160 Err(e) => {
161 output::print_error(&format!("Could not update your PATH: {e:#}"));
162 left_behind.push("the PATH entry".to_string());
163 }
164 }
165 }
166
167 // 6. The binaries themselves.
168 let channel = Channel::detect();
169 remove_binaries(
170 deep,
171 channel.owns_its_files(),
172 &mut left_behind,
173 &mut pending_files,
174 &mut pending_dirs,
175 );
176
177 // 7. Every other copy on the machine. A machine that has tried more than one
178 // install channel has more than one binary, and the ones not currently first on
179 // PATH would quietly *become* the installation the moment the managed pair above
180 // is gone.
181 //
182 // The channel this binary came from needs no separate handling: the sweep looks in
183 // the running executable's own directory, so a manager-owned copy is found there
184 // like any other and removed the same way.
185 let mut manager_hints: Vec<Channel> = Vec::new();
186 let mut pending_commands: Vec<Channel> = Vec::new();
187 sweep_stray_copies(
188 yes,
189 &mut manager_hints,
190 &mut left_behind,
191 &mut pending_files,
192 &mut pending_commands,
193 );
194
195 if deep {
196 // Per-repo configs, then the config directory itself.
197 //
198 // Only the personal file. `project.devprune.json` is a tracked file somebody
199 // committed, and uninstalling a tool from one machine is not a mandate to delete
200 // a file from a shared repository -- the deletion would show up in `git status`
201 // on a branch the user never meant to touch, and reach their colleagues on the
202 // next push.
203 if let Some(reg) = registry {
204 for repo_path in reg.repositories.keys() {
205 let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
206 if cfg_file.exists() {
207 let _ = fs::remove_file(cfg_file);
208 }
209 }
210 }
211
212 if let Ok(config_dir) = Registry::config_dir()
213 && config_dir.exists()
214 {
215 match fs::remove_dir_all(&config_dir) {
216 Ok(()) => output::print_info("Removed global configuration directory."),
217 Err(e) => {
218 // Routine on Windows: the managed copy under `<config>/bin` is
219 // often the very binary running this command, and a running
220 // executable cannot be deleted. That case is finished by the
221 // helper; anything else really is left behind.
222 let running_inside =
223 std::env::current_exe().is_ok_and(|exe| exe.starts_with(&config_dir));
224 if cfg!(windows) && running_inside {
225 pending_dirs.push(config_dir);
226 } else {
227 output::print_error(&format!(
228 "Could not remove {}: {e}",
229 output::clean_path(&config_dir)
230 ));
231 left_behind.push("the global configuration directory".to_string());
232 }
233 }
234 }
235 }
236 } else {
237 // Stamp the current version so a surviving copy (a package-manager install, a
238 // dev build) does not reinstall, on its very next command, everything this
239 // command was run to remove.
240 setup::suppress_next_auto_setup();
241 }
242
243 // 8. Everything above that Windows refused because this very process holds the file
244 // open. It is finished here, in this process, or it is reported -- never handed to
245 // a background shell. `finish_locked_removals` has the reasoning.
246 let (residue, still_installed) = finish_locked_removals(&pending_files, &pending_dirs);
247 if !residue.is_empty() {
248 report_manual_removal(&residue);
249 } else if !pending_files.is_empty() || !pending_dirs.is_empty() {
250 output::print_info(
251 "The binary running this command cannot delete itself. Its name is free \
252 again, and Windows removes what is left of the file at the next restart.",
253 );
254 }
255 if still_installed {
256 left_behind.push("the binaries".to_string());
257 }
258 // A package manager cannot uninstall a binary that is executing either, and no
259 // ordering inside this process changes that -- see the note at the top of the file.
260 // Its command goes back onto the list printed below, for the user to run once this
261 // command has exited.
262 for channel in &pending_commands {
263 if !manager_hints.contains(channel) {
264 manager_hints.push(*channel);
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, which ships in the Windows archives as its own
320 // build target. `WINDOWS_WINDOWLESS_BIN` already carries its `.exe`.
321 #[cfg(windows)]
322 candidates.push(bin_dir.join(crate::constants::WINDOWS_WINDOWLESS_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 // `devpw` is the windowless scheduler twin. It is a build target like the other
699 // two, so `cargo install` puts it on PATH beside them and an uninstall that did not
700 // look for it would leave a working copy of the whole CLI behind under a name
701 // nobody thinks to check.
702 let stems = ["dev-prune", "devp", "devpw"];
703 if cfg!(windows) {
704 let mut names: Vec<String> = Vec::new();
705 for stem in stems {
706 // `exe.old` is dev-prune's own debris: an update renames the running binary
707 // aside so the channel can write a fresh one at the real name, and the
708 // delete that follows is best-effort because the file is still the running
709 // image. The next update sweeps it -- but somebody who updates once and then
710 // uninstalls never has a next update, and the orphan outlives the install.
711 // `.new` likewise: an update stages the downloaded bytes beside the target
712 // and renames them in, and a stage that failed to rename is debris nothing
713 // else ever looks at.
714 for ext in ["exe", "cmd", "ps1", "bat", "exe.old", "new"] {
715 names.push(format!("{stem}.{ext}"));
716 }
717 names.push(stem.to_string());
718 }
719 names
720 } else {
721 stems
722 .iter()
723 .flat_map(|s| [s.to_string(), format!("{s}.new")])
724 .collect()
725 }
726}
727
728/// One canonical string per path, so `C:\X\Bin\` and `c:\x\bin` count once.
729pub(crate) fn canon_key(path: &Path) -> String {
730 let key = path.to_string_lossy().replace('\\', "/");
731 let key = key.trim_end_matches('/').to_string();
732 if cfg!(windows) {
733 key.to_lowercase()
734 } else {
735 key
736 }
737}
738
739/// The on-disk file name for one of the pair, on this platform.
740fn exe_name(stem: &str) -> String {
741 if cfg!(windows) {
742 format!("{stem}.exe")
743 } else {
744 stem.to_string()
745 }
746}
747
748/// Whether a deletion failure means "in use right now" — the one case the detached
749/// helper can finish. 5 is ERROR_ACCESS_DENIED, which is what deleting the running
750/// image reports; 32 is ERROR_SHARING_VIOLATION. Anything else (read-only media, a
751/// policy block) the helper would only inherit, so it is reported instead of queued.
752fn is_in_use_error(e: &std::io::Error) -> bool {
753 matches!(e.raw_os_error(), Some(5) | Some(32))
754}
755
756/// Whether this executable is running out of a Cargo build directory.
757fn is_dev_build(exe: &Path) -> bool {
758 let path = exe.to_string_lossy().replace('\\', "/");
759 path.contains("/target/debug/") || path.contains("/target/release/")
760}
761
762/// The install one-liner for this platform, for the goodbye message.
763fn reinstall_hint() -> String {
764 if cfg!(windows) {
765 format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL)
766 } else {
767 format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL)
768 }
769}
770
771/// List what could not be scheduled, with enough detail to act on, plus the command
772/// that removes it.
773///
774/// The stray-copy sweep lists every path before it deletes anything; this is the same
775/// courtesy for the residue. "Some files could not be removed" leaves someone hunting
776/// through Program Files for a name they were never told, so each line carries the
777/// name, the directory it sits in, what kind of thing it is and how big it is.
778fn report_manual_removal(paths: &[PathBuf]) {
779 output::print_warning(&format!(
780 "{} item(s) were open in this process and could not be removed from inside it. \
781 Delete them once this command has exited, or restart — from an elevated shell \
782 Windows would have taken them at the next boot on its own.",
783 paths.len()
784 ));
785 for path in paths {
786 let meta = fs::symlink_metadata(path).ok();
787 let kind = match meta.as_ref() {
788 Some(m) if m.is_dir() => "directory".to_string(),
789 Some(m) => format!("file, {}", output::format_bytes(m.len())),
790 None => "already gone".to_string(),
791 };
792 let name = path
793 .file_name()
794 .map(|n| n.to_string_lossy().into_owned())
795 .unwrap_or_else(|| output::clean_path(path));
796 let parent = path
797 .parent()
798 .map(output::clean_path)
799 .unwrap_or_else(|| "—".to_string());
800 println!(" {name} ({kind})");
801 println!(" in {parent}");
802 }
803 println!("\n Remove them yourself with:");
804 for path in paths {
805 // `-LiteralPath` and single quotes, because these are exactly the paths whose
806 // `%` the fallback could not survive — the command printed here has to be one
807 // that can be pasted verbatim.
808 println!(
809 " Remove-Item -LiteralPath '{}' -Recurse -Force",
810 path.display().to_string().replace('\'', "''")
811 );
812 }
813}
814
815/// What became of one file that Windows would not let this process delete.
816#[cfg(windows)]
817enum Retired {
818 /// Gone, or queued with the session manager for the next boot.
819 Handled,
820 /// Renamed out of the way. Still on disk, but nothing resolves to it any more.
821 Aside(PathBuf),
822 /// Still sitting at the name it was installed under.
823 Stuck(PathBuf),
824}
825
826/// Finish, from inside this process, the removals Windows refused while the binary was
827/// executing -- and report, rather than defer, whatever is left.
828///
829/// Returns the paths still on disk, and whether any of them is still at the name it was
830/// installed under. That second value is the difference between an uninstall that
831/// worked and one that did not: a file renamed aside resolves to nothing, is on the
832/// sweep list, and is cleared by the next install; a file still at `devp.exe` is a
833/// working install that was asked to go away and did not.
834///
835/// Nothing here starts a child process, and that is deliberate. The obvious way to
836/// delete a running executable on Windows is to leave a shell behind to do it once you
837/// have exited: a `cmd /C` line that pings itself to pass the time and then deletes the
838/// file is the canonical form, and a hidden PowerShell running `Start-Sleep` before
839/// `Remove-Item -Recurse -Force` is the same idea in better clothes. Both are also the
840/// canonical *malware* self-delete. The literal strings are enough for a static scanner
841/// to score on, and spawning either with `CREATE_NO_WINDOW` out of an unsigned binary
842/// is enough for a behavioural one. dev-prune has been quarantined for less, so it does
843/// neither, and what is left is what Windows itself offers for a file that is in use:
844///
845/// 1. Rename the locked image aside. A running executable cannot be deleted, but it
846/// can be renamed -- the handle follows the file, not the name -- so the name is
847/// free at once and a reinstall is never blocked by the copy it replaces.
848/// 2. Ask the session manager to delete the residue at the next boot, through
849/// `MoveFileEx` with `MOVEFILE_DELAY_UNTIL_REBOOT`.
850/// 3. Report what survived both, with the command that finishes it by hand.
851#[cfg(windows)]
852fn finish_locked_removals(files: &[PathBuf], dirs: &[PathBuf]) -> (Vec<PathBuf>, bool) {
853 let mut residue: Vec<PathBuf> = Vec::new();
854 let mut stuck = false;
855 for file in files {
856 match retire_file(file) {
857 Retired::Handled => {}
858 Retired::Aside(path) => residue.push(path),
859 Retired::Stuck(path) => {
860 residue.push(path);
861 stuck = true;
862 }
863 }
864 }
865 for dir in dirs {
866 stuck |= purge_tree(dir, &mut residue);
867 }
868 (residue, stuck)
869}
870
871/// The name a locked file is renamed to: `devp.exe` becomes `devp.exe.old`.
872///
873/// That name is already on the sweep list, so the residue is cleared by the next
874/// install or the next uninstall without anyone having to be told about it.
875#[cfg(windows)]
876fn aside_name(path: &Path) -> PathBuf {
877 match path.extension() {
878 Some(ext) => path.with_extension(format!("{}.old", ext.to_string_lossy())),
879 None => path.with_extension("old"),
880 }
881}
882
883/// Delete one file; failing that, get its name out of the way and queue the rest.
884#[cfg(windows)]
885fn retire_file(path: &Path) -> Retired {
886 if fs::remove_file(path).is_ok() || fs::symlink_metadata(path).is_err() {
887 return Retired::Handled;
888 }
889 let aside = aside_name(path);
890 let Ok(()) = fs::rename(path, &aside) else {
891 // The rename is the part that matters, so its failure is the real one: the
892 // install is still resolvable at this name.
893 return if delete_on_reboot(path) {
894 Retired::Handled
895 } else {
896 Retired::Stuck(path.to_path_buf())
897 };
898 };
899 if delete_on_reboot(&aside) {
900 Retired::Handled
901 } else {
902 Retired::Aside(aside)
903 }
904}
905
906/// Remove a directory tree, carrying on past the entries that are locked.
907///
908/// `fs::remove_dir_all` stops at the first error, which here is reliably the running
909/// binary -- so everything beside it would be left standing for no reason. Returns
910/// whether any file inside is still at its installed name; the directory itself
911/// surviving is residue to report, not an install that refused to go.
912#[cfg(windows)]
913fn purge_tree(dir: &Path, residue: &mut Vec<PathBuf>) -> bool {
914 if fs::remove_dir_all(dir).is_ok() || fs::symlink_metadata(dir).is_err() {
915 return false;
916 }
917 let Ok(entries) = fs::read_dir(dir) else {
918 residue.push(dir.to_path_buf());
919 return false;
920 };
921 let mut stuck = false;
922 for entry in entries.flatten() {
923 let path = entry.path();
924 // The entry's own type, never the target's: a link here is removed, not
925 // followed. A directory link needs `remove_dir` where a file link needs
926 // `remove_file`, and neither touches whatever it points at.
927 match entry.file_type() {
928 Ok(t) if t.is_symlink() => {
929 if fs::remove_file(&path).is_err() && fs::remove_dir(&path).is_err() {
930 residue.push(path);
931 }
932 }
933 Ok(t) if t.is_dir() => stuck |= purge_tree(&path, residue),
934 _ => match retire_file(&path) {
935 Retired::Handled => {}
936 Retired::Aside(left) => residue.push(left),
937 Retired::Stuck(left) => {
938 residue.push(left);
939 stuck = true;
940 }
941 },
942 }
943 }
944 if fs::remove_dir(dir).is_ok() {
945 return stuck;
946 }
947 // Something inside is still open. The reboot queue is replayed in the order it was
948 // written and every file above was queued first, so this directory is empty by the
949 // time its own entry is read.
950 if !delete_on_reboot(dir) {
951 residue.push(dir.to_path_buf());
952 }
953 stuck
954}
955
956/// Queue a path with the session manager for deletion at the next boot.
957///
958/// `MoveFileEx` with a null destination and `MOVEFILE_DELAY_UNTIL_REBOOT` is what
959/// Windows offers for a file that is in use, and what an installer replacing one is
960/// expected to use. It appends to `PendingFileRenameOperations` under `HKLM`, so it
961/// needs administrator rights: from an elevated shell the residue is gone after the
962/// next restart, and from an ordinary one this fails and the caller reports the path
963/// instead. Nothing is left running either way.
964#[cfg(windows)]
965fn delete_on_reboot(path: &Path) -> bool {
966 use std::os::windows::ffi::OsStrExt;
967 use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_DELAY_UNTIL_REBOOT, MoveFileExW};
968
969 let wide: Vec<u16> = path
970 .as_os_str()
971 .encode_wide()
972 .chain(std::iter::once(0))
973 .collect();
974 // SAFETY: `wide` is NUL-terminated and outlives the call, and a null destination is
975 // the documented spelling of "delete this" for this function.
976 unsafe { MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0 }
977}
978
979/// On Unix an open file can be unlinked, so nothing is ever left locked; this exists so
980/// the call site compiles unconditionally and is unreachable in practice.
981#[cfg(not(windows))]
982fn finish_locked_removals(_files: &[PathBuf], _dirs: &[PathBuf]) -> (Vec<PathBuf>, bool) {
983 (Vec::new(), false)
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 /// The sweep has to know the name the update leaves behind, or dev-prune's own
991 /// debris outlives the uninstall that reported it gone.
992 #[test]
993 #[cfg(windows)]
994 fn the_sweep_looks_for_the_file_an_update_renames_aside() {
995 let names = sweep_names();
996 assert!(names.contains(&"devp.exe.old".to_string()), "{names:?}");
997 assert!(
998 names.contains(&"dev-prune.exe.old".to_string()),
999 "{names:?}"
1000 );
1001 }
1002
1003 /// Only Windows needs the rename-aside; elsewhere the sweep is the three stems plus
1004 /// the `.new` staging name an interrupted update can leave beside any of them.
1005 #[test]
1006 #[cfg(not(windows))]
1007 fn elsewhere_the_sweep_is_the_stems_and_their_staging_names() {
1008 assert_eq!(
1009 sweep_names(),
1010 [
1011 "dev-prune",
1012 "dev-prune.new",
1013 "devp",
1014 "devp.new",
1015 "devpw",
1016 "devpw.new"
1017 ]
1018 );
1019 }
1020
1021 /// The residue has to land on a name the sweep already knows, or an uninstall that
1022 /// could not delete the running image leaves a file nothing will ever clear.
1023 #[test]
1024 #[cfg(windows)]
1025 fn the_name_a_locked_binary_is_renamed_to_is_one_the_sweep_looks_for() {
1026 let aside = aside_name(Path::new(r"C:\bin\devp.exe"));
1027 assert_eq!(aside.file_name().unwrap(), "devp.exe.old");
1028 assert!(sweep_names().contains(&"devp.exe.old".to_string()));
1029 // A name with no extension at all still gets one, rather than being left alone
1030 // under the name the install resolves to.
1031 assert_eq!(
1032 aside_name(Path::new(r"C:\bin\devp")).file_name().unwrap(),
1033 "devp.old"
1034 );
1035 }
1036
1037 /// Nothing is locked in a test, so every path here is the one that just deletes the
1038 /// file — which is the case that has to stay cheap and silent.
1039 #[test]
1040 #[cfg(windows)]
1041 fn an_unlocked_file_is_simply_deleted_and_no_residue_is_reported() {
1042 let dir = tempfile::tempdir().unwrap();
1043 let file = dir.path().join("devp.exe");
1044 std::fs::write(&file, b"binary").unwrap();
1045
1046 let (residue, still_installed) = finish_locked_removals(std::slice::from_ref(&file), &[]);
1047
1048 assert!(residue.is_empty(), "{residue:?}");
1049 assert!(!still_installed);
1050 assert!(!file.exists());
1051 assert!(
1052 !aside_name(&file).exists(),
1053 "nothing should be renamed aside"
1054 );
1055 }
1056
1057 /// `remove_dir_all` stops at the first entry it cannot take; the tree walk must not,
1058 /// or one locked binary strands every unrelated file beside it.
1059 #[test]
1060 #[cfg(windows)]
1061 fn a_tree_with_nothing_locked_in_it_goes_completely() {
1062 let root = tempfile::tempdir().unwrap();
1063 let config = root.path().join("dev-prune");
1064 std::fs::create_dir_all(config.join("bin")).unwrap();
1065 std::fs::write(config.join("bin").join("devp.exe"), b"binary").unwrap();
1066 std::fs::write(config.join("registry.json"), b"{}").unwrap();
1067
1068 let (residue, still_installed) = finish_locked_removals(&[], std::slice::from_ref(&config));
1069
1070 assert!(residue.is_empty(), "{residue:?}");
1071 assert!(!still_installed);
1072 assert!(!config.exists());
1073 }
1074
1075 /// A directory that is already gone is the normal case after a light uninstall, and
1076 /// must not be reported as something the user has to clean up.
1077 #[test]
1078 #[cfg(windows)]
1079 fn a_path_that_is_already_gone_is_not_reported() {
1080 let root = tempfile::tempdir().unwrap();
1081 let missing = root.path().join("never-existed");
1082
1083 let (residue, still_installed) =
1084 finish_locked_removals(&[missing.join("devp.exe")], &[missing]);
1085
1086 assert!(residue.is_empty(), "{residue:?}");
1087 assert!(!still_installed);
1088 }
1089}