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(®istry);
231 let found = discover_in(roots, ®istry).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(®istry);
260 let found = discover_in(roots, ®istry).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(®istry));
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}