1use std::fs;
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use anyhow::Result;
20use chrono::{DateTime, Utc};
21
22use crate::adapters::BloatDir;
23use crate::config::{Registry, RepoEntry};
24use crate::constants;
25use crate::scanner;
26use crate::scanner::git;
27use crate::workspace;
28
29#[derive(Debug, Clone)]
31pub enum PruneStatus {
32 Pruned,
34 SkippedActive,
36 SkippedDryRun,
38 LockfileError(String),
40 ActivityCheckError(String),
45 PathMissing,
47 NoBloat,
49 Disabled,
51 SkippedIgnored,
53 DeleteError(String),
55 SkippedSymlink(String),
61 ConfigError(String),
63}
64
65impl std::fmt::Display for PruneStatus {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 PruneStatus::Pruned => write!(f, "Pruned"),
69 PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
70 PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
71 PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
72 PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
73 PruneStatus::PathMissing => {
74 write!(
75 f,
76 "Path no longer exists (`devp unlink --missing` clears it)"
77 )
78 }
79 PruneStatus::NoBloat => write!(f, "No bloat found"),
80 PruneStatus::Disabled => write!(f, "Disabled"),
81 PruneStatus::SkippedIgnored => write!(
82 f,
83 "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
84 ),
85 PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
86 PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
87 PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
88 }
89 }
90}
91
92pub const BYTES_PER_MIB: u64 = 1024 * 1024;
95
96#[derive(Debug, Clone, Default, PartialEq)]
103pub struct AdapterFilter {
104 only: Option<Vec<String>>,
105 skip: Vec<String>,
106}
107
108impl AdapterFilter {
109 pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
114 let known: Vec<&'static str> = crate::adapters::get_all_adapters()
115 .iter()
116 .map(|a| a.name())
117 .collect();
118
119 let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
120 let mut out = Vec::new();
121 for token in raw.split(',') {
122 let name = token.trim().to_lowercase();
123 if name.is_empty() {
124 continue;
125 }
126 if !known.contains(&name.as_str()) {
127 anyhow::bail!(
128 "`--{flag} {name}` names no known package manager. Available: {}.",
129 known.join(", ")
130 );
131 }
132 if !out.contains(&name) {
133 out.push(name);
134 }
135 }
136 if out.is_empty() {
137 anyhow::bail!("`--{flag}` was given no adapter names.");
138 }
139 Ok(out)
140 };
141
142 let only = only.map(|raw| parse(raw, "only")).transpose()?;
143 let skip = skip
144 .map(|raw| parse(raw, "skip"))
145 .transpose()?
146 .unwrap_or_default();
147
148 if let Some(only) = &only
149 && let Some(clash) = only.iter().find(|n| skip.contains(n))
150 {
151 anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
152 }
153
154 Ok(Self { only, skip })
155 }
156
157 pub fn allows(&self, name: &str) -> bool {
159 if self.skip.iter().any(|s| s == name) {
160 return false;
161 }
162 match &self.only {
163 Some(only) => only.iter().any(|o| o == name),
164 None => true,
165 }
166 }
167
168 pub fn is_unrestricted(&self) -> bool {
170 self.only.is_none() && self.skip.is_empty()
171 }
172
173 pub fn describe(&self) -> Option<String> {
175 if self.is_unrestricted() {
176 return None;
177 }
178 let mut parts = Vec::new();
179 if let Some(only) = &self.only {
180 parts.push(format!("only {}", only.join(", ")));
181 }
182 if !self.skip.is_empty() {
183 parts.push(format!("skipping {}", self.skip.join(", ")));
184 }
185 Some(parts.join("; "))
186 }
187}
188
189#[derive(Debug, Clone)]
195pub struct PruneOptions {
196 pub idle_days: u64,
198 pub dry_run: bool,
200 pub force: bool,
202 pub only_dirs: Option<Vec<String>>,
208 pub adapters: AdapterFilter,
210 pub min_size_bytes: u64,
212 pub scan_depth: usize,
217 pub allow_manifest_rewrite: bool,
219 pub command_timeout_secs: u64,
224 pub build_idle_days: u64,
230}
231
232impl Default for PruneOptions {
233 fn default() -> Self {
234 Self {
235 idle_days: 0,
236 dry_run: false,
237 force: false,
238 only_dirs: None,
239 adapters: AdapterFilter::default(),
240 min_size_bytes: 0,
241 scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
242 allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
243 command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
244 build_idle_days: crate::constants::DEFAULT_BUILD_IDLE_DAYS,
245 }
246 }
247}
248
249impl PruneOptions {
250 pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
252 Self {
253 idle_days,
254 dry_run,
255 force,
256 ..Self::default()
257 }
258 }
259}
260
261#[derive(Debug, Clone)]
263pub struct PruneResult {
264 pub repo_path: PathBuf,
266 pub adapter_name: String,
268 pub bloat_dir: String,
270 pub size_freed: u64,
272 pub shared_bytes: u64,
276 pub status: PruneStatus,
278}
279
280pub fn prune_repo(
287 repo_path: &Path,
288 idle_days: u64,
289 dry_run: bool,
290 force: bool,
291) -> Vec<PruneResult> {
292 prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
293}
294
295pub fn prune_repo_selected(
304 repo_path: &Path,
305 idle_days: u64,
306 dry_run: bool,
307 force: bool,
308 only: Option<&[String]>,
309) -> Vec<PruneResult> {
310 prune_repo_with(
311 repo_path,
312 &PruneOptions {
313 only_dirs: only.map(<[String]>::to_vec),
314 ..PruneOptions::new(idle_days, dry_run, force)
315 },
316 )
317}
318
319pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
324 let idle_days = opts.idle_days;
325 let dry_run = opts.dry_run;
326 let force = opts.force;
327 let only = opts.only_dirs.as_deref();
328 let mut results = Vec::new();
329
330 if !repo_path.exists() {
334 results.push(PruneResult {
335 repo_path: repo_path.to_path_buf(),
336 adapter_name: "-".to_string(),
337 bloat_dir: "-".to_string(),
338 size_freed: 0,
339 shared_bytes: 0,
340 status: PruneStatus::PathMissing,
341 });
342 return results;
343 }
344
345 if !scanner::is_git_repo(repo_path) {
349 results.push(PruneResult {
350 repo_path: repo_path.to_path_buf(),
351 adapter_name: "-".to_string(),
352 bloat_dir: "-".to_string(),
353 size_freed: 0,
354 shared_bytes: 0,
355 status: PruneStatus::ActivityCheckError(format!(
356 "`{}` is no longer a git repository — nothing was touched. \
357 `devp unlink` removes it from the registry.",
358 repo_path.display()
359 )),
360 });
361 return results;
362 }
363
364 if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
366 results.push(PruneResult {
367 repo_path: repo_path.to_path_buf(),
368 adapter_name: "-".to_string(),
369 bloat_dir: "-".to_string(),
370 size_freed: 0,
371 shared_bytes: 0,
372 status: PruneStatus::SkippedIgnored,
373 });
374 return results;
375 }
376
377 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
381 Ok(cfg) => cfg,
382 Err(e) => {
383 results.push(PruneResult {
384 repo_path: repo_path.to_path_buf(),
385 adapter_name: "-".to_string(),
386 bloat_dir: "-".to_string(),
387 size_freed: 0,
388 shared_bytes: 0,
389 status: PruneStatus::ConfigError(e),
390 });
391 return results;
392 }
393 };
394 if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
395 results.push(PruneResult {
396 repo_path: repo_path.to_path_buf(),
397 adapter_name: "-".to_string(),
398 bloat_dir: "-".to_string(),
399 size_freed: 0,
400 shared_bytes: 0,
401 status: PruneStatus::SkippedIgnored,
402 });
403 return results;
404 }
405
406 let effective_idle_days = per_repo_config
408 .as_ref()
409 .and_then(|c| c.override_idle_days)
410 .unwrap_or(idle_days);
411
412 let min_size_bytes = if only.is_some() {
415 0
416 } else {
417 per_repo_config
418 .as_ref()
419 .and_then(|c| c.min_size_mb)
420 .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
421 .unwrap_or(opts.min_size_bytes)
422 };
423
424 if !force {
426 match git::is_repo_idle(repo_path, effective_idle_days) {
427 Ok(false) => {
428 results.push(PruneResult {
429 repo_path: repo_path.to_path_buf(),
430 adapter_name: "-".to_string(),
431 bloat_dir: "-".to_string(),
432 size_freed: 0,
433 shared_bytes: 0,
434 status: PruneStatus::SkippedActive,
435 });
436 return results;
437 }
438 Ok(true) => {} Err(e) => {
440 results.push(PruneResult {
441 repo_path: repo_path.to_path_buf(),
442 adapter_name: "-".to_string(),
443 bloat_dir: "-".to_string(),
444 size_freed: 0,
445 shared_bytes: 0,
446 status: PruneStatus::ActivityCheckError(e.to_string()),
447 });
448 return results;
449 }
450 }
451 }
452
453 let projects = workspace::discover_to_depth(
457 repo_path,
458 workspace::resolve_depth(repo_path, opts.scan_depth),
459 );
460
461 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
465
466 let mut build_idle: Option<bool> = None;
469
470 for project in &projects {
471 for adapter in &project.adapters {
472 if !opts.adapters.allows(adapter.name()) {
473 continue;
474 }
475
476 if adapter.opt_in() && !force {
480 let threshold = opts.build_idle_days.max(effective_idle_days);
481 let idle_enough = *build_idle.get_or_insert_with(|| {
482 git::is_repo_idle(repo_path, threshold).unwrap_or(false)
483 });
484 if !idle_enough {
485 continue;
486 }
487 }
488
489 let bloat_dirs: Vec<(String, BloatDir)> = adapter
496 .bloat_dirs(&project.path)
497 .into_iter()
498 .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
499 .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
500 .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
501 .filter(|(_, bd)| claimed.insert(bd.path.clone()))
502 .collect();
503
504 if bloat_dirs.is_empty() {
505 continue;
506 }
507
508 let mut deletable: Vec<(String, BloatDir)> = Vec::new();
517 for (label, bd) in bloat_dirs {
518 if fs::symlink_metadata(&bd.path)
523 .map(|m| m.file_type().is_symlink())
524 .unwrap_or(false)
525 {
526 results.push(PruneResult {
527 repo_path: repo_path.to_path_buf(),
528 adapter_name: adapter.name().to_string(),
529 bloat_dir: label,
530 size_freed: 0,
531 shared_bytes: 0,
532 status: PruneStatus::SkippedSymlink(format!(
533 "`{}` is a symlink to storage dev-prune does not own — \
534 left alone. Remove the link yourself if you really want \
535 it gone.",
536 bd.path.display()
537 )),
538 });
539 continue;
540 }
541
542 if is_mount_point(&bd.path) {
547 results.push(PruneResult {
548 repo_path: repo_path.to_path_buf(),
549 adapter_name: adapter.name().to_string(),
550 bloat_dir: label,
551 size_freed: 0,
552 shared_bytes: 0,
553 status: PruneStatus::SkippedSymlink(format!(
554 "`{}` is a mount point — it is on a different filesystem \
555 than the repository around it, so its contents are shared \
556 with whatever mounted it. Left alone.",
557 bd.path.display()
558 )),
559 });
560 continue;
561 }
562
563 if let Some(nested) = find_nested_git(&bd.path) {
568 results.push(PruneResult {
569 repo_path: repo_path.to_path_buf(),
570 adapter_name: adapter.name().to_string(),
571 bloat_dir: label,
572 size_freed: 0,
573 shared_bytes: 0,
574 status: PruneStatus::DeleteError(format!(
575 "`{}` contains a git repository at `{}` — refusing to \
576 delete it. Move or remove that checkout yourself if it \
577 holds nothing you need.",
578 bd.path.display(),
579 nested.display()
580 )),
581 });
582 continue;
583 }
584
585 deletable.push((label, bd));
586 }
587
588 if deletable.is_empty() {
589 continue;
590 }
591
592 if !dry_run {
594 let policy = crate::adapters::EnforcePolicy {
595 allow_rewrite: opts.allow_manifest_rewrite,
596 timeout: std::time::Duration::from_secs(opts.command_timeout_secs),
597 };
598 if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
599 for (label, _) in &deletable {
600 results.push(PruneResult {
601 repo_path: repo_path.to_path_buf(),
602 adapter_name: adapter.name().to_string(),
603 bloat_dir: label.clone(),
604 size_freed: 0,
605 shared_bytes: 0,
606 status: PruneStatus::LockfileError(e.to_string()),
607 });
608 }
609 continue;
610 }
611 }
612
613 for (label, bd) in deletable {
614 if dry_run {
615 results.push(PruneResult {
616 repo_path: repo_path.to_path_buf(),
617 adapter_name: adapter.name().to_string(),
618 bloat_dir: label,
619 size_freed: bd.size_bytes,
620 shared_bytes: bd.shared_bytes,
621 status: PruneStatus::SkippedDryRun,
622 });
623 continue;
624 }
625
626 let size = bd.size_bytes;
627 let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
634 std::thread::sleep(std::time::Duration::from_millis(250));
635 fs::remove_dir_all(&bd.path)
636 });
637 match delete {
638 Ok(()) => {
641 results.push(PruneResult {
642 repo_path: repo_path.to_path_buf(),
643 adapter_name: adapter.name().to_string(),
644 bloat_dir: label,
645 size_freed: size,
646 shared_bytes: bd.shared_bytes,
647 status: PruneStatus::Pruned,
648 });
649 }
650 Err(_) if !bd.path.exists() => {
651 results.push(PruneResult {
652 repo_path: repo_path.to_path_buf(),
653 adapter_name: adapter.name().to_string(),
654 bloat_dir: label,
655 size_freed: size,
656 shared_bytes: bd.shared_bytes,
657 status: PruneStatus::Pruned,
658 });
659 }
660 Err(e) => {
661 let remaining = crate::adapters::dir_size(&bd.path);
667 let freed = size.saturating_sub(remaining);
668 let message = if freed > 0 {
669 format!(
670 "{e} — `{}` was partially deleted ({} of {} remains) \
671 and is no longer usable. Close whatever holds it open, \
672 then run `devp restore` to rebuild it.",
673 bd.path.display(),
674 crate::output::format_bytes(remaining),
675 crate::output::format_bytes(size)
676 )
677 } else {
678 e.to_string()
679 };
680 results.push(PruneResult {
681 repo_path: repo_path.to_path_buf(),
682 adapter_name: adapter.name().to_string(),
683 bloat_dir: label,
684 size_freed: freed,
685 shared_bytes: 0,
686 status: PruneStatus::DeleteError(message),
687 });
688 }
689 }
690 }
691 }
692 }
693
694 if results.is_empty() {
696 results.push(PruneResult {
697 repo_path: repo_path.to_path_buf(),
698 adapter_name: "-".to_string(),
699 bloat_dir: "-".to_string(),
700 size_freed: 0,
701 shared_bytes: 0,
702 status: PruneStatus::NoBloat,
703 });
704 }
705
706 results
707}
708
709#[cfg(unix)]
721fn is_mount_point(path: &Path) -> bool {
722 use std::os::unix::fs::MetadataExt;
723 let Some(parent) = path.parent() else {
724 return false;
725 };
726 match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
727 (Ok(here), Ok(above)) => here.dev() != above.dev(),
728 _ => false,
730 }
731}
732
733#[cfg(not(unix))]
734fn is_mount_point(_path: &Path) -> bool {
735 false
736}
737
738fn find_nested_git(dir: &Path) -> Option<PathBuf> {
744 walkdir::WalkDir::new(dir)
745 .follow_links(false)
746 .into_iter()
747 .flatten()
748 .find(|e| e.file_name() == ".git")
749 .map(|e| e.into_path())
750}
751
752fn collect_bloat(
759 repo_path: &Path,
760 min_size_bytes: u64,
761 depth: usize,
762) -> (Vec<String>, Vec<BloatDir>) {
763 let mut adapter_names: Vec<String> = Vec::new();
764 let mut bloat: Vec<BloatDir> = Vec::new();
765 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
766
767 for project in workspace::discover_to_depth(repo_path, depth) {
768 for adapter in &project.adapters {
769 let name = adapter.name();
770 if !adapter_names.iter().any(|existing| existing == name) {
771 adapter_names.push(name.to_string());
772 }
773 for bd in adapter.bloat_dirs(&project.path) {
774 if bd.size_bytes < min_size_bytes {
775 continue;
776 }
777 if fs::symlink_metadata(&bd.path)
783 .map(|m| m.file_type().is_symlink())
784 .unwrap_or(false)
785 || is_mount_point(&bd.path)
786 || find_nested_git(&bd.path).is_some()
787 {
788 continue;
789 }
790 if claimed.insert(bd.path.clone()) {
791 bloat.push(BloatDir {
792 name: workspace::relative_label(repo_path, &bd.path),
793 ..bd
794 });
795 }
796 }
797 }
798 }
799
800 (adapter_names, bloat)
801}
802
803pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
808 let mut all_results = Vec::new();
809
810 let mut repos: Vec<(PathBuf, u64, bool)> = registry
816 .repositories
817 .iter()
818 .map(|(path, entry)| {
819 let idle_days = entry
820 .override_idle_days
821 .unwrap_or(registry.settings.idle_days);
822 (path.clone(), idle_days, entry.enabled)
823 })
824 .collect();
825 repos.sort_by(|a, b| a.0.cmp(&b.0));
826
827 for (path, idle_days, enabled) in repos {
828 if !enabled {
829 all_results.push(PruneResult {
830 repo_path: path.clone(),
831 adapter_name: "-".to_string(),
832 bloat_dir: "-".to_string(),
833 size_freed: 0,
834 shared_bytes: 0,
835 status: PruneStatus::Disabled,
836 });
837 continue;
838 }
839
840 let results = prune_repo_with(
841 &path,
842 &PruneOptions {
843 idle_days,
844 ..opts.clone()
845 },
846 );
847
848 let path_freed: u64 = results
849 .iter()
850 .filter(|r| matches!(r.status, PruneStatus::Pruned))
851 .map(|r| r.size_freed)
852 .sum();
853
854 if path_freed > 0 {
855 registry.mark_pruned(&path, path_freed);
856 }
857
858 all_results.extend(results);
859 }
860
861 all_results
862}
863
864pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
866 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
867}
868
869pub fn restore_project_to_depth(
881 project_path: &Path,
882 global_depth: usize,
883 timeout: std::time::Duration,
884) -> Result<Vec<(String, Result<()>)>> {
885 let depth = workspace::resolve_depth(project_path, global_depth);
886 let projects = workspace::discover_to_depth(project_path, depth);
887
888 if projects.is_empty() {
889 anyhow::bail!(
890 "No recognized package manager found in {}",
891 project_path.display()
892 );
893 }
894
895 let mut results = Vec::new();
896 for project in &projects {
897 for adapter in &project.adapters {
898 let label = if project.relative == "." {
899 adapter.name().to_string()
900 } else {
901 format!("{} ({})", adapter.name(), project.relative)
902 };
903 results.push((label, adapter.restore(&project.path, timeout)));
904 }
905 }
906
907 Ok(results)
908}
909
910fn owning_project(bloat_label: &str) -> &str {
918 match bloat_label.rsplit_once('/') {
919 Some((parent, _)) => parent,
920 None => ".",
921 }
922}
923
924pub fn restore_deleted(
936 repo_path: &Path,
937 deleted: &[(String, String)],
938 global_depth: usize,
939 timeout: std::time::Duration,
940) -> Vec<(String, Result<()>)> {
941 let depth = workspace::resolve_depth(repo_path, global_depth);
942 let projects = workspace::discover_to_depth(repo_path, depth);
943
944 let mut results = Vec::new();
945 for (bloat_label, adapter_name) in deleted {
946 let wanted = owning_project(bloat_label);
947 let label = format!("{adapter_name} ({bloat_label})");
948 let dir_name = bloat_label
951 .rsplit_once('/')
952 .map_or(bloat_label.as_str(), |(_, name)| name);
953
954 let found = projects
955 .iter()
956 .filter(|p| p.relative == wanted)
957 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
958 .find(|(_, a)| a.name() == adapter_name);
959
960 if let Some((project, adapter)) = found {
961 results.push((
962 label,
963 adapter.restore_named(&project.path, dir_name, timeout),
964 ));
965 continue;
966 }
967
968 let project_dir = if wanted == "." {
974 repo_path.to_path_buf()
975 } else {
976 repo_path.join(wanted)
977 };
978 let recorded = crate::adapters::get_all_adapters()
979 .into_iter()
980 .find(|a| a.name() == adapter_name);
981 match recorded {
982 Some(adapter) if project_dir.is_dir() => {
983 results.push((
984 label,
985 adapter.restore_named(&project_dir, dir_name, timeout),
986 ));
987 }
988 _ => results.push((
989 label,
990 Err(anyhow::anyhow!(
991 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
992 moved or removed since the prune. Restore it by hand if it still exists.",
993 repo_path.display()
994 )),
995 )),
996 }
997 }
998
999 results
1000}
1001
1002#[derive(Debug, Clone, PartialEq)]
1004pub enum SkipReason {
1005 Candidate,
1007 Active,
1009 Ignored,
1012 NoBloat,
1014 PathMissing,
1016 ConfigError(String),
1018}
1019
1020impl std::fmt::Display for SkipReason {
1021 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1022 match self {
1023 SkipReason::Candidate => write!(f, "Candidate"),
1024 SkipReason::Active => write!(f, "Active (not idle)"),
1025 SkipReason::Ignored => write!(f, "Ignored"),
1026 SkipReason::NoBloat => write!(f, "No bloat found"),
1027 SkipReason::PathMissing => write!(f, "Path missing"),
1028 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1029 }
1030 }
1031}
1032
1033#[derive(Debug, Clone)]
1035pub struct RepoStatusEntry {
1036 pub path: PathBuf,
1038 pub entry: RepoEntry,
1040 pub reason: SkipReason,
1042 pub adapters: Vec<String>,
1044 pub bloat_dirs: Vec<BloatDir>,
1046 pub reclaimable_bytes: u64,
1048 pub last_activity: Option<DateTime<Utc>>,
1050 pub idle_days: u64,
1052}
1053
1054pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1059 let mut entries: Vec<RepoStatusEntry> = Vec::new();
1060
1061 for (path, reg_entry) in ®istry.repositories {
1062 let registry_idle_days = reg_entry
1063 .override_idle_days
1064 .unwrap_or(registry.settings.idle_days);
1065
1066 if !path.exists() {
1069 entries.push(RepoStatusEntry {
1070 path: path.clone(),
1071 entry: reg_entry.clone(),
1072 reason: SkipReason::PathMissing,
1073 adapters: Vec::new(),
1074 bloat_dirs: Vec::new(),
1075 reclaimable_bytes: 0,
1076 last_activity: None,
1077 idle_days: registry_idle_days,
1078 });
1079 continue;
1080 }
1081
1082 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1087 Ok(cfg) => cfg,
1088 Err(e) => {
1089 entries.push(RepoStatusEntry {
1090 path: path.clone(),
1091 entry: reg_entry.clone(),
1092 reason: SkipReason::ConfigError(e),
1093 adapters: Vec::new(),
1094 bloat_dirs: Vec::new(),
1095 reclaimable_bytes: 0,
1096 last_activity: last_activity_time(path),
1097 idle_days: registry_idle_days,
1098 });
1099 continue;
1100 }
1101 };
1102 let idle_days = per_repo_config
1103 .as_ref()
1104 .and_then(|c| c.override_idle_days)
1105 .unwrap_or(registry_idle_days);
1106
1107 let is_ignored = !reg_entry.enabled
1109 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1110 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1111 if is_ignored {
1112 entries.push(RepoStatusEntry {
1113 path: path.clone(),
1114 entry: reg_entry.clone(),
1115 reason: SkipReason::Ignored,
1116 adapters: Vec::new(),
1117 bloat_dirs: Vec::new(),
1118 reclaimable_bytes: 0,
1119 last_activity: last_activity_time(path),
1120 idle_days,
1121 });
1122 continue;
1123 }
1124
1125 let activity = git::get_last_activity(path).ok().flatten();
1130 let activity_time = to_utc(activity);
1131 let is_idle = git::is_idle_at(activity, idle_days);
1132
1133 let min_size_bytes = per_repo_config
1135 .as_ref()
1136 .and_then(|c| c.min_size_mb)
1137 .unwrap_or(registry.settings.min_size_mb)
1138 .saturating_mul(BYTES_PER_MIB);
1139 let depth = workspace::clamp_depth(
1143 per_repo_config
1144 .as_ref()
1145 .and_then(|c| c.scan_depth)
1146 .unwrap_or(registry.settings.scan_depth),
1147 );
1148 let (adapter_names, all_bloat) = collect_bloat(path, min_size_bytes, depth);
1149 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1150
1151 let reason = if !is_idle {
1152 SkipReason::Active
1153 } else if all_bloat.is_empty() {
1154 SkipReason::NoBloat
1155 } else {
1156 SkipReason::Candidate
1157 };
1158
1159 entries.push(RepoStatusEntry {
1160 path: path.clone(),
1161 entry: reg_entry.clone(),
1162 reason,
1163 adapters: adapter_names,
1164 bloat_dirs: all_bloat,
1165 reclaimable_bytes: reclaimable,
1166 last_activity: activity_time,
1167 idle_days,
1168 });
1169 }
1170
1171 entries.sort_by(|a, b| {
1173 let a_cand = matches!(a.reason, SkipReason::Candidate);
1174 let b_cand = matches!(b.reason, SkipReason::Candidate);
1175 b_cand.cmp(&a_cand).then_with(|| a.path.cmp(&b.path))
1176 });
1177
1178 entries
1179}
1180
1181pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1190 let Some(n) = top else {
1191 return repos.to_vec();
1192 };
1193
1194 let mut ranked: Vec<usize> = (0..repos.len()).collect();
1195 ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1196 ranked.truncate(n);
1197 ranked.sort_unstable();
1198 ranked.into_iter().map(|i| repos[i].clone()).collect()
1199}
1200
1201pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1206 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1209 .ok()
1210 .flatten()
1211 && let Some(custom) = cfg.project_name
1212 && !custom.trim().is_empty()
1213 {
1214 return custom;
1215 }
1216
1217 let folder_name = repo_path
1218 .file_name()
1219 .map(|n| n.to_string_lossy().to_string())
1220 .unwrap_or_else(|| crate::output::clean_path(repo_path));
1221
1222 let duplicate_count = all_paths
1224 .iter()
1225 .filter(|p| {
1226 p.file_name()
1227 .map(|n| n.to_string_lossy().to_string())
1228 .as_deref()
1229 == Some(&folder_name)
1230 })
1231 .count();
1232
1233 if duplicate_count > 1
1234 && let Some(parent) = repo_path.parent()
1235 && let Some(parent_name) = parent.file_name()
1236 {
1237 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1238 }
1239
1240 folder_name
1241}
1242
1243fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1246 to_utc(git::get_last_activity(path).ok().flatten())
1247}
1248
1249fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1251 system_time.map(|st| {
1252 let duration = st
1253 .duration_since(SystemTime::UNIX_EPOCH)
1254 .unwrap_or_default();
1255 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1256 })
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 use super::*;
1262 use std::fs;
1263 use std::process::Command;
1264 use tempfile::TempDir;
1265
1266 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1269
1270 #[test]
1271 fn an_ordinary_directory_is_not_a_mount_point() {
1272 let tmp = TempDir::new().unwrap();
1275 let dir = tmp.path().join("node_modules");
1276 fs::create_dir_all(&dir).unwrap();
1277 assert!(!is_mount_point(&dir));
1278 }
1279
1280 #[test]
1281 fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1282 let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1285 assert!(!is_mount_point(root));
1286 }
1287
1288 fn create_git_repo_with_commit(path: &Path) {
1289 fs::create_dir_all(path).unwrap();
1290 Command::new("git")
1291 .args(["init"])
1292 .current_dir(path)
1293 .output()
1294 .unwrap();
1295 fs::write(path.join("README.md"), "# Test").unwrap();
1296 Command::new("git")
1297 .args(["add", "."])
1298 .current_dir(path)
1299 .output()
1300 .unwrap();
1301 Command::new("git")
1302 .args([
1303 "-c",
1304 "user.name=Test",
1305 "-c",
1306 "user.email=test@test.com",
1307 "commit",
1308 "-m",
1309 "initial",
1310 ])
1311 .current_dir(path)
1312 .output()
1313 .unwrap();
1314 }
1315
1316 #[test]
1317 fn a_bloat_label_names_the_project_that_owns_it() {
1318 assert_eq!(owning_project("node_modules"), ".");
1319 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1320 assert_eq!(
1321 owning_project("packages/@scope/app/.venv"),
1322 "packages/@scope/app"
1323 );
1324 }
1325
1326 #[test]
1327 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1328 let tmp = TempDir::new().unwrap();
1331 let root = tmp.path();
1332 for name in ["frontend", "docs"] {
1333 let dir = root.join(name);
1334 fs::create_dir_all(&dir).unwrap();
1335 fs::write(dir.join("package.json"), "{}").unwrap();
1336 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1337 }
1338
1339 let deleted = vec![("frontend/node_modules".to_string(), "npm".to_string())];
1340 let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1341
1342 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1343 assert_eq!(results[0].0, "npm (frontend/node_modules)");
1344 }
1345
1346 #[test]
1347 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1348 let tmp = TempDir::new().unwrap();
1351 let deleted = vec![("services/api/.venv".to_string(), "uv".to_string())];
1352 let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1353
1354 assert_eq!(results.len(), 1);
1355 assert_eq!(results[0].0, "uv (services/api/.venv)");
1356 let err = results[0].1.as_ref().unwrap_err().to_string();
1357 assert!(err.contains("services/api"), "names the missing project");
1358 assert!(err.contains("uv"), "names the adapter that owned it");
1359 }
1360
1361 #[test]
1362 fn test_prune_status_display() {
1363 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1364 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1365 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1366 }
1367
1368 #[test]
1369 fn test_prune_repo_non_git() {
1370 let tmp = TempDir::new().unwrap();
1373 let results = prune_repo(tmp.path(), 15, false, false);
1374 assert_eq!(results.len(), 1);
1375 assert!(matches!(
1376 results[0].status,
1377 PruneStatus::ActivityCheckError(_)
1378 ));
1379 }
1380
1381 #[test]
1382 fn test_prune_repo_active_skipped() {
1383 let tmp = TempDir::new().unwrap();
1384 let repo = tmp.path().join("repo");
1385 create_git_repo_with_commit(&repo);
1386 let results = prune_repo(&repo, 15, false, false);
1388 assert_eq!(results.len(), 1);
1389 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1390 }
1391
1392 #[test]
1395 fn test_unparseable_per_repo_config_skips_the_repo() {
1396 let tmp = TempDir::new().unwrap();
1397 let repo = tmp.path().join("repo");
1398 create_git_repo_with_commit(&repo);
1399 fs::create_dir(repo.join("target")).unwrap();
1400 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1401 fs::write(
1402 repo.join("Cargo.toml"),
1403 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1404 )
1405 .unwrap();
1406 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1407 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1409
1410 let results = prune_repo(&repo, 15, false, true);
1412
1413 assert_eq!(results.len(), 1);
1414 assert!(
1415 matches!(results[0].status, PruneStatus::ConfigError(_)),
1416 "expected ConfigError, got {:?}",
1417 results[0].status
1418 );
1419 assert!(repo.join("target").exists(), "target must survive");
1420 }
1421
1422 #[test]
1426 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1427 let tmp = TempDir::new().unwrap();
1428 let repo = tmp.path().join("repo");
1429 create_git_repo_with_commit(&repo);
1430 create_python_project(&repo);
1431 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1432
1433 let mut registry = Registry::default();
1434 registry.add_repo(repo.clone());
1435
1436 let entries = get_full_status(®istry);
1437 assert_eq!(entries.len(), 1);
1438 assert!(
1439 matches!(entries[0].reason, SkipReason::ConfigError(_)),
1440 "expected ConfigError, got {:?}",
1441 entries[0].reason
1442 );
1443 assert_eq!(entries[0].reclaimable_bytes, 0);
1444 }
1445
1446 #[test]
1447 fn test_prune_repo_dry_run() {
1448 let tmp = TempDir::new().unwrap();
1449 let repo = tmp.path().join("repo");
1450 create_git_repo_with_commit(&repo);
1451 fs::create_dir(repo.join("target")).unwrap();
1453 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1454 fs::write(
1455 repo.join("Cargo.toml"),
1456 "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2024\"",
1457 )
1458 .unwrap();
1459 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1460 let results = prune_repo(&repo, 15, true, true);
1462 let dry_run_results: Vec<_> = results
1463 .iter()
1464 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1465 .collect();
1466 assert!(!dry_run_results.is_empty());
1467 assert!(repo.join("target").exists());
1469 }
1470
1471 fn create_python_project(dir: &Path) {
1476 fs::create_dir_all(dir).unwrap();
1477 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1478 let venv = dir.join(".venv");
1479 fs::create_dir_all(&venv).unwrap();
1480 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1481 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1482 }
1483
1484 fn labels(results: &[PruneResult]) -> Vec<String> {
1486 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1487 out.sort();
1488 out
1489 }
1490
1491 #[test]
1492 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1493 let tmp = TempDir::new().unwrap();
1494 let repo = tmp.path().join("repo");
1495 create_git_repo_with_commit(&repo);
1496
1497 fs::write(repo.join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
1498 fs::create_dir(repo.join("target")).unwrap();
1499 fs::write(repo.join("package.json"), "{}").unwrap();
1500 fs::write(repo.join("package-lock.json"), "{}").unwrap();
1501 fs::create_dir(repo.join("node_modules")).unwrap();
1502 create_python_project(&repo);
1503
1504 let results = prune_repo(&repo, 15, true, true);
1505 assert_eq!(labels(&results), vec![".venv", "node_modules", "target"]);
1506 }
1507
1508 #[test]
1509 fn test_prune_finds_ecosystems_at_different_depths() {
1510 let tmp = TempDir::new().unwrap();
1511 let repo = tmp.path().join("repo");
1512 create_git_repo_with_commit(&repo);
1513
1514 fs::create_dir_all(repo.join("frontend")).unwrap();
1515 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1516 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1517 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1518
1519 fs::create_dir_all(repo.join("tools/cli")).unwrap();
1520 fs::write(repo.join("tools/cli/Cargo.toml"), "[package]\nname = \"y\"").unwrap();
1521 fs::create_dir(repo.join("tools/cli/target")).unwrap();
1522
1523 create_python_project(&repo.join("services/api"));
1524
1525 let results = prune_repo(&repo, 15, true, true);
1526 assert_eq!(
1527 labels(&results),
1528 vec![
1529 "frontend/node_modules",
1530 "services/api/.venv",
1531 "tools/cli/target",
1532 ]
1533 );
1534 }
1535
1536 #[test]
1537 fn test_prune_deletes_only_the_selected_nested_directory() {
1538 let tmp = TempDir::new().unwrap();
1539 let repo = tmp.path().join("repo");
1540 create_git_repo_with_commit(&repo);
1541 create_python_project(&repo.join("a"));
1542 create_python_project(&repo.join("b"));
1543
1544 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1545
1546 assert_eq!(labels(&results), vec!["a/.venv"]);
1547 assert!(matches!(results[0].status, PruneStatus::Pruned));
1548 assert!(!repo.join("a/.venv").exists());
1549 assert!(repo.join("b/.venv").exists());
1550 }
1551
1552 #[test]
1553 fn test_prune_ignores_bloat_inside_a_nested_repository() {
1554 let tmp = TempDir::new().unwrap();
1555 let repo = tmp.path().join("repo");
1556 create_git_repo_with_commit(&repo);
1557 create_python_project(&repo.join("outer"));
1558
1559 let nested = repo.join("nested");
1562 create_git_repo_with_commit(&nested);
1563 create_python_project(&nested);
1564
1565 let results = prune_repo(&repo, 15, true, true);
1566 assert_eq!(labels(&results), vec!["outer/.venv"]);
1567 }
1568
1569 #[test]
1570 fn test_prune_repo_no_adapters() {
1571 let tmp = TempDir::new().unwrap();
1572 let repo = tmp.path().join("repo");
1573 create_git_repo_with_commit(&repo);
1574 let results = prune_repo(&repo, 15, false, true);
1576 assert!(
1577 results
1578 .iter()
1579 .any(|r| matches!(r.status, PruneStatus::NoBloat))
1580 );
1581 }
1582
1583 #[test]
1584 fn test_prune_all_disabled() {
1585 let tmp = TempDir::new().unwrap();
1586 let _registry_path = tmp.path().join("registry.json");
1587
1588 let mut registry = Registry::default();
1589 let repo_path = PathBuf::from("/nonexistent/repo");
1590 registry.add_repo(repo_path.clone());
1591 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1592
1593 let results = prune_all(&mut registry, false, false);
1594 assert!(
1595 results
1596 .iter()
1597 .any(|r| matches!(r.status, PruneStatus::Disabled))
1598 );
1599 }
1600
1601 #[test]
1602 fn test_restore_project_no_adapters() {
1603 let tmp = TempDir::new().unwrap();
1604 let result = restore_project_to_depth(
1605 tmp.path(),
1606 crate::constants::DEFAULT_SCAN_DEPTH,
1607 TEST_TIMEOUT,
1608 );
1609 assert!(result.is_err());
1610 }
1611
1612 #[test]
1613 fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
1614 let tmp = TempDir::new().unwrap();
1618 let api = tmp.path().join("api");
1619 fs::create_dir_all(&api).unwrap();
1620 fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1621 let deleted = vec![("api/.venv".to_string(), "venv".to_string())];
1624 let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
1627
1628 assert_eq!(results.len(), 1);
1629 assert_eq!(results[0].0, "venv (api/.venv)");
1630 if let Err(e) = &results[0].1 {
1631 assert!(
1632 !e.to_string().contains("no longer a"),
1633 "the recorded adapter must be attempted, got: {e}"
1634 );
1635 }
1636 }
1637
1638 #[test]
1639 fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
1640 let tmp = TempDir::new().unwrap();
1643 let repo = tmp.path().join("repo");
1644 create_git_repo_with_commit(&repo);
1645 create_python_project(&repo);
1646 fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
1647
1648 let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
1649
1650 assert_eq!(results.len(), 1);
1651 let PruneStatus::DeleteError(msg) = &results[0].status else {
1652 panic!("expected a refusal, got {:?}", results[0].status);
1653 };
1654 assert!(msg.contains("git repository"), "says why: {msg}");
1655 assert!(repo.join(".venv").exists(), "nothing may be deleted");
1656 assert_eq!(results[0].size_freed, 0);
1657 }
1658
1659 fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
1660 RepoStatusEntry {
1661 path: PathBuf::from(name),
1662 entry: RepoEntry::new(),
1663 reason: SkipReason::Candidate,
1664 adapters: Vec::new(),
1665 bloat_dirs: Vec::new(),
1666 reclaimable_bytes: reclaimable,
1667 last_activity: None,
1668 idle_days: 15,
1669 }
1670 }
1671
1672 #[test]
1673 fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
1674 let repos = [
1675 status_entry("small", 10),
1676 status_entry("big", 300),
1677 status_entry("mid", 200),
1678 ];
1679 let names: Vec<String> = take_top(&repos, Some(2))
1680 .iter()
1681 .map(|e| e.path.display().to_string())
1682 .collect();
1683 assert_eq!(names, vec!["big", "mid"]);
1687 }
1688
1689 #[test]
1690 fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
1691 let repos = [status_entry("a", 1), status_entry("b", 2)];
1692 assert_eq!(take_top(&repos, None).len(), 2);
1693 assert_eq!(take_top(&repos, Some(10)).len(), 2);
1694 assert_eq!(take_top(&repos, Some(0)).len(), 0);
1695 }
1696
1697 #[test]
1698 fn test_restore_project_with_npm() {
1699 let tmp = TempDir::new().unwrap();
1700 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1701 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1702 let results = restore_project_to_depth(
1703 tmp.path(),
1704 crate::constants::DEFAULT_SCAN_DEPTH,
1705 TEST_TIMEOUT,
1706 );
1707 assert!(results.is_ok());
1709 let results = results.unwrap();
1710 assert!(!results.is_empty());
1711 assert_eq!(results[0].0, "npm");
1712 }
1713}