1use std::collections::BTreeMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::time::SystemTime;
19
20use anyhow::Result;
21use chrono::{DateTime, Utc};
22
23use crate::adapters::BloatDir;
24use crate::config::{Registry, RepoEntry};
25use crate::constants;
26use crate::scanner;
27use crate::scanner::git;
28use crate::workspace;
29
30#[derive(Debug, Clone)]
32pub enum PruneStatus {
33 Pruned,
35 SkippedActive,
37 SkippedDryRun,
39 LockfileError(String),
41 ActivityCheckError(String),
46 PathMissing,
48 NotARepo,
58 SkippedAdapterWindow(u64),
69 NoBloat,
71 Disabled,
73 SkippedIgnored,
75 DeleteError(String),
77 SkippedSymlink(String),
83 ConfigError(String),
85 SkippedDeclaration(String),
91 SkippedNestedRepo(String),
99}
100
101impl std::fmt::Display for PruneStatus {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 match self {
104 PruneStatus::Pruned => write!(f, "Pruned"),
105 PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
106 PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
107 PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
108 PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
109 PruneStatus::PathMissing => {
110 write!(
111 f,
112 "Path no longer exists (`devp unlink --missing` clears it)"
113 )
114 }
115 PruneStatus::NotARepo => {
116 write!(
117 f,
118 "No longer a git repository (`devp unlink` removes the entry)"
119 )
120 }
121 PruneStatus::SkippedAdapterWindow(days) => {
122 write!(f, "Not examined (adapter idle window: {days} days)")
123 }
124 PruneStatus::NoBloat => write!(f, "No bloat found"),
125 PruneStatus::Disabled => write!(f, "Disabled"),
126 PruneStatus::SkippedIgnored => write!(
127 f,
128 "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
129 ),
130 PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
131 PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
132 PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
133 PruneStatus::SkippedDeclaration(e) => write!(f, "Skipped (declaration): {e}"),
134 PruneStatus::SkippedNestedRepo(e) => write!(f, "Skipped (nested repository): {e}"),
135 }
136 }
137}
138
139pub const BYTES_PER_MIB: u64 = 1024 * 1024;
142
143#[derive(Debug, Clone, Default, PartialEq)]
150pub struct AdapterFilter {
151 only: Option<Vec<String>>,
152 skip: Vec<String>,
153}
154
155impl AdapterFilter {
156 pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
161 let known: Vec<&'static str> = crate::adapters::get_all_adapters()
165 .iter()
166 .map(|a| a.name())
167 .chain(std::iter::once(constants::DECLARED_ADAPTER_NAME))
168 .collect();
169
170 let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
171 let mut out = Vec::new();
172 for token in raw.split(',') {
173 let name = token.trim().to_lowercase();
174 if name.is_empty() {
175 continue;
176 }
177 if !known.contains(&name.as_str()) {
178 anyhow::bail!(
179 "`--{flag} {name}` names no known package manager. Available: {}.",
180 known.join(", ")
181 );
182 }
183 if !out.contains(&name) {
184 out.push(name);
185 }
186 }
187 if out.is_empty() {
188 anyhow::bail!("`--{flag}` was given no adapter names.");
189 }
190 Ok(out)
191 };
192
193 let only = only.map(|raw| parse(raw, "only")).transpose()?;
194 let skip = skip
195 .map(|raw| parse(raw, "skip"))
196 .transpose()?
197 .unwrap_or_default();
198
199 if let Some(only) = &only
200 && let Some(clash) = only.iter().find(|n| skip.contains(n))
201 {
202 anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
203 }
204
205 Ok(Self { only, skip })
206 }
207
208 pub fn allows(&self, name: &str) -> bool {
210 if self.skip.iter().any(|s| s == name) {
211 return false;
212 }
213 match &self.only {
214 Some(only) => only.iter().any(|o| o == name),
215 None => true,
216 }
217 }
218
219 pub fn is_unrestricted(&self) -> bool {
221 self.only.is_none() && self.skip.is_empty()
222 }
223
224 pub fn describe(&self) -> Option<String> {
226 if self.is_unrestricted() {
227 return None;
228 }
229 let mut parts = Vec::new();
230 if let Some(only) = &self.only {
231 parts.push(format!("only {}", only.join(", ")));
232 }
233 if !self.skip.is_empty() {
234 parts.push(format!("skipping {}", self.skip.join(", ")));
235 }
236 Some(parts.join("; "))
237 }
238}
239
240#[derive(Debug, Clone)]
246pub struct PruneOptions {
247 pub idle_days: u64,
249 pub dry_run: bool,
251 pub force: bool,
253 pub only_dirs: Option<Vec<String>>,
259 pub adapters: AdapterFilter,
261 pub min_size_bytes: u64,
263 pub scan_depth: usize,
268 pub allow_manifest_rewrite: bool,
270 pub command_timeout_secs: u64,
275 pub build_idle_days: u64,
281 pub adapter_idle_days: BTreeMap<String, u64>,
286}
287
288impl Default for PruneOptions {
289 fn default() -> Self {
290 Self {
291 idle_days: 0,
292 dry_run: false,
293 force: false,
294 only_dirs: None,
295 adapters: AdapterFilter::default(),
296 min_size_bytes: 0,
297 scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
298 allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
299 command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
300 build_idle_days: crate::constants::DEFAULT_BUILD_IDLE_DAYS,
301 adapter_idle_days: BTreeMap::new(),
302 }
303 }
304}
305
306impl PruneOptions {
307 fn idle_threshold_for(&self, name: &str, opt_in: bool, base: u64) -> u64 {
321 adapter_idle_threshold(
322 name,
323 opt_in,
324 base,
325 self.build_idle_days,
326 &self.adapter_idle_days,
327 )
328 }
329
330 pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
332 Self {
333 idle_days,
334 dry_run,
335 force,
336 ..Self::default()
337 }
338 }
339}
340
341pub(crate) fn adapter_idle_threshold(
349 name: &str,
350 opt_in: bool,
351 base: u64,
352 build_idle_days: u64,
353 adapter_idle_days: &BTreeMap<String, u64>,
354) -> u64 {
355 let mut days = base;
356 if opt_in {
357 days = days.max(build_idle_days);
358 }
359 if let Some(&explicit) = adapter_idle_days.get(name) {
360 days = days.max(explicit);
361 }
362 days
363}
364
365#[derive(Debug, Clone)]
367pub struct PruneResult {
368 pub repo_path: PathBuf,
370 pub adapter_name: String,
372 pub bloat_dir: String,
374 pub size_freed: u64,
376 pub shared_bytes: u64,
380 pub runtime: Option<String>,
383 pub status: PruneStatus,
385}
386
387impl PruneResult {
388 pub fn project_dir(&self) -> PathBuf {
396 self.repo_path
397 .join(&self.bloat_dir)
398 .parent()
399 .map(Path::to_path_buf)
400 .unwrap_or_else(|| self.repo_path.clone())
401 }
402}
403
404pub fn prune_repo(
411 repo_path: &Path,
412 idle_days: u64,
413 dry_run: bool,
414 force: bool,
415) -> Vec<PruneResult> {
416 prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
417}
418
419pub fn prune_repo_selected(
428 repo_path: &Path,
429 idle_days: u64,
430 dry_run: bool,
431 force: bool,
432 only: Option<&[String]>,
433) -> Vec<PruneResult> {
434 prune_repo_with(
435 repo_path,
436 &PruneOptions {
437 only_dirs: only.map(<[String]>::to_vec),
438 ..PruneOptions::new(idle_days, dry_run, force)
439 },
440 )
441}
442
443pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
448 let idle_days = opts.idle_days;
449 let dry_run = opts.dry_run;
450 let force = opts.force;
451 let only = opts.only_dirs.as_deref();
452 let mut results = Vec::new();
453
454 if !repo_path.exists() {
458 results.push(PruneResult {
459 repo_path: repo_path.to_path_buf(),
460 adapter_name: "-".to_string(),
461 bloat_dir: "-".to_string(),
462 size_freed: 0,
463 shared_bytes: 0,
464 runtime: None,
465 status: PruneStatus::PathMissing,
466 });
467 return results;
468 }
469
470 if !scanner::is_git_repo(repo_path) {
477 results.push(PruneResult {
478 repo_path: repo_path.to_path_buf(),
479 adapter_name: "-".to_string(),
480 bloat_dir: "-".to_string(),
481 size_freed: 0,
482 shared_bytes: 0,
483 runtime: None,
484 status: PruneStatus::NotARepo,
485 });
486 return results;
487 }
488
489 if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
491 results.push(PruneResult {
492 repo_path: repo_path.to_path_buf(),
493 adapter_name: "-".to_string(),
494 bloat_dir: "-".to_string(),
495 size_freed: 0,
496 shared_bytes: 0,
497 runtime: None,
498 status: PruneStatus::SkippedIgnored,
499 });
500 return results;
501 }
502
503 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
507 Ok(cfg) => cfg,
508 Err(e) => {
509 results.push(PruneResult {
510 repo_path: repo_path.to_path_buf(),
511 adapter_name: "-".to_string(),
512 bloat_dir: "-".to_string(),
513 size_freed: 0,
514 shared_bytes: 0,
515 runtime: None,
516 status: PruneStatus::ConfigError(e),
517 });
518 return results;
519 }
520 };
521 if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
522 results.push(PruneResult {
523 repo_path: repo_path.to_path_buf(),
524 adapter_name: "-".to_string(),
525 bloat_dir: "-".to_string(),
526 size_freed: 0,
527 shared_bytes: 0,
528 runtime: None,
529 status: PruneStatus::SkippedIgnored,
530 });
531 return results;
532 }
533
534 let effective_idle_days = per_repo_config
536 .as_ref()
537 .and_then(|c| c.override_idle_days)
538 .unwrap_or(idle_days);
539
540 let min_size_bytes = if only.is_some() {
543 0
544 } else {
545 per_repo_config
546 .as_ref()
547 .and_then(|c| c.min_size_mb)
548 .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
549 .unwrap_or(opts.min_size_bytes)
550 };
551
552 if !force {
554 match git::is_repo_idle(repo_path, effective_idle_days) {
555 Ok(false) => {
556 results.push(PruneResult {
557 repo_path: repo_path.to_path_buf(),
558 adapter_name: "-".to_string(),
559 bloat_dir: "-".to_string(),
560 size_freed: 0,
561 shared_bytes: 0,
562 runtime: None,
563 status: PruneStatus::SkippedActive,
564 });
565 return results;
566 }
567 Ok(true) => {} Err(e) => {
569 results.push(PruneResult {
570 repo_path: repo_path.to_path_buf(),
571 adapter_name: "-".to_string(),
572 bloat_dir: "-".to_string(),
573 size_freed: 0,
574 shared_bytes: 0,
575 runtime: None,
576 status: PruneStatus::ActivityCheckError(e.to_string()),
577 });
578 return results;
579 }
580 }
581 }
582
583 let projects = workspace::discover_to_depth(
587 repo_path,
588 workspace::resolve_depth(repo_path, opts.scan_depth),
589 );
590
591 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
595
596 let mut idle_at: BTreeMap<u64, bool> = BTreeMap::new();
601
602 let mut window_reported: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
605
606 for project in &projects {
607 for adapter in &project.adapters {
608 if !opts.adapters.allows(adapter.name()) {
609 continue;
610 }
611
612 let threshold =
616 opts.idle_threshold_for(adapter.name(), adapter.opt_in(), effective_idle_days);
617 if threshold > effective_idle_days && !force {
618 let idle_enough = *idle_at
619 .entry(threshold)
620 .or_insert_with(|| git::is_repo_idle(repo_path, threshold).unwrap_or(false));
621 if !idle_enough {
622 if window_reported.insert(adapter.name().to_string()) {
626 results.push(PruneResult {
627 repo_path: repo_path.to_path_buf(),
628 adapter_name: adapter.name().to_string(),
629 bloat_dir: "-".to_string(),
630 size_freed: 0,
631 shared_bytes: 0,
632 runtime: None,
633 status: PruneStatus::SkippedAdapterWindow(threshold),
634 });
635 }
636 continue;
637 }
638 }
639
640 let bloat_dirs: Vec<(String, BloatDir)> = adapter
647 .bloat_dirs(&project.path)
648 .into_iter()
649 .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
650 .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
651 .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
652 .filter(|(_, bd)| claimed.insert(bd.path.clone()))
653 .collect();
654
655 if bloat_dirs.is_empty() {
656 continue;
657 }
658
659 let mut deletable: Vec<(String, BloatDir)> = Vec::new();
668 for (label, bd) in bloat_dirs {
669 if let Some(status) = shared_storage_refusal(&bd.path) {
670 results.push(PruneResult {
671 repo_path: repo_path.to_path_buf(),
672 adapter_name: adapter.name().to_string(),
673 bloat_dir: label,
674 size_freed: 0,
675 shared_bytes: 0,
676 runtime: None,
677 status,
678 });
679 continue;
680 }
681
682 deletable.push((label, bd));
683 }
684
685 if deletable.is_empty() {
686 continue;
687 }
688
689 if !dry_run {
691 let policy = crate::adapters::EnforcePolicy {
692 allow_rewrite: opts.allow_manifest_rewrite,
693 timeout: crate::adapters::command_timeout(opts.command_timeout_secs),
694 };
695 if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
696 for (label, _) in &deletable {
697 results.push(PruneResult {
698 repo_path: repo_path.to_path_buf(),
699 adapter_name: adapter.name().to_string(),
700 bloat_dir: label.clone(),
701 size_freed: 0,
702 shared_bytes: 0,
703 runtime: None,
704 status: PruneStatus::LockfileError(e.to_string()),
705 });
706 }
707 continue;
708 }
709 }
710
711 for (label, bd) in deletable {
712 if dry_run {
713 results.push(PruneResult {
714 repo_path: repo_path.to_path_buf(),
715 adapter_name: adapter.name().to_string(),
716 bloat_dir: label,
717 size_freed: bd.size_bytes,
718 shared_bytes: bd.shared_bytes,
719 runtime: None,
720 status: PruneStatus::SkippedDryRun,
721 });
722 continue;
723 }
724
725 let runtime = adapter.runtime_tag(&project.path, &bd.name);
729 results.push(delete_bloat(repo_path, adapter.name(), label, &bd, runtime));
730 }
731 }
732 }
733
734 results.extend(prune_declarations(
735 repo_path,
736 per_repo_config.as_ref(),
737 opts,
738 min_size_bytes,
739 only,
740 &mut claimed,
741 ));
742
743 if results.is_empty() {
745 results.push(PruneResult {
746 repo_path: repo_path.to_path_buf(),
747 adapter_name: "-".to_string(),
748 bloat_dir: "-".to_string(),
749 size_freed: 0,
750 shared_bytes: 0,
751 runtime: None,
752 status: PruneStatus::NoBloat,
753 });
754 }
755
756 results
757}
758
759fn shared_storage_refusal(path: &Path) -> Option<PruneStatus> {
768 if fs::symlink_metadata(path)
772 .map(|m| m.file_type().is_symlink())
773 .unwrap_or(false)
774 {
775 return Some(PruneStatus::SkippedSymlink(format!(
776 "`{}` is a symlink to storage dev-prune does not own — left alone. Remove \
777 the link yourself if you really want it gone.",
778 crate::output::clean_path(path)
779 )));
780 }
781
782 if is_mount_point(path) {
787 return Some(PruneStatus::SkippedSymlink(format!(
788 "`{}` is a mount point — it is on a different filesystem than the repository \
789 around it, so its contents are shared with whatever mounted it. Left alone.",
790 crate::output::clean_path(path)
791 )));
792 }
793
794 if let Some(nested) = find_nested_git(path) {
799 return Some(PruneStatus::SkippedNestedRepo(format!(
800 "`{}` contains a git repository at `{}` — refusing to delete it. Move or \
801 remove that checkout yourself if it holds nothing you need.",
802 crate::output::clean_path(path),
803 crate::output::clean_path(&nested)
804 )));
805 }
806
807 None
808}
809
810#[cfg(windows)]
817fn clear_readonly_attributes(path: &Path) {
818 let Ok(meta) = fs::symlink_metadata(path) else {
819 return;
820 };
821 if meta.file_type().is_symlink() {
822 return;
823 }
824 if meta.permissions().readonly() {
825 let mut perms = meta.permissions();
826 #[allow(clippy::permissions_set_readonly_false)]
830 perms.set_readonly(false);
831 let _ = fs::set_permissions(path, perms);
832 }
833 if meta.is_dir()
834 && let Ok(entries) = fs::read_dir(path)
835 {
836 for entry in entries.flatten() {
837 clear_readonly_attributes(&entry.path());
838 }
839 }
840}
841
842fn delete_bloat(
844 repo_path: &Path,
845 adapter_name: &str,
846 label: String,
847 bd: &BloatDir,
848 runtime: Option<String>,
849) -> PruneResult {
850 let size = bd.size_bytes;
851 let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
857 std::thread::sleep(std::time::Duration::from_millis(250));
858 fs::remove_dir_all(&bd.path)
859 });
860 #[cfg(windows)]
865 let delete = delete.or_else(|e| {
866 if e.kind() == std::io::ErrorKind::PermissionDenied {
867 clear_readonly_attributes(&bd.path);
868 fs::remove_dir_all(&bd.path)
869 } else {
870 Err(e)
871 }
872 });
873 let (size_freed, shared_bytes, status) = match delete {
876 Ok(()) => (size, bd.shared_bytes, PruneStatus::Pruned),
877 Err(_) if !bd.path.exists() => (size, bd.shared_bytes, PruneStatus::Pruned),
878 Err(e) => {
879 let remaining = crate::adapters::dir_size(&bd.path);
884 let freed = size.saturating_sub(remaining);
885 let message = if freed > 0 {
886 format!(
887 "{e} — `{}` was partially deleted ({} of {} remains) and is no \
888 longer usable. Close whatever holds it open, then run `devp \
889 restore` to rebuild it.",
890 crate::output::clean_path(&bd.path),
891 crate::output::format_bytes(remaining),
892 crate::output::format_bytes(size)
893 )
894 } else {
895 e.to_string()
896 };
897 (freed, 0, PruneStatus::DeleteError(message))
898 }
899 };
900 PruneResult {
901 repo_path: repo_path.to_path_buf(),
902 adapter_name: adapter_name.to_string(),
903 bloat_dir: label,
904 size_freed,
905 shared_bytes,
906 runtime,
907 status,
908 }
909}
910
911fn prune_declarations(
917 repo_path: &Path,
918 config: Option<&crate::config::PerRepoConfig>,
919 opts: &PruneOptions,
920 min_size_bytes: u64,
921 only: Option<&[String]>,
922 claimed: &mut std::collections::HashSet<PathBuf>,
923) -> Vec<PruneResult> {
924 let name = constants::DECLARED_ADAPTER_NAME;
925 if !opts.adapters.allows(name) {
926 return Vec::new();
927 }
928 let Some(declared) = config.and_then(|c| c.prunable.as_ref()) else {
929 return Vec::new();
930 };
931
932 let mut results = Vec::new();
933 for outcome in crate::declared::resolve(repo_path, declared) {
934 let target = match outcome {
935 crate::declared::Declaration::Prunable(target) => target,
936 crate::declared::Declaration::Refused { label, reason } => {
940 results.push(PruneResult {
941 repo_path: repo_path.to_path_buf(),
942 adapter_name: name.to_string(),
943 bloat_dir: label,
944 size_freed: 0,
945 shared_bytes: 0,
946 runtime: None,
947 status: PruneStatus::SkippedDeclaration(reason),
948 });
949 continue;
950 }
951 };
952
953 if only.is_some_and(|names| !names.contains(&target.label)) {
954 continue;
955 }
956 if target.size_bytes < min_size_bytes {
957 continue;
958 }
959 if !claimed.insert(target.path.clone()) {
960 continue;
961 }
962
963 let bd = BloatDir {
964 name: target.label.clone(),
965 path: target.path.clone(),
966 size_bytes: target.size_bytes,
967 shared_bytes: 0,
968 };
969 if let Some(status) = shared_storage_refusal(&bd.path) {
970 results.push(PruneResult {
971 repo_path: repo_path.to_path_buf(),
972 adapter_name: name.to_string(),
973 bloat_dir: target.label,
974 size_freed: 0,
975 shared_bytes: 0,
976 runtime: None,
977 status,
978 });
979 continue;
980 }
981 if opts.dry_run {
982 results.push(PruneResult {
983 repo_path: repo_path.to_path_buf(),
984 adapter_name: name.to_string(),
985 bloat_dir: target.label,
986 size_freed: target.size_bytes,
987 shared_bytes: 0,
988 runtime: None,
989 status: PruneStatus::SkippedDryRun,
990 });
991 continue;
992 }
993
994 results.push(delete_bloat(
999 repo_path,
1000 name,
1001 target.label.clone(),
1002 &bd,
1003 Some(target.rebuild.clone()),
1004 ));
1005 }
1006 results
1007}
1008
1009#[cfg(unix)]
1021fn is_mount_point(path: &Path) -> bool {
1022 use std::os::unix::fs::MetadataExt;
1023 let Some(parent) = path.parent() else {
1024 return false;
1025 };
1026 match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
1027 (Ok(here), Ok(above)) => here.dev() != above.dev(),
1028 _ => false,
1030 }
1031}
1032
1033#[cfg(not(unix))]
1034fn is_mount_point(_path: &Path) -> bool {
1035 false
1036}
1037
1038fn find_nested_git(dir: &Path) -> Option<PathBuf> {
1044 walkdir::WalkDir::new(dir)
1045 .follow_links(false)
1046 .into_iter()
1047 .flatten()
1048 .find(|e| e.file_name() == ".git")
1049 .map(|e| e.into_path())
1050}
1051
1052fn collect_bloat(
1063 repo_path: &Path,
1064 min_size_bytes: u64,
1065 depth: usize,
1066) -> (Vec<String>, Vec<BloatDir>, Vec<(String, u64)>) {
1067 let mut adapter_names: Vec<String> = Vec::new();
1068 let mut bloat: Vec<BloatDir> = Vec::new();
1069 let mut by_adapter: BTreeMap<String, u64> = BTreeMap::new();
1070 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1071
1072 for project in workspace::discover_to_depth(repo_path, depth) {
1073 for adapter in &project.adapters {
1074 let name = adapter.name();
1075 if !adapter_names.iter().any(|existing| existing == name) {
1076 adapter_names.push(name.to_string());
1077 }
1078 for bd in adapter.bloat_dirs(&project.path) {
1079 if bd.size_bytes < min_size_bytes {
1080 continue;
1081 }
1082 if shared_storage_refusal(&bd.path).is_some() {
1088 continue;
1089 }
1090 if claimed.insert(bd.path.clone()) {
1091 *by_adapter.entry(name.to_string()).or_default() += bd.size_bytes;
1092 bloat.push(BloatDir {
1093 name: workspace::relative_label(repo_path, &bd.path),
1094 ..bd
1095 });
1096 }
1097 }
1098 }
1099 }
1100
1101 let declared = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1105 .ok()
1106 .flatten()
1107 .and_then(|c| c.prunable)
1108 .unwrap_or_default();
1109 for outcome in crate::declared::resolve(repo_path, &declared) {
1110 let crate::declared::Declaration::Prunable(target) = outcome else {
1111 continue;
1112 };
1113 if target.size_bytes < min_size_bytes
1114 || shared_storage_refusal(&target.path).is_some()
1115 || !claimed.insert(target.path.clone())
1116 {
1117 continue;
1118 }
1119 let name = constants::DECLARED_ADAPTER_NAME;
1120 if !adapter_names.iter().any(|existing| existing == name) {
1121 adapter_names.push(name.to_string());
1122 }
1123 *by_adapter.entry(name.to_string()).or_default() += target.size_bytes;
1124 bloat.push(BloatDir {
1125 name: target.label,
1126 path: target.path,
1127 size_bytes: target.size_bytes,
1128 shared_bytes: 0,
1129 });
1130 }
1131
1132 (adapter_names, bloat, by_adapter.into_iter().collect())
1133}
1134
1135pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
1140 let mut all_results = Vec::new();
1141
1142 let mut repos: Vec<(PathBuf, u64, bool)> = registry
1148 .repositories
1149 .iter()
1150 .map(|(path, entry)| {
1151 let idle_days = entry
1152 .override_idle_days
1153 .unwrap_or(registry.settings.idle_days);
1154 (path.clone(), idle_days, entry.enabled)
1155 })
1156 .collect();
1157 repos.sort_by(|a, b| a.0.cmp(&b.0));
1158
1159 for (path, idle_days, enabled) in repos {
1160 if !enabled {
1161 all_results.push(PruneResult {
1162 repo_path: path.clone(),
1163 adapter_name: "-".to_string(),
1164 bloat_dir: "-".to_string(),
1165 size_freed: 0,
1166 shared_bytes: 0,
1167 runtime: None,
1168 status: PruneStatus::Disabled,
1169 });
1170 continue;
1171 }
1172
1173 let results = prune_repo_with(
1174 &path,
1175 &PruneOptions {
1176 idle_days,
1177 ..opts.clone()
1178 },
1179 );
1180
1181 let path_freed: u64 = results
1182 .iter()
1183 .filter(|r| matches!(r.status, PruneStatus::Pruned))
1184 .map(|r| r.size_freed)
1185 .sum();
1186
1187 if path_freed > 0 {
1188 registry.mark_pruned(&path, path_freed);
1189 }
1190
1191 all_results.extend(results);
1192 }
1193
1194 all_results
1195}
1196
1197pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
1199 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
1200}
1201
1202pub fn restore_project_to_depth(
1214 project_path: &Path,
1215 global_depth: usize,
1216 timeout: std::time::Duration,
1217) -> Result<Vec<(String, Result<()>)>> {
1218 let depth = workspace::resolve_depth(project_path, global_depth);
1219 let projects = workspace::discover_to_depth(project_path, depth);
1220
1221 if projects.is_empty() {
1222 anyhow::bail!(
1223 "No recognized package manager found in {}",
1224 project_path.display()
1225 );
1226 }
1227
1228 let mut results = Vec::new();
1229 for project in &projects {
1230 for adapter in &project.adapters {
1231 let label = if project.relative == "." {
1232 adapter.name().to_string()
1233 } else {
1234 format!("{} ({})", adapter.name(), project.relative)
1235 };
1236 results.push((label, adapter.restore(&project.path, timeout)));
1237 }
1238 }
1239
1240 Ok(results)
1241}
1242
1243fn owning_project(bloat_label: &str) -> &str {
1251 match bloat_label.rsplit_once('/') {
1252 Some((parent, _)) => parent,
1253 None => ".",
1254 }
1255}
1256
1257pub struct RestoreOutcome {
1276 pub label: String,
1278 pub adapter: String,
1280 pub bytes: u64,
1282 pub elapsed: std::time::Duration,
1284 pub result: Result<()>,
1286}
1287
1288pub fn restore_deleted(
1289 repo_path: &Path,
1290 deleted: &[crate::config::PrunedDir],
1291 global_depth: usize,
1292 timeout: std::time::Duration,
1293) -> Vec<RestoreOutcome> {
1294 let depth = workspace::resolve_depth(repo_path, global_depth);
1295 let projects = workspace::discover_to_depth(repo_path, depth);
1296
1297 let mut results = Vec::new();
1298 for dir in deleted {
1299 let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
1300 let timed = |result: Result<()>, started: std::time::Instant| RestoreOutcome {
1303 label: format!("{adapter_name} ({bloat_label})"),
1304 adapter: adapter_name.clone(),
1305 bytes: dir.size_freed,
1306 elapsed: started.elapsed(),
1307 result,
1308 };
1309 let runtime = dir.runtime.as_deref();
1310 let wanted = owning_project(bloat_label);
1311 let dir_name = bloat_label
1314 .rsplit_once('/')
1315 .map_or(bloat_label.as_str(), |(_, name)| name);
1316
1317 let found = projects
1318 .iter()
1319 .filter(|p| p.relative == wanted)
1320 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
1321 .find(|(_, a)| a.name() == adapter_name);
1322
1323 if let Some((project, adapter)) = found {
1324 let started = std::time::Instant::now();
1325 let result = adapter.restore_named(&project.path, dir_name, runtime, timeout);
1326 results.push(timed(result, started));
1327 continue;
1328 }
1329
1330 let project_dir = if wanted == "." {
1336 repo_path.to_path_buf()
1337 } else {
1338 repo_path.join(wanted)
1339 };
1340 let recorded = crate::adapters::get_all_adapters()
1341 .into_iter()
1342 .find(|a| a.name() == adapter_name);
1343 match recorded {
1344 Some(adapter) if project_dir.is_dir() => {
1345 let started = std::time::Instant::now();
1346 let result = adapter.restore_named(&project_dir, dir_name, runtime, timeout);
1347 results.push(timed(result, started));
1348 }
1349 _ => results.push(timed(
1350 Err(anyhow::anyhow!(
1351 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
1352 moved or removed since the prune. Restore it by hand if it still exists.",
1353 repo_path.display()
1354 )),
1355 std::time::Instant::now(),
1356 )),
1357 }
1358 }
1359
1360 results
1361}
1362
1363#[derive(Debug, Clone, PartialEq)]
1365pub enum SkipReason {
1366 Candidate,
1368 Active,
1370 Ignored,
1373 NoBloat,
1375 PathMissing,
1377 ConfigError(String),
1379}
1380
1381impl std::fmt::Display for SkipReason {
1382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1383 match self {
1384 SkipReason::Candidate => write!(f, "Candidate"),
1385 SkipReason::Active => write!(f, "Active (not idle)"),
1386 SkipReason::Ignored => write!(f, "Ignored"),
1387 SkipReason::NoBloat => write!(f, "No bloat found"),
1388 SkipReason::PathMissing => write!(f, "Path missing"),
1389 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1390 }
1391 }
1392}
1393
1394#[derive(Debug, Clone)]
1396pub struct RepoStatusEntry {
1397 pub path: PathBuf,
1399 pub entry: RepoEntry,
1401 pub reason: SkipReason,
1403 pub adapters: Vec<String>,
1405 pub bloat_dirs: Vec<BloatDir>,
1407 pub reclaimable_bytes: u64,
1409 pub reclaimable_by_adapter: Vec<(String, u64)>,
1412 pub last_activity: Option<DateTime<Utc>>,
1414 pub idle_days: u64,
1416}
1417
1418fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
1427 let registry_idle_days = reg_entry
1428 .override_idle_days
1429 .unwrap_or(registry.settings.idle_days);
1430
1431 if !path.exists() {
1434 return RepoStatusEntry {
1435 path: path.to_path_buf(),
1436 entry: reg_entry.clone(),
1437 reason: SkipReason::PathMissing,
1438 adapters: Vec::new(),
1439 bloat_dirs: Vec::new(),
1440 reclaimable_by_adapter: Vec::new(),
1441 reclaimable_bytes: 0,
1442 last_activity: None,
1443 idle_days: registry_idle_days,
1444 };
1445 }
1446
1447 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1452 Ok(cfg) => cfg,
1453 Err(e) => {
1454 return RepoStatusEntry {
1455 path: path.to_path_buf(),
1456 entry: reg_entry.clone(),
1457 reason: SkipReason::ConfigError(e),
1458 adapters: Vec::new(),
1459 bloat_dirs: Vec::new(),
1460 reclaimable_by_adapter: Vec::new(),
1461 reclaimable_bytes: 0,
1462 last_activity: last_activity_time(path),
1463 idle_days: registry_idle_days,
1464 };
1465 }
1466 };
1467 let idle_days = per_repo_config
1468 .as_ref()
1469 .and_then(|c| c.override_idle_days)
1470 .unwrap_or(registry_idle_days);
1471
1472 let is_ignored = !reg_entry.enabled
1474 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1475 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1476 if is_ignored {
1477 return RepoStatusEntry {
1478 path: path.to_path_buf(),
1479 entry: reg_entry.clone(),
1480 reason: SkipReason::Ignored,
1481 adapters: Vec::new(),
1482 bloat_dirs: Vec::new(),
1483 reclaimable_by_adapter: Vec::new(),
1484 reclaimable_bytes: 0,
1485 last_activity: last_activity_time(path),
1486 idle_days,
1487 };
1488 }
1489
1490 let activity = git::get_last_activity(path).ok().flatten();
1495 let activity_time = to_utc(activity);
1496 let is_idle = git::is_idle_at(activity, idle_days);
1497
1498 let min_size_bytes = per_repo_config
1500 .as_ref()
1501 .and_then(|c| c.min_size_mb)
1502 .unwrap_or(registry.settings.min_size_mb)
1503 .saturating_mul(BYTES_PER_MIB);
1504 let depth = workspace::clamp_depth(
1508 per_repo_config
1509 .as_ref()
1510 .and_then(|c| c.scan_depth)
1511 .unwrap_or(registry.settings.scan_depth),
1512 );
1513 let (adapter_names, all_bloat, by_adapter) = collect_bloat(path, min_size_bytes, depth);
1514 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1515
1516 let opt_in_names = crate::adapters::opt_in_adapter_names();
1525 let lowest_unmet_window = (is_idle && !by_adapter.is_empty())
1526 .then(|| {
1527 by_adapter
1528 .iter()
1529 .map(|(name, _)| {
1530 let threshold = adapter_idle_threshold(
1531 name,
1532 opt_in_names.contains(&name.as_str()),
1533 idle_days,
1534 registry.settings.build_idle_days,
1535 ®istry.settings.adapter_idle_days,
1536 );
1537 (!git::is_idle_at(activity, threshold)).then_some(threshold)
1538 })
1539 .collect::<Option<Vec<u64>>>()
1540 .and_then(|unmet| unmet.into_iter().min())
1541 })
1542 .flatten();
1543
1544 let reason = if !is_idle || lowest_unmet_window.is_some() {
1545 SkipReason::Active
1546 } else if all_bloat.is_empty() {
1547 SkipReason::NoBloat
1548 } else {
1549 SkipReason::Candidate
1550 };
1551
1552 RepoStatusEntry {
1553 path: path.to_path_buf(),
1554 entry: reg_entry.clone(),
1555 reason,
1556 adapters: adapter_names,
1557 bloat_dirs: all_bloat,
1558 reclaimable_bytes: reclaimable,
1559 reclaimable_by_adapter: by_adapter,
1560 last_activity: activity_time,
1561 idle_days: lowest_unmet_window.unwrap_or(idle_days),
1562 }
1563}
1564
1565fn scan_thread_count(total: usize) -> usize {
1582 let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
1583 .ok()
1584 .and_then(|v| v.trim().parse::<usize>().ok())
1585 .filter(|n| *n > 0)
1586 .unwrap_or_else(|| {
1587 std::thread::available_parallelism()
1588 .map(std::num::NonZeroUsize::get)
1589 .unwrap_or(4)
1590 .saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
1591 });
1592 clamp_scan_threads(requested, total)
1593}
1594
1595fn clamp_scan_threads(requested: usize, total: usize) -> usize {
1598 requested
1599 .clamp(1, constants::STATUS_SCAN_MAX_THREADS)
1600 .min(total.max(1))
1601}
1602
1603pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1604 get_full_status_reporting(registry, &|_done, _total| {})
1605}
1606
1607pub fn get_full_status_reporting(
1618 registry: &Registry,
1619 progress: &(dyn Fn(usize, usize) + Sync),
1620) -> Vec<RepoStatusEntry> {
1621 use std::sync::atomic::{AtomicUsize, Ordering};
1622
1623 let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
1624 let total = repos.len();
1625 let workers = scan_thread_count(total);
1626
1627 let next = AtomicUsize::new(0);
1632 let done = AtomicUsize::new(0);
1633
1634 let take_work = || {
1635 let mut mine = Vec::new();
1636 loop {
1637 let i = next.fetch_add(1, Ordering::Relaxed);
1638 if i >= total {
1639 break;
1640 }
1641 let (path, reg_entry) = repos[i];
1642 mine.push(status_for_repo(registry, path, reg_entry));
1643 progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
1644 }
1645 mine
1646 };
1647
1648 let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
1649 let mut handles = Vec::with_capacity(workers.saturating_sub(1));
1657 for n in 1..workers {
1658 match std::thread::Builder::new()
1659 .name(format!("devp-scan-{n}"))
1660 .spawn_scoped(scope, take_work)
1661 {
1662 Ok(handle) => handles.push(handle),
1663 Err(_) => break,
1664 }
1665 }
1666
1667 let mut chunks = vec![take_work()];
1670 chunks.extend(
1671 handles
1672 .into_iter()
1673 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
1676 );
1677 chunks
1678 });
1679
1680 let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
1681
1682 fn rank(reason: &SkipReason) -> u8 {
1687 match reason {
1688 SkipReason::Candidate => 0,
1689 SkipReason::PathMissing => 2,
1690 _ => 1,
1691 }
1692 }
1693 entries.sort_by(|a, b| {
1694 rank(&a.reason)
1695 .cmp(&rank(&b.reason))
1696 .then_with(|| a.path.cmp(&b.path))
1697 });
1698
1699 entries
1700}
1701
1702pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1711 let Some(n) = top else {
1712 return repos.to_vec();
1713 };
1714
1715 let mut ranked: Vec<usize> = (0..repos.len()).collect();
1716 ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1717 ranked.truncate(n);
1718 ranked.sort_unstable();
1719 ranked.into_iter().map(|i| repos[i].clone()).collect()
1720}
1721
1722pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1727 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1730 .ok()
1731 .flatten()
1732 && let Some(custom) = cfg.project_name
1733 && !custom.trim().is_empty()
1734 {
1735 return custom;
1736 }
1737
1738 let folder_name = repo_path
1739 .file_name()
1740 .map(|n| n.to_string_lossy().to_string())
1741 .unwrap_or_else(|| crate::output::clean_path(repo_path));
1742
1743 let duplicate_count = all_paths
1745 .iter()
1746 .filter(|p| {
1747 p.file_name()
1748 .map(|n| n.to_string_lossy().to_string())
1749 .as_deref()
1750 == Some(&folder_name)
1751 })
1752 .count();
1753
1754 if duplicate_count > 1
1755 && let Some(parent) = repo_path.parent()
1756 && let Some(parent_name) = parent.file_name()
1757 {
1758 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1759 }
1760
1761 folder_name
1762}
1763
1764fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1767 to_utc(git::get_last_activity(path).ok().flatten())
1768}
1769
1770fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1772 system_time.map(|st| {
1773 let duration = st
1774 .duration_since(SystemTime::UNIX_EPOCH)
1775 .unwrap_or_default();
1776 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1777 })
1778}
1779
1780#[cfg(test)]
1781mod tests {
1782 use super::*;
1783 use std::fs;
1784 use std::process::Command;
1785 use tempfile::TempDir;
1786
1787 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1790
1791 #[test]
1792 fn an_ordinary_directory_is_not_a_mount_point() {
1793 let tmp = TempDir::new().unwrap();
1796 let dir = tmp.path().join("node_modules");
1797 fs::create_dir_all(&dir).unwrap();
1798 assert!(!is_mount_point(&dir));
1799 }
1800
1801 #[test]
1802 fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1803 let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1806 assert!(!is_mount_point(root));
1807 }
1808
1809 #[cfg(windows)]
1810 #[test]
1811 fn a_read_only_file_does_not_stop_the_delete() {
1812 let tmp = TempDir::new().unwrap();
1817 let bloat = tmp.path().join("node_modules");
1818 fs::create_dir_all(bloat.join("pkg")).unwrap();
1819 let file = bloat.join("pkg").join("locked.js");
1820 fs::write(&file, "x").unwrap();
1821 let mut perms = fs::metadata(&file).unwrap().permissions();
1822 perms.set_readonly(true);
1823 fs::set_permissions(&file, perms).unwrap();
1824
1825 let bd = BloatDir {
1826 name: "node_modules".into(),
1827 path: bloat.clone(),
1828 size_bytes: 1,
1829 shared_bytes: 0,
1830 };
1831 let result = delete_bloat(tmp.path(), "npm", "node_modules".into(), &bd, None);
1832 assert!(
1833 matches!(result.status, PruneStatus::Pruned),
1834 "expected Pruned, got {:?}",
1835 result.status
1836 );
1837 assert!(!bloat.exists(), "the directory must be gone");
1838 }
1839
1840 fn create_git_repo_with_commit(path: &Path) {
1841 fs::create_dir_all(path).unwrap();
1842 Command::new("git")
1843 .args(["init"])
1844 .current_dir(path)
1845 .output()
1846 .unwrap();
1847 fs::write(path.join("README.md"), "# Test").unwrap();
1848 Command::new("git")
1849 .args(["add", "."])
1850 .current_dir(path)
1851 .output()
1852 .unwrap();
1853 Command::new("git")
1854 .args([
1855 "-c",
1856 "user.name=Test",
1857 "-c",
1858 "user.email=test@test.com",
1859 "commit",
1860 "-m",
1861 "initial",
1862 ])
1863 .current_dir(path)
1864 .output()
1865 .unwrap();
1866 }
1867
1868 #[test]
1869 fn a_bloat_label_names_the_project_that_owns_it() {
1870 assert_eq!(owning_project("node_modules"), ".");
1871 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1872 assert_eq!(
1873 owning_project("packages/@scope/app/.venv"),
1874 "packages/@scope/app"
1875 );
1876 }
1877
1878 #[test]
1879 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1880 let tmp = TempDir::new().unwrap();
1883 let root = tmp.path();
1884 for name in ["frontend", "docs"] {
1885 let dir = root.join(name);
1886 fs::create_dir_all(&dir).unwrap();
1887 fs::write(dir.join("package.json"), "{}").unwrap();
1888 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1889 }
1890
1891 let deleted = vec![crate::config::PrunedDir {
1892 repo_path: root.to_path_buf(),
1893 bloat_dir: "frontend/node_modules".to_string(),
1894 adapter: "npm".to_string(),
1895 size_freed: 0,
1896 runtime: None,
1897 }];
1898 let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1899
1900 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1901 assert_eq!(results[0].label, "npm (frontend/node_modules)");
1902 }
1903
1904 #[test]
1905 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1906 let tmp = TempDir::new().unwrap();
1909 let deleted = vec![crate::config::PrunedDir {
1910 repo_path: tmp.path().to_path_buf(),
1911 bloat_dir: "services/api/.venv".to_string(),
1912 adapter: "uv".to_string(),
1913 size_freed: 0,
1914 runtime: None,
1915 }];
1916 let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1917
1918 assert_eq!(results.len(), 1);
1919 assert_eq!(results[0].label, "uv (services/api/.venv)");
1920 let err = results[0].result.as_ref().unwrap_err().to_string();
1921 assert!(err.contains("services/api"), "names the missing project");
1922 assert!(err.contains("uv"), "names the adapter that owned it");
1923 }
1924
1925 #[test]
1926 fn test_prune_status_display() {
1927 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1928 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1929 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1930 assert_eq!(
1931 PruneStatus::SkippedAdapterWindow(45).to_string(),
1932 "Not examined (adapter idle window: 45 days)"
1933 );
1934 }
1935
1936 #[test]
1937 fn test_prune_repo_non_git() {
1938 let tmp = TempDir::new().unwrap();
1941 let results = prune_repo(tmp.path(), 15, false, false);
1942 assert_eq!(results.len(), 1);
1943 assert!(matches!(results[0].status, PruneStatus::NotARepo));
1944 }
1945
1946 #[test]
1947 fn test_prune_repo_active_skipped() {
1948 let tmp = TempDir::new().unwrap();
1949 let repo = tmp.path().join("repo");
1950 create_git_repo_with_commit(&repo);
1951 let results = prune_repo(&repo, 15, false, false);
1953 assert_eq!(results.len(), 1);
1954 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1955 }
1956
1957 #[test]
1960 fn test_unparseable_per_repo_config_skips_the_repo() {
1961 let tmp = TempDir::new().unwrap();
1962 let repo = tmp.path().join("repo");
1963 create_git_repo_with_commit(&repo);
1964 fs::create_dir(repo.join("target")).unwrap();
1965 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1966 fs::write(
1967 repo.join("Cargo.toml"),
1968 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1969 )
1970 .unwrap();
1971 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1972 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1974
1975 let results = prune_repo(&repo, 15, false, true);
1977
1978 assert_eq!(results.len(), 1);
1979 assert!(
1980 matches!(results[0].status, PruneStatus::ConfigError(_)),
1981 "expected ConfigError, got {:?}",
1982 results[0].status
1983 );
1984 assert!(repo.join("target").exists(), "target must survive");
1985 }
1986
1987 #[test]
1991 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1992 let tmp = TempDir::new().unwrap();
1993 let repo = tmp.path().join("repo");
1994 create_git_repo_with_commit(&repo);
1995 create_python_project(&repo);
1996 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1997
1998 let mut registry = Registry::default();
1999 registry.add_repo(repo.clone());
2000
2001 let entries = get_full_status(®istry);
2002 assert_eq!(entries.len(), 1);
2003 assert!(
2004 matches!(entries[0].reason, SkipReason::ConfigError(_)),
2005 "expected ConfigError, got {:?}",
2006 entries[0].reason
2007 );
2008 assert_eq!(entries[0].reclaimable_bytes, 0);
2009 }
2010
2011 #[test]
2012 fn test_prune_repo_dry_run() {
2013 let tmp = TempDir::new().unwrap();
2014 let repo = tmp.path().join("repo");
2015 create_git_repo_with_commit(&repo);
2016 create_go_project(&repo);
2019 let results = prune_repo(&repo, 15, true, true);
2021 let dry_run_results: Vec<_> = results
2022 .iter()
2023 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
2024 .collect();
2025 assert!(!dry_run_results.is_empty());
2026 assert!(repo.join("vendor").exists());
2028 }
2029
2030 #[test]
2031 fn an_unmet_adapter_window_reports_one_row_per_adapter_not_one_per_project() {
2032 let tmp = TempDir::new().unwrap();
2033 let repo = tmp.path().join("repo");
2034 create_git_repo_with_commit(&repo);
2035 create_go_project(&repo);
2038 create_go_project(&repo.join("sub"));
2039
2040 let mut opts = PruneOptions::new(0, true, false);
2044 opts.adapter_idle_days.insert("go".to_string(), 10_000);
2045 let results = prune_repo_with(&repo, &opts);
2046
2047 let windows: Vec<_> = results
2048 .iter()
2049 .filter(|r| matches!(r.status, PruneStatus::SkippedAdapterWindow(_)))
2050 .collect();
2051 assert_eq!(windows.len(), 1, "{results:?}");
2052 assert_eq!(windows[0].adapter_name, "go");
2053 assert!(matches!(
2054 windows[0].status,
2055 PruneStatus::SkippedAdapterWindow(10_000)
2056 ));
2057 assert!(
2058 !results
2059 .iter()
2060 .any(|r| matches!(r.status, PruneStatus::SkippedDryRun)),
2061 "a held adapter must not also report candidates: {results:?}"
2062 );
2063 }
2064
2065 fn create_python_project(dir: &Path) {
2070 fs::create_dir_all(dir).unwrap();
2071 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
2072 let venv = dir.join(".venv");
2073 fs::create_dir_all(&venv).unwrap();
2074 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
2075 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
2076 }
2077
2078 fn create_go_project(dir: &Path) {
2083 fs::create_dir_all(dir).unwrap();
2084 fs::write(dir.join("go.mod"), "module example.com/x\n\ngo 1.22\n").unwrap();
2085 fs::write(dir.join("go.sum"), "").unwrap();
2086 let vendor = dir.join("vendor");
2087 fs::create_dir_all(&vendor).unwrap();
2088 fs::write(vendor.join("modules.txt"), "# example.com/dep v1.0.0\n").unwrap();
2089 fs::write(vendor.join("payload.bin"), vec![0u8; 4096]).unwrap();
2090 }
2091
2092 fn labels(results: &[PruneResult]) -> Vec<String> {
2094 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
2095 out.sort();
2096 out
2097 }
2098
2099 #[test]
2100 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
2101 let tmp = TempDir::new().unwrap();
2102 let repo = tmp.path().join("repo");
2103 create_git_repo_with_commit(&repo);
2104
2105 create_go_project(&repo);
2106 fs::write(repo.join("package.json"), "{}").unwrap();
2107 fs::write(repo.join("package-lock.json"), "{}").unwrap();
2108 fs::create_dir(repo.join("node_modules")).unwrap();
2109 create_python_project(&repo);
2110
2111 let results = prune_repo(&repo, 15, true, true);
2112 assert_eq!(labels(&results), vec![".venv", "node_modules", "vendor"]);
2113 }
2114
2115 #[test]
2116 fn test_prune_finds_ecosystems_at_different_depths() {
2117 let tmp = TempDir::new().unwrap();
2118 let repo = tmp.path().join("repo");
2119 create_git_repo_with_commit(&repo);
2120
2121 fs::create_dir_all(repo.join("frontend")).unwrap();
2122 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
2123 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
2124 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
2125
2126 create_go_project(&repo.join("tools/cli"));
2127
2128 create_python_project(&repo.join("services/api"));
2129
2130 let results = prune_repo(&repo, 15, true, true);
2131 assert_eq!(
2132 labels(&results),
2133 vec![
2134 "frontend/node_modules",
2135 "services/api/.venv",
2136 "tools/cli/vendor",
2137 ]
2138 );
2139 }
2140
2141 #[test]
2142 fn test_prune_deletes_only_the_selected_nested_directory() {
2143 let tmp = TempDir::new().unwrap();
2144 let repo = tmp.path().join("repo");
2145 create_git_repo_with_commit(&repo);
2146 create_python_project(&repo.join("a"));
2147 create_python_project(&repo.join("b"));
2148
2149 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
2150
2151 assert_eq!(labels(&results), vec!["a/.venv"]);
2152 assert!(matches!(results[0].status, PruneStatus::Pruned));
2153 assert!(!repo.join("a/.venv").exists());
2154 assert!(repo.join("b/.venv").exists());
2155 }
2156
2157 #[test]
2158 fn test_prune_ignores_bloat_inside_a_nested_repository() {
2159 let tmp = TempDir::new().unwrap();
2160 let repo = tmp.path().join("repo");
2161 create_git_repo_with_commit(&repo);
2162 create_python_project(&repo.join("outer"));
2163
2164 let nested = repo.join("nested");
2167 create_git_repo_with_commit(&nested);
2168 create_python_project(&nested);
2169
2170 let results = prune_repo(&repo, 15, true, true);
2171 assert_eq!(labels(&results), vec!["outer/.venv"]);
2172 }
2173
2174 #[test]
2175 fn test_prune_repo_no_adapters() {
2176 let tmp = TempDir::new().unwrap();
2177 let repo = tmp.path().join("repo");
2178 create_git_repo_with_commit(&repo);
2179 let results = prune_repo(&repo, 15, false, true);
2181 assert!(
2182 results
2183 .iter()
2184 .any(|r| matches!(r.status, PruneStatus::NoBloat))
2185 );
2186 }
2187
2188 #[test]
2189 fn test_prune_all_disabled() {
2190 let tmp = TempDir::new().unwrap();
2191 let _registry_path = tmp.path().join("registry.json");
2192
2193 let mut registry = Registry::default();
2194 let repo_path = PathBuf::from("/nonexistent/repo");
2195 registry.add_repo(repo_path.clone());
2196 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
2197
2198 let results = prune_all(&mut registry, false, false);
2199 assert!(
2200 results
2201 .iter()
2202 .any(|r| matches!(r.status, PruneStatus::Disabled))
2203 );
2204 }
2205
2206 #[test]
2207 fn test_restore_project_no_adapters() {
2208 let tmp = TempDir::new().unwrap();
2209 let result = restore_project_to_depth(
2210 tmp.path(),
2211 crate::constants::DEFAULT_SCAN_DEPTH,
2212 TEST_TIMEOUT,
2213 );
2214 assert!(result.is_err());
2215 }
2216
2217 #[test]
2218 fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
2219 let tmp = TempDir::new().unwrap();
2223 let api = tmp.path().join("api");
2224 fs::create_dir_all(&api).unwrap();
2225 fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
2226 let deleted = vec![crate::config::PrunedDir {
2229 repo_path: tmp.path().to_path_buf(),
2230 bloat_dir: "api/.venv".to_string(),
2231 adapter: "venv".to_string(),
2232 size_freed: 0,
2233 runtime: None,
2234 }];
2235 let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
2238
2239 assert_eq!(results.len(), 1);
2240 assert_eq!(results[0].label, "venv (api/.venv)");
2241 if let Err(e) = &results[0].result {
2242 assert!(
2243 !e.to_string().contains("no longer a"),
2244 "the recorded adapter must be attempted, got: {e}"
2245 );
2246 }
2247 }
2248
2249 #[test]
2250 fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
2251 let tmp = TempDir::new().unwrap();
2254 let repo = tmp.path().join("repo");
2255 create_git_repo_with_commit(&repo);
2256 create_python_project(&repo);
2257 fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
2258
2259 let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
2260
2261 assert_eq!(results.len(), 1);
2262 let PruneStatus::SkippedNestedRepo(msg) = &results[0].status else {
2263 panic!("expected a refusal, got {:?}", results[0].status);
2264 };
2265 assert!(msg.contains("git repository"), "says why: {msg}");
2266 assert!(repo.join(".venv").exists(), "nothing may be deleted");
2267 assert_eq!(results[0].size_freed, 0);
2268 }
2269
2270 fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
2271 RepoStatusEntry {
2272 path: PathBuf::from(name),
2273 entry: RepoEntry::new(),
2274 reason: SkipReason::Candidate,
2275 adapters: Vec::new(),
2276 bloat_dirs: Vec::new(),
2277 reclaimable_by_adapter: Vec::new(),
2278 reclaimable_bytes: reclaimable,
2279 last_activity: None,
2280 idle_days: 15,
2281 }
2282 }
2283
2284 #[test]
2285 fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
2286 let repos = [
2287 status_entry("small", 10),
2288 status_entry("big", 300),
2289 status_entry("mid", 200),
2290 ];
2291 let names: Vec<String> = take_top(&repos, Some(2))
2292 .iter()
2293 .map(|e| e.path.display().to_string())
2294 .collect();
2295 assert_eq!(names, vec!["big", "mid"]);
2299 }
2300
2301 #[test]
2302 fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
2303 let repos = [status_entry("a", 1), status_entry("b", 2)];
2304 assert_eq!(take_top(&repos, None).len(), 2);
2305 assert_eq!(take_top(&repos, Some(10)).len(), 2);
2306 assert_eq!(take_top(&repos, Some(0)).len(), 0);
2307 }
2308
2309 #[test]
2310 fn test_restore_project_with_npm() {
2311 let tmp = TempDir::new().unwrap();
2312 fs::write(tmp.path().join("package.json"), "{}").unwrap();
2313 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
2314 let results = restore_project_to_depth(
2315 tmp.path(),
2316 crate::constants::DEFAULT_SCAN_DEPTH,
2317 TEST_TIMEOUT,
2318 );
2319 assert!(results.is_ok());
2321 let results = results.unwrap();
2322 assert!(!results.is_empty());
2323 assert_eq!(results[0].0, "npm");
2324 }
2325
2326 #[test]
2327 fn the_scan_never_starts_more_threads_than_there_is_work() {
2328 assert_eq!(clamp_scan_threads(32, 3), 3);
2330 assert_eq!(clamp_scan_threads(0, 0), 1);
2333 assert_eq!(clamp_scan_threads(0, 50), 1);
2334 }
2335
2336 #[test]
2337 fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
2338 assert_eq!(
2340 clamp_scan_threads(9_999, 500),
2341 constants::STATUS_SCAN_MAX_THREADS
2342 );
2343 }
2344
2345 #[test]
2346 fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
2347 assert_eq!(clamp_scan_threads(16, 1), 1);
2348 }
2349}