Skip to main content

dev_prune/commands/
restore.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for the `dev-prune restore` command.
5//
6// Detects package managers in a project and restores dependencies from lockfiles.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12
13use crate::config::{PrunedDir, Registry};
14use crate::engine;
15use crate::output;
16
17/// Run the `restore` command.
18pub fn run(path_str: &str) -> Result<()> {
19    let path = Path::new(path_str)
20        .canonicalize()
21        .with_context(|| format!("Path not found: {path_str}"))?;
22
23    output::print_header("dev-prune restore");
24    output::print_info(&format!(
25        "Restoring dependencies in {}",
26        output::clean_path(&path)
27    ));
28
29    // Read through the user's configured depth and timeout, not the built-in defaults:
30    // a repository pruned at a deeper setting has to be restored at that same setting,
31    // and a reinstall is the longest command this tool runs — the raised
32    // `command_timeout_secs` was almost certainly raised *for* it.
33    let (global_depth, timeout_secs) = crate::config::Registry::load()
34        .map(|r| (r.settings.scan_depth, r.settings.command_timeout_secs))
35        .unwrap_or((
36            crate::constants::DEFAULT_SCAN_DEPTH,
37            crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
38        ));
39    let timeout = std::time::Duration::from_secs(timeout_secs);
40    let results = engine::restore_project_to_depth(&path, global_depth, timeout)?;
41
42    let mut failed = 0usize;
43
44    for (adapter_name, result) in &results {
45        match result {
46            Ok(()) => {
47                output::print_success(&format!("{adapter_name}: dependencies restored"));
48            }
49            Err(e) => {
50                output::print_error(&format!("{adapter_name}: {e}"));
51                failed += 1;
52            }
53        }
54    }
55
56    // A restore that failed has to exit non-zero. `devp prune && devp restore` in a
57    // script, or a CI step that runs it, otherwise carries on against a project whose
58    // dependencies are half installed — the one situation the exit code exists for.
59    if failed > 0 {
60        anyhow::bail!(
61            "{failed} of {} {} failed to restore — see the errors above",
62            results.len(),
63            output::plural(results.len(), "adapter", "adapters")
64        );
65    }
66
67    output::print_success(&format!(
68        "Restored {} {}.",
69        results.len(),
70        output::plural(results.len(), "adapter", "adapters")
71    ));
72
73    Ok(())
74}
75
76/// Run `restore --last-run`: put back exactly what the most recent prune pass deleted.
77///
78/// The undo the tool did not have. `devp undo` reverses an `init` or a `link`, which are
79/// registry edits; the thing people actually want reversed is the pass that emptied
80/// twelve directories across four repositories a minute ago, and reconstructing that list
81/// by hand means remembering which repositories were even in it.
82///
83/// The record is not cleared afterwards. Restoring twice is harmless — the second pass is
84/// each manager's own no-op — and a `--last-run` that could only be used once would fail
85/// exactly when a partial restore made the user want to re-run it.
86pub fn run_last_run() -> Result<()> {
87    let registry = Registry::load()?;
88
89    let Some(last) = registry.last_prune.as_ref() else {
90        anyhow::bail!(
91            "No prune pass has been recorded yet, so there is nothing to put back.\n  \
92             `devp restore <path>` restores a project you name."
93        );
94    };
95
96    let total: u64 = last.dirs.iter().map(|d| d.size_freed).sum();
97
98    output::print_header("dev-prune restore --last-run");
99    output::print_info(&format!(
100        "Putting back {} {} deleted on {} ({}).",
101        last.dirs.len(),
102        output::plural(last.dirs.len(), "directory", "directories"),
103        last.at.format("%Y-%m-%d %H:%M UTC"),
104        output::format_bytes(total)
105    ));
106
107    // Settled once, before anything is rebuilt: asking per directory would put the same
108    // question in front of the user forty times in a pass that touched forty projects.
109    let dropped = settle_runtimes(&last.dirs)?;
110
111    // Grouped by repository so each tree is walked once, in a stable order, however the
112    // pass that recorded them happened to interleave.
113    let mut by_repo: BTreeMap<PathBuf, Vec<PrunedDir>> = BTreeMap::new();
114    for dir in &last.dirs {
115        let mut dir = dir.clone();
116        if dir
117            .runtime
118            .as_deref()
119            .is_some_and(|t| dropped.iter().any(|d| d == t))
120        {
121            dir.runtime = None;
122        }
123        by_repo.entry(dir.repo_path.clone()).or_default().push(dir);
124    }
125
126    let global_depth = registry.settings.scan_depth;
127    let timeout = std::time::Duration::from_secs(registry.settings.command_timeout_secs);
128    let mut attempted = 0usize;
129    let mut failed = 0usize;
130    // (adapter, bytes, milliseconds). Held until the pass is over rather than written as
131    // they arrive: this pass can run for an hour, and the registry it would be writing
132    // into is the one loaded before it started.
133    let mut measured: Vec<(String, u64, u64)> = Vec::new();
134
135    for (repo_path, deleted) in &by_repo {
136        println!();
137        output::print_info(&output::clean_path(repo_path));
138
139        // A repository that is gone is reported per directory, not skipped, so the count
140        // at the end still adds up to what the prune took.
141        if !repo_path.exists() {
142            for dir in deleted {
143                attempted += 1;
144                failed += 1;
145                output::print_error(&format!(
146                    "  {} ({}): the repository no longer exists at this path",
147                    dir.adapter, dir.bloat_dir
148                ));
149            }
150            continue;
151        }
152
153        for outcome in engine::restore_deleted(repo_path, deleted, global_depth, timeout) {
154            attempted += 1;
155            match &outcome.result {
156                Ok(()) => {
157                    // Only a restore that worked says anything about how fast this
158                    // machine restores. A failure's duration is the time to the error,
159                    // which is a measurement of the error.
160                    measured.push((
161                        outcome.adapter.clone(),
162                        outcome.bytes,
163                        outcome.elapsed.as_millis().min(u128::from(u64::MAX)) as u64,
164                    ));
165                    output::print_success(&format!("  {}: restored", outcome.label));
166                }
167                Err(e) => {
168                    failed += 1;
169                    output::print_error(&format!("  {}: {e}", outcome.label));
170                }
171            }
172        }
173    }
174
175    // Before the failure exit, not after: a pass that put back nine directories and lost
176    // the tenth still measured nine restores, and throwing that away would mean the
177    // estimate never learns anything from the passes that need it most. Reloaded so a
178    // registry edit made while this pass ran is not overwritten, and a save that fails is
179    // silent — losing a timing sample is not worth failing a restore over.
180    record_restore_rates(measured);
181
182    println!();
183    if failed > 0 {
184        anyhow::bail!(
185            "{failed} of {attempted} {} failed to restore — see the errors above",
186            output::plural(attempted, "directory", "directories")
187        );
188    }
189
190    output::print_success(&format!(
191        "Restored {attempted} {} across {} {}.",
192        output::plural(attempted, "directory", "directories"),
193        by_repo.len(),
194        output::plural(by_repo.len(), "repository", "repositories")
195    ));
196
197    Ok(())
198}
199
200/// Fold a pass's measurements into the throughput averages `devp status` estimates from.
201///
202/// Local only, and nothing but timings: the adapter, the byte count and the duration. No
203/// path and no project name, which is what keeps this on the right side of
204/// [`PRIVACY.md`](../../docs/PRIVACY.md).
205fn record_restore_rates(measured: Vec<(String, u64, u64)>) {
206    if measured.is_empty() {
207        return;
208    }
209    let Ok(mut registry) = Registry::load() else {
210        return;
211    };
212    for (adapter, bytes, millis) in measured {
213        registry.record_restore(&adapter, bytes, millis);
214    }
215    let _ = registry.save();
216}
217
218/// What a `--last-run` restore intends to do about the interpreters it recorded.
219///
220/// Split out from the command so the decision is testable without a terminal: the
221/// question of *whether* to ask is a pure function of what was recorded and what is
222/// installed, and only the asking needs stdin.
223#[derive(Debug, PartialEq, Eq)]
224struct RuntimePlan {
225    /// Recorded versions this machine has, which will be used as recorded.
226    honoured: Vec<String>,
227    /// Recorded versions this machine does not have. Rebuilding those directories means
228    /// rebuilding them on a different interpreter, which is the thing worth asking about.
229    missing: Vec<String>,
230}
231
232impl RuntimePlan {
233    /// `available` is asked once per distinct version rather than once per directory —
234    /// a prune of forty Python projects would otherwise spawn forty identical probes.
235    fn build(dirs: &[PrunedDir], available: impl Fn(&str) -> bool) -> Self {
236        let mut honoured = Vec::new();
237        let mut missing = Vec::new();
238        for tag in dirs.iter().filter_map(|d| d.runtime.as_deref()) {
239            if honoured.iter().any(|t| t == tag) || missing.iter().any(|t| t == tag) {
240                continue;
241            }
242            if available(tag) {
243                honoured.push(tag.to_string());
244            } else {
245                missing.push(tag.to_string());
246            }
247        }
248        honoured.sort();
249        missing.sort();
250        Self { honoured, missing }
251    }
252}
253
254/// Decide, and say out loud, which interpreter each recorded directory is rebuilt on.
255///
256/// Returns the versions to drop — the ones this machine cannot provide and the user has
257/// agreed to rebuild on whatever `python` is. Bails instead when they say no, because a
258/// restore onto the wrong interpreter is not something to do by default: it is the
259/// failure this recording exists to prevent, and it surfaces much later as an import
260/// error nobody connects back to here.
261fn settle_runtimes(dirs: &[PrunedDir]) -> Result<Vec<String>> {
262    let plan = RuntimePlan::build(dirs, crate::adapters::python_runtime_available);
263
264    for tag in &plan.honoured {
265        output::print_info(&format!(
266            "Python {tag} environments will be rebuilt on Python {tag}, as recorded."
267        ));
268    }
269    if plan.missing.is_empty() {
270        return Ok(Vec::new());
271    }
272
273    let versions = plan.missing.join(", ");
274    output::print_warning(&format!(
275        "Python {versions} {} recorded for some of these environments, but not installed \
276         here. Rebuilding them means rebuilding on whatever `python` resolves to, and \
277         pinned wheels may not exist for it.",
278        output::plural(plan.missing.len(), "was", "were"),
279    ));
280    for tag in &plan.missing {
281        output::print_info(&format!("  Install it first:  uv python install {tag}"));
282    }
283
284    if !confirm_other_interpreter() {
285        anyhow::bail!(
286            "Nothing was restored. Install the recorded {} and run `devp restore \
287             --last-run` again, or answer yes to rebuild on the interpreter you have.",
288            output::plural(plan.missing.len(), "interpreter", "interpreters"),
289        );
290    }
291    Ok(plan.missing)
292}
293
294/// Default no. Everything else this command does puts back exactly what was taken; this
295/// is the one step that knowingly puts back something slightly different, so a reflexive
296/// Enter should not be what agrees to it. The question goes to stderr so a piped stdout
297/// cannot eat it.
298fn confirm_other_interpreter() -> bool {
299    use std::io::{IsTerminal, Write};
300    if !std::io::stdin().is_terminal() {
301        output::print_info(
302            "Not running in a terminal, so this is not being answered for you — install \
303             the recorded interpreter, or re-run this where the question can be asked.",
304        );
305        return false;
306    }
307    eprint!("Rebuild them on the interpreter you have? [y/N]: ");
308    if std::io::stderr().flush().is_err() {
309        return false;
310    }
311    let mut input = String::new();
312    if std::io::stdin().read_line(&mut input).is_err() {
313        return false;
314    }
315    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    fn dir_with(runtime: Option<&str>) -> PrunedDir {
323        PrunedDir {
324            repo_path: PathBuf::from("/repo"),
325            bloat_dir: ".venv".to_string(),
326            adapter: "venv".to_string(),
327            size_freed: 1,
328            runtime: runtime.map(str::to_string),
329        }
330    }
331
332    #[test]
333    fn a_recorded_interpreter_that_is_installed_is_used_without_asking() {
334        let dirs = [dir_with(Some("3.12")), dir_with(None)];
335        let plan = RuntimePlan::build(&dirs, |_| true);
336        assert_eq!(plan.honoured, vec!["3.12".to_string()]);
337        assert!(plan.missing.is_empty(), "nothing to ask about");
338    }
339
340    #[test]
341    fn each_version_is_probed_once_however_many_directories_recorded_it() {
342        // Forty Python projects in one pass must not mean forty identical probes.
343        let dirs: Vec<PrunedDir> = (0..40).map(|_| dir_with(Some("3.12"))).collect();
344        let probes = std::cell::Cell::new(0);
345        let plan = RuntimePlan::build(&dirs, |_| {
346            probes.set(probes.get() + 1);
347            true
348        });
349        assert_eq!(probes.get(), 1);
350        assert_eq!(plan.honoured.len(), 1);
351    }
352
353    #[test]
354    fn an_interpreter_this_machine_does_not_have_is_what_gets_asked_about() {
355        let dirs = [dir_with(Some("3.12")), dir_with(Some("3.9"))];
356        let plan = RuntimePlan::build(&dirs, |tag| tag == "3.12");
357        assert_eq!(plan.honoured, vec!["3.12".to_string()]);
358        assert_eq!(plan.missing, vec!["3.9".to_string()]);
359    }
360
361    #[test]
362    fn a_pass_that_recorded_nothing_asks_nothing() {
363        // Everything pruned before 1.4.0 lands here, and so does a pass that only
364        // touched node_modules. Neither should produce a question.
365        let dirs = [dir_with(None), dir_with(None)];
366        let plan = RuntimePlan::build(&dirs, |_| unreachable!("nothing to probe"));
367        assert!(plan.honoured.is_empty() && plan.missing.is_empty());
368    }
369}