Skip to main content

dev_prune/commands/
init.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for the `dev-prune init` command.
5//
6// Scans provided paths for Git repositories, registers them in the registry,
7// auto-configures background daemon & hooks, and self-heals project configuration schemas.
8
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12
13use crate::config::{PerRepoConfig, Registry};
14use crate::output;
15use crate::{constants, discovery, scanner};
16
17/// Run the `init` command.
18///
19/// Scans each provided path for Git repositories and adds them to the registry. With
20/// `auto`, the paths are worked out rather than given — see [`crate::discovery`].
21pub fn run(paths: &[String], dry_run: bool, auto: bool) -> Result<()> {
22    output::print_banner();
23    output::print_header("dev-prune init");
24
25    let mut registry = Registry::load()?;
26    let mut total_found = 0;
27    let mut skipped_throwaway = 0;
28    let mut skipped_opted_out = 0;
29    let mut newly_added_repos: Vec<PathBuf> = Vec::new();
30
31    // In `--auto` the candidates are already filtered — discovery has dropped the
32    // registered, the disposable and the opted-out — so the scan loop below is fed a
33    // single synthetic "path" list and does no scanning of its own.
34    let auto_found = if auto {
35        let found = discovery::discover(&registry)?;
36        output::print_info(&format!(
37            "Looked for unregistered repositories in {} {}:",
38            found.roots.len(),
39            output::plural(found.roots.len(), "location", "locations")
40        ));
41        for root in &found.roots {
42            output::print_info(&format!("  {}", output::clean_path(root)));
43        }
44        total_found = found.found.len();
45        skipped_throwaway = found.throwaway;
46        skipped_opted_out = found.opted_out;
47        Some(found.found)
48    } else {
49        None
50    };
51
52    let scans: Vec<(PathBuf, Vec<PathBuf>)> = match auto_found {
53        Some(repos) => vec![(PathBuf::new(), repos)],
54        None => paths
55            .iter()
56            .map(|path_str| {
57                let path = Path::new(path_str)
58                    .canonicalize()
59                    .with_context(|| format!("Path not found: {path_str}"))?;
60                output::print_info(&format!(
61                    "Scanning {} for Git repositories...",
62                    output::clean_path(&path)
63                ));
64                let repos = scanner::scan_for_repos(&path)?;
65                total_found += repos.len();
66                Ok((path, repos))
67            })
68            .collect::<Result<_>>()?,
69    };
70
71    for (path, repos) in scans {
72        for repo in repos {
73            // A plugin manager's throwaway clone is not a workspace. Skipped quietly and
74            // counted, rather than listed: on the scan that motivated this there were
75            // twenty-eight of them, and twenty-eight lines of explanation would have
76            // buried the repositories the user actually wanted registered.
77            if !auto && super::link::is_throwaway_checkout(&path, &repo) {
78                skipped_throwaway += 1;
79                continue;
80            }
81            // Honoured before registration, not just before deletion. `devp init` is a
82            // bulk scan of somebody else's directory tree, and a repository carrying the
83            // opt-out has already said no to being one of dev-prune's. `devp link <path>`
84            // still registers it, because naming a repository is not a bulk scan.
85            if !auto && repo.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
86                skipped_opted_out += 1;
87                continue;
88            }
89            if registry.add_repo(repo.clone()) {
90                newly_added_repos.push(repo.clone());
91                let verb = if dry_run {
92                    "Would register"
93                } else {
94                    "Registered"
95                };
96                output::print_success(&format!("{verb}: {}", output::clean_path(&repo)));
97                let adoption =
98                    registry.adopt_moved_entry(&repo, scanner::git::repo_identity(&repo));
99                super::link::report_adoption(&adoption);
100            } else {
101                output::print_info(&format!(
102                    "Already registered: {}",
103                    output::clean_path(&repo)
104                ));
105                // Backfill, so one `devp init ~/code` teaches the whole registry to
106                // recognise a move later. Only when it is missing: this scan visits
107                // every repository under the path, every time it is run.
108                if registry.needs_identity(&repo) {
109                    let adoption =
110                        registry.adopt_moved_entry(&repo, scanner::git::repo_identity(&repo));
111                    super::link::report_adoption(&adoption);
112                }
113            }
114        }
115    }
116
117    if !dry_run {
118        registry.last_added_repos = newly_added_repos.clone();
119        registry.save()?;
120    }
121
122    output::print_header("Summary");
123    // The registry was mutated in memory either way; only the save is skipped. Saying
124    // "added" after a `--dry-run` would describe a file that was never written.
125    output::print_info(&format!(
126        "Found {total_found} {}{}, {} {} new",
127        if auto { "unregistered " } else { "Git " },
128        output::plural(total_found, "repo", "repos"),
129        if dry_run { "would add" } else { "added" },
130        newly_added_repos.len()
131    ));
132    if skipped_opted_out > 0 {
133        output::print_info(&format!(
134            "Skipped {skipped_opted_out} {} carrying `{}`.",
135            output::plural(skipped_opted_out, "repository", "repositories"),
136            constants::DEVPRUNE_IGNORE_FILE,
137        ));
138    }
139    if skipped_throwaway > 0 {
140        // Named, not silent. A scan that quietly drops repositories is one the user
141        // cannot debug when it drops one they wanted — and `devp link` is the way back.
142        output::print_info(&format!(
143            "Skipped {skipped_throwaway} disposable {} (plugin-manager clones, temp directories). \
144             `devp link <path>` registers one anyway.",
145            output::plural(skipped_throwaway, "checkout", "checkouts"),
146        ));
147    }
148    // After a dry run the in-memory count includes repositories that were never
149    // written; "would be tracked" is the honest phrasing for it.
150    output::print_info(&format!(
151        "{}: {}",
152        if dry_run {
153            "Would be tracked"
154        } else {
155            "Total tracked"
156        },
157        registry.repo_count()
158    ));
159
160    if !dry_run {
161        // Install anything missing. Idempotent, and identical to what `devp setup` and
162        // the post-upgrade pass do, so there is one code path and one set of rules —
163        // but only on a machine that has said yes to it. `devp init` was typed to
164        // register repositories, which is not yet a yes to a scheduled task, and
165        // stamping while the question is open would swallow the question itself.
166        if crate::setup::consent_state() == crate::setup::SetupConsent::Granted {
167            if let Some(report) = crate::setup::ensure_integrations_if_enabled(&registry)
168                && (report.changed_anything() || report.needs_attention())
169            {
170                output::print_header("Integrations");
171                report.print(false);
172            }
173            crate::setup::suppress_next_auto_setup();
174        }
175
176        if registry.settings.auto_config {
177            for repo in &newly_added_repos {
178                crate::commands::link::ensure_default_repo_config(repo);
179            }
180        }
181
182        // Validate (do NOT rewrite) existing per-repo configs.
183        //
184        // The previous behaviour re-serialised `unwrap_or_default()` into every repo,
185        // which silently replaced a malformed `.devprune.json` with defaults and created
186        // a config plus an exclude entry in repos that never had one. Report instead.
187        for repo in registry.repositories.keys() {
188            if let Err(e) = PerRepoConfig::load_with_diagnostics(repo) {
189                output::print_warning(&format!(
190                    "{}: `.devprune.json` could not be parsed and was left untouched — {e}",
191                    output::clean_path(repo)
192                ));
193            }
194        }
195    }
196
197    // Setting a machine up is exactly the moment to find out the binary is a version
198    // behind, so this asks now rather than waiting for the weekly interval that governs
199    // `devp run`. `--dry-run` included: the check writes nothing to disk of its own, and
200    // knowing before you commit to the real run is the point.
201    output::print_header("Version");
202    output::print_info(&format!("Installed: v{}", crate::constants::VERSION));
203    if registry.settings.update_check {
204        let mut checked = registry;
205        if crate::commands::update::check_now(&mut checked) && !dry_run {
206            let _ = checked.save();
207        }
208        registry = checked;
209    } else {
210        output::print_info(
211            "The release check is off (`devp config set update_check true` re-enables it).",
212        );
213    }
214
215    if dry_run {
216        output::print_header("Dry Run Complete");
217        output::print_info("Nothing was written. Re-run without `--dry-run` to register.");
218        return Ok(());
219    }
220
221    output::print_header("Initialization Complete");
222    output::print_success(&format!(
223        "dev-prune initialization complete! All {} tracked {} registered & verified.",
224        registry.repo_count(),
225        output::plural(registry.repo_count(), "repository", "repositories")
226    ));
227
228    output::print_info("Review or undo the integrations with `devp setup --status`.");
229
230    Ok(())
231}