Skip to main content

dev_prune/commands/
uninstall.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune uninstall` command.
5//
6// Provides two uninstall modes:
7// - Light (default): Removes OS background daemon, global Git hooks, and binary aliases.
8//   Preserves configuration registry for seamless future re-installations.
9// - Deep (`devp uninstall --deep`): Complete purge — removes daemon, hooks, binary aliases,
10//   global configuration folder, and removes `.devprune.json` from registered repos.
11
12use anyhow::Result;
13use std::fs;
14
15use crate::commands::hook;
16use crate::config::Registry;
17use crate::output;
18
19pub fn run(deep: bool, yes: bool) -> Result<()> {
20    output::print_header(if deep {
21        "dev-prune Deep Uninstaller (Full Purge)"
22    } else {
23        "dev-prune Light Uninstaller"
24    });
25
26    let registry = Registry::load().ok();
27
28    // A deep uninstall deletes files inside the user's own repositories and destroys
29    // the prune history. That is not something to do on a mistyped flag.
30    if deep && !yes {
31        use std::io::{IsTerminal, Write};
32        let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
33        output::print_warning(&format!(
34            "This deletes the global config directory (including prune history) and \
35             removes `.devprune.json` from {repo_count} registered repositories."
36        ));
37        if !std::io::stdin().is_terminal() {
38            anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
39        }
40        print!("Continue? [y/N]: ");
41        std::io::stdout().flush()?;
42        let mut input = String::new();
43        std::io::stdin().read_line(&mut input)?;
44        if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
45            output::print_info("Deep uninstall cancelled.");
46            return Ok(());
47        }
48    }
49
50    // Steps 1 and 2 keep going when one of them fails — a scheduler that refuses to
51    // uninstall must not stop the hooks being removed — but neither is silent about it.
52    // Both leave the machine in a half-integrated state that the user has to know about.
53    let mut left_behind: Vec<String> = Vec::new();
54
55    // 1. Remove background daemon
56    output::print_info("Removing background daemon scheduler...");
57    if let Err(e) = crate::daemon::uninstall_daemon() {
58        output::print_error(&format!("Background scheduler: {e:#}"));
59        left_behind.push("the background scheduler".to_string());
60    }
61
62    // 2. Remove global Git hooks
63    output::print_info("Removing global Git auto-registration hooks...");
64    if let Err(e) = hook::run_uninstall() {
65        output::print_error(&format!("Git hooks: {e:#}"));
66        left_behind.push("the global Git hooks".to_string());
67    }
68
69    // 3. Remove the `devp` alias, so deleting the binary afterwards leaves nothing.
70    //
71    // Every invocation recreates it (`ensure_devp_alias` at the top of `run_cli`), this
72    // one included — so it is back the moment dev-prune runs again. Removing it here is
73    // only worth anything as the first half of "and now delete the binary", which is why
74    // the binary's location is printed below rather than left for the user to find.
75    if let Ok(current_exe) = std::env::current_exe() {
76        if let Some(parent) = current_exe.parent() {
77            #[cfg(windows)]
78            let alias = parent.join("devp.exe");
79            #[cfg(not(windows))]
80            let alias = parent.join("devp");
81
82            if alias.exists() && alias != current_exe {
83                let _ = fs::remove_file(alias);
84            }
85        }
86    }
87
88    // 4. Take the `*.devprune.json` file type back out of the desktop database.
89    crate::commands::icon::unregister_file_type();
90
91    if deep {
92        // Deep uninstall: remove per-repo configs & global config dir
93        if let Some(reg) = registry {
94            for repo_path in reg.repositories.keys() {
95                let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
96                if cfg_file.exists() {
97                    let _ = fs::remove_file(cfg_file);
98                }
99            }
100        }
101
102        if let Ok(config_dir) = Registry::config_dir() {
103            if config_dir.exists() {
104                match fs::remove_dir_all(&config_dir) {
105                    Ok(()) => output::print_info("Removed global configuration directory."),
106                    Err(e) => {
107                        // Routine on Windows: the managed copy under `<config>/bin` is
108                        // often the very binary running this command, and a running
109                        // executable cannot be deleted. Claiming success here left a
110                        // directory the user believed purged.
111                        let running_inside =
112                            std::env::current_exe().is_ok_and(|exe| exe.starts_with(&config_dir));
113                        let hint = if running_inside {
114                            " The running binary lives inside it — delete the directory \
115                             by hand once this command exits."
116                        } else {
117                            ""
118                        };
119                        output::print_error(&format!(
120                            "Could not remove {}: {e}.{hint}",
121                            output::clean_path(&config_dir)
122                        ));
123                        left_behind.push("the global configuration directory".to_string());
124                    }
125                }
126            }
127        }
128
129        if left_behind.is_empty() {
130            output::print_success(
131                "Deep uninstall complete: All configurations, background daemons, and registry files purged.",
132            );
133        }
134        // The stamp that suppresses the automatic pass lived in the directory that was
135        // just deleted, so the next dev-prune command looks like a fresh install and
136        // puts the hooks and the scheduler straight back. Deleting the binary is the
137        // only thing that ends it, so say so instead of letting it surprise them.
138        output::print_warning(
139            "Running dev-prune again would reinstall the hooks and the scheduler — a \
140             machine with no config directory looks like a fresh install.",
141        );
142        print_binary_removal_hint();
143    } else {
144        // Stamp the current version so the automatic pass does not reinstall, on the
145        // very next command, everything this command was run to remove.
146        crate::setup::suppress_next_auto_setup();
147        if left_behind.is_empty() {
148            output::print_success(
149                "Light uninstall complete: Background daemon and Git hooks removed. Configuration preserved for future reinstall.",
150            );
151        }
152        output::print_info("Put them back at any time with `devp setup`.");
153        // Honesty about the stamp: it suppresses the pass for *this* version only.
154        output::print_info(
155            "The next upgrade will reinstall them. To keep them off permanently: \
156             `devp config set auto_setup false`.",
157        );
158    }
159
160    if !left_behind.is_empty() {
161        anyhow::bail!("Uninstall finished, but {} is still installed.", {
162            left_behind.join(" and ")
163        });
164    }
165
166    Ok(())
167}
168
169/// Where the binaries themselves live, since nothing above removes them.
170///
171/// Both names, not just the one that is running: invoked as `devp`, step 3 above could
172/// not remove the alias (it *is* this process), and the canonical `dev-prune` is never
173/// removed by anything — a hint that named only one of the pair left the other behind.
174fn print_binary_removal_hint() {
175    let Ok(exe) = std::env::current_exe() else {
176        return;
177    };
178    let mut targets = vec![output::clean_path(&exe)];
179    if let Some(parent) = exe.parent() {
180        for stem in ["dev-prune", "devp"] {
181            let name = if cfg!(windows) {
182                format!("{stem}.exe")
183            } else {
184                stem.to_string()
185            };
186            let twin = parent.join(name);
187            if twin.exists() && twin != exe {
188                targets.push(output::clean_path(&twin));
189            }
190        }
191    }
192    output::print_info(&format!(
193        "To finish, delete {}: {}",
194        if targets.len() > 1 {
195            "the binaries"
196        } else {
197            "the binary"
198        },
199        targets.join(" and ")
200    ));
201}