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
810fn delete_bloat(
812 repo_path: &Path,
813 adapter_name: &str,
814 label: String,
815 bd: &BloatDir,
816 runtime: Option<String>,
817) -> PruneResult {
818 let size = bd.size_bytes;
819 let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
825 std::thread::sleep(std::time::Duration::from_millis(250));
826 fs::remove_dir_all(&bd.path)
827 });
828 let (size_freed, shared_bytes, status) = match delete {
831 Ok(()) => (size, bd.shared_bytes, PruneStatus::Pruned),
832 Err(_) if !bd.path.exists() => (size, bd.shared_bytes, PruneStatus::Pruned),
833 Err(e) => {
834 let remaining = crate::adapters::dir_size(&bd.path);
839 let freed = size.saturating_sub(remaining);
840 let message = if freed > 0 {
841 format!(
842 "{e} — `{}` was partially deleted ({} of {} remains) and is no \
843 longer usable. Close whatever holds it open, then run `devp \
844 restore` to rebuild it.",
845 crate::output::clean_path(&bd.path),
846 crate::output::format_bytes(remaining),
847 crate::output::format_bytes(size)
848 )
849 } else {
850 e.to_string()
851 };
852 (freed, 0, PruneStatus::DeleteError(message))
853 }
854 };
855 PruneResult {
856 repo_path: repo_path.to_path_buf(),
857 adapter_name: adapter_name.to_string(),
858 bloat_dir: label,
859 size_freed,
860 shared_bytes,
861 runtime,
862 status,
863 }
864}
865
866fn prune_declarations(
872 repo_path: &Path,
873 config: Option<&crate::config::PerRepoConfig>,
874 opts: &PruneOptions,
875 min_size_bytes: u64,
876 only: Option<&[String]>,
877 claimed: &mut std::collections::HashSet<PathBuf>,
878) -> Vec<PruneResult> {
879 let name = constants::DECLARED_ADAPTER_NAME;
880 if !opts.adapters.allows(name) {
881 return Vec::new();
882 }
883 let Some(declared) = config.and_then(|c| c.prunable.as_ref()) else {
884 return Vec::new();
885 };
886
887 let mut results = Vec::new();
888 for outcome in crate::declared::resolve(repo_path, declared) {
889 let target = match outcome {
890 crate::declared::Declaration::Prunable(target) => target,
891 crate::declared::Declaration::Refused { label, reason } => {
895 results.push(PruneResult {
896 repo_path: repo_path.to_path_buf(),
897 adapter_name: name.to_string(),
898 bloat_dir: label,
899 size_freed: 0,
900 shared_bytes: 0,
901 runtime: None,
902 status: PruneStatus::SkippedDeclaration(reason),
903 });
904 continue;
905 }
906 };
907
908 if only.is_some_and(|names| !names.contains(&target.label)) {
909 continue;
910 }
911 if target.size_bytes < min_size_bytes {
912 continue;
913 }
914 if !claimed.insert(target.path.clone()) {
915 continue;
916 }
917
918 let bd = BloatDir {
919 name: target.label.clone(),
920 path: target.path.clone(),
921 size_bytes: target.size_bytes,
922 shared_bytes: 0,
923 };
924 if let Some(status) = shared_storage_refusal(&bd.path) {
925 results.push(PruneResult {
926 repo_path: repo_path.to_path_buf(),
927 adapter_name: name.to_string(),
928 bloat_dir: target.label,
929 size_freed: 0,
930 shared_bytes: 0,
931 runtime: None,
932 status,
933 });
934 continue;
935 }
936 if opts.dry_run {
937 results.push(PruneResult {
938 repo_path: repo_path.to_path_buf(),
939 adapter_name: name.to_string(),
940 bloat_dir: target.label,
941 size_freed: target.size_bytes,
942 shared_bytes: 0,
943 runtime: None,
944 status: PruneStatus::SkippedDryRun,
945 });
946 continue;
947 }
948
949 results.push(delete_bloat(
954 repo_path,
955 name,
956 target.label.clone(),
957 &bd,
958 Some(target.rebuild.clone()),
959 ));
960 }
961 results
962}
963
964#[cfg(unix)]
976fn is_mount_point(path: &Path) -> bool {
977 use std::os::unix::fs::MetadataExt;
978 let Some(parent) = path.parent() else {
979 return false;
980 };
981 match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
982 (Ok(here), Ok(above)) => here.dev() != above.dev(),
983 _ => false,
985 }
986}
987
988#[cfg(not(unix))]
989fn is_mount_point(_path: &Path) -> bool {
990 false
991}
992
993fn find_nested_git(dir: &Path) -> Option<PathBuf> {
999 walkdir::WalkDir::new(dir)
1000 .follow_links(false)
1001 .into_iter()
1002 .flatten()
1003 .find(|e| e.file_name() == ".git")
1004 .map(|e| e.into_path())
1005}
1006
1007fn collect_bloat(
1018 repo_path: &Path,
1019 min_size_bytes: u64,
1020 depth: usize,
1021) -> (Vec<String>, Vec<BloatDir>, Vec<(String, u64)>) {
1022 let mut adapter_names: Vec<String> = Vec::new();
1023 let mut bloat: Vec<BloatDir> = Vec::new();
1024 let mut by_adapter: BTreeMap<String, u64> = BTreeMap::new();
1025 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1026
1027 for project in workspace::discover_to_depth(repo_path, depth) {
1028 for adapter in &project.adapters {
1029 let name = adapter.name();
1030 if !adapter_names.iter().any(|existing| existing == name) {
1031 adapter_names.push(name.to_string());
1032 }
1033 for bd in adapter.bloat_dirs(&project.path) {
1034 if bd.size_bytes < min_size_bytes {
1035 continue;
1036 }
1037 if shared_storage_refusal(&bd.path).is_some() {
1043 continue;
1044 }
1045 if claimed.insert(bd.path.clone()) {
1046 *by_adapter.entry(name.to_string()).or_default() += bd.size_bytes;
1047 bloat.push(BloatDir {
1048 name: workspace::relative_label(repo_path, &bd.path),
1049 ..bd
1050 });
1051 }
1052 }
1053 }
1054 }
1055
1056 let declared = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1060 .ok()
1061 .flatten()
1062 .and_then(|c| c.prunable)
1063 .unwrap_or_default();
1064 for outcome in crate::declared::resolve(repo_path, &declared) {
1065 let crate::declared::Declaration::Prunable(target) = outcome else {
1066 continue;
1067 };
1068 if target.size_bytes < min_size_bytes
1069 || shared_storage_refusal(&target.path).is_some()
1070 || !claimed.insert(target.path.clone())
1071 {
1072 continue;
1073 }
1074 let name = constants::DECLARED_ADAPTER_NAME;
1075 if !adapter_names.iter().any(|existing| existing == name) {
1076 adapter_names.push(name.to_string());
1077 }
1078 *by_adapter.entry(name.to_string()).or_default() += target.size_bytes;
1079 bloat.push(BloatDir {
1080 name: target.label,
1081 path: target.path,
1082 size_bytes: target.size_bytes,
1083 shared_bytes: 0,
1084 });
1085 }
1086
1087 (adapter_names, bloat, by_adapter.into_iter().collect())
1088}
1089
1090pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
1095 let mut all_results = Vec::new();
1096
1097 let mut repos: Vec<(PathBuf, u64, bool)> = registry
1103 .repositories
1104 .iter()
1105 .map(|(path, entry)| {
1106 let idle_days = entry
1107 .override_idle_days
1108 .unwrap_or(registry.settings.idle_days);
1109 (path.clone(), idle_days, entry.enabled)
1110 })
1111 .collect();
1112 repos.sort_by(|a, b| a.0.cmp(&b.0));
1113
1114 for (path, idle_days, enabled) in repos {
1115 if !enabled {
1116 all_results.push(PruneResult {
1117 repo_path: path.clone(),
1118 adapter_name: "-".to_string(),
1119 bloat_dir: "-".to_string(),
1120 size_freed: 0,
1121 shared_bytes: 0,
1122 runtime: None,
1123 status: PruneStatus::Disabled,
1124 });
1125 continue;
1126 }
1127
1128 let results = prune_repo_with(
1129 &path,
1130 &PruneOptions {
1131 idle_days,
1132 ..opts.clone()
1133 },
1134 );
1135
1136 let path_freed: u64 = results
1137 .iter()
1138 .filter(|r| matches!(r.status, PruneStatus::Pruned))
1139 .map(|r| r.size_freed)
1140 .sum();
1141
1142 if path_freed > 0 {
1143 registry.mark_pruned(&path, path_freed);
1144 }
1145
1146 all_results.extend(results);
1147 }
1148
1149 all_results
1150}
1151
1152pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
1154 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
1155}
1156
1157pub fn restore_project_to_depth(
1169 project_path: &Path,
1170 global_depth: usize,
1171 timeout: std::time::Duration,
1172) -> Result<Vec<(String, Result<()>)>> {
1173 let depth = workspace::resolve_depth(project_path, global_depth);
1174 let projects = workspace::discover_to_depth(project_path, depth);
1175
1176 if projects.is_empty() {
1177 anyhow::bail!(
1178 "No recognized package manager found in {}",
1179 project_path.display()
1180 );
1181 }
1182
1183 let mut results = Vec::new();
1184 for project in &projects {
1185 for adapter in &project.adapters {
1186 let label = if project.relative == "." {
1187 adapter.name().to_string()
1188 } else {
1189 format!("{} ({})", adapter.name(), project.relative)
1190 };
1191 results.push((label, adapter.restore(&project.path, timeout)));
1192 }
1193 }
1194
1195 Ok(results)
1196}
1197
1198fn owning_project(bloat_label: &str) -> &str {
1206 match bloat_label.rsplit_once('/') {
1207 Some((parent, _)) => parent,
1208 None => ".",
1209 }
1210}
1211
1212pub struct RestoreOutcome {
1231 pub label: String,
1233 pub adapter: String,
1235 pub bytes: u64,
1237 pub elapsed: std::time::Duration,
1239 pub result: Result<()>,
1241}
1242
1243pub fn restore_deleted(
1244 repo_path: &Path,
1245 deleted: &[crate::config::PrunedDir],
1246 global_depth: usize,
1247 timeout: std::time::Duration,
1248) -> Vec<RestoreOutcome> {
1249 let depth = workspace::resolve_depth(repo_path, global_depth);
1250 let projects = workspace::discover_to_depth(repo_path, depth);
1251
1252 let mut results = Vec::new();
1253 for dir in deleted {
1254 let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
1255 let timed = |result: Result<()>, started: std::time::Instant| RestoreOutcome {
1258 label: format!("{adapter_name} ({bloat_label})"),
1259 adapter: adapter_name.clone(),
1260 bytes: dir.size_freed,
1261 elapsed: started.elapsed(),
1262 result,
1263 };
1264 let runtime = dir.runtime.as_deref();
1265 let wanted = owning_project(bloat_label);
1266 let dir_name = bloat_label
1269 .rsplit_once('/')
1270 .map_or(bloat_label.as_str(), |(_, name)| name);
1271
1272 let found = projects
1273 .iter()
1274 .filter(|p| p.relative == wanted)
1275 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
1276 .find(|(_, a)| a.name() == adapter_name);
1277
1278 if let Some((project, adapter)) = found {
1279 let started = std::time::Instant::now();
1280 let result = adapter.restore_named(&project.path, dir_name, runtime, timeout);
1281 results.push(timed(result, started));
1282 continue;
1283 }
1284
1285 let project_dir = if wanted == "." {
1291 repo_path.to_path_buf()
1292 } else {
1293 repo_path.join(wanted)
1294 };
1295 let recorded = crate::adapters::get_all_adapters()
1296 .into_iter()
1297 .find(|a| a.name() == adapter_name);
1298 match recorded {
1299 Some(adapter) if project_dir.is_dir() => {
1300 let started = std::time::Instant::now();
1301 let result = adapter.restore_named(&project_dir, dir_name, runtime, timeout);
1302 results.push(timed(result, started));
1303 }
1304 _ => results.push(timed(
1305 Err(anyhow::anyhow!(
1306 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
1307 moved or removed since the prune. Restore it by hand if it still exists.",
1308 repo_path.display()
1309 )),
1310 std::time::Instant::now(),
1311 )),
1312 }
1313 }
1314
1315 results
1316}
1317
1318#[derive(Debug, Clone, PartialEq)]
1320pub enum SkipReason {
1321 Candidate,
1323 Active,
1325 Ignored,
1328 NoBloat,
1330 PathMissing,
1332 ConfigError(String),
1334}
1335
1336impl std::fmt::Display for SkipReason {
1337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1338 match self {
1339 SkipReason::Candidate => write!(f, "Candidate"),
1340 SkipReason::Active => write!(f, "Active (not idle)"),
1341 SkipReason::Ignored => write!(f, "Ignored"),
1342 SkipReason::NoBloat => write!(f, "No bloat found"),
1343 SkipReason::PathMissing => write!(f, "Path missing"),
1344 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1345 }
1346 }
1347}
1348
1349#[derive(Debug, Clone)]
1351pub struct RepoStatusEntry {
1352 pub path: PathBuf,
1354 pub entry: RepoEntry,
1356 pub reason: SkipReason,
1358 pub adapters: Vec<String>,
1360 pub bloat_dirs: Vec<BloatDir>,
1362 pub reclaimable_bytes: u64,
1364 pub reclaimable_by_adapter: Vec<(String, u64)>,
1367 pub last_activity: Option<DateTime<Utc>>,
1369 pub idle_days: u64,
1371}
1372
1373fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
1382 let registry_idle_days = reg_entry
1383 .override_idle_days
1384 .unwrap_or(registry.settings.idle_days);
1385
1386 if !path.exists() {
1389 return RepoStatusEntry {
1390 path: path.to_path_buf(),
1391 entry: reg_entry.clone(),
1392 reason: SkipReason::PathMissing,
1393 adapters: Vec::new(),
1394 bloat_dirs: Vec::new(),
1395 reclaimable_by_adapter: Vec::new(),
1396 reclaimable_bytes: 0,
1397 last_activity: None,
1398 idle_days: registry_idle_days,
1399 };
1400 }
1401
1402 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1407 Ok(cfg) => cfg,
1408 Err(e) => {
1409 return RepoStatusEntry {
1410 path: path.to_path_buf(),
1411 entry: reg_entry.clone(),
1412 reason: SkipReason::ConfigError(e),
1413 adapters: Vec::new(),
1414 bloat_dirs: Vec::new(),
1415 reclaimable_by_adapter: Vec::new(),
1416 reclaimable_bytes: 0,
1417 last_activity: last_activity_time(path),
1418 idle_days: registry_idle_days,
1419 };
1420 }
1421 };
1422 let idle_days = per_repo_config
1423 .as_ref()
1424 .and_then(|c| c.override_idle_days)
1425 .unwrap_or(registry_idle_days);
1426
1427 let is_ignored = !reg_entry.enabled
1429 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1430 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1431 if is_ignored {
1432 return RepoStatusEntry {
1433 path: path.to_path_buf(),
1434 entry: reg_entry.clone(),
1435 reason: SkipReason::Ignored,
1436 adapters: Vec::new(),
1437 bloat_dirs: Vec::new(),
1438 reclaimable_by_adapter: Vec::new(),
1439 reclaimable_bytes: 0,
1440 last_activity: last_activity_time(path),
1441 idle_days,
1442 };
1443 }
1444
1445 let activity = git::get_last_activity(path).ok().flatten();
1450 let activity_time = to_utc(activity);
1451 let is_idle = git::is_idle_at(activity, idle_days);
1452
1453 let min_size_bytes = per_repo_config
1455 .as_ref()
1456 .and_then(|c| c.min_size_mb)
1457 .unwrap_or(registry.settings.min_size_mb)
1458 .saturating_mul(BYTES_PER_MIB);
1459 let depth = workspace::clamp_depth(
1463 per_repo_config
1464 .as_ref()
1465 .and_then(|c| c.scan_depth)
1466 .unwrap_or(registry.settings.scan_depth),
1467 );
1468 let (adapter_names, all_bloat, by_adapter) = collect_bloat(path, min_size_bytes, depth);
1469 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1470
1471 let opt_in_names = crate::adapters::opt_in_adapter_names();
1480 let lowest_unmet_window = (is_idle && !by_adapter.is_empty())
1481 .then(|| {
1482 by_adapter
1483 .iter()
1484 .map(|(name, _)| {
1485 let threshold = adapter_idle_threshold(
1486 name,
1487 opt_in_names.contains(&name.as_str()),
1488 idle_days,
1489 registry.settings.build_idle_days,
1490 ®istry.settings.adapter_idle_days,
1491 );
1492 (!git::is_idle_at(activity, threshold)).then_some(threshold)
1493 })
1494 .collect::<Option<Vec<u64>>>()
1495 .and_then(|unmet| unmet.into_iter().min())
1496 })
1497 .flatten();
1498
1499 let reason = if !is_idle || lowest_unmet_window.is_some() {
1500 SkipReason::Active
1501 } else if all_bloat.is_empty() {
1502 SkipReason::NoBloat
1503 } else {
1504 SkipReason::Candidate
1505 };
1506
1507 RepoStatusEntry {
1508 path: path.to_path_buf(),
1509 entry: reg_entry.clone(),
1510 reason,
1511 adapters: adapter_names,
1512 bloat_dirs: all_bloat,
1513 reclaimable_bytes: reclaimable,
1514 reclaimable_by_adapter: by_adapter,
1515 last_activity: activity_time,
1516 idle_days: lowest_unmet_window.unwrap_or(idle_days),
1517 }
1518}
1519
1520fn scan_thread_count(total: usize) -> usize {
1537 let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
1538 .ok()
1539 .and_then(|v| v.trim().parse::<usize>().ok())
1540 .filter(|n| *n > 0)
1541 .unwrap_or_else(|| {
1542 std::thread::available_parallelism()
1543 .map(std::num::NonZeroUsize::get)
1544 .unwrap_or(4)
1545 .saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
1546 });
1547 clamp_scan_threads(requested, total)
1548}
1549
1550fn clamp_scan_threads(requested: usize, total: usize) -> usize {
1553 requested
1554 .clamp(1, constants::STATUS_SCAN_MAX_THREADS)
1555 .min(total.max(1))
1556}
1557
1558pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1559 get_full_status_reporting(registry, &|_done, _total| {})
1560}
1561
1562pub fn get_full_status_reporting(
1573 registry: &Registry,
1574 progress: &(dyn Fn(usize, usize) + Sync),
1575) -> Vec<RepoStatusEntry> {
1576 use std::sync::atomic::{AtomicUsize, Ordering};
1577
1578 let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
1579 let total = repos.len();
1580 let workers = scan_thread_count(total);
1581
1582 let next = AtomicUsize::new(0);
1587 let done = AtomicUsize::new(0);
1588
1589 let take_work = || {
1590 let mut mine = Vec::new();
1591 loop {
1592 let i = next.fetch_add(1, Ordering::Relaxed);
1593 if i >= total {
1594 break;
1595 }
1596 let (path, reg_entry) = repos[i];
1597 mine.push(status_for_repo(registry, path, reg_entry));
1598 progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
1599 }
1600 mine
1601 };
1602
1603 let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
1604 let mut handles = Vec::with_capacity(workers.saturating_sub(1));
1612 for n in 1..workers {
1613 match std::thread::Builder::new()
1614 .name(format!("devp-scan-{n}"))
1615 .spawn_scoped(scope, take_work)
1616 {
1617 Ok(handle) => handles.push(handle),
1618 Err(_) => break,
1619 }
1620 }
1621
1622 let mut chunks = vec![take_work()];
1625 chunks.extend(
1626 handles
1627 .into_iter()
1628 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
1631 );
1632 chunks
1633 });
1634
1635 let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
1636
1637 fn rank(reason: &SkipReason) -> u8 {
1642 match reason {
1643 SkipReason::Candidate => 0,
1644 SkipReason::PathMissing => 2,
1645 _ => 1,
1646 }
1647 }
1648 entries.sort_by(|a, b| {
1649 rank(&a.reason)
1650 .cmp(&rank(&b.reason))
1651 .then_with(|| a.path.cmp(&b.path))
1652 });
1653
1654 entries
1655}
1656
1657pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1666 let Some(n) = top else {
1667 return repos.to_vec();
1668 };
1669
1670 let mut ranked: Vec<usize> = (0..repos.len()).collect();
1671 ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1672 ranked.truncate(n);
1673 ranked.sort_unstable();
1674 ranked.into_iter().map(|i| repos[i].clone()).collect()
1675}
1676
1677pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1682 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1685 .ok()
1686 .flatten()
1687 && let Some(custom) = cfg.project_name
1688 && !custom.trim().is_empty()
1689 {
1690 return custom;
1691 }
1692
1693 let folder_name = repo_path
1694 .file_name()
1695 .map(|n| n.to_string_lossy().to_string())
1696 .unwrap_or_else(|| crate::output::clean_path(repo_path));
1697
1698 let duplicate_count = all_paths
1700 .iter()
1701 .filter(|p| {
1702 p.file_name()
1703 .map(|n| n.to_string_lossy().to_string())
1704 .as_deref()
1705 == Some(&folder_name)
1706 })
1707 .count();
1708
1709 if duplicate_count > 1
1710 && let Some(parent) = repo_path.parent()
1711 && let Some(parent_name) = parent.file_name()
1712 {
1713 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1714 }
1715
1716 folder_name
1717}
1718
1719fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1722 to_utc(git::get_last_activity(path).ok().flatten())
1723}
1724
1725fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1727 system_time.map(|st| {
1728 let duration = st
1729 .duration_since(SystemTime::UNIX_EPOCH)
1730 .unwrap_or_default();
1731 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1732 })
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737 use super::*;
1738 use std::fs;
1739 use std::process::Command;
1740 use tempfile::TempDir;
1741
1742 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1745
1746 #[test]
1747 fn an_ordinary_directory_is_not_a_mount_point() {
1748 let tmp = TempDir::new().unwrap();
1751 let dir = tmp.path().join("node_modules");
1752 fs::create_dir_all(&dir).unwrap();
1753 assert!(!is_mount_point(&dir));
1754 }
1755
1756 #[test]
1757 fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1758 let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1761 assert!(!is_mount_point(root));
1762 }
1763
1764 fn create_git_repo_with_commit(path: &Path) {
1765 fs::create_dir_all(path).unwrap();
1766 Command::new("git")
1767 .args(["init"])
1768 .current_dir(path)
1769 .output()
1770 .unwrap();
1771 fs::write(path.join("README.md"), "# Test").unwrap();
1772 Command::new("git")
1773 .args(["add", "."])
1774 .current_dir(path)
1775 .output()
1776 .unwrap();
1777 Command::new("git")
1778 .args([
1779 "-c",
1780 "user.name=Test",
1781 "-c",
1782 "user.email=test@test.com",
1783 "commit",
1784 "-m",
1785 "initial",
1786 ])
1787 .current_dir(path)
1788 .output()
1789 .unwrap();
1790 }
1791
1792 #[test]
1793 fn a_bloat_label_names_the_project_that_owns_it() {
1794 assert_eq!(owning_project("node_modules"), ".");
1795 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1796 assert_eq!(
1797 owning_project("packages/@scope/app/.venv"),
1798 "packages/@scope/app"
1799 );
1800 }
1801
1802 #[test]
1803 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1804 let tmp = TempDir::new().unwrap();
1807 let root = tmp.path();
1808 for name in ["frontend", "docs"] {
1809 let dir = root.join(name);
1810 fs::create_dir_all(&dir).unwrap();
1811 fs::write(dir.join("package.json"), "{}").unwrap();
1812 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1813 }
1814
1815 let deleted = vec![crate::config::PrunedDir {
1816 repo_path: root.to_path_buf(),
1817 bloat_dir: "frontend/node_modules".to_string(),
1818 adapter: "npm".to_string(),
1819 size_freed: 0,
1820 runtime: None,
1821 }];
1822 let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1823
1824 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1825 assert_eq!(results[0].label, "npm (frontend/node_modules)");
1826 }
1827
1828 #[test]
1829 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1830 let tmp = TempDir::new().unwrap();
1833 let deleted = vec![crate::config::PrunedDir {
1834 repo_path: tmp.path().to_path_buf(),
1835 bloat_dir: "services/api/.venv".to_string(),
1836 adapter: "uv".to_string(),
1837 size_freed: 0,
1838 runtime: None,
1839 }];
1840 let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1841
1842 assert_eq!(results.len(), 1);
1843 assert_eq!(results[0].label, "uv (services/api/.venv)");
1844 let err = results[0].result.as_ref().unwrap_err().to_string();
1845 assert!(err.contains("services/api"), "names the missing project");
1846 assert!(err.contains("uv"), "names the adapter that owned it");
1847 }
1848
1849 #[test]
1850 fn test_prune_status_display() {
1851 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1852 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1853 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1854 assert_eq!(
1855 PruneStatus::SkippedAdapterWindow(45).to_string(),
1856 "Not examined (adapter idle window: 45 days)"
1857 );
1858 }
1859
1860 #[test]
1861 fn test_prune_repo_non_git() {
1862 let tmp = TempDir::new().unwrap();
1865 let results = prune_repo(tmp.path(), 15, false, false);
1866 assert_eq!(results.len(), 1);
1867 assert!(matches!(results[0].status, PruneStatus::NotARepo));
1868 }
1869
1870 #[test]
1871 fn test_prune_repo_active_skipped() {
1872 let tmp = TempDir::new().unwrap();
1873 let repo = tmp.path().join("repo");
1874 create_git_repo_with_commit(&repo);
1875 let results = prune_repo(&repo, 15, false, false);
1877 assert_eq!(results.len(), 1);
1878 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1879 }
1880
1881 #[test]
1884 fn test_unparseable_per_repo_config_skips_the_repo() {
1885 let tmp = TempDir::new().unwrap();
1886 let repo = tmp.path().join("repo");
1887 create_git_repo_with_commit(&repo);
1888 fs::create_dir(repo.join("target")).unwrap();
1889 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1890 fs::write(
1891 repo.join("Cargo.toml"),
1892 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1893 )
1894 .unwrap();
1895 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1896 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1898
1899 let results = prune_repo(&repo, 15, false, true);
1901
1902 assert_eq!(results.len(), 1);
1903 assert!(
1904 matches!(results[0].status, PruneStatus::ConfigError(_)),
1905 "expected ConfigError, got {:?}",
1906 results[0].status
1907 );
1908 assert!(repo.join("target").exists(), "target must survive");
1909 }
1910
1911 #[test]
1915 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1916 let tmp = TempDir::new().unwrap();
1917 let repo = tmp.path().join("repo");
1918 create_git_repo_with_commit(&repo);
1919 create_python_project(&repo);
1920 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1921
1922 let mut registry = Registry::default();
1923 registry.add_repo(repo.clone());
1924
1925 let entries = get_full_status(®istry);
1926 assert_eq!(entries.len(), 1);
1927 assert!(
1928 matches!(entries[0].reason, SkipReason::ConfigError(_)),
1929 "expected ConfigError, got {:?}",
1930 entries[0].reason
1931 );
1932 assert_eq!(entries[0].reclaimable_bytes, 0);
1933 }
1934
1935 #[test]
1936 fn test_prune_repo_dry_run() {
1937 let tmp = TempDir::new().unwrap();
1938 let repo = tmp.path().join("repo");
1939 create_git_repo_with_commit(&repo);
1940 create_go_project(&repo);
1943 let results = prune_repo(&repo, 15, true, true);
1945 let dry_run_results: Vec<_> = results
1946 .iter()
1947 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1948 .collect();
1949 assert!(!dry_run_results.is_empty());
1950 assert!(repo.join("vendor").exists());
1952 }
1953
1954 #[test]
1955 fn an_unmet_adapter_window_reports_one_row_per_adapter_not_one_per_project() {
1956 let tmp = TempDir::new().unwrap();
1957 let repo = tmp.path().join("repo");
1958 create_git_repo_with_commit(&repo);
1959 create_go_project(&repo);
1962 create_go_project(&repo.join("sub"));
1963
1964 let mut opts = PruneOptions::new(0, true, false);
1968 opts.adapter_idle_days.insert("go".to_string(), 10_000);
1969 let results = prune_repo_with(&repo, &opts);
1970
1971 let windows: Vec<_> = results
1972 .iter()
1973 .filter(|r| matches!(r.status, PruneStatus::SkippedAdapterWindow(_)))
1974 .collect();
1975 assert_eq!(windows.len(), 1, "{results:?}");
1976 assert_eq!(windows[0].adapter_name, "go");
1977 assert!(matches!(
1978 windows[0].status,
1979 PruneStatus::SkippedAdapterWindow(10_000)
1980 ));
1981 assert!(
1982 !results
1983 .iter()
1984 .any(|r| matches!(r.status, PruneStatus::SkippedDryRun)),
1985 "a held adapter must not also report candidates: {results:?}"
1986 );
1987 }
1988
1989 fn create_python_project(dir: &Path) {
1994 fs::create_dir_all(dir).unwrap();
1995 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1996 let venv = dir.join(".venv");
1997 fs::create_dir_all(&venv).unwrap();
1998 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1999 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
2000 }
2001
2002 fn create_go_project(dir: &Path) {
2007 fs::create_dir_all(dir).unwrap();
2008 fs::write(dir.join("go.mod"), "module example.com/x\n\ngo 1.22\n").unwrap();
2009 fs::write(dir.join("go.sum"), "").unwrap();
2010 let vendor = dir.join("vendor");
2011 fs::create_dir_all(&vendor).unwrap();
2012 fs::write(vendor.join("modules.txt"), "# example.com/dep v1.0.0\n").unwrap();
2013 fs::write(vendor.join("payload.bin"), vec![0u8; 4096]).unwrap();
2014 }
2015
2016 fn labels(results: &[PruneResult]) -> Vec<String> {
2018 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
2019 out.sort();
2020 out
2021 }
2022
2023 #[test]
2024 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
2025 let tmp = TempDir::new().unwrap();
2026 let repo = tmp.path().join("repo");
2027 create_git_repo_with_commit(&repo);
2028
2029 create_go_project(&repo);
2030 fs::write(repo.join("package.json"), "{}").unwrap();
2031 fs::write(repo.join("package-lock.json"), "{}").unwrap();
2032 fs::create_dir(repo.join("node_modules")).unwrap();
2033 create_python_project(&repo);
2034
2035 let results = prune_repo(&repo, 15, true, true);
2036 assert_eq!(labels(&results), vec![".venv", "node_modules", "vendor"]);
2037 }
2038
2039 #[test]
2040 fn test_prune_finds_ecosystems_at_different_depths() {
2041 let tmp = TempDir::new().unwrap();
2042 let repo = tmp.path().join("repo");
2043 create_git_repo_with_commit(&repo);
2044
2045 fs::create_dir_all(repo.join("frontend")).unwrap();
2046 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
2047 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
2048 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
2049
2050 create_go_project(&repo.join("tools/cli"));
2051
2052 create_python_project(&repo.join("services/api"));
2053
2054 let results = prune_repo(&repo, 15, true, true);
2055 assert_eq!(
2056 labels(&results),
2057 vec![
2058 "frontend/node_modules",
2059 "services/api/.venv",
2060 "tools/cli/vendor",
2061 ]
2062 );
2063 }
2064
2065 #[test]
2066 fn test_prune_deletes_only_the_selected_nested_directory() {
2067 let tmp = TempDir::new().unwrap();
2068 let repo = tmp.path().join("repo");
2069 create_git_repo_with_commit(&repo);
2070 create_python_project(&repo.join("a"));
2071 create_python_project(&repo.join("b"));
2072
2073 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
2074
2075 assert_eq!(labels(&results), vec!["a/.venv"]);
2076 assert!(matches!(results[0].status, PruneStatus::Pruned));
2077 assert!(!repo.join("a/.venv").exists());
2078 assert!(repo.join("b/.venv").exists());
2079 }
2080
2081 #[test]
2082 fn test_prune_ignores_bloat_inside_a_nested_repository() {
2083 let tmp = TempDir::new().unwrap();
2084 let repo = tmp.path().join("repo");
2085 create_git_repo_with_commit(&repo);
2086 create_python_project(&repo.join("outer"));
2087
2088 let nested = repo.join("nested");
2091 create_git_repo_with_commit(&nested);
2092 create_python_project(&nested);
2093
2094 let results = prune_repo(&repo, 15, true, true);
2095 assert_eq!(labels(&results), vec!["outer/.venv"]);
2096 }
2097
2098 #[test]
2099 fn test_prune_repo_no_adapters() {
2100 let tmp = TempDir::new().unwrap();
2101 let repo = tmp.path().join("repo");
2102 create_git_repo_with_commit(&repo);
2103 let results = prune_repo(&repo, 15, false, true);
2105 assert!(
2106 results
2107 .iter()
2108 .any(|r| matches!(r.status, PruneStatus::NoBloat))
2109 );
2110 }
2111
2112 #[test]
2113 fn test_prune_all_disabled() {
2114 let tmp = TempDir::new().unwrap();
2115 let _registry_path = tmp.path().join("registry.json");
2116
2117 let mut registry = Registry::default();
2118 let repo_path = PathBuf::from("/nonexistent/repo");
2119 registry.add_repo(repo_path.clone());
2120 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
2121
2122 let results = prune_all(&mut registry, false, false);
2123 assert!(
2124 results
2125 .iter()
2126 .any(|r| matches!(r.status, PruneStatus::Disabled))
2127 );
2128 }
2129
2130 #[test]
2131 fn test_restore_project_no_adapters() {
2132 let tmp = TempDir::new().unwrap();
2133 let result = restore_project_to_depth(
2134 tmp.path(),
2135 crate::constants::DEFAULT_SCAN_DEPTH,
2136 TEST_TIMEOUT,
2137 );
2138 assert!(result.is_err());
2139 }
2140
2141 #[test]
2142 fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
2143 let tmp = TempDir::new().unwrap();
2147 let api = tmp.path().join("api");
2148 fs::create_dir_all(&api).unwrap();
2149 fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
2150 let deleted = vec![crate::config::PrunedDir {
2153 repo_path: tmp.path().to_path_buf(),
2154 bloat_dir: "api/.venv".to_string(),
2155 adapter: "venv".to_string(),
2156 size_freed: 0,
2157 runtime: None,
2158 }];
2159 let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
2162
2163 assert_eq!(results.len(), 1);
2164 assert_eq!(results[0].label, "venv (api/.venv)");
2165 if let Err(e) = &results[0].result {
2166 assert!(
2167 !e.to_string().contains("no longer a"),
2168 "the recorded adapter must be attempted, got: {e}"
2169 );
2170 }
2171 }
2172
2173 #[test]
2174 fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
2175 let tmp = TempDir::new().unwrap();
2178 let repo = tmp.path().join("repo");
2179 create_git_repo_with_commit(&repo);
2180 create_python_project(&repo);
2181 fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
2182
2183 let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
2184
2185 assert_eq!(results.len(), 1);
2186 let PruneStatus::SkippedNestedRepo(msg) = &results[0].status else {
2187 panic!("expected a refusal, got {:?}", results[0].status);
2188 };
2189 assert!(msg.contains("git repository"), "says why: {msg}");
2190 assert!(repo.join(".venv").exists(), "nothing may be deleted");
2191 assert_eq!(results[0].size_freed, 0);
2192 }
2193
2194 fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
2195 RepoStatusEntry {
2196 path: PathBuf::from(name),
2197 entry: RepoEntry::new(),
2198 reason: SkipReason::Candidate,
2199 adapters: Vec::new(),
2200 bloat_dirs: Vec::new(),
2201 reclaimable_by_adapter: Vec::new(),
2202 reclaimable_bytes: reclaimable,
2203 last_activity: None,
2204 idle_days: 15,
2205 }
2206 }
2207
2208 #[test]
2209 fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
2210 let repos = [
2211 status_entry("small", 10),
2212 status_entry("big", 300),
2213 status_entry("mid", 200),
2214 ];
2215 let names: Vec<String> = take_top(&repos, Some(2))
2216 .iter()
2217 .map(|e| e.path.display().to_string())
2218 .collect();
2219 assert_eq!(names, vec!["big", "mid"]);
2223 }
2224
2225 #[test]
2226 fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
2227 let repos = [status_entry("a", 1), status_entry("b", 2)];
2228 assert_eq!(take_top(&repos, None).len(), 2);
2229 assert_eq!(take_top(&repos, Some(10)).len(), 2);
2230 assert_eq!(take_top(&repos, Some(0)).len(), 0);
2231 }
2232
2233 #[test]
2234 fn test_restore_project_with_npm() {
2235 let tmp = TempDir::new().unwrap();
2236 fs::write(tmp.path().join("package.json"), "{}").unwrap();
2237 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
2238 let results = restore_project_to_depth(
2239 tmp.path(),
2240 crate::constants::DEFAULT_SCAN_DEPTH,
2241 TEST_TIMEOUT,
2242 );
2243 assert!(results.is_ok());
2245 let results = results.unwrap();
2246 assert!(!results.is_empty());
2247 assert_eq!(results[0].0, "npm");
2248 }
2249
2250 #[test]
2251 fn the_scan_never_starts_more_threads_than_there_is_work() {
2252 assert_eq!(clamp_scan_threads(32, 3), 3);
2254 assert_eq!(clamp_scan_threads(0, 0), 1);
2257 assert_eq!(clamp_scan_threads(0, 50), 1);
2258 }
2259
2260 #[test]
2261 fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
2262 assert_eq!(
2264 clamp_scan_threads(9_999, 500),
2265 constants::STATUS_SCAN_MAX_THREADS
2266 );
2267 }
2268
2269 #[test]
2270 fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
2271 assert_eq!(clamp_scan_threads(16, 1), 1);
2272 }
2273}