1use std::fs;
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use anyhow::Result;
20use chrono::{DateTime, Utc};
21
22use crate::adapters::BloatDir;
23use crate::config::{Registry, RepoEntry};
24use crate::constants;
25use crate::scanner;
26use crate::scanner::git;
27use crate::workspace;
28
29#[derive(Debug, Clone)]
31pub enum PruneStatus {
32 Pruned,
34 SkippedActive,
36 SkippedDryRun,
38 LockfileError(String),
40 ActivityCheckError(String),
45 PathMissing,
47 NoBloat,
49 Disabled,
51 SkippedIgnored,
53 DeleteError(String),
55 SkippedSymlink(String),
61 ConfigError(String),
63}
64
65impl std::fmt::Display for PruneStatus {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 PruneStatus::Pruned => write!(f, "Pruned"),
69 PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
70 PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
71 PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
72 PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
73 PruneStatus::PathMissing => {
74 write!(
75 f,
76 "Path no longer exists (`devp unlink --missing` clears it)"
77 )
78 }
79 PruneStatus::NoBloat => write!(f, "No bloat found"),
80 PruneStatus::Disabled => write!(f, "Disabled"),
81 PruneStatus::SkippedIgnored => write!(
82 f,
83 "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
84 ),
85 PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
86 PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
87 PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
88 }
89 }
90}
91
92pub const BYTES_PER_MIB: u64 = 1024 * 1024;
95
96#[derive(Debug, Clone, Default, PartialEq)]
103pub struct AdapterFilter {
104 only: Option<Vec<String>>,
105 skip: Vec<String>,
106}
107
108impl AdapterFilter {
109 pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
114 let known: Vec<&'static str> = crate::adapters::get_all_adapters()
115 .iter()
116 .map(|a| a.name())
117 .collect();
118
119 let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
120 let mut out = Vec::new();
121 for token in raw.split(',') {
122 let name = token.trim().to_lowercase();
123 if name.is_empty() {
124 continue;
125 }
126 if !known.contains(&name.as_str()) {
127 anyhow::bail!(
128 "`--{flag} {name}` names no known package manager. Available: {}.",
129 known.join(", ")
130 );
131 }
132 if !out.contains(&name) {
133 out.push(name);
134 }
135 }
136 if out.is_empty() {
137 anyhow::bail!("`--{flag}` was given no adapter names.");
138 }
139 Ok(out)
140 };
141
142 let only = only.map(|raw| parse(raw, "only")).transpose()?;
143 let skip = skip
144 .map(|raw| parse(raw, "skip"))
145 .transpose()?
146 .unwrap_or_default();
147
148 if let Some(only) = &only
149 && let Some(clash) = only.iter().find(|n| skip.contains(n))
150 {
151 anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
152 }
153
154 Ok(Self { only, skip })
155 }
156
157 pub fn allows(&self, name: &str) -> bool {
159 if self.skip.iter().any(|s| s == name) {
160 return false;
161 }
162 match &self.only {
163 Some(only) => only.iter().any(|o| o == name),
164 None => true,
165 }
166 }
167
168 pub fn is_unrestricted(&self) -> bool {
170 self.only.is_none() && self.skip.is_empty()
171 }
172
173 pub fn describe(&self) -> Option<String> {
175 if self.is_unrestricted() {
176 return None;
177 }
178 let mut parts = Vec::new();
179 if let Some(only) = &self.only {
180 parts.push(format!("only {}", only.join(", ")));
181 }
182 if !self.skip.is_empty() {
183 parts.push(format!("skipping {}", self.skip.join(", ")));
184 }
185 Some(parts.join("; "))
186 }
187}
188
189#[derive(Debug, Clone)]
195pub struct PruneOptions {
196 pub idle_days: u64,
198 pub dry_run: bool,
200 pub force: bool,
202 pub only_dirs: Option<Vec<String>>,
208 pub adapters: AdapterFilter,
210 pub min_size_bytes: u64,
212 pub scan_depth: usize,
217 pub allow_manifest_rewrite: bool,
219 pub command_timeout_secs: u64,
224 pub build_idle_days: u64,
230}
231
232impl Default for PruneOptions {
233 fn default() -> Self {
234 Self {
235 idle_days: 0,
236 dry_run: false,
237 force: false,
238 only_dirs: None,
239 adapters: AdapterFilter::default(),
240 min_size_bytes: 0,
241 scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
242 allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
243 command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
244 build_idle_days: crate::constants::DEFAULT_BUILD_IDLE_DAYS,
245 }
246 }
247}
248
249impl PruneOptions {
250 pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
252 Self {
253 idle_days,
254 dry_run,
255 force,
256 ..Self::default()
257 }
258 }
259}
260
261#[derive(Debug, Clone)]
263pub struct PruneResult {
264 pub repo_path: PathBuf,
266 pub adapter_name: String,
268 pub bloat_dir: String,
270 pub size_freed: u64,
272 pub shared_bytes: u64,
276 pub runtime: Option<String>,
279 pub status: PruneStatus,
281}
282
283impl PruneResult {
284 pub fn project_dir(&self) -> PathBuf {
292 self.repo_path
293 .join(&self.bloat_dir)
294 .parent()
295 .map(Path::to_path_buf)
296 .unwrap_or_else(|| self.repo_path.clone())
297 }
298}
299
300pub fn prune_repo(
307 repo_path: &Path,
308 idle_days: u64,
309 dry_run: bool,
310 force: bool,
311) -> Vec<PruneResult> {
312 prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
313}
314
315pub fn prune_repo_selected(
324 repo_path: &Path,
325 idle_days: u64,
326 dry_run: bool,
327 force: bool,
328 only: Option<&[String]>,
329) -> Vec<PruneResult> {
330 prune_repo_with(
331 repo_path,
332 &PruneOptions {
333 only_dirs: only.map(<[String]>::to_vec),
334 ..PruneOptions::new(idle_days, dry_run, force)
335 },
336 )
337}
338
339pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
344 let idle_days = opts.idle_days;
345 let dry_run = opts.dry_run;
346 let force = opts.force;
347 let only = opts.only_dirs.as_deref();
348 let mut results = Vec::new();
349
350 if !repo_path.exists() {
354 results.push(PruneResult {
355 repo_path: repo_path.to_path_buf(),
356 adapter_name: "-".to_string(),
357 bloat_dir: "-".to_string(),
358 size_freed: 0,
359 shared_bytes: 0,
360 runtime: None,
361 status: PruneStatus::PathMissing,
362 });
363 return results;
364 }
365
366 if !scanner::is_git_repo(repo_path) {
370 results.push(PruneResult {
371 repo_path: repo_path.to_path_buf(),
372 adapter_name: "-".to_string(),
373 bloat_dir: "-".to_string(),
374 size_freed: 0,
375 shared_bytes: 0,
376 runtime: None,
377 status: PruneStatus::ActivityCheckError(format!(
378 "`{}` is no longer a git repository — nothing was touched. \
379 `devp unlink` removes it from the registry.",
380 repo_path.display()
381 )),
382 });
383 return results;
384 }
385
386 if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
388 results.push(PruneResult {
389 repo_path: repo_path.to_path_buf(),
390 adapter_name: "-".to_string(),
391 bloat_dir: "-".to_string(),
392 size_freed: 0,
393 shared_bytes: 0,
394 runtime: None,
395 status: PruneStatus::SkippedIgnored,
396 });
397 return results;
398 }
399
400 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
404 Ok(cfg) => cfg,
405 Err(e) => {
406 results.push(PruneResult {
407 repo_path: repo_path.to_path_buf(),
408 adapter_name: "-".to_string(),
409 bloat_dir: "-".to_string(),
410 size_freed: 0,
411 shared_bytes: 0,
412 runtime: None,
413 status: PruneStatus::ConfigError(e),
414 });
415 return results;
416 }
417 };
418 if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
419 results.push(PruneResult {
420 repo_path: repo_path.to_path_buf(),
421 adapter_name: "-".to_string(),
422 bloat_dir: "-".to_string(),
423 size_freed: 0,
424 shared_bytes: 0,
425 runtime: None,
426 status: PruneStatus::SkippedIgnored,
427 });
428 return results;
429 }
430
431 let effective_idle_days = per_repo_config
433 .as_ref()
434 .and_then(|c| c.override_idle_days)
435 .unwrap_or(idle_days);
436
437 let min_size_bytes = if only.is_some() {
440 0
441 } else {
442 per_repo_config
443 .as_ref()
444 .and_then(|c| c.min_size_mb)
445 .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
446 .unwrap_or(opts.min_size_bytes)
447 };
448
449 if !force {
451 match git::is_repo_idle(repo_path, effective_idle_days) {
452 Ok(false) => {
453 results.push(PruneResult {
454 repo_path: repo_path.to_path_buf(),
455 adapter_name: "-".to_string(),
456 bloat_dir: "-".to_string(),
457 size_freed: 0,
458 shared_bytes: 0,
459 runtime: None,
460 status: PruneStatus::SkippedActive,
461 });
462 return results;
463 }
464 Ok(true) => {} Err(e) => {
466 results.push(PruneResult {
467 repo_path: repo_path.to_path_buf(),
468 adapter_name: "-".to_string(),
469 bloat_dir: "-".to_string(),
470 size_freed: 0,
471 shared_bytes: 0,
472 runtime: None,
473 status: PruneStatus::ActivityCheckError(e.to_string()),
474 });
475 return results;
476 }
477 }
478 }
479
480 let projects = workspace::discover_to_depth(
484 repo_path,
485 workspace::resolve_depth(repo_path, opts.scan_depth),
486 );
487
488 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
492
493 let mut build_idle: Option<bool> = None;
496
497 for project in &projects {
498 for adapter in &project.adapters {
499 if !opts.adapters.allows(adapter.name()) {
500 continue;
501 }
502
503 if adapter.opt_in() && !force {
507 let threshold = opts.build_idle_days.max(effective_idle_days);
508 let idle_enough = *build_idle.get_or_insert_with(|| {
509 git::is_repo_idle(repo_path, threshold).unwrap_or(false)
510 });
511 if !idle_enough {
512 continue;
513 }
514 }
515
516 let bloat_dirs: Vec<(String, BloatDir)> = adapter
523 .bloat_dirs(&project.path)
524 .into_iter()
525 .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
526 .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
527 .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
528 .filter(|(_, bd)| claimed.insert(bd.path.clone()))
529 .collect();
530
531 if bloat_dirs.is_empty() {
532 continue;
533 }
534
535 let mut deletable: Vec<(String, BloatDir)> = Vec::new();
544 for (label, bd) in bloat_dirs {
545 if fs::symlink_metadata(&bd.path)
550 .map(|m| m.file_type().is_symlink())
551 .unwrap_or(false)
552 {
553 results.push(PruneResult {
554 repo_path: repo_path.to_path_buf(),
555 adapter_name: adapter.name().to_string(),
556 bloat_dir: label,
557 size_freed: 0,
558 shared_bytes: 0,
559 runtime: None,
560 status: PruneStatus::SkippedSymlink(format!(
561 "`{}` is a symlink to storage dev-prune does not own — \
562 left alone. Remove the link yourself if you really want \
563 it gone.",
564 bd.path.display()
565 )),
566 });
567 continue;
568 }
569
570 if is_mount_point(&bd.path) {
575 results.push(PruneResult {
576 repo_path: repo_path.to_path_buf(),
577 adapter_name: adapter.name().to_string(),
578 bloat_dir: label,
579 size_freed: 0,
580 shared_bytes: 0,
581 runtime: None,
582 status: PruneStatus::SkippedSymlink(format!(
583 "`{}` is a mount point — it is on a different filesystem \
584 than the repository around it, so its contents are shared \
585 with whatever mounted it. Left alone.",
586 bd.path.display()
587 )),
588 });
589 continue;
590 }
591
592 if let Some(nested) = find_nested_git(&bd.path) {
597 results.push(PruneResult {
598 repo_path: repo_path.to_path_buf(),
599 adapter_name: adapter.name().to_string(),
600 bloat_dir: label,
601 size_freed: 0,
602 shared_bytes: 0,
603 runtime: None,
604 status: PruneStatus::DeleteError(format!(
605 "`{}` contains a git repository at `{}` — refusing to \
606 delete it. Move or remove that checkout yourself if it \
607 holds nothing you need.",
608 bd.path.display(),
609 nested.display()
610 )),
611 });
612 continue;
613 }
614
615 deletable.push((label, bd));
616 }
617
618 if deletable.is_empty() {
619 continue;
620 }
621
622 if !dry_run {
624 let policy = crate::adapters::EnforcePolicy {
625 allow_rewrite: opts.allow_manifest_rewrite,
626 timeout: std::time::Duration::from_secs(opts.command_timeout_secs),
627 };
628 if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
629 for (label, _) in &deletable {
630 results.push(PruneResult {
631 repo_path: repo_path.to_path_buf(),
632 adapter_name: adapter.name().to_string(),
633 bloat_dir: label.clone(),
634 size_freed: 0,
635 shared_bytes: 0,
636 runtime: None,
637 status: PruneStatus::LockfileError(e.to_string()),
638 });
639 }
640 continue;
641 }
642 }
643
644 for (label, bd) in deletable {
645 if dry_run {
646 results.push(PruneResult {
647 repo_path: repo_path.to_path_buf(),
648 adapter_name: adapter.name().to_string(),
649 bloat_dir: label,
650 size_freed: bd.size_bytes,
651 shared_bytes: bd.shared_bytes,
652 runtime: None,
653 status: PruneStatus::SkippedDryRun,
654 });
655 continue;
656 }
657
658 let size = bd.size_bytes;
659 let runtime = adapter.runtime_tag(&project.path, &bd.name);
663 let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
670 std::thread::sleep(std::time::Duration::from_millis(250));
671 fs::remove_dir_all(&bd.path)
672 });
673 match delete {
674 Ok(()) => {
677 results.push(PruneResult {
678 repo_path: repo_path.to_path_buf(),
679 adapter_name: adapter.name().to_string(),
680 bloat_dir: label,
681 size_freed: size,
682 shared_bytes: bd.shared_bytes,
683 runtime: runtime.clone(),
684 status: PruneStatus::Pruned,
685 });
686 }
687 Err(_) if !bd.path.exists() => {
688 results.push(PruneResult {
689 repo_path: repo_path.to_path_buf(),
690 adapter_name: adapter.name().to_string(),
691 bloat_dir: label,
692 size_freed: size,
693 shared_bytes: bd.shared_bytes,
694 runtime: runtime.clone(),
695 status: PruneStatus::Pruned,
696 });
697 }
698 Err(e) => {
699 let remaining = crate::adapters::dir_size(&bd.path);
705 let freed = size.saturating_sub(remaining);
706 let message = if freed > 0 {
707 format!(
708 "{e} — `{}` was partially deleted ({} of {} remains) \
709 and is no longer usable. Close whatever holds it open, \
710 then run `devp restore` to rebuild it.",
711 bd.path.display(),
712 crate::output::format_bytes(remaining),
713 crate::output::format_bytes(size)
714 )
715 } else {
716 e.to_string()
717 };
718 results.push(PruneResult {
719 repo_path: repo_path.to_path_buf(),
720 adapter_name: adapter.name().to_string(),
721 bloat_dir: label,
722 size_freed: freed,
723 shared_bytes: 0,
724 runtime,
725 status: PruneStatus::DeleteError(message),
726 });
727 }
728 }
729 }
730 }
731 }
732
733 if results.is_empty() {
735 results.push(PruneResult {
736 repo_path: repo_path.to_path_buf(),
737 adapter_name: "-".to_string(),
738 bloat_dir: "-".to_string(),
739 size_freed: 0,
740 shared_bytes: 0,
741 runtime: None,
742 status: PruneStatus::NoBloat,
743 });
744 }
745
746 results
747}
748
749#[cfg(unix)]
761fn is_mount_point(path: &Path) -> bool {
762 use std::os::unix::fs::MetadataExt;
763 let Some(parent) = path.parent() else {
764 return false;
765 };
766 match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
767 (Ok(here), Ok(above)) => here.dev() != above.dev(),
768 _ => false,
770 }
771}
772
773#[cfg(not(unix))]
774fn is_mount_point(_path: &Path) -> bool {
775 false
776}
777
778fn find_nested_git(dir: &Path) -> Option<PathBuf> {
784 walkdir::WalkDir::new(dir)
785 .follow_links(false)
786 .into_iter()
787 .flatten()
788 .find(|e| e.file_name() == ".git")
789 .map(|e| e.into_path())
790}
791
792fn collect_bloat(
799 repo_path: &Path,
800 min_size_bytes: u64,
801 depth: usize,
802) -> (Vec<String>, Vec<BloatDir>) {
803 let mut adapter_names: Vec<String> = Vec::new();
804 let mut bloat: Vec<BloatDir> = Vec::new();
805 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
806
807 for project in workspace::discover_to_depth(repo_path, depth) {
808 for adapter in &project.adapters {
809 let name = adapter.name();
810 if !adapter_names.iter().any(|existing| existing == name) {
811 adapter_names.push(name.to_string());
812 }
813 for bd in adapter.bloat_dirs(&project.path) {
814 if bd.size_bytes < min_size_bytes {
815 continue;
816 }
817 if fs::symlink_metadata(&bd.path)
823 .map(|m| m.file_type().is_symlink())
824 .unwrap_or(false)
825 || is_mount_point(&bd.path)
826 || find_nested_git(&bd.path).is_some()
827 {
828 continue;
829 }
830 if claimed.insert(bd.path.clone()) {
831 bloat.push(BloatDir {
832 name: workspace::relative_label(repo_path, &bd.path),
833 ..bd
834 });
835 }
836 }
837 }
838 }
839
840 (adapter_names, bloat)
841}
842
843pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
848 let mut all_results = Vec::new();
849
850 let mut repos: Vec<(PathBuf, u64, bool)> = registry
856 .repositories
857 .iter()
858 .map(|(path, entry)| {
859 let idle_days = entry
860 .override_idle_days
861 .unwrap_or(registry.settings.idle_days);
862 (path.clone(), idle_days, entry.enabled)
863 })
864 .collect();
865 repos.sort_by(|a, b| a.0.cmp(&b.0));
866
867 for (path, idle_days, enabled) in repos {
868 if !enabled {
869 all_results.push(PruneResult {
870 repo_path: path.clone(),
871 adapter_name: "-".to_string(),
872 bloat_dir: "-".to_string(),
873 size_freed: 0,
874 shared_bytes: 0,
875 runtime: None,
876 status: PruneStatus::Disabled,
877 });
878 continue;
879 }
880
881 let results = prune_repo_with(
882 &path,
883 &PruneOptions {
884 idle_days,
885 ..opts.clone()
886 },
887 );
888
889 let path_freed: u64 = results
890 .iter()
891 .filter(|r| matches!(r.status, PruneStatus::Pruned))
892 .map(|r| r.size_freed)
893 .sum();
894
895 if path_freed > 0 {
896 registry.mark_pruned(&path, path_freed);
897 }
898
899 all_results.extend(results);
900 }
901
902 all_results
903}
904
905pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
907 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
908}
909
910pub fn restore_project_to_depth(
922 project_path: &Path,
923 global_depth: usize,
924 timeout: std::time::Duration,
925) -> Result<Vec<(String, Result<()>)>> {
926 let depth = workspace::resolve_depth(project_path, global_depth);
927 let projects = workspace::discover_to_depth(project_path, depth);
928
929 if projects.is_empty() {
930 anyhow::bail!(
931 "No recognized package manager found in {}",
932 project_path.display()
933 );
934 }
935
936 let mut results = Vec::new();
937 for project in &projects {
938 for adapter in &project.adapters {
939 let label = if project.relative == "." {
940 adapter.name().to_string()
941 } else {
942 format!("{} ({})", adapter.name(), project.relative)
943 };
944 results.push((label, adapter.restore(&project.path, timeout)));
945 }
946 }
947
948 Ok(results)
949}
950
951fn owning_project(bloat_label: &str) -> &str {
959 match bloat_label.rsplit_once('/') {
960 Some((parent, _)) => parent,
961 None => ".",
962 }
963}
964
965pub fn restore_deleted(
977 repo_path: &Path,
978 deleted: &[crate::config::PrunedDir],
979 global_depth: usize,
980 timeout: std::time::Duration,
981) -> Vec<(String, Result<()>)> {
982 let depth = workspace::resolve_depth(repo_path, global_depth);
983 let projects = workspace::discover_to_depth(repo_path, depth);
984
985 let mut results = Vec::new();
986 for dir in deleted {
987 let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
988 let runtime = dir.runtime.as_deref();
989 let wanted = owning_project(bloat_label);
990 let label = format!("{adapter_name} ({bloat_label})");
991 let dir_name = bloat_label
994 .rsplit_once('/')
995 .map_or(bloat_label.as_str(), |(_, name)| name);
996
997 let found = projects
998 .iter()
999 .filter(|p| p.relative == wanted)
1000 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
1001 .find(|(_, a)| a.name() == adapter_name);
1002
1003 if let Some((project, adapter)) = found {
1004 results.push((
1005 label,
1006 adapter.restore_named(&project.path, dir_name, runtime, timeout),
1007 ));
1008 continue;
1009 }
1010
1011 let project_dir = if wanted == "." {
1017 repo_path.to_path_buf()
1018 } else {
1019 repo_path.join(wanted)
1020 };
1021 let recorded = crate::adapters::get_all_adapters()
1022 .into_iter()
1023 .find(|a| a.name() == adapter_name);
1024 match recorded {
1025 Some(adapter) if project_dir.is_dir() => {
1026 results.push((
1027 label,
1028 adapter.restore_named(&project_dir, dir_name, runtime, timeout),
1029 ));
1030 }
1031 _ => results.push((
1032 label,
1033 Err(anyhow::anyhow!(
1034 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
1035 moved or removed since the prune. Restore it by hand if it still exists.",
1036 repo_path.display()
1037 )),
1038 )),
1039 }
1040 }
1041
1042 results
1043}
1044
1045#[derive(Debug, Clone, PartialEq)]
1047pub enum SkipReason {
1048 Candidate,
1050 Active,
1052 Ignored,
1055 NoBloat,
1057 PathMissing,
1059 ConfigError(String),
1061}
1062
1063impl std::fmt::Display for SkipReason {
1064 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1065 match self {
1066 SkipReason::Candidate => write!(f, "Candidate"),
1067 SkipReason::Active => write!(f, "Active (not idle)"),
1068 SkipReason::Ignored => write!(f, "Ignored"),
1069 SkipReason::NoBloat => write!(f, "No bloat found"),
1070 SkipReason::PathMissing => write!(f, "Path missing"),
1071 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1072 }
1073 }
1074}
1075
1076#[derive(Debug, Clone)]
1078pub struct RepoStatusEntry {
1079 pub path: PathBuf,
1081 pub entry: RepoEntry,
1083 pub reason: SkipReason,
1085 pub adapters: Vec<String>,
1087 pub bloat_dirs: Vec<BloatDir>,
1089 pub reclaimable_bytes: u64,
1091 pub last_activity: Option<DateTime<Utc>>,
1093 pub idle_days: u64,
1095}
1096
1097fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
1106 let registry_idle_days = reg_entry
1107 .override_idle_days
1108 .unwrap_or(registry.settings.idle_days);
1109
1110 if !path.exists() {
1113 return RepoStatusEntry {
1114 path: path.to_path_buf(),
1115 entry: reg_entry.clone(),
1116 reason: SkipReason::PathMissing,
1117 adapters: Vec::new(),
1118 bloat_dirs: Vec::new(),
1119 reclaimable_bytes: 0,
1120 last_activity: None,
1121 idle_days: registry_idle_days,
1122 };
1123 }
1124
1125 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1130 Ok(cfg) => cfg,
1131 Err(e) => {
1132 return RepoStatusEntry {
1133 path: path.to_path_buf(),
1134 entry: reg_entry.clone(),
1135 reason: SkipReason::ConfigError(e),
1136 adapters: Vec::new(),
1137 bloat_dirs: Vec::new(),
1138 reclaimable_bytes: 0,
1139 last_activity: last_activity_time(path),
1140 idle_days: registry_idle_days,
1141 };
1142 }
1143 };
1144 let idle_days = per_repo_config
1145 .as_ref()
1146 .and_then(|c| c.override_idle_days)
1147 .unwrap_or(registry_idle_days);
1148
1149 let is_ignored = !reg_entry.enabled
1151 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1152 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1153 if is_ignored {
1154 return RepoStatusEntry {
1155 path: path.to_path_buf(),
1156 entry: reg_entry.clone(),
1157 reason: SkipReason::Ignored,
1158 adapters: Vec::new(),
1159 bloat_dirs: Vec::new(),
1160 reclaimable_bytes: 0,
1161 last_activity: last_activity_time(path),
1162 idle_days,
1163 };
1164 }
1165
1166 let activity = git::get_last_activity(path).ok().flatten();
1171 let activity_time = to_utc(activity);
1172 let is_idle = git::is_idle_at(activity, idle_days);
1173
1174 let min_size_bytes = per_repo_config
1176 .as_ref()
1177 .and_then(|c| c.min_size_mb)
1178 .unwrap_or(registry.settings.min_size_mb)
1179 .saturating_mul(BYTES_PER_MIB);
1180 let depth = workspace::clamp_depth(
1184 per_repo_config
1185 .as_ref()
1186 .and_then(|c| c.scan_depth)
1187 .unwrap_or(registry.settings.scan_depth),
1188 );
1189 let (adapter_names, all_bloat) = collect_bloat(path, min_size_bytes, depth);
1190 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1191
1192 let reason = if !is_idle {
1193 SkipReason::Active
1194 } else if all_bloat.is_empty() {
1195 SkipReason::NoBloat
1196 } else {
1197 SkipReason::Candidate
1198 };
1199
1200 RepoStatusEntry {
1201 path: path.to_path_buf(),
1202 entry: reg_entry.clone(),
1203 reason,
1204 adapters: adapter_names,
1205 bloat_dirs: all_bloat,
1206 reclaimable_bytes: reclaimable,
1207 last_activity: activity_time,
1208 idle_days,
1209 }
1210}
1211
1212fn scan_thread_count(total: usize) -> usize {
1229 let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
1230 .ok()
1231 .and_then(|v| v.trim().parse::<usize>().ok())
1232 .filter(|n| *n > 0)
1233 .unwrap_or_else(|| {
1234 std::thread::available_parallelism()
1235 .map(std::num::NonZeroUsize::get)
1236 .unwrap_or(4)
1237 .saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
1238 });
1239 clamp_scan_threads(requested, total)
1240}
1241
1242fn clamp_scan_threads(requested: usize, total: usize) -> usize {
1245 requested
1246 .clamp(1, constants::STATUS_SCAN_MAX_THREADS)
1247 .min(total.max(1))
1248}
1249
1250pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1251 get_full_status_reporting(registry, &|_done, _total| {})
1252}
1253
1254pub fn get_full_status_reporting(
1265 registry: &Registry,
1266 progress: &(dyn Fn(usize, usize) + Sync),
1267) -> Vec<RepoStatusEntry> {
1268 use std::sync::atomic::{AtomicUsize, Ordering};
1269
1270 let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
1271 let total = repos.len();
1272 let workers = scan_thread_count(total);
1273
1274 let next = AtomicUsize::new(0);
1279 let done = AtomicUsize::new(0);
1280
1281 let take_work = || {
1282 let mut mine = Vec::new();
1283 loop {
1284 let i = next.fetch_add(1, Ordering::Relaxed);
1285 if i >= total {
1286 break;
1287 }
1288 let (path, reg_entry) = repos[i];
1289 mine.push(status_for_repo(registry, path, reg_entry));
1290 progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
1291 }
1292 mine
1293 };
1294
1295 let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
1296 let mut handles = Vec::with_capacity(workers.saturating_sub(1));
1304 for n in 1..workers {
1305 match std::thread::Builder::new()
1306 .name(format!("devp-scan-{n}"))
1307 .spawn_scoped(scope, take_work)
1308 {
1309 Ok(handle) => handles.push(handle),
1310 Err(_) => break,
1311 }
1312 }
1313
1314 let mut chunks = vec![take_work()];
1317 chunks.extend(
1318 handles
1319 .into_iter()
1320 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
1323 );
1324 chunks
1325 });
1326
1327 let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
1328
1329 fn rank(reason: &SkipReason) -> u8 {
1334 match reason {
1335 SkipReason::Candidate => 0,
1336 SkipReason::PathMissing => 2,
1337 _ => 1,
1338 }
1339 }
1340 entries.sort_by(|a, b| {
1341 rank(&a.reason)
1342 .cmp(&rank(&b.reason))
1343 .then_with(|| a.path.cmp(&b.path))
1344 });
1345
1346 entries
1347}
1348
1349pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1358 let Some(n) = top else {
1359 return repos.to_vec();
1360 };
1361
1362 let mut ranked: Vec<usize> = (0..repos.len()).collect();
1363 ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1364 ranked.truncate(n);
1365 ranked.sort_unstable();
1366 ranked.into_iter().map(|i| repos[i].clone()).collect()
1367}
1368
1369pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1374 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1377 .ok()
1378 .flatten()
1379 && let Some(custom) = cfg.project_name
1380 && !custom.trim().is_empty()
1381 {
1382 return custom;
1383 }
1384
1385 let folder_name = repo_path
1386 .file_name()
1387 .map(|n| n.to_string_lossy().to_string())
1388 .unwrap_or_else(|| crate::output::clean_path(repo_path));
1389
1390 let duplicate_count = all_paths
1392 .iter()
1393 .filter(|p| {
1394 p.file_name()
1395 .map(|n| n.to_string_lossy().to_string())
1396 .as_deref()
1397 == Some(&folder_name)
1398 })
1399 .count();
1400
1401 if duplicate_count > 1
1402 && let Some(parent) = repo_path.parent()
1403 && let Some(parent_name) = parent.file_name()
1404 {
1405 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1406 }
1407
1408 folder_name
1409}
1410
1411fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1414 to_utc(git::get_last_activity(path).ok().flatten())
1415}
1416
1417fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1419 system_time.map(|st| {
1420 let duration = st
1421 .duration_since(SystemTime::UNIX_EPOCH)
1422 .unwrap_or_default();
1423 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1424 })
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429 use super::*;
1430 use std::fs;
1431 use std::process::Command;
1432 use tempfile::TempDir;
1433
1434 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1437
1438 #[test]
1439 fn an_ordinary_directory_is_not_a_mount_point() {
1440 let tmp = TempDir::new().unwrap();
1443 let dir = tmp.path().join("node_modules");
1444 fs::create_dir_all(&dir).unwrap();
1445 assert!(!is_mount_point(&dir));
1446 }
1447
1448 #[test]
1449 fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1450 let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1453 assert!(!is_mount_point(root));
1454 }
1455
1456 fn create_git_repo_with_commit(path: &Path) {
1457 fs::create_dir_all(path).unwrap();
1458 Command::new("git")
1459 .args(["init"])
1460 .current_dir(path)
1461 .output()
1462 .unwrap();
1463 fs::write(path.join("README.md"), "# Test").unwrap();
1464 Command::new("git")
1465 .args(["add", "."])
1466 .current_dir(path)
1467 .output()
1468 .unwrap();
1469 Command::new("git")
1470 .args([
1471 "-c",
1472 "user.name=Test",
1473 "-c",
1474 "user.email=test@test.com",
1475 "commit",
1476 "-m",
1477 "initial",
1478 ])
1479 .current_dir(path)
1480 .output()
1481 .unwrap();
1482 }
1483
1484 #[test]
1485 fn a_bloat_label_names_the_project_that_owns_it() {
1486 assert_eq!(owning_project("node_modules"), ".");
1487 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1488 assert_eq!(
1489 owning_project("packages/@scope/app/.venv"),
1490 "packages/@scope/app"
1491 );
1492 }
1493
1494 #[test]
1495 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1496 let tmp = TempDir::new().unwrap();
1499 let root = tmp.path();
1500 for name in ["frontend", "docs"] {
1501 let dir = root.join(name);
1502 fs::create_dir_all(&dir).unwrap();
1503 fs::write(dir.join("package.json"), "{}").unwrap();
1504 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1505 }
1506
1507 let deleted = vec![crate::config::PrunedDir {
1508 repo_path: root.to_path_buf(),
1509 bloat_dir: "frontend/node_modules".to_string(),
1510 adapter: "npm".to_string(),
1511 size_freed: 0,
1512 runtime: None,
1513 }];
1514 let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1515
1516 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1517 assert_eq!(results[0].0, "npm (frontend/node_modules)");
1518 }
1519
1520 #[test]
1521 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1522 let tmp = TempDir::new().unwrap();
1525 let deleted = vec![crate::config::PrunedDir {
1526 repo_path: tmp.path().to_path_buf(),
1527 bloat_dir: "services/api/.venv".to_string(),
1528 adapter: "uv".to_string(),
1529 size_freed: 0,
1530 runtime: None,
1531 }];
1532 let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1533
1534 assert_eq!(results.len(), 1);
1535 assert_eq!(results[0].0, "uv (services/api/.venv)");
1536 let err = results[0].1.as_ref().unwrap_err().to_string();
1537 assert!(err.contains("services/api"), "names the missing project");
1538 assert!(err.contains("uv"), "names the adapter that owned it");
1539 }
1540
1541 #[test]
1542 fn test_prune_status_display() {
1543 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1544 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1545 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1546 }
1547
1548 #[test]
1549 fn test_prune_repo_non_git() {
1550 let tmp = TempDir::new().unwrap();
1553 let results = prune_repo(tmp.path(), 15, false, false);
1554 assert_eq!(results.len(), 1);
1555 assert!(matches!(
1556 results[0].status,
1557 PruneStatus::ActivityCheckError(_)
1558 ));
1559 }
1560
1561 #[test]
1562 fn test_prune_repo_active_skipped() {
1563 let tmp = TempDir::new().unwrap();
1564 let repo = tmp.path().join("repo");
1565 create_git_repo_with_commit(&repo);
1566 let results = prune_repo(&repo, 15, false, false);
1568 assert_eq!(results.len(), 1);
1569 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1570 }
1571
1572 #[test]
1575 fn test_unparseable_per_repo_config_skips_the_repo() {
1576 let tmp = TempDir::new().unwrap();
1577 let repo = tmp.path().join("repo");
1578 create_git_repo_with_commit(&repo);
1579 fs::create_dir(repo.join("target")).unwrap();
1580 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1581 fs::write(
1582 repo.join("Cargo.toml"),
1583 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1584 )
1585 .unwrap();
1586 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1587 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1589
1590 let results = prune_repo(&repo, 15, false, true);
1592
1593 assert_eq!(results.len(), 1);
1594 assert!(
1595 matches!(results[0].status, PruneStatus::ConfigError(_)),
1596 "expected ConfigError, got {:?}",
1597 results[0].status
1598 );
1599 assert!(repo.join("target").exists(), "target must survive");
1600 }
1601
1602 #[test]
1606 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1607 let tmp = TempDir::new().unwrap();
1608 let repo = tmp.path().join("repo");
1609 create_git_repo_with_commit(&repo);
1610 create_python_project(&repo);
1611 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1612
1613 let mut registry = Registry::default();
1614 registry.add_repo(repo.clone());
1615
1616 let entries = get_full_status(®istry);
1617 assert_eq!(entries.len(), 1);
1618 assert!(
1619 matches!(entries[0].reason, SkipReason::ConfigError(_)),
1620 "expected ConfigError, got {:?}",
1621 entries[0].reason
1622 );
1623 assert_eq!(entries[0].reclaimable_bytes, 0);
1624 }
1625
1626 #[test]
1627 fn test_prune_repo_dry_run() {
1628 let tmp = TempDir::new().unwrap();
1629 let repo = tmp.path().join("repo");
1630 create_git_repo_with_commit(&repo);
1631 fs::create_dir(repo.join("target")).unwrap();
1633 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1634 fs::write(
1635 repo.join("Cargo.toml"),
1636 "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2024\"",
1637 )
1638 .unwrap();
1639 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1640 let results = prune_repo(&repo, 15, true, true);
1642 let dry_run_results: Vec<_> = results
1643 .iter()
1644 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1645 .collect();
1646 assert!(!dry_run_results.is_empty());
1647 assert!(repo.join("target").exists());
1649 }
1650
1651 fn create_python_project(dir: &Path) {
1656 fs::create_dir_all(dir).unwrap();
1657 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1658 let venv = dir.join(".venv");
1659 fs::create_dir_all(&venv).unwrap();
1660 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1661 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1662 }
1663
1664 fn labels(results: &[PruneResult]) -> Vec<String> {
1666 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1667 out.sort();
1668 out
1669 }
1670
1671 #[test]
1672 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1673 let tmp = TempDir::new().unwrap();
1674 let repo = tmp.path().join("repo");
1675 create_git_repo_with_commit(&repo);
1676
1677 fs::write(repo.join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
1678 fs::create_dir(repo.join("target")).unwrap();
1679 fs::write(repo.join("package.json"), "{}").unwrap();
1680 fs::write(repo.join("package-lock.json"), "{}").unwrap();
1681 fs::create_dir(repo.join("node_modules")).unwrap();
1682 create_python_project(&repo);
1683
1684 let results = prune_repo(&repo, 15, true, true);
1685 assert_eq!(labels(&results), vec![".venv", "node_modules", "target"]);
1686 }
1687
1688 #[test]
1689 fn test_prune_finds_ecosystems_at_different_depths() {
1690 let tmp = TempDir::new().unwrap();
1691 let repo = tmp.path().join("repo");
1692 create_git_repo_with_commit(&repo);
1693
1694 fs::create_dir_all(repo.join("frontend")).unwrap();
1695 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1696 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1697 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1698
1699 fs::create_dir_all(repo.join("tools/cli")).unwrap();
1700 fs::write(repo.join("tools/cli/Cargo.toml"), "[package]\nname = \"y\"").unwrap();
1701 fs::create_dir(repo.join("tools/cli/target")).unwrap();
1702
1703 create_python_project(&repo.join("services/api"));
1704
1705 let results = prune_repo(&repo, 15, true, true);
1706 assert_eq!(
1707 labels(&results),
1708 vec![
1709 "frontend/node_modules",
1710 "services/api/.venv",
1711 "tools/cli/target",
1712 ]
1713 );
1714 }
1715
1716 #[test]
1717 fn test_prune_deletes_only_the_selected_nested_directory() {
1718 let tmp = TempDir::new().unwrap();
1719 let repo = tmp.path().join("repo");
1720 create_git_repo_with_commit(&repo);
1721 create_python_project(&repo.join("a"));
1722 create_python_project(&repo.join("b"));
1723
1724 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1725
1726 assert_eq!(labels(&results), vec!["a/.venv"]);
1727 assert!(matches!(results[0].status, PruneStatus::Pruned));
1728 assert!(!repo.join("a/.venv").exists());
1729 assert!(repo.join("b/.venv").exists());
1730 }
1731
1732 #[test]
1733 fn test_prune_ignores_bloat_inside_a_nested_repository() {
1734 let tmp = TempDir::new().unwrap();
1735 let repo = tmp.path().join("repo");
1736 create_git_repo_with_commit(&repo);
1737 create_python_project(&repo.join("outer"));
1738
1739 let nested = repo.join("nested");
1742 create_git_repo_with_commit(&nested);
1743 create_python_project(&nested);
1744
1745 let results = prune_repo(&repo, 15, true, true);
1746 assert_eq!(labels(&results), vec!["outer/.venv"]);
1747 }
1748
1749 #[test]
1750 fn test_prune_repo_no_adapters() {
1751 let tmp = TempDir::new().unwrap();
1752 let repo = tmp.path().join("repo");
1753 create_git_repo_with_commit(&repo);
1754 let results = prune_repo(&repo, 15, false, true);
1756 assert!(
1757 results
1758 .iter()
1759 .any(|r| matches!(r.status, PruneStatus::NoBloat))
1760 );
1761 }
1762
1763 #[test]
1764 fn test_prune_all_disabled() {
1765 let tmp = TempDir::new().unwrap();
1766 let _registry_path = tmp.path().join("registry.json");
1767
1768 let mut registry = Registry::default();
1769 let repo_path = PathBuf::from("/nonexistent/repo");
1770 registry.add_repo(repo_path.clone());
1771 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1772
1773 let results = prune_all(&mut registry, false, false);
1774 assert!(
1775 results
1776 .iter()
1777 .any(|r| matches!(r.status, PruneStatus::Disabled))
1778 );
1779 }
1780
1781 #[test]
1782 fn test_restore_project_no_adapters() {
1783 let tmp = TempDir::new().unwrap();
1784 let result = restore_project_to_depth(
1785 tmp.path(),
1786 crate::constants::DEFAULT_SCAN_DEPTH,
1787 TEST_TIMEOUT,
1788 );
1789 assert!(result.is_err());
1790 }
1791
1792 #[test]
1793 fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
1794 let tmp = TempDir::new().unwrap();
1798 let api = tmp.path().join("api");
1799 fs::create_dir_all(&api).unwrap();
1800 fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1801 let deleted = vec![crate::config::PrunedDir {
1804 repo_path: tmp.path().to_path_buf(),
1805 bloat_dir: "api/.venv".to_string(),
1806 adapter: "venv".to_string(),
1807 size_freed: 0,
1808 runtime: None,
1809 }];
1810 let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
1813
1814 assert_eq!(results.len(), 1);
1815 assert_eq!(results[0].0, "venv (api/.venv)");
1816 if let Err(e) = &results[0].1 {
1817 assert!(
1818 !e.to_string().contains("no longer a"),
1819 "the recorded adapter must be attempted, got: {e}"
1820 );
1821 }
1822 }
1823
1824 #[test]
1825 fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
1826 let tmp = TempDir::new().unwrap();
1829 let repo = tmp.path().join("repo");
1830 create_git_repo_with_commit(&repo);
1831 create_python_project(&repo);
1832 fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
1833
1834 let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
1835
1836 assert_eq!(results.len(), 1);
1837 let PruneStatus::DeleteError(msg) = &results[0].status else {
1838 panic!("expected a refusal, got {:?}", results[0].status);
1839 };
1840 assert!(msg.contains("git repository"), "says why: {msg}");
1841 assert!(repo.join(".venv").exists(), "nothing may be deleted");
1842 assert_eq!(results[0].size_freed, 0);
1843 }
1844
1845 fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
1846 RepoStatusEntry {
1847 path: PathBuf::from(name),
1848 entry: RepoEntry::new(),
1849 reason: SkipReason::Candidate,
1850 adapters: Vec::new(),
1851 bloat_dirs: Vec::new(),
1852 reclaimable_bytes: reclaimable,
1853 last_activity: None,
1854 idle_days: 15,
1855 }
1856 }
1857
1858 #[test]
1859 fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
1860 let repos = [
1861 status_entry("small", 10),
1862 status_entry("big", 300),
1863 status_entry("mid", 200),
1864 ];
1865 let names: Vec<String> = take_top(&repos, Some(2))
1866 .iter()
1867 .map(|e| e.path.display().to_string())
1868 .collect();
1869 assert_eq!(names, vec!["big", "mid"]);
1873 }
1874
1875 #[test]
1876 fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
1877 let repos = [status_entry("a", 1), status_entry("b", 2)];
1878 assert_eq!(take_top(&repos, None).len(), 2);
1879 assert_eq!(take_top(&repos, Some(10)).len(), 2);
1880 assert_eq!(take_top(&repos, Some(0)).len(), 0);
1881 }
1882
1883 #[test]
1884 fn test_restore_project_with_npm() {
1885 let tmp = TempDir::new().unwrap();
1886 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1887 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1888 let results = restore_project_to_depth(
1889 tmp.path(),
1890 crate::constants::DEFAULT_SCAN_DEPTH,
1891 TEST_TIMEOUT,
1892 );
1893 assert!(results.is_ok());
1895 let results = results.unwrap();
1896 assert!(!results.is_empty());
1897 assert_eq!(results[0].0, "npm");
1898 }
1899
1900 #[test]
1901 fn the_scan_never_starts_more_threads_than_there_is_work() {
1902 assert_eq!(clamp_scan_threads(32, 3), 3);
1904 assert_eq!(clamp_scan_threads(0, 0), 1);
1907 assert_eq!(clamp_scan_threads(0, 50), 1);
1908 }
1909
1910 #[test]
1911 fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
1912 assert_eq!(
1914 clamp_scan_threads(9_999, 500),
1915 constants::STATUS_SCAN_MAX_THREADS
1916 );
1917 }
1918
1919 #[test]
1920 fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
1921 assert_eq!(clamp_scan_threads(16, 1), 1);
1922 }
1923}