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 NoBloat,
50 Disabled,
52 SkippedIgnored,
54 DeleteError(String),
56 SkippedSymlink(String),
62 ConfigError(String),
64 SkippedDeclaration(String),
70 SkippedNestedRepo(String),
78}
79
80impl std::fmt::Display for PruneStatus {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 PruneStatus::Pruned => write!(f, "Pruned"),
84 PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
85 PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
86 PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
87 PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
88 PruneStatus::PathMissing => {
89 write!(
90 f,
91 "Path no longer exists (`devp unlink --missing` clears it)"
92 )
93 }
94 PruneStatus::NoBloat => write!(f, "No bloat found"),
95 PruneStatus::Disabled => write!(f, "Disabled"),
96 PruneStatus::SkippedIgnored => write!(
97 f,
98 "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
99 ),
100 PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
101 PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
102 PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
103 PruneStatus::SkippedDeclaration(e) => write!(f, "Skipped (declaration): {e}"),
104 PruneStatus::SkippedNestedRepo(e) => write!(f, "Skipped (nested repository): {e}"),
105 }
106 }
107}
108
109pub const BYTES_PER_MIB: u64 = 1024 * 1024;
112
113#[derive(Debug, Clone, Default, PartialEq)]
120pub struct AdapterFilter {
121 only: Option<Vec<String>>,
122 skip: Vec<String>,
123}
124
125impl AdapterFilter {
126 pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
131 let known: Vec<&'static str> = crate::adapters::get_all_adapters()
135 .iter()
136 .map(|a| a.name())
137 .chain(std::iter::once(constants::DECLARED_ADAPTER_NAME))
138 .collect();
139
140 let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
141 let mut out = Vec::new();
142 for token in raw.split(',') {
143 let name = token.trim().to_lowercase();
144 if name.is_empty() {
145 continue;
146 }
147 if !known.contains(&name.as_str()) {
148 anyhow::bail!(
149 "`--{flag} {name}` names no known package manager. Available: {}.",
150 known.join(", ")
151 );
152 }
153 if !out.contains(&name) {
154 out.push(name);
155 }
156 }
157 if out.is_empty() {
158 anyhow::bail!("`--{flag}` was given no adapter names.");
159 }
160 Ok(out)
161 };
162
163 let only = only.map(|raw| parse(raw, "only")).transpose()?;
164 let skip = skip
165 .map(|raw| parse(raw, "skip"))
166 .transpose()?
167 .unwrap_or_default();
168
169 if let Some(only) = &only
170 && let Some(clash) = only.iter().find(|n| skip.contains(n))
171 {
172 anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
173 }
174
175 Ok(Self { only, skip })
176 }
177
178 pub fn allows(&self, name: &str) -> bool {
180 if self.skip.iter().any(|s| s == name) {
181 return false;
182 }
183 match &self.only {
184 Some(only) => only.iter().any(|o| o == name),
185 None => true,
186 }
187 }
188
189 pub fn is_unrestricted(&self) -> bool {
191 self.only.is_none() && self.skip.is_empty()
192 }
193
194 pub fn describe(&self) -> Option<String> {
196 if self.is_unrestricted() {
197 return None;
198 }
199 let mut parts = Vec::new();
200 if let Some(only) = &self.only {
201 parts.push(format!("only {}", only.join(", ")));
202 }
203 if !self.skip.is_empty() {
204 parts.push(format!("skipping {}", self.skip.join(", ")));
205 }
206 Some(parts.join("; "))
207 }
208}
209
210#[derive(Debug, Clone)]
216pub struct PruneOptions {
217 pub idle_days: u64,
219 pub dry_run: bool,
221 pub force: bool,
223 pub only_dirs: Option<Vec<String>>,
229 pub adapters: AdapterFilter,
231 pub min_size_bytes: u64,
233 pub scan_depth: usize,
238 pub allow_manifest_rewrite: bool,
240 pub command_timeout_secs: u64,
245 pub build_idle_days: u64,
251 pub adapter_idle_days: BTreeMap<String, u64>,
256}
257
258impl Default for PruneOptions {
259 fn default() -> Self {
260 Self {
261 idle_days: 0,
262 dry_run: false,
263 force: false,
264 only_dirs: None,
265 adapters: AdapterFilter::default(),
266 min_size_bytes: 0,
267 scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
268 allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
269 command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
270 build_idle_days: crate::constants::DEFAULT_BUILD_IDLE_DAYS,
271 adapter_idle_days: BTreeMap::new(),
272 }
273 }
274}
275
276impl PruneOptions {
277 fn idle_threshold_for(&self, name: &str, opt_in: bool, base: u64) -> u64 {
291 let mut days = base;
292 if opt_in {
293 days = days.max(self.build_idle_days);
294 }
295 if let Some(&explicit) = self.adapter_idle_days.get(name) {
296 days = days.max(explicit);
297 }
298 days
299 }
300
301 pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
303 Self {
304 idle_days,
305 dry_run,
306 force,
307 ..Self::default()
308 }
309 }
310}
311
312#[derive(Debug, Clone)]
314pub struct PruneResult {
315 pub repo_path: PathBuf,
317 pub adapter_name: String,
319 pub bloat_dir: String,
321 pub size_freed: u64,
323 pub shared_bytes: u64,
327 pub runtime: Option<String>,
330 pub status: PruneStatus,
332}
333
334impl PruneResult {
335 pub fn project_dir(&self) -> PathBuf {
343 self.repo_path
344 .join(&self.bloat_dir)
345 .parent()
346 .map(Path::to_path_buf)
347 .unwrap_or_else(|| self.repo_path.clone())
348 }
349}
350
351pub fn prune_repo(
358 repo_path: &Path,
359 idle_days: u64,
360 dry_run: bool,
361 force: bool,
362) -> Vec<PruneResult> {
363 prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
364}
365
366pub fn prune_repo_selected(
375 repo_path: &Path,
376 idle_days: u64,
377 dry_run: bool,
378 force: bool,
379 only: Option<&[String]>,
380) -> Vec<PruneResult> {
381 prune_repo_with(
382 repo_path,
383 &PruneOptions {
384 only_dirs: only.map(<[String]>::to_vec),
385 ..PruneOptions::new(idle_days, dry_run, force)
386 },
387 )
388}
389
390pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
395 let idle_days = opts.idle_days;
396 let dry_run = opts.dry_run;
397 let force = opts.force;
398 let only = opts.only_dirs.as_deref();
399 let mut results = Vec::new();
400
401 if !repo_path.exists() {
405 results.push(PruneResult {
406 repo_path: repo_path.to_path_buf(),
407 adapter_name: "-".to_string(),
408 bloat_dir: "-".to_string(),
409 size_freed: 0,
410 shared_bytes: 0,
411 runtime: None,
412 status: PruneStatus::PathMissing,
413 });
414 return results;
415 }
416
417 if !scanner::is_git_repo(repo_path) {
421 results.push(PruneResult {
422 repo_path: repo_path.to_path_buf(),
423 adapter_name: "-".to_string(),
424 bloat_dir: "-".to_string(),
425 size_freed: 0,
426 shared_bytes: 0,
427 runtime: None,
428 status: PruneStatus::ActivityCheckError(format!(
429 "`{}` is no longer a git repository — nothing was touched. \
430 `devp unlink` removes it from the registry.",
431 crate::output::clean_path(repo_path)
432 )),
433 });
434 return results;
435 }
436
437 if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
439 results.push(PruneResult {
440 repo_path: repo_path.to_path_buf(),
441 adapter_name: "-".to_string(),
442 bloat_dir: "-".to_string(),
443 size_freed: 0,
444 shared_bytes: 0,
445 runtime: None,
446 status: PruneStatus::SkippedIgnored,
447 });
448 return results;
449 }
450
451 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
455 Ok(cfg) => cfg,
456 Err(e) => {
457 results.push(PruneResult {
458 repo_path: repo_path.to_path_buf(),
459 adapter_name: "-".to_string(),
460 bloat_dir: "-".to_string(),
461 size_freed: 0,
462 shared_bytes: 0,
463 runtime: None,
464 status: PruneStatus::ConfigError(e),
465 });
466 return results;
467 }
468 };
469 if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
470 results.push(PruneResult {
471 repo_path: repo_path.to_path_buf(),
472 adapter_name: "-".to_string(),
473 bloat_dir: "-".to_string(),
474 size_freed: 0,
475 shared_bytes: 0,
476 runtime: None,
477 status: PruneStatus::SkippedIgnored,
478 });
479 return results;
480 }
481
482 let effective_idle_days = per_repo_config
484 .as_ref()
485 .and_then(|c| c.override_idle_days)
486 .unwrap_or(idle_days);
487
488 let min_size_bytes = if only.is_some() {
491 0
492 } else {
493 per_repo_config
494 .as_ref()
495 .and_then(|c| c.min_size_mb)
496 .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
497 .unwrap_or(opts.min_size_bytes)
498 };
499
500 if !force {
502 match git::is_repo_idle(repo_path, effective_idle_days) {
503 Ok(false) => {
504 results.push(PruneResult {
505 repo_path: repo_path.to_path_buf(),
506 adapter_name: "-".to_string(),
507 bloat_dir: "-".to_string(),
508 size_freed: 0,
509 shared_bytes: 0,
510 runtime: None,
511 status: PruneStatus::SkippedActive,
512 });
513 return results;
514 }
515 Ok(true) => {} Err(e) => {
517 results.push(PruneResult {
518 repo_path: repo_path.to_path_buf(),
519 adapter_name: "-".to_string(),
520 bloat_dir: "-".to_string(),
521 size_freed: 0,
522 shared_bytes: 0,
523 runtime: None,
524 status: PruneStatus::ActivityCheckError(e.to_string()),
525 });
526 return results;
527 }
528 }
529 }
530
531 let projects = workspace::discover_to_depth(
535 repo_path,
536 workspace::resolve_depth(repo_path, opts.scan_depth),
537 );
538
539 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
543
544 let mut idle_at: BTreeMap<u64, bool> = BTreeMap::new();
549
550 for project in &projects {
551 for adapter in &project.adapters {
552 if !opts.adapters.allows(adapter.name()) {
553 continue;
554 }
555
556 let threshold =
560 opts.idle_threshold_for(adapter.name(), adapter.opt_in(), effective_idle_days);
561 if threshold > effective_idle_days && !force {
562 let idle_enough = *idle_at
563 .entry(threshold)
564 .or_insert_with(|| git::is_repo_idle(repo_path, threshold).unwrap_or(false));
565 if !idle_enough {
566 continue;
567 }
568 }
569
570 let bloat_dirs: Vec<(String, BloatDir)> = adapter
577 .bloat_dirs(&project.path)
578 .into_iter()
579 .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
580 .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
581 .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
582 .filter(|(_, bd)| claimed.insert(bd.path.clone()))
583 .collect();
584
585 if bloat_dirs.is_empty() {
586 continue;
587 }
588
589 let mut deletable: Vec<(String, BloatDir)> = Vec::new();
598 for (label, bd) in bloat_dirs {
599 if let Some(status) = shared_storage_refusal(&bd.path) {
600 results.push(PruneResult {
601 repo_path: repo_path.to_path_buf(),
602 adapter_name: adapter.name().to_string(),
603 bloat_dir: label,
604 size_freed: 0,
605 shared_bytes: 0,
606 runtime: None,
607 status,
608 });
609 continue;
610 }
611
612 deletable.push((label, bd));
613 }
614
615 if deletable.is_empty() {
616 continue;
617 }
618
619 if !dry_run {
621 let policy = crate::adapters::EnforcePolicy {
622 allow_rewrite: opts.allow_manifest_rewrite,
623 timeout: crate::adapters::command_timeout(opts.command_timeout_secs),
624 };
625 if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
626 for (label, _) in &deletable {
627 results.push(PruneResult {
628 repo_path: repo_path.to_path_buf(),
629 adapter_name: adapter.name().to_string(),
630 bloat_dir: label.clone(),
631 size_freed: 0,
632 shared_bytes: 0,
633 runtime: None,
634 status: PruneStatus::LockfileError(e.to_string()),
635 });
636 }
637 continue;
638 }
639 }
640
641 for (label, bd) in deletable {
642 if dry_run {
643 results.push(PruneResult {
644 repo_path: repo_path.to_path_buf(),
645 adapter_name: adapter.name().to_string(),
646 bloat_dir: label,
647 size_freed: bd.size_bytes,
648 shared_bytes: bd.shared_bytes,
649 runtime: None,
650 status: PruneStatus::SkippedDryRun,
651 });
652 continue;
653 }
654
655 let runtime = adapter.runtime_tag(&project.path, &bd.name);
659 results.push(delete_bloat(repo_path, adapter.name(), label, &bd, runtime));
660 }
661 }
662 }
663
664 results.extend(prune_declarations(
665 repo_path,
666 per_repo_config.as_ref(),
667 opts,
668 min_size_bytes,
669 only,
670 &mut claimed,
671 ));
672
673 if results.is_empty() {
675 results.push(PruneResult {
676 repo_path: repo_path.to_path_buf(),
677 adapter_name: "-".to_string(),
678 bloat_dir: "-".to_string(),
679 size_freed: 0,
680 shared_bytes: 0,
681 runtime: None,
682 status: PruneStatus::NoBloat,
683 });
684 }
685
686 results
687}
688
689fn shared_storage_refusal(path: &Path) -> Option<PruneStatus> {
698 if fs::symlink_metadata(path)
702 .map(|m| m.file_type().is_symlink())
703 .unwrap_or(false)
704 {
705 return Some(PruneStatus::SkippedSymlink(format!(
706 "`{}` is a symlink to storage dev-prune does not own — left alone. Remove \
707 the link yourself if you really want it gone.",
708 crate::output::clean_path(path)
709 )));
710 }
711
712 if is_mount_point(path) {
717 return Some(PruneStatus::SkippedSymlink(format!(
718 "`{}` is a mount point — it is on a different filesystem than the repository \
719 around it, so its contents are shared with whatever mounted it. Left alone.",
720 crate::output::clean_path(path)
721 )));
722 }
723
724 if let Some(nested) = find_nested_git(path) {
729 return Some(PruneStatus::SkippedNestedRepo(format!(
730 "`{}` contains a git repository at `{}` — refusing to delete it. Move or \
731 remove that checkout yourself if it holds nothing you need.",
732 crate::output::clean_path(path),
733 crate::output::clean_path(&nested)
734 )));
735 }
736
737 None
738}
739
740fn delete_bloat(
742 repo_path: &Path,
743 adapter_name: &str,
744 label: String,
745 bd: &BloatDir,
746 runtime: Option<String>,
747) -> PruneResult {
748 let size = bd.size_bytes;
749 let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
755 std::thread::sleep(std::time::Duration::from_millis(250));
756 fs::remove_dir_all(&bd.path)
757 });
758 let (size_freed, shared_bytes, status) = match delete {
761 Ok(()) => (size, bd.shared_bytes, PruneStatus::Pruned),
762 Err(_) if !bd.path.exists() => (size, bd.shared_bytes, PruneStatus::Pruned),
763 Err(e) => {
764 let remaining = crate::adapters::dir_size(&bd.path);
769 let freed = size.saturating_sub(remaining);
770 let message = if freed > 0 {
771 format!(
772 "{e} — `{}` was partially deleted ({} of {} remains) and is no \
773 longer usable. Close whatever holds it open, then run `devp \
774 restore` to rebuild it.",
775 crate::output::clean_path(&bd.path),
776 crate::output::format_bytes(remaining),
777 crate::output::format_bytes(size)
778 )
779 } else {
780 e.to_string()
781 };
782 (freed, 0, PruneStatus::DeleteError(message))
783 }
784 };
785 PruneResult {
786 repo_path: repo_path.to_path_buf(),
787 adapter_name: adapter_name.to_string(),
788 bloat_dir: label,
789 size_freed,
790 shared_bytes,
791 runtime,
792 status,
793 }
794}
795
796fn prune_declarations(
802 repo_path: &Path,
803 config: Option<&crate::config::PerRepoConfig>,
804 opts: &PruneOptions,
805 min_size_bytes: u64,
806 only: Option<&[String]>,
807 claimed: &mut std::collections::HashSet<PathBuf>,
808) -> Vec<PruneResult> {
809 let name = constants::DECLARED_ADAPTER_NAME;
810 if !opts.adapters.allows(name) {
811 return Vec::new();
812 }
813 let Some(declared) = config.and_then(|c| c.prunable.as_ref()) else {
814 return Vec::new();
815 };
816
817 let mut results = Vec::new();
818 for outcome in crate::declared::resolve(repo_path, declared) {
819 let target = match outcome {
820 crate::declared::Declaration::Prunable(target) => target,
821 crate::declared::Declaration::Refused { label, reason } => {
825 results.push(PruneResult {
826 repo_path: repo_path.to_path_buf(),
827 adapter_name: name.to_string(),
828 bloat_dir: label,
829 size_freed: 0,
830 shared_bytes: 0,
831 runtime: None,
832 status: PruneStatus::SkippedDeclaration(reason),
833 });
834 continue;
835 }
836 };
837
838 if only.is_some_and(|names| !names.contains(&target.label)) {
839 continue;
840 }
841 if target.size_bytes < min_size_bytes {
842 continue;
843 }
844 if !claimed.insert(target.path.clone()) {
845 continue;
846 }
847
848 let bd = BloatDir {
849 name: target.label.clone(),
850 path: target.path.clone(),
851 size_bytes: target.size_bytes,
852 shared_bytes: 0,
853 };
854 if let Some(status) = shared_storage_refusal(&bd.path) {
855 results.push(PruneResult {
856 repo_path: repo_path.to_path_buf(),
857 adapter_name: name.to_string(),
858 bloat_dir: target.label,
859 size_freed: 0,
860 shared_bytes: 0,
861 runtime: None,
862 status,
863 });
864 continue;
865 }
866 if opts.dry_run {
867 results.push(PruneResult {
868 repo_path: repo_path.to_path_buf(),
869 adapter_name: name.to_string(),
870 bloat_dir: target.label,
871 size_freed: target.size_bytes,
872 shared_bytes: 0,
873 runtime: None,
874 status: PruneStatus::SkippedDryRun,
875 });
876 continue;
877 }
878
879 results.push(delete_bloat(
884 repo_path,
885 name,
886 target.label.clone(),
887 &bd,
888 Some(target.rebuild.clone()),
889 ));
890 }
891 results
892}
893
894#[cfg(unix)]
906fn is_mount_point(path: &Path) -> bool {
907 use std::os::unix::fs::MetadataExt;
908 let Some(parent) = path.parent() else {
909 return false;
910 };
911 match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
912 (Ok(here), Ok(above)) => here.dev() != above.dev(),
913 _ => false,
915 }
916}
917
918#[cfg(not(unix))]
919fn is_mount_point(_path: &Path) -> bool {
920 false
921}
922
923fn find_nested_git(dir: &Path) -> Option<PathBuf> {
929 walkdir::WalkDir::new(dir)
930 .follow_links(false)
931 .into_iter()
932 .flatten()
933 .find(|e| e.file_name() == ".git")
934 .map(|e| e.into_path())
935}
936
937fn collect_bloat(
948 repo_path: &Path,
949 min_size_bytes: u64,
950 depth: usize,
951) -> (Vec<String>, Vec<BloatDir>, Vec<(String, u64)>) {
952 let mut adapter_names: Vec<String> = Vec::new();
953 let mut bloat: Vec<BloatDir> = Vec::new();
954 let mut by_adapter: BTreeMap<String, u64> = BTreeMap::new();
955 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
956
957 for project in workspace::discover_to_depth(repo_path, depth) {
958 for adapter in &project.adapters {
959 let name = adapter.name();
960 if !adapter_names.iter().any(|existing| existing == name) {
961 adapter_names.push(name.to_string());
962 }
963 for bd in adapter.bloat_dirs(&project.path) {
964 if bd.size_bytes < min_size_bytes {
965 continue;
966 }
967 if shared_storage_refusal(&bd.path).is_some() {
973 continue;
974 }
975 if claimed.insert(bd.path.clone()) {
976 *by_adapter.entry(name.to_string()).or_default() += bd.size_bytes;
977 bloat.push(BloatDir {
978 name: workspace::relative_label(repo_path, &bd.path),
979 ..bd
980 });
981 }
982 }
983 }
984 }
985
986 let declared = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
990 .ok()
991 .flatten()
992 .and_then(|c| c.prunable)
993 .unwrap_or_default();
994 for outcome in crate::declared::resolve(repo_path, &declared) {
995 let crate::declared::Declaration::Prunable(target) = outcome else {
996 continue;
997 };
998 if target.size_bytes < min_size_bytes
999 || shared_storage_refusal(&target.path).is_some()
1000 || !claimed.insert(target.path.clone())
1001 {
1002 continue;
1003 }
1004 let name = constants::DECLARED_ADAPTER_NAME;
1005 if !adapter_names.iter().any(|existing| existing == name) {
1006 adapter_names.push(name.to_string());
1007 }
1008 *by_adapter.entry(name.to_string()).or_default() += target.size_bytes;
1009 bloat.push(BloatDir {
1010 name: target.label,
1011 path: target.path,
1012 size_bytes: target.size_bytes,
1013 shared_bytes: 0,
1014 });
1015 }
1016
1017 (adapter_names, bloat, by_adapter.into_iter().collect())
1018}
1019
1020pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
1025 let mut all_results = Vec::new();
1026
1027 let mut repos: Vec<(PathBuf, u64, bool)> = registry
1033 .repositories
1034 .iter()
1035 .map(|(path, entry)| {
1036 let idle_days = entry
1037 .override_idle_days
1038 .unwrap_or(registry.settings.idle_days);
1039 (path.clone(), idle_days, entry.enabled)
1040 })
1041 .collect();
1042 repos.sort_by(|a, b| a.0.cmp(&b.0));
1043
1044 for (path, idle_days, enabled) in repos {
1045 if !enabled {
1046 all_results.push(PruneResult {
1047 repo_path: path.clone(),
1048 adapter_name: "-".to_string(),
1049 bloat_dir: "-".to_string(),
1050 size_freed: 0,
1051 shared_bytes: 0,
1052 runtime: None,
1053 status: PruneStatus::Disabled,
1054 });
1055 continue;
1056 }
1057
1058 let results = prune_repo_with(
1059 &path,
1060 &PruneOptions {
1061 idle_days,
1062 ..opts.clone()
1063 },
1064 );
1065
1066 let path_freed: u64 = results
1067 .iter()
1068 .filter(|r| matches!(r.status, PruneStatus::Pruned))
1069 .map(|r| r.size_freed)
1070 .sum();
1071
1072 if path_freed > 0 {
1073 registry.mark_pruned(&path, path_freed);
1074 }
1075
1076 all_results.extend(results);
1077 }
1078
1079 all_results
1080}
1081
1082pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
1084 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
1085}
1086
1087pub fn restore_project_to_depth(
1099 project_path: &Path,
1100 global_depth: usize,
1101 timeout: std::time::Duration,
1102) -> Result<Vec<(String, Result<()>)>> {
1103 let depth = workspace::resolve_depth(project_path, global_depth);
1104 let projects = workspace::discover_to_depth(project_path, depth);
1105
1106 if projects.is_empty() {
1107 anyhow::bail!(
1108 "No recognized package manager found in {}",
1109 project_path.display()
1110 );
1111 }
1112
1113 let mut results = Vec::new();
1114 for project in &projects {
1115 for adapter in &project.adapters {
1116 let label = if project.relative == "." {
1117 adapter.name().to_string()
1118 } else {
1119 format!("{} ({})", adapter.name(), project.relative)
1120 };
1121 results.push((label, adapter.restore(&project.path, timeout)));
1122 }
1123 }
1124
1125 Ok(results)
1126}
1127
1128fn owning_project(bloat_label: &str) -> &str {
1136 match bloat_label.rsplit_once('/') {
1137 Some((parent, _)) => parent,
1138 None => ".",
1139 }
1140}
1141
1142pub struct RestoreOutcome {
1161 pub label: String,
1163 pub adapter: String,
1165 pub bytes: u64,
1167 pub elapsed: std::time::Duration,
1169 pub result: Result<()>,
1171}
1172
1173pub fn restore_deleted(
1174 repo_path: &Path,
1175 deleted: &[crate::config::PrunedDir],
1176 global_depth: usize,
1177 timeout: std::time::Duration,
1178) -> Vec<RestoreOutcome> {
1179 let depth = workspace::resolve_depth(repo_path, global_depth);
1180 let projects = workspace::discover_to_depth(repo_path, depth);
1181
1182 let mut results = Vec::new();
1183 for dir in deleted {
1184 let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
1185 let timed = |result: Result<()>, started: std::time::Instant| RestoreOutcome {
1188 label: format!("{adapter_name} ({bloat_label})"),
1189 adapter: adapter_name.clone(),
1190 bytes: dir.size_freed,
1191 elapsed: started.elapsed(),
1192 result,
1193 };
1194 let runtime = dir.runtime.as_deref();
1195 let wanted = owning_project(bloat_label);
1196 let dir_name = bloat_label
1199 .rsplit_once('/')
1200 .map_or(bloat_label.as_str(), |(_, name)| name);
1201
1202 let found = projects
1203 .iter()
1204 .filter(|p| p.relative == wanted)
1205 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
1206 .find(|(_, a)| a.name() == adapter_name);
1207
1208 if let Some((project, adapter)) = found {
1209 let started = std::time::Instant::now();
1210 let result = adapter.restore_named(&project.path, dir_name, runtime, timeout);
1211 results.push(timed(result, started));
1212 continue;
1213 }
1214
1215 let project_dir = if wanted == "." {
1221 repo_path.to_path_buf()
1222 } else {
1223 repo_path.join(wanted)
1224 };
1225 let recorded = crate::adapters::get_all_adapters()
1226 .into_iter()
1227 .find(|a| a.name() == adapter_name);
1228 match recorded {
1229 Some(adapter) if project_dir.is_dir() => {
1230 let started = std::time::Instant::now();
1231 let result = adapter.restore_named(&project_dir, dir_name, runtime, timeout);
1232 results.push(timed(result, started));
1233 }
1234 _ => results.push(timed(
1235 Err(anyhow::anyhow!(
1236 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
1237 moved or removed since the prune. Restore it by hand if it still exists.",
1238 repo_path.display()
1239 )),
1240 std::time::Instant::now(),
1241 )),
1242 }
1243 }
1244
1245 results
1246}
1247
1248#[derive(Debug, Clone, PartialEq)]
1250pub enum SkipReason {
1251 Candidate,
1253 Active,
1255 Ignored,
1258 NoBloat,
1260 PathMissing,
1262 ConfigError(String),
1264}
1265
1266impl std::fmt::Display for SkipReason {
1267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1268 match self {
1269 SkipReason::Candidate => write!(f, "Candidate"),
1270 SkipReason::Active => write!(f, "Active (not idle)"),
1271 SkipReason::Ignored => write!(f, "Ignored"),
1272 SkipReason::NoBloat => write!(f, "No bloat found"),
1273 SkipReason::PathMissing => write!(f, "Path missing"),
1274 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1275 }
1276 }
1277}
1278
1279#[derive(Debug, Clone)]
1281pub struct RepoStatusEntry {
1282 pub path: PathBuf,
1284 pub entry: RepoEntry,
1286 pub reason: SkipReason,
1288 pub adapters: Vec<String>,
1290 pub bloat_dirs: Vec<BloatDir>,
1292 pub reclaimable_bytes: u64,
1294 pub reclaimable_by_adapter: Vec<(String, u64)>,
1297 pub last_activity: Option<DateTime<Utc>>,
1299 pub idle_days: u64,
1301}
1302
1303fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
1312 let registry_idle_days = reg_entry
1313 .override_idle_days
1314 .unwrap_or(registry.settings.idle_days);
1315
1316 if !path.exists() {
1319 return RepoStatusEntry {
1320 path: path.to_path_buf(),
1321 entry: reg_entry.clone(),
1322 reason: SkipReason::PathMissing,
1323 adapters: Vec::new(),
1324 bloat_dirs: Vec::new(),
1325 reclaimable_by_adapter: Vec::new(),
1326 reclaimable_bytes: 0,
1327 last_activity: None,
1328 idle_days: registry_idle_days,
1329 };
1330 }
1331
1332 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1337 Ok(cfg) => cfg,
1338 Err(e) => {
1339 return RepoStatusEntry {
1340 path: path.to_path_buf(),
1341 entry: reg_entry.clone(),
1342 reason: SkipReason::ConfigError(e),
1343 adapters: Vec::new(),
1344 bloat_dirs: Vec::new(),
1345 reclaimable_by_adapter: Vec::new(),
1346 reclaimable_bytes: 0,
1347 last_activity: last_activity_time(path),
1348 idle_days: registry_idle_days,
1349 };
1350 }
1351 };
1352 let idle_days = per_repo_config
1353 .as_ref()
1354 .and_then(|c| c.override_idle_days)
1355 .unwrap_or(registry_idle_days);
1356
1357 let is_ignored = !reg_entry.enabled
1359 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1360 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1361 if is_ignored {
1362 return RepoStatusEntry {
1363 path: path.to_path_buf(),
1364 entry: reg_entry.clone(),
1365 reason: SkipReason::Ignored,
1366 adapters: Vec::new(),
1367 bloat_dirs: Vec::new(),
1368 reclaimable_by_adapter: Vec::new(),
1369 reclaimable_bytes: 0,
1370 last_activity: last_activity_time(path),
1371 idle_days,
1372 };
1373 }
1374
1375 let activity = git::get_last_activity(path).ok().flatten();
1380 let activity_time = to_utc(activity);
1381 let is_idle = git::is_idle_at(activity, idle_days);
1382
1383 let min_size_bytes = per_repo_config
1385 .as_ref()
1386 .and_then(|c| c.min_size_mb)
1387 .unwrap_or(registry.settings.min_size_mb)
1388 .saturating_mul(BYTES_PER_MIB);
1389 let depth = workspace::clamp_depth(
1393 per_repo_config
1394 .as_ref()
1395 .and_then(|c| c.scan_depth)
1396 .unwrap_or(registry.settings.scan_depth),
1397 );
1398 let (adapter_names, all_bloat, by_adapter) = collect_bloat(path, min_size_bytes, depth);
1399 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1400
1401 let reason = if !is_idle {
1402 SkipReason::Active
1403 } else if all_bloat.is_empty() {
1404 SkipReason::NoBloat
1405 } else {
1406 SkipReason::Candidate
1407 };
1408
1409 RepoStatusEntry {
1410 path: path.to_path_buf(),
1411 entry: reg_entry.clone(),
1412 reason,
1413 adapters: adapter_names,
1414 bloat_dirs: all_bloat,
1415 reclaimable_bytes: reclaimable,
1416 reclaimable_by_adapter: by_adapter,
1417 last_activity: activity_time,
1418 idle_days,
1419 }
1420}
1421
1422fn scan_thread_count(total: usize) -> usize {
1439 let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
1440 .ok()
1441 .and_then(|v| v.trim().parse::<usize>().ok())
1442 .filter(|n| *n > 0)
1443 .unwrap_or_else(|| {
1444 std::thread::available_parallelism()
1445 .map(std::num::NonZeroUsize::get)
1446 .unwrap_or(4)
1447 .saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
1448 });
1449 clamp_scan_threads(requested, total)
1450}
1451
1452fn clamp_scan_threads(requested: usize, total: usize) -> usize {
1455 requested
1456 .clamp(1, constants::STATUS_SCAN_MAX_THREADS)
1457 .min(total.max(1))
1458}
1459
1460pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1461 get_full_status_reporting(registry, &|_done, _total| {})
1462}
1463
1464pub fn get_full_status_reporting(
1475 registry: &Registry,
1476 progress: &(dyn Fn(usize, usize) + Sync),
1477) -> Vec<RepoStatusEntry> {
1478 use std::sync::atomic::{AtomicUsize, Ordering};
1479
1480 let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
1481 let total = repos.len();
1482 let workers = scan_thread_count(total);
1483
1484 let next = AtomicUsize::new(0);
1489 let done = AtomicUsize::new(0);
1490
1491 let take_work = || {
1492 let mut mine = Vec::new();
1493 loop {
1494 let i = next.fetch_add(1, Ordering::Relaxed);
1495 if i >= total {
1496 break;
1497 }
1498 let (path, reg_entry) = repos[i];
1499 mine.push(status_for_repo(registry, path, reg_entry));
1500 progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
1501 }
1502 mine
1503 };
1504
1505 let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
1506 let mut handles = Vec::with_capacity(workers.saturating_sub(1));
1514 for n in 1..workers {
1515 match std::thread::Builder::new()
1516 .name(format!("devp-scan-{n}"))
1517 .spawn_scoped(scope, take_work)
1518 {
1519 Ok(handle) => handles.push(handle),
1520 Err(_) => break,
1521 }
1522 }
1523
1524 let mut chunks = vec![take_work()];
1527 chunks.extend(
1528 handles
1529 .into_iter()
1530 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
1533 );
1534 chunks
1535 });
1536
1537 let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
1538
1539 fn rank(reason: &SkipReason) -> u8 {
1544 match reason {
1545 SkipReason::Candidate => 0,
1546 SkipReason::PathMissing => 2,
1547 _ => 1,
1548 }
1549 }
1550 entries.sort_by(|a, b| {
1551 rank(&a.reason)
1552 .cmp(&rank(&b.reason))
1553 .then_with(|| a.path.cmp(&b.path))
1554 });
1555
1556 entries
1557}
1558
1559pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1568 let Some(n) = top else {
1569 return repos.to_vec();
1570 };
1571
1572 let mut ranked: Vec<usize> = (0..repos.len()).collect();
1573 ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1574 ranked.truncate(n);
1575 ranked.sort_unstable();
1576 ranked.into_iter().map(|i| repos[i].clone()).collect()
1577}
1578
1579pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1584 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1587 .ok()
1588 .flatten()
1589 && let Some(custom) = cfg.project_name
1590 && !custom.trim().is_empty()
1591 {
1592 return custom;
1593 }
1594
1595 let folder_name = repo_path
1596 .file_name()
1597 .map(|n| n.to_string_lossy().to_string())
1598 .unwrap_or_else(|| crate::output::clean_path(repo_path));
1599
1600 let duplicate_count = all_paths
1602 .iter()
1603 .filter(|p| {
1604 p.file_name()
1605 .map(|n| n.to_string_lossy().to_string())
1606 .as_deref()
1607 == Some(&folder_name)
1608 })
1609 .count();
1610
1611 if duplicate_count > 1
1612 && let Some(parent) = repo_path.parent()
1613 && let Some(parent_name) = parent.file_name()
1614 {
1615 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1616 }
1617
1618 folder_name
1619}
1620
1621fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1624 to_utc(git::get_last_activity(path).ok().flatten())
1625}
1626
1627fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1629 system_time.map(|st| {
1630 let duration = st
1631 .duration_since(SystemTime::UNIX_EPOCH)
1632 .unwrap_or_default();
1633 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1634 })
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639 use super::*;
1640 use std::fs;
1641 use std::process::Command;
1642 use tempfile::TempDir;
1643
1644 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1647
1648 #[test]
1649 fn an_ordinary_directory_is_not_a_mount_point() {
1650 let tmp = TempDir::new().unwrap();
1653 let dir = tmp.path().join("node_modules");
1654 fs::create_dir_all(&dir).unwrap();
1655 assert!(!is_mount_point(&dir));
1656 }
1657
1658 #[test]
1659 fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1660 let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1663 assert!(!is_mount_point(root));
1664 }
1665
1666 fn create_git_repo_with_commit(path: &Path) {
1667 fs::create_dir_all(path).unwrap();
1668 Command::new("git")
1669 .args(["init"])
1670 .current_dir(path)
1671 .output()
1672 .unwrap();
1673 fs::write(path.join("README.md"), "# Test").unwrap();
1674 Command::new("git")
1675 .args(["add", "."])
1676 .current_dir(path)
1677 .output()
1678 .unwrap();
1679 Command::new("git")
1680 .args([
1681 "-c",
1682 "user.name=Test",
1683 "-c",
1684 "user.email=test@test.com",
1685 "commit",
1686 "-m",
1687 "initial",
1688 ])
1689 .current_dir(path)
1690 .output()
1691 .unwrap();
1692 }
1693
1694 #[test]
1695 fn a_bloat_label_names_the_project_that_owns_it() {
1696 assert_eq!(owning_project("node_modules"), ".");
1697 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1698 assert_eq!(
1699 owning_project("packages/@scope/app/.venv"),
1700 "packages/@scope/app"
1701 );
1702 }
1703
1704 #[test]
1705 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1706 let tmp = TempDir::new().unwrap();
1709 let root = tmp.path();
1710 for name in ["frontend", "docs"] {
1711 let dir = root.join(name);
1712 fs::create_dir_all(&dir).unwrap();
1713 fs::write(dir.join("package.json"), "{}").unwrap();
1714 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1715 }
1716
1717 let deleted = vec![crate::config::PrunedDir {
1718 repo_path: root.to_path_buf(),
1719 bloat_dir: "frontend/node_modules".to_string(),
1720 adapter: "npm".to_string(),
1721 size_freed: 0,
1722 runtime: None,
1723 }];
1724 let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1725
1726 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1727 assert_eq!(results[0].label, "npm (frontend/node_modules)");
1728 }
1729
1730 #[test]
1731 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1732 let tmp = TempDir::new().unwrap();
1735 let deleted = vec![crate::config::PrunedDir {
1736 repo_path: tmp.path().to_path_buf(),
1737 bloat_dir: "services/api/.venv".to_string(),
1738 adapter: "uv".to_string(),
1739 size_freed: 0,
1740 runtime: None,
1741 }];
1742 let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1743
1744 assert_eq!(results.len(), 1);
1745 assert_eq!(results[0].label, "uv (services/api/.venv)");
1746 let err = results[0].result.as_ref().unwrap_err().to_string();
1747 assert!(err.contains("services/api"), "names the missing project");
1748 assert!(err.contains("uv"), "names the adapter that owned it");
1749 }
1750
1751 #[test]
1752 fn test_prune_status_display() {
1753 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1754 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1755 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1756 }
1757
1758 #[test]
1759 fn test_prune_repo_non_git() {
1760 let tmp = TempDir::new().unwrap();
1763 let results = prune_repo(tmp.path(), 15, false, false);
1764 assert_eq!(results.len(), 1);
1765 assert!(matches!(
1766 results[0].status,
1767 PruneStatus::ActivityCheckError(_)
1768 ));
1769 }
1770
1771 #[test]
1772 fn test_prune_repo_active_skipped() {
1773 let tmp = TempDir::new().unwrap();
1774 let repo = tmp.path().join("repo");
1775 create_git_repo_with_commit(&repo);
1776 let results = prune_repo(&repo, 15, false, false);
1778 assert_eq!(results.len(), 1);
1779 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1780 }
1781
1782 #[test]
1785 fn test_unparseable_per_repo_config_skips_the_repo() {
1786 let tmp = TempDir::new().unwrap();
1787 let repo = tmp.path().join("repo");
1788 create_git_repo_with_commit(&repo);
1789 fs::create_dir(repo.join("target")).unwrap();
1790 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1791 fs::write(
1792 repo.join("Cargo.toml"),
1793 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1794 )
1795 .unwrap();
1796 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1797 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1799
1800 let results = prune_repo(&repo, 15, false, true);
1802
1803 assert_eq!(results.len(), 1);
1804 assert!(
1805 matches!(results[0].status, PruneStatus::ConfigError(_)),
1806 "expected ConfigError, got {:?}",
1807 results[0].status
1808 );
1809 assert!(repo.join("target").exists(), "target must survive");
1810 }
1811
1812 #[test]
1816 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1817 let tmp = TempDir::new().unwrap();
1818 let repo = tmp.path().join("repo");
1819 create_git_repo_with_commit(&repo);
1820 create_python_project(&repo);
1821 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1822
1823 let mut registry = Registry::default();
1824 registry.add_repo(repo.clone());
1825
1826 let entries = get_full_status(®istry);
1827 assert_eq!(entries.len(), 1);
1828 assert!(
1829 matches!(entries[0].reason, SkipReason::ConfigError(_)),
1830 "expected ConfigError, got {:?}",
1831 entries[0].reason
1832 );
1833 assert_eq!(entries[0].reclaimable_bytes, 0);
1834 }
1835
1836 #[test]
1837 fn test_prune_repo_dry_run() {
1838 let tmp = TempDir::new().unwrap();
1839 let repo = tmp.path().join("repo");
1840 create_git_repo_with_commit(&repo);
1841 create_go_project(&repo);
1844 let results = prune_repo(&repo, 15, true, true);
1846 let dry_run_results: Vec<_> = results
1847 .iter()
1848 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1849 .collect();
1850 assert!(!dry_run_results.is_empty());
1851 assert!(repo.join("vendor").exists());
1853 }
1854
1855 fn create_python_project(dir: &Path) {
1860 fs::create_dir_all(dir).unwrap();
1861 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1862 let venv = dir.join(".venv");
1863 fs::create_dir_all(&venv).unwrap();
1864 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1865 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1866 }
1867
1868 fn create_go_project(dir: &Path) {
1873 fs::create_dir_all(dir).unwrap();
1874 fs::write(dir.join("go.mod"), "module example.com/x\n\ngo 1.22\n").unwrap();
1875 fs::write(dir.join("go.sum"), "").unwrap();
1876 let vendor = dir.join("vendor");
1877 fs::create_dir_all(&vendor).unwrap();
1878 fs::write(vendor.join("modules.txt"), "# example.com/dep v1.0.0\n").unwrap();
1879 fs::write(vendor.join("payload.bin"), vec![0u8; 4096]).unwrap();
1880 }
1881
1882 fn labels(results: &[PruneResult]) -> Vec<String> {
1884 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1885 out.sort();
1886 out
1887 }
1888
1889 #[test]
1890 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1891 let tmp = TempDir::new().unwrap();
1892 let repo = tmp.path().join("repo");
1893 create_git_repo_with_commit(&repo);
1894
1895 create_go_project(&repo);
1896 fs::write(repo.join("package.json"), "{}").unwrap();
1897 fs::write(repo.join("package-lock.json"), "{}").unwrap();
1898 fs::create_dir(repo.join("node_modules")).unwrap();
1899 create_python_project(&repo);
1900
1901 let results = prune_repo(&repo, 15, true, true);
1902 assert_eq!(labels(&results), vec![".venv", "node_modules", "vendor"]);
1903 }
1904
1905 #[test]
1906 fn test_prune_finds_ecosystems_at_different_depths() {
1907 let tmp = TempDir::new().unwrap();
1908 let repo = tmp.path().join("repo");
1909 create_git_repo_with_commit(&repo);
1910
1911 fs::create_dir_all(repo.join("frontend")).unwrap();
1912 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1913 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1914 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1915
1916 create_go_project(&repo.join("tools/cli"));
1917
1918 create_python_project(&repo.join("services/api"));
1919
1920 let results = prune_repo(&repo, 15, true, true);
1921 assert_eq!(
1922 labels(&results),
1923 vec![
1924 "frontend/node_modules",
1925 "services/api/.venv",
1926 "tools/cli/vendor",
1927 ]
1928 );
1929 }
1930
1931 #[test]
1932 fn test_prune_deletes_only_the_selected_nested_directory() {
1933 let tmp = TempDir::new().unwrap();
1934 let repo = tmp.path().join("repo");
1935 create_git_repo_with_commit(&repo);
1936 create_python_project(&repo.join("a"));
1937 create_python_project(&repo.join("b"));
1938
1939 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1940
1941 assert_eq!(labels(&results), vec!["a/.venv"]);
1942 assert!(matches!(results[0].status, PruneStatus::Pruned));
1943 assert!(!repo.join("a/.venv").exists());
1944 assert!(repo.join("b/.venv").exists());
1945 }
1946
1947 #[test]
1948 fn test_prune_ignores_bloat_inside_a_nested_repository() {
1949 let tmp = TempDir::new().unwrap();
1950 let repo = tmp.path().join("repo");
1951 create_git_repo_with_commit(&repo);
1952 create_python_project(&repo.join("outer"));
1953
1954 let nested = repo.join("nested");
1957 create_git_repo_with_commit(&nested);
1958 create_python_project(&nested);
1959
1960 let results = prune_repo(&repo, 15, true, true);
1961 assert_eq!(labels(&results), vec!["outer/.venv"]);
1962 }
1963
1964 #[test]
1965 fn test_prune_repo_no_adapters() {
1966 let tmp = TempDir::new().unwrap();
1967 let repo = tmp.path().join("repo");
1968 create_git_repo_with_commit(&repo);
1969 let results = prune_repo(&repo, 15, false, true);
1971 assert!(
1972 results
1973 .iter()
1974 .any(|r| matches!(r.status, PruneStatus::NoBloat))
1975 );
1976 }
1977
1978 #[test]
1979 fn test_prune_all_disabled() {
1980 let tmp = TempDir::new().unwrap();
1981 let _registry_path = tmp.path().join("registry.json");
1982
1983 let mut registry = Registry::default();
1984 let repo_path = PathBuf::from("/nonexistent/repo");
1985 registry.add_repo(repo_path.clone());
1986 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1987
1988 let results = prune_all(&mut registry, false, false);
1989 assert!(
1990 results
1991 .iter()
1992 .any(|r| matches!(r.status, PruneStatus::Disabled))
1993 );
1994 }
1995
1996 #[test]
1997 fn test_restore_project_no_adapters() {
1998 let tmp = TempDir::new().unwrap();
1999 let result = restore_project_to_depth(
2000 tmp.path(),
2001 crate::constants::DEFAULT_SCAN_DEPTH,
2002 TEST_TIMEOUT,
2003 );
2004 assert!(result.is_err());
2005 }
2006
2007 #[test]
2008 fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
2009 let tmp = TempDir::new().unwrap();
2013 let api = tmp.path().join("api");
2014 fs::create_dir_all(&api).unwrap();
2015 fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
2016 let deleted = vec![crate::config::PrunedDir {
2019 repo_path: tmp.path().to_path_buf(),
2020 bloat_dir: "api/.venv".to_string(),
2021 adapter: "venv".to_string(),
2022 size_freed: 0,
2023 runtime: None,
2024 }];
2025 let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
2028
2029 assert_eq!(results.len(), 1);
2030 assert_eq!(results[0].label, "venv (api/.venv)");
2031 if let Err(e) = &results[0].result {
2032 assert!(
2033 !e.to_string().contains("no longer a"),
2034 "the recorded adapter must be attempted, got: {e}"
2035 );
2036 }
2037 }
2038
2039 #[test]
2040 fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
2041 let tmp = TempDir::new().unwrap();
2044 let repo = tmp.path().join("repo");
2045 create_git_repo_with_commit(&repo);
2046 create_python_project(&repo);
2047 fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
2048
2049 let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
2050
2051 assert_eq!(results.len(), 1);
2052 let PruneStatus::SkippedNestedRepo(msg) = &results[0].status else {
2053 panic!("expected a refusal, got {:?}", results[0].status);
2054 };
2055 assert!(msg.contains("git repository"), "says why: {msg}");
2056 assert!(repo.join(".venv").exists(), "nothing may be deleted");
2057 assert_eq!(results[0].size_freed, 0);
2058 }
2059
2060 fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
2061 RepoStatusEntry {
2062 path: PathBuf::from(name),
2063 entry: RepoEntry::new(),
2064 reason: SkipReason::Candidate,
2065 adapters: Vec::new(),
2066 bloat_dirs: Vec::new(),
2067 reclaimable_by_adapter: Vec::new(),
2068 reclaimable_bytes: reclaimable,
2069 last_activity: None,
2070 idle_days: 15,
2071 }
2072 }
2073
2074 #[test]
2075 fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
2076 let repos = [
2077 status_entry("small", 10),
2078 status_entry("big", 300),
2079 status_entry("mid", 200),
2080 ];
2081 let names: Vec<String> = take_top(&repos, Some(2))
2082 .iter()
2083 .map(|e| e.path.display().to_string())
2084 .collect();
2085 assert_eq!(names, vec!["big", "mid"]);
2089 }
2090
2091 #[test]
2092 fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
2093 let repos = [status_entry("a", 1), status_entry("b", 2)];
2094 assert_eq!(take_top(&repos, None).len(), 2);
2095 assert_eq!(take_top(&repos, Some(10)).len(), 2);
2096 assert_eq!(take_top(&repos, Some(0)).len(), 0);
2097 }
2098
2099 #[test]
2100 fn test_restore_project_with_npm() {
2101 let tmp = TempDir::new().unwrap();
2102 fs::write(tmp.path().join("package.json"), "{}").unwrap();
2103 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
2104 let results = restore_project_to_depth(
2105 tmp.path(),
2106 crate::constants::DEFAULT_SCAN_DEPTH,
2107 TEST_TIMEOUT,
2108 );
2109 assert!(results.is_ok());
2111 let results = results.unwrap();
2112 assert!(!results.is_empty());
2113 assert_eq!(results[0].0, "npm");
2114 }
2115
2116 #[test]
2117 fn the_scan_never_starts_more_threads_than_there_is_work() {
2118 assert_eq!(clamp_scan_threads(32, 3), 3);
2120 assert_eq!(clamp_scan_threads(0, 0), 1);
2123 assert_eq!(clamp_scan_threads(0, 50), 1);
2124 }
2125
2126 #[test]
2127 fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
2128 assert_eq!(
2130 clamp_scan_threads(9_999, 500),
2131 constants::STATUS_SCAN_MAX_THREADS
2132 );
2133 }
2134
2135 #[test]
2136 fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
2137 assert_eq!(clamp_scan_threads(16, 1), 1);
2138 }
2139}