Skip to main content

dev_prune/
discovery.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4//! Working out where the repositories are, without being told.
5//!
6//! `devp init <path>` has always registered a whole tree at once, but somebody has to
7//! know which tree to name. That is a fine thing to ask of a person setting their own
8//! machine up and a bad thing to ask of an assistant driving the tool on their behalf:
9//! an agent that has to guess `~/Code` guesses wrong on the machine that keeps its work
10//! on `D:\`, and the repositories it never found are the ones that go on wasting disk.
11//!
12//! So the roots are derived rather than guessed, from two sources that cost nothing:
13//!
14//! 1. **The neighbourhood.** People keep repositories next to each other. The parent of
15//!    every registered repository is therefore a place where more of them probably live,
16//!    and rescanning those parents is how registering one project ends up finding the
17//!    rest of the workspace around it. This is the source that works on any layout,
18//!    including drives and directory names no list could have anticipated.
19//! 2. **The working directory.** Bare `devp init` already scans `.`, so the workspace the
20//!    command was run from is evidence that costs nothing in surprise — and it is the only
21//!    evidence there is on a cold start where the code lives on a second drive.
22//! 3. **The conventions.** A machine with an empty registry has no neighbourhood yet, so
23//!    [`constants::CODE_ROOT_NAMES`] is probed by name under the home directory. This is
24//!    only ever the bootstrap: one registered repository anywhere makes source 1 better
25//!    than this one will ever be.
26//!
27//! Discovery registers. It does not delete, and nothing here shortens the distance
28//! between "registered" and "pruned": a discovered repository still has to go idle, still
29//! has to have every candidate directory proved recoverable by a lockfile, and still has
30//! to clear all seven safety invariants before anything is removed. That is what makes
31//! registering by itself a safe thing to do — the worst outcome of a wrong guess is a row
32//! in `devp status`.
33
34use std::collections::BTreeSet;
35use std::path::{Path, PathBuf};
36
37use anyhow::Result;
38
39use crate::config::{Registry, canonical_key};
40use crate::{constants, scanner};
41
42/// What one discovery pass found.
43#[derive(Debug, Default, PartialEq, Eq)]
44pub struct Discovery {
45    /// Repositories that are not in the registry and carry no opt-out.
46    pub found: Vec<PathBuf>,
47    /// Repositories skipped because they hold an `ignore.devprune.json`.
48    ///
49    /// Counted rather than listed, and counted rather than dropped silently: a discovery
50    /// pass that says "found nothing" on a machine where fifty repositories opted out
51    /// reads as a broken scan.
52    pub opted_out: usize,
53    /// Repositories skipped for being a package manager's disposable checkout.
54    pub throwaway: usize,
55    /// The directories that were actually walked.
56    pub roots: Vec<PathBuf>,
57}
58
59/// The workspace the command was run from.
60///
61/// Bare `devp init` already scans `.`, so including it costs nothing in surprise and
62/// closes the cold-start case the other two sources cannot: a machine whose code lives on
63/// a second drive has nothing under `~` to probe and no registered repository to work
64/// outwards from, and `~/Code` does not exist to be found. Standing in the workspace is
65/// the one piece of evidence available at that point, so the *parent* of the enclosing
66/// repository is used — that is the workspace, where its siblings are.
67fn working_directory_root() -> Option<PathBuf> {
68    let cwd = std::env::current_dir().ok()?;
69    let root = if scanner::is_git_repo(&cwd) {
70        cwd.parent()?.to_path_buf()
71    } else {
72        cwd
73    };
74    is_scannable_root(&root).then_some(root)
75}
76
77/// The home-relative conventional roots that exist on this machine.
78fn conventional_roots() -> Vec<PathBuf> {
79    let Some(home) = dirs::home_dir() else {
80        return Vec::new();
81    };
82    constants::CODE_ROOT_NAMES
83        .iter()
84        .map(|name| home.join(name))
85        .filter(|path| path.is_dir())
86        .collect()
87}
88
89/// The parent directory of every registered repository, where that parent is deep enough
90/// to be a workspace rather than a home directory or a drive root.
91pub fn neighbourhood_roots(registry: &Registry) -> Vec<PathBuf> {
92    registry
93        .repositories
94        .keys()
95        .filter_map(|repo| repo.parent())
96        .filter(|parent| is_scannable_root(parent))
97        .map(Path::to_path_buf)
98        .collect()
99}
100
101/// Whether a directory is specific enough to be walked.
102///
103/// The depth floor is the guard that matters. A repository cloned straight into `~` has
104/// the home directory as its parent, and scanning that means walking every cache and
105/// application-support tree on the machine — so one repository in an unusual place would
106/// silently turn a cheap pass into a full-disk crawl. A directory that is also the home
107/// directory is refused outright for the same reason, however deep it happens to sit.
108fn is_scannable_root(path: &Path) -> bool {
109    if !path.is_dir() {
110        return false;
111    }
112    if dirs::home_dir().is_some_and(|home| home == path) {
113        return false;
114    }
115    path.components().count() > constants::MIN_DISCOVERY_ROOT_DEPTH
116}
117
118/// Every root worth walking, with the ones contained in another root removed.
119///
120/// Without the containment pass, a registry holding `~/Code/api` and `~/Code/api/web`
121/// would walk `~/Code` twice over — once per parent — and a machine with fifty
122/// repositories in one tree would walk it fifty times.
123pub fn candidate_roots(registry: &Registry) -> Vec<PathBuf> {
124    let mut roots = neighbourhood_roots(registry);
125    roots.extend(conventional_roots());
126    roots.extend(working_directory_root());
127    dedupe_nested(roots)
128}
129
130/// Drop every root that lies inside another root in the same set.
131///
132/// Canonicalising first is not cosmetic on Windows: registry keys go through
133/// [`canonical_key`] and come back in `\\?\` verbatim form, while `dirs::home_dir()` does
134/// not, so `\\?\C:\Users\me\Code\api` and `C:\Users\me\Code` share no textual prefix and
135/// the tree would be walked twice.
136fn dedupe_nested(roots: Vec<PathBuf>) -> Vec<PathBuf> {
137    // Sorting first is what makes one pass enough: any container of a path sorts before
138    // it, so the shortest enclosing root is always already in `kept` by the time a path
139    // inside it is considered.
140    let sorted: BTreeSet<PathBuf> = roots.iter().map(|r| canonical_key(r)).collect();
141    let mut kept: Vec<PathBuf> = Vec::new();
142    for root in sorted {
143        if !kept.iter().any(|k| root.starts_with(k)) {
144            kept.push(root);
145        }
146    }
147    kept
148}
149
150/// Look for repositories this registry does not know about.
151///
152/// Nothing is registered here and the registry is not written — the caller decides what
153/// to do with the result, which is what lets `--dry-run` and the scheduled pass share one
154/// implementation.
155pub fn discover(registry: &Registry) -> Result<Discovery> {
156    discover_in(candidate_roots(registry), registry)
157}
158
159/// The body of [`discover`], with the roots supplied rather than derived.
160///
161/// Separated so tests can walk a temporary directory. A test that called [`discover`]
162/// would walk whatever `~/Code` happens to hold on the machine running it, which is both
163/// slow and a different answer on every machine.
164pub fn discover_in(roots: Vec<PathBuf>, registry: &Registry) -> Result<Discovery> {
165    let mut result = Discovery {
166        roots: dedupe_nested(roots),
167        ..Discovery::default()
168    };
169
170    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
171    for root in &result.roots {
172        // One unreadable root must not sink the pass. A drive that has gone away since it
173        // was last registered is the ordinary case, not an exceptional one.
174        let Ok(repos) = scanner::scan_for_repos(root) else {
175            continue;
176        };
177        for repo in repos {
178            if !seen.insert(repo.clone()) {
179                continue;
180            }
181            // Compared through `canonical_key` because that is the form `add_repo` stores:
182            // a root reached through a symlink would otherwise spell an already-registered
183            // repository a second way and offer it as a new find on every pass.
184            if registry.repositories.contains_key(&canonical_key(&repo)) {
185                continue;
186            }
187            if crate::commands::link::is_throwaway_checkout(root, &repo) {
188                result.throwaway += 1;
189                continue;
190            }
191            // The opt-out is honoured *before* registration, not just before deletion.
192            // A repository whose owner has said no should not appear in the registry at
193            // all — otherwise every `devp status` lists rows the user already declined.
194            if repo.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
195                result.opted_out += 1;
196                continue;
197            }
198            result.found.push(repo);
199        }
200    }
201
202    result.found.sort();
203    Ok(result)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::fs;
210    use tempfile::TempDir;
211
212    fn make_repo(at: &Path) {
213        fs::create_dir_all(at.join(".git")).expect("repo");
214    }
215
216    /// The neighbourhood is the point: one registered repository should make its
217    /// siblings discoverable without anyone naming the directory they share.
218    #[test]
219    fn a_registered_repository_makes_its_siblings_discoverable() {
220        let tmp = TempDir::new().expect("temp");
221        let workspace = tmp.path().join("a").join("b").join("workspace");
222        let known = workspace.join("known");
223        let sibling = workspace.join("sibling");
224        make_repo(&known);
225        make_repo(&sibling);
226
227        let mut registry = Registry::default();
228        registry.add_repo(known.clone());
229
230        let roots = neighbourhood_roots(&registry);
231        let found = discover_in(roots, &registry).expect("discovery");
232        assert!(
233            found.found.iter().any(|p| p.ends_with("sibling")),
234            "{:?}",
235            found.found
236        );
237        assert!(
238            !found.found.iter().any(|p| p.ends_with("known")),
239            "an already registered repository is not a find: {:?}",
240            found.found
241        );
242    }
243
244    /// The opt-out has to be honoured at registration, or declining a repository only
245    /// means seeing it in every status listing instead of pruning it.
246    #[test]
247    fn a_repository_that_opted_out_is_never_offered() {
248        let tmp = TempDir::new().expect("temp");
249        let workspace = tmp.path().join("a").join("b").join("workspace");
250        let known = workspace.join("known");
251        let declined = workspace.join("declined");
252        make_repo(&known);
253        make_repo(&declined);
254        fs::write(declined.join(constants::DEVPRUNE_IGNORE_FILE), "{}").expect("opt-out");
255
256        let mut registry = Registry::default();
257        registry.add_repo(known);
258
259        let roots = neighbourhood_roots(&registry);
260        let found = discover_in(roots, &registry).expect("discovery");
261        assert_eq!(found.opted_out, 1);
262        assert!(
263            !found.found.iter().any(|p| p.ends_with("declined")),
264            "{:?}",
265            found.found
266        );
267    }
268
269    /// A repository sitting directly in the home directory must not turn the pass into a
270    /// walk of the entire home directory.
271    #[test]
272    fn the_home_directory_is_never_a_scan_root() {
273        let Some(home) = dirs::home_dir() else {
274            return;
275        };
276        assert!(!is_scannable_root(&home));
277    }
278
279    /// Roots contained in another root are dropped, so a tree is walked once however
280    /// many of its repositories are registered.
281    #[test]
282    fn a_root_inside_another_root_is_not_walked_twice() {
283        let tmp = TempDir::new().expect("temp");
284        let outer = tmp.path().join("a").join("b").join("outer");
285        let inner = outer.join("nested").join("deeper");
286        make_repo(&outer.join("one"));
287        make_repo(&inner.join("two"));
288
289        let mut registry = Registry::default();
290        registry.add_repo(outer.join("one"));
291        registry.add_repo(inner.join("two"));
292
293        let roots = dedupe_nested(neighbourhood_roots(&registry));
294        let outer = canonical_key(&outer);
295        let under_outer: Vec<_> = roots.iter().filter(|r| r.starts_with(&outer)).collect();
296        assert_eq!(under_outer.len(), 1, "{roots:?}");
297    }
298
299    /// A shallow directory is refused however real it is, because the floor is what keeps
300    /// a drive root from being scanned.
301    #[test]
302    fn a_directory_too_close_to_the_filesystem_root_is_refused() {
303        let shallow = if cfg!(windows) {
304            PathBuf::from(r"C:\")
305        } else {
306            PathBuf::from("/")
307        };
308        assert!(!is_scannable_root(&shallow));
309    }
310}