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 if let Some(clash) = only.iter().find(|n| skip.contains(n)) {
150 anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
151 }
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}
225
226impl Default for PruneOptions {
227 fn default() -> Self {
228 Self {
229 idle_days: 0,
230 dry_run: false,
231 force: false,
232 only_dirs: None,
233 adapters: AdapterFilter::default(),
234 min_size_bytes: 0,
235 scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
236 allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
237 command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
238 }
239 }
240}
241
242impl PruneOptions {
243 pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
245 Self {
246 idle_days,
247 dry_run,
248 force,
249 ..Self::default()
250 }
251 }
252}
253
254#[derive(Debug, Clone)]
256pub struct PruneResult {
257 pub repo_path: PathBuf,
259 pub adapter_name: String,
261 pub bloat_dir: String,
263 pub size_freed: u64,
265 pub shared_bytes: u64,
269 pub status: PruneStatus,
271}
272
273pub fn prune_repo(
280 repo_path: &Path,
281 idle_days: u64,
282 dry_run: bool,
283 force: bool,
284) -> Vec<PruneResult> {
285 prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
286}
287
288pub fn prune_repo_selected(
297 repo_path: &Path,
298 idle_days: u64,
299 dry_run: bool,
300 force: bool,
301 only: Option<&[String]>,
302) -> Vec<PruneResult> {
303 prune_repo_with(
304 repo_path,
305 &PruneOptions {
306 only_dirs: only.map(<[String]>::to_vec),
307 ..PruneOptions::new(idle_days, dry_run, force)
308 },
309 )
310}
311
312pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
317 let idle_days = opts.idle_days;
318 let dry_run = opts.dry_run;
319 let force = opts.force;
320 let only = opts.only_dirs.as_deref();
321 let mut results = Vec::new();
322
323 if !repo_path.exists() {
327 results.push(PruneResult {
328 repo_path: repo_path.to_path_buf(),
329 adapter_name: "-".to_string(),
330 bloat_dir: "-".to_string(),
331 size_freed: 0,
332 shared_bytes: 0,
333 status: PruneStatus::PathMissing,
334 });
335 return results;
336 }
337
338 if !scanner::is_git_repo(repo_path) {
340 return results;
341 }
342
343 if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
345 results.push(PruneResult {
346 repo_path: repo_path.to_path_buf(),
347 adapter_name: "-".to_string(),
348 bloat_dir: "-".to_string(),
349 size_freed: 0,
350 shared_bytes: 0,
351 status: PruneStatus::SkippedIgnored,
352 });
353 return results;
354 }
355
356 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
360 Ok(cfg) => cfg,
361 Err(e) => {
362 results.push(PruneResult {
363 repo_path: repo_path.to_path_buf(),
364 adapter_name: "-".to_string(),
365 bloat_dir: "-".to_string(),
366 size_freed: 0,
367 shared_bytes: 0,
368 status: PruneStatus::ConfigError(e),
369 });
370 return results;
371 }
372 };
373 if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
374 results.push(PruneResult {
375 repo_path: repo_path.to_path_buf(),
376 adapter_name: "-".to_string(),
377 bloat_dir: "-".to_string(),
378 size_freed: 0,
379 shared_bytes: 0,
380 status: PruneStatus::SkippedIgnored,
381 });
382 return results;
383 }
384
385 let effective_idle_days = per_repo_config
387 .as_ref()
388 .and_then(|c| c.override_idle_days)
389 .unwrap_or(idle_days);
390
391 let min_size_bytes = if only.is_some() {
394 0
395 } else {
396 per_repo_config
397 .as_ref()
398 .and_then(|c| c.min_size_mb)
399 .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
400 .unwrap_or(opts.min_size_bytes)
401 };
402
403 if !force {
405 match git::is_repo_idle(repo_path, effective_idle_days) {
406 Ok(false) => {
407 results.push(PruneResult {
408 repo_path: repo_path.to_path_buf(),
409 adapter_name: "-".to_string(),
410 bloat_dir: "-".to_string(),
411 size_freed: 0,
412 shared_bytes: 0,
413 status: PruneStatus::SkippedActive,
414 });
415 return results;
416 }
417 Ok(true) => {} Err(e) => {
419 results.push(PruneResult {
420 repo_path: repo_path.to_path_buf(),
421 adapter_name: "-".to_string(),
422 bloat_dir: "-".to_string(),
423 size_freed: 0,
424 shared_bytes: 0,
425 status: PruneStatus::ActivityCheckError(e.to_string()),
426 });
427 return results;
428 }
429 }
430 }
431
432 let projects = workspace::discover_to_depth(
436 repo_path,
437 workspace::resolve_depth(repo_path, opts.scan_depth),
438 );
439
440 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
444
445 for project in &projects {
446 for adapter in &project.adapters {
447 if !opts.adapters.allows(adapter.name()) {
448 continue;
449 }
450
451 let bloat_dirs: Vec<(String, BloatDir)> = adapter
458 .bloat_dirs(&project.path)
459 .into_iter()
460 .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
461 .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
462 .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
463 .filter(|(_, bd)| claimed.insert(bd.path.clone()))
464 .collect();
465
466 if bloat_dirs.is_empty() {
467 continue;
468 }
469
470 if !dry_run {
472 let policy = crate::adapters::EnforcePolicy {
473 allow_rewrite: opts.allow_manifest_rewrite,
474 timeout: std::time::Duration::from_secs(opts.command_timeout_secs),
475 };
476 if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
477 for (label, _) in &bloat_dirs {
478 results.push(PruneResult {
479 repo_path: repo_path.to_path_buf(),
480 adapter_name: adapter.name().to_string(),
481 bloat_dir: label.clone(),
482 size_freed: 0,
483 shared_bytes: 0,
484 status: PruneStatus::LockfileError(e.to_string()),
485 });
486 }
487 continue;
488 }
489 }
490
491 for (label, bd) in bloat_dirs {
492 if fs::symlink_metadata(&bd.path)
498 .map(|m| m.file_type().is_symlink())
499 .unwrap_or(false)
500 {
501 results.push(PruneResult {
502 repo_path: repo_path.to_path_buf(),
503 adapter_name: adapter.name().to_string(),
504 bloat_dir: label,
505 size_freed: 0,
506 shared_bytes: 0,
507 status: PruneStatus::SkippedSymlink(format!(
508 "`{}` is a symlink to storage dev-prune does not own — \
509 left alone. Remove the link yourself if you really want \
510 it gone.",
511 bd.path.display()
512 )),
513 });
514 continue;
515 }
516
517 if dry_run {
518 results.push(PruneResult {
519 repo_path: repo_path.to_path_buf(),
520 adapter_name: adapter.name().to_string(),
521 bloat_dir: label,
522 size_freed: bd.size_bytes,
523 shared_bytes: bd.shared_bytes,
524 status: PruneStatus::SkippedDryRun,
525 });
526 continue;
527 }
528
529 if let Some(nested) = find_nested_git(&bd.path) {
534 results.push(PruneResult {
535 repo_path: repo_path.to_path_buf(),
536 adapter_name: adapter.name().to_string(),
537 bloat_dir: label,
538 size_freed: 0,
539 shared_bytes: 0,
540 status: PruneStatus::DeleteError(format!(
541 "`{}` contains a git repository at `{}` — refusing to \
542 delete it. Move or remove that checkout yourself if it \
543 holds nothing you need.",
544 bd.path.display(),
545 nested.display()
546 )),
547 });
548 continue;
549 }
550
551 let size = bd.size_bytes;
552 let delete = fs::remove_dir_all(&bd.path).or_else(|_| fs::remove_dir_all(&bd.path));
557 match delete {
558 Ok(()) => {
561 results.push(PruneResult {
562 repo_path: repo_path.to_path_buf(),
563 adapter_name: adapter.name().to_string(),
564 bloat_dir: label,
565 size_freed: size,
566 shared_bytes: bd.shared_bytes,
567 status: PruneStatus::Pruned,
568 });
569 }
570 Err(_) if !bd.path.exists() => {
571 results.push(PruneResult {
572 repo_path: repo_path.to_path_buf(),
573 adapter_name: adapter.name().to_string(),
574 bloat_dir: label,
575 size_freed: size,
576 shared_bytes: bd.shared_bytes,
577 status: PruneStatus::Pruned,
578 });
579 }
580 Err(e) => {
581 let remaining = crate::adapters::dir_size(&bd.path);
587 let freed = size.saturating_sub(remaining);
588 let message = if freed > 0 {
589 format!(
590 "{e} — `{}` was partially deleted ({} of {} remains) \
591 and is no longer usable. Close whatever holds it open, \
592 then run `devp restore` to rebuild it.",
593 bd.path.display(),
594 crate::output::format_bytes(remaining),
595 crate::output::format_bytes(size)
596 )
597 } else {
598 e.to_string()
599 };
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: freed,
605 shared_bytes: 0,
606 status: PruneStatus::DeleteError(message),
607 });
608 }
609 }
610 }
611 }
612 }
613
614 if results.is_empty() {
616 results.push(PruneResult {
617 repo_path: repo_path.to_path_buf(),
618 adapter_name: "-".to_string(),
619 bloat_dir: "-".to_string(),
620 size_freed: 0,
621 shared_bytes: 0,
622 status: PruneStatus::NoBloat,
623 });
624 }
625
626 results
627}
628
629fn find_nested_git(dir: &Path) -> Option<PathBuf> {
635 walkdir::WalkDir::new(dir)
636 .follow_links(false)
637 .into_iter()
638 .flatten()
639 .find(|e| e.file_name() == ".git")
640 .map(|e| e.into_path())
641}
642
643fn collect_bloat(
650 repo_path: &Path,
651 min_size_bytes: u64,
652 depth: usize,
653) -> (Vec<String>, Vec<BloatDir>) {
654 let mut adapter_names: Vec<String> = Vec::new();
655 let mut bloat: Vec<BloatDir> = Vec::new();
656 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
657
658 for project in workspace::discover_to_depth(repo_path, depth) {
659 for adapter in &project.adapters {
660 let name = adapter.name();
661 if !adapter_names.iter().any(|existing| existing == name) {
662 adapter_names.push(name.to_string());
663 }
664 for bd in adapter.bloat_dirs(&project.path) {
665 if bd.size_bytes < min_size_bytes {
666 continue;
667 }
668 if claimed.insert(bd.path.clone()) {
669 bloat.push(BloatDir {
670 name: workspace::relative_label(repo_path, &bd.path),
671 ..bd
672 });
673 }
674 }
675 }
676 }
677
678 (adapter_names, bloat)
679}
680
681pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
686 let mut all_results = Vec::new();
687
688 let mut repos: Vec<(PathBuf, u64, bool)> = registry
694 .repositories
695 .iter()
696 .map(|(path, entry)| {
697 let idle_days = entry
698 .override_idle_days
699 .unwrap_or(registry.settings.idle_days);
700 (path.clone(), idle_days, entry.enabled)
701 })
702 .collect();
703 repos.sort_by(|a, b| a.0.cmp(&b.0));
704
705 for (path, idle_days, enabled) in repos {
706 if !enabled {
707 all_results.push(PruneResult {
708 repo_path: path.clone(),
709 adapter_name: "-".to_string(),
710 bloat_dir: "-".to_string(),
711 size_freed: 0,
712 shared_bytes: 0,
713 status: PruneStatus::Disabled,
714 });
715 continue;
716 }
717
718 let results = prune_repo_with(
719 &path,
720 &PruneOptions {
721 idle_days,
722 ..opts.clone()
723 },
724 );
725
726 let path_freed: u64 = results
727 .iter()
728 .filter(|r| matches!(r.status, PruneStatus::Pruned))
729 .map(|r| r.size_freed)
730 .sum();
731
732 if path_freed > 0 {
733 registry.mark_pruned(&path, path_freed);
734 }
735
736 all_results.extend(results);
737 }
738
739 all_results
740}
741
742pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
744 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
745}
746
747pub fn restore_project_to_depth(
759 project_path: &Path,
760 global_depth: usize,
761 timeout: std::time::Duration,
762) -> Result<Vec<(String, Result<()>)>> {
763 let depth = workspace::resolve_depth(project_path, global_depth);
764 let projects = workspace::discover_to_depth(project_path, depth);
765
766 if projects.is_empty() {
767 anyhow::bail!(
768 "No recognized package manager found in {}",
769 project_path.display()
770 );
771 }
772
773 let mut results = Vec::new();
774 for project in &projects {
775 for adapter in &project.adapters {
776 let label = if project.relative == "." {
777 adapter.name().to_string()
778 } else {
779 format!("{} ({})", adapter.name(), project.relative)
780 };
781 results.push((label, adapter.restore(&project.path, timeout)));
782 }
783 }
784
785 Ok(results)
786}
787
788fn owning_project(bloat_label: &str) -> &str {
796 match bloat_label.rsplit_once('/') {
797 Some((parent, _)) => parent,
798 None => ".",
799 }
800}
801
802pub fn restore_deleted(
814 repo_path: &Path,
815 deleted: &[(String, String)],
816 global_depth: usize,
817 timeout: std::time::Duration,
818) -> Vec<(String, Result<()>)> {
819 let depth = workspace::resolve_depth(repo_path, global_depth);
820 let projects = workspace::discover_to_depth(repo_path, depth);
821
822 let mut results = Vec::new();
823 for (bloat_label, adapter_name) in deleted {
824 let wanted = owning_project(bloat_label);
825 let label = format!("{adapter_name} ({bloat_label})");
826 let dir_name = bloat_label
829 .rsplit_once('/')
830 .map_or(bloat_label.as_str(), |(_, name)| name);
831
832 let found = projects
833 .iter()
834 .filter(|p| p.relative == wanted)
835 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
836 .find(|(_, a)| a.name() == adapter_name);
837
838 if let Some((project, adapter)) = found {
839 results.push((
840 label,
841 adapter.restore_named(&project.path, dir_name, timeout),
842 ));
843 continue;
844 }
845
846 let project_dir = if wanted == "." {
852 repo_path.to_path_buf()
853 } else {
854 repo_path.join(wanted)
855 };
856 let recorded = crate::adapters::get_all_adapters()
857 .into_iter()
858 .find(|a| a.name() == adapter_name);
859 match recorded {
860 Some(adapter) if project_dir.is_dir() => {
861 results.push((
862 label,
863 adapter.restore_named(&project_dir, dir_name, timeout),
864 ));
865 }
866 _ => results.push((
867 label,
868 Err(anyhow::anyhow!(
869 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
870 moved or removed since the prune. Restore it by hand if it still exists.",
871 repo_path.display()
872 )),
873 )),
874 }
875 }
876
877 results
878}
879
880#[derive(Debug, Clone, PartialEq)]
882pub enum SkipReason {
883 Candidate,
885 Active,
887 Ignored,
890 NoBloat,
892 PathMissing,
894 ConfigError(String),
896}
897
898impl std::fmt::Display for SkipReason {
899 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
900 match self {
901 SkipReason::Candidate => write!(f, "Candidate"),
902 SkipReason::Active => write!(f, "Active (not idle)"),
903 SkipReason::Ignored => write!(f, "Ignored"),
904 SkipReason::NoBloat => write!(f, "No bloat found"),
905 SkipReason::PathMissing => write!(f, "Path missing"),
906 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
907 }
908 }
909}
910
911#[derive(Debug, Clone)]
913pub struct RepoStatusEntry {
914 pub path: PathBuf,
916 pub entry: RepoEntry,
918 pub reason: SkipReason,
920 pub adapters: Vec<String>,
922 pub bloat_dirs: Vec<BloatDir>,
924 pub reclaimable_bytes: u64,
926 pub last_activity: Option<DateTime<Utc>>,
928 pub idle_days: u64,
930}
931
932pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
937 let mut entries: Vec<RepoStatusEntry> = Vec::new();
938
939 for (path, reg_entry) in ®istry.repositories {
940 let registry_idle_days = reg_entry
941 .override_idle_days
942 .unwrap_or(registry.settings.idle_days);
943
944 if !path.exists() {
947 entries.push(RepoStatusEntry {
948 path: path.clone(),
949 entry: reg_entry.clone(),
950 reason: SkipReason::PathMissing,
951 adapters: Vec::new(),
952 bloat_dirs: Vec::new(),
953 reclaimable_bytes: 0,
954 last_activity: None,
955 idle_days: registry_idle_days,
956 });
957 continue;
958 }
959
960 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
965 Ok(cfg) => cfg,
966 Err(e) => {
967 entries.push(RepoStatusEntry {
968 path: path.clone(),
969 entry: reg_entry.clone(),
970 reason: SkipReason::ConfigError(e),
971 adapters: Vec::new(),
972 bloat_dirs: Vec::new(),
973 reclaimable_bytes: 0,
974 last_activity: last_activity_time(path),
975 idle_days: registry_idle_days,
976 });
977 continue;
978 }
979 };
980 let idle_days = per_repo_config
981 .as_ref()
982 .and_then(|c| c.override_idle_days)
983 .unwrap_or(registry_idle_days);
984
985 let is_ignored = !reg_entry.enabled
987 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
988 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
989 if is_ignored {
990 entries.push(RepoStatusEntry {
991 path: path.clone(),
992 entry: reg_entry.clone(),
993 reason: SkipReason::Ignored,
994 adapters: Vec::new(),
995 bloat_dirs: Vec::new(),
996 reclaimable_bytes: 0,
997 last_activity: last_activity_time(path),
998 idle_days,
999 });
1000 continue;
1001 }
1002
1003 let activity = git::get_last_activity(path).ok().flatten();
1008 let activity_time = to_utc(activity);
1009 let is_idle = git::is_idle_at(activity, idle_days);
1010
1011 let min_size_bytes = per_repo_config
1013 .as_ref()
1014 .and_then(|c| c.min_size_mb)
1015 .unwrap_or(registry.settings.min_size_mb)
1016 .saturating_mul(BYTES_PER_MIB);
1017 let depth = workspace::clamp_depth(
1021 per_repo_config
1022 .as_ref()
1023 .and_then(|c| c.scan_depth)
1024 .unwrap_or(registry.settings.scan_depth),
1025 );
1026 let (adapter_names, all_bloat) = collect_bloat(path, min_size_bytes, depth);
1027 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1028
1029 let reason = if !is_idle {
1030 SkipReason::Active
1031 } else if all_bloat.is_empty() {
1032 SkipReason::NoBloat
1033 } else {
1034 SkipReason::Candidate
1035 };
1036
1037 entries.push(RepoStatusEntry {
1038 path: path.clone(),
1039 entry: reg_entry.clone(),
1040 reason,
1041 adapters: adapter_names,
1042 bloat_dirs: all_bloat,
1043 reclaimable_bytes: reclaimable,
1044 last_activity: activity_time,
1045 idle_days,
1046 });
1047 }
1048
1049 entries.sort_by(|a, b| {
1051 let a_cand = matches!(a.reason, SkipReason::Candidate);
1052 let b_cand = matches!(b.reason, SkipReason::Candidate);
1053 b_cand.cmp(&a_cand).then_with(|| a.path.cmp(&b.path))
1054 });
1055
1056 entries
1057}
1058
1059pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1068 let Some(n) = top else {
1069 return repos.to_vec();
1070 };
1071
1072 let mut ranked: Vec<usize> = (0..repos.len()).collect();
1073 ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1074 ranked.truncate(n);
1075 ranked.sort_unstable();
1076 ranked.into_iter().map(|i| repos[i].clone()).collect()
1077}
1078
1079pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1084 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1087 .ok()
1088 .flatten()
1089 {
1090 if let Some(custom) = cfg.project_name {
1091 if !custom.trim().is_empty() {
1092 return custom;
1093 }
1094 }
1095 }
1096
1097 let folder_name = repo_path
1098 .file_name()
1099 .map(|n| n.to_string_lossy().to_string())
1100 .unwrap_or_else(|| crate::output::clean_path(repo_path));
1101
1102 let duplicate_count = all_paths
1104 .iter()
1105 .filter(|p| {
1106 p.file_name()
1107 .map(|n| n.to_string_lossy().to_string())
1108 .as_deref()
1109 == Some(&folder_name)
1110 })
1111 .count();
1112
1113 if duplicate_count > 1 {
1114 if let Some(parent) = repo_path.parent() {
1115 if let Some(parent_name) = parent.file_name() {
1116 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1117 }
1118 }
1119 }
1120
1121 folder_name
1122}
1123
1124fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1127 to_utc(git::get_last_activity(path).ok().flatten())
1128}
1129
1130fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1132 system_time.map(|st| {
1133 let duration = st
1134 .duration_since(SystemTime::UNIX_EPOCH)
1135 .unwrap_or_default();
1136 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1137 })
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142 use super::*;
1143 use std::fs;
1144 use std::process::Command;
1145 use tempfile::TempDir;
1146
1147 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1150
1151 fn create_git_repo_with_commit(path: &Path) {
1152 fs::create_dir_all(path).unwrap();
1153 Command::new("git")
1154 .args(["init"])
1155 .current_dir(path)
1156 .output()
1157 .unwrap();
1158 fs::write(path.join("README.md"), "# Test").unwrap();
1159 Command::new("git")
1160 .args(["add", "."])
1161 .current_dir(path)
1162 .output()
1163 .unwrap();
1164 Command::new("git")
1165 .args([
1166 "-c",
1167 "user.name=Test",
1168 "-c",
1169 "user.email=test@test.com",
1170 "commit",
1171 "-m",
1172 "initial",
1173 ])
1174 .current_dir(path)
1175 .output()
1176 .unwrap();
1177 }
1178
1179 #[test]
1180 fn a_bloat_label_names_the_project_that_owns_it() {
1181 assert_eq!(owning_project("node_modules"), ".");
1182 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1183 assert_eq!(
1184 owning_project("packages/@scope/app/.venv"),
1185 "packages/@scope/app"
1186 );
1187 }
1188
1189 #[test]
1190 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1191 let tmp = TempDir::new().unwrap();
1194 let root = tmp.path();
1195 for name in ["frontend", "docs"] {
1196 let dir = root.join(name);
1197 fs::create_dir_all(&dir).unwrap();
1198 fs::write(dir.join("package.json"), "{}").unwrap();
1199 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1200 }
1201
1202 let deleted = vec![("frontend/node_modules".to_string(), "npm".to_string())];
1203 let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1204
1205 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1206 assert_eq!(results[0].0, "npm (frontend/node_modules)");
1207 }
1208
1209 #[test]
1210 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1211 let tmp = TempDir::new().unwrap();
1214 let deleted = vec![("services/api/.venv".to_string(), "uv".to_string())];
1215 let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1216
1217 assert_eq!(results.len(), 1);
1218 assert_eq!(results[0].0, "uv (services/api/.venv)");
1219 let err = results[0].1.as_ref().unwrap_err().to_string();
1220 assert!(err.contains("services/api"), "names the missing project");
1221 assert!(err.contains("uv"), "names the adapter that owned it");
1222 }
1223
1224 #[test]
1225 fn test_prune_status_display() {
1226 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1227 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1228 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1229 }
1230
1231 #[test]
1232 fn test_prune_repo_non_git() {
1233 let tmp = TempDir::new().unwrap();
1234 let results = prune_repo(tmp.path(), 15, false, false);
1235 assert!(results.is_empty());
1236 }
1237
1238 #[test]
1239 fn test_prune_repo_active_skipped() {
1240 let tmp = TempDir::new().unwrap();
1241 let repo = tmp.path().join("repo");
1242 create_git_repo_with_commit(&repo);
1243 let results = prune_repo(&repo, 15, false, false);
1245 assert_eq!(results.len(), 1);
1246 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1247 }
1248
1249 #[test]
1252 fn test_unparseable_per_repo_config_skips_the_repo() {
1253 let tmp = TempDir::new().unwrap();
1254 let repo = tmp.path().join("repo");
1255 create_git_repo_with_commit(&repo);
1256 fs::create_dir(repo.join("target")).unwrap();
1257 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1258 fs::write(
1259 repo.join("Cargo.toml"),
1260 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1261 )
1262 .unwrap();
1263 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1264 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1266
1267 let results = prune_repo(&repo, 15, false, true);
1269
1270 assert_eq!(results.len(), 1);
1271 assert!(
1272 matches!(results[0].status, PruneStatus::ConfigError(_)),
1273 "expected ConfigError, got {:?}",
1274 results[0].status
1275 );
1276 assert!(repo.join("target").exists(), "target must survive");
1277 }
1278
1279 #[test]
1283 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1284 let tmp = TempDir::new().unwrap();
1285 let repo = tmp.path().join("repo");
1286 create_git_repo_with_commit(&repo);
1287 create_python_project(&repo);
1288 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1289
1290 let mut registry = Registry::default();
1291 registry.add_repo(repo.clone());
1292
1293 let entries = get_full_status(®istry);
1294 assert_eq!(entries.len(), 1);
1295 assert!(
1296 matches!(entries[0].reason, SkipReason::ConfigError(_)),
1297 "expected ConfigError, got {:?}",
1298 entries[0].reason
1299 );
1300 assert_eq!(entries[0].reclaimable_bytes, 0);
1301 }
1302
1303 #[test]
1304 fn test_prune_repo_dry_run() {
1305 let tmp = TempDir::new().unwrap();
1306 let repo = tmp.path().join("repo");
1307 create_git_repo_with_commit(&repo);
1308 fs::create_dir(repo.join("target")).unwrap();
1310 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1311 fs::write(
1312 repo.join("Cargo.toml"),
1313 "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2024\"",
1314 )
1315 .unwrap();
1316 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1317 let results = prune_repo(&repo, 15, true, true);
1319 let dry_run_results: Vec<_> = results
1320 .iter()
1321 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1322 .collect();
1323 assert!(!dry_run_results.is_empty());
1324 assert!(repo.join("target").exists());
1326 }
1327
1328 fn create_python_project(dir: &Path) {
1333 fs::create_dir_all(dir).unwrap();
1334 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1335 let venv = dir.join(".venv");
1336 fs::create_dir_all(&venv).unwrap();
1337 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1338 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1339 }
1340
1341 fn labels(results: &[PruneResult]) -> Vec<String> {
1343 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1344 out.sort();
1345 out
1346 }
1347
1348 #[test]
1349 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1350 let tmp = TempDir::new().unwrap();
1351 let repo = tmp.path().join("repo");
1352 create_git_repo_with_commit(&repo);
1353
1354 fs::write(repo.join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
1355 fs::create_dir(repo.join("target")).unwrap();
1356 fs::write(repo.join("package.json"), "{}").unwrap();
1357 fs::write(repo.join("package-lock.json"), "{}").unwrap();
1358 fs::create_dir(repo.join("node_modules")).unwrap();
1359 create_python_project(&repo);
1360
1361 let results = prune_repo(&repo, 15, true, true);
1362 assert_eq!(labels(&results), vec![".venv", "node_modules", "target"]);
1363 }
1364
1365 #[test]
1366 fn test_prune_finds_ecosystems_at_different_depths() {
1367 let tmp = TempDir::new().unwrap();
1368 let repo = tmp.path().join("repo");
1369 create_git_repo_with_commit(&repo);
1370
1371 fs::create_dir_all(repo.join("frontend")).unwrap();
1372 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1373 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1374 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1375
1376 fs::create_dir_all(repo.join("tools/cli")).unwrap();
1377 fs::write(repo.join("tools/cli/Cargo.toml"), "[package]\nname = \"y\"").unwrap();
1378 fs::create_dir(repo.join("tools/cli/target")).unwrap();
1379
1380 create_python_project(&repo.join("services/api"));
1381
1382 let results = prune_repo(&repo, 15, true, true);
1383 assert_eq!(
1384 labels(&results),
1385 vec![
1386 "frontend/node_modules",
1387 "services/api/.venv",
1388 "tools/cli/target",
1389 ]
1390 );
1391 }
1392
1393 #[test]
1394 fn test_prune_deletes_only_the_selected_nested_directory() {
1395 let tmp = TempDir::new().unwrap();
1396 let repo = tmp.path().join("repo");
1397 create_git_repo_with_commit(&repo);
1398 create_python_project(&repo.join("a"));
1399 create_python_project(&repo.join("b"));
1400
1401 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1402
1403 assert_eq!(labels(&results), vec!["a/.venv"]);
1404 assert!(matches!(results[0].status, PruneStatus::Pruned));
1405 assert!(!repo.join("a/.venv").exists());
1406 assert!(repo.join("b/.venv").exists());
1407 }
1408
1409 #[test]
1410 fn test_prune_ignores_bloat_inside_a_nested_repository() {
1411 let tmp = TempDir::new().unwrap();
1412 let repo = tmp.path().join("repo");
1413 create_git_repo_with_commit(&repo);
1414 create_python_project(&repo.join("outer"));
1415
1416 let nested = repo.join("nested");
1419 create_git_repo_with_commit(&nested);
1420 create_python_project(&nested);
1421
1422 let results = prune_repo(&repo, 15, true, true);
1423 assert_eq!(labels(&results), vec!["outer/.venv"]);
1424 }
1425
1426 #[test]
1427 fn test_prune_repo_no_adapters() {
1428 let tmp = TempDir::new().unwrap();
1429 let repo = tmp.path().join("repo");
1430 create_git_repo_with_commit(&repo);
1431 let results = prune_repo(&repo, 15, false, true);
1433 assert!(
1434 results
1435 .iter()
1436 .any(|r| matches!(r.status, PruneStatus::NoBloat))
1437 );
1438 }
1439
1440 #[test]
1441 fn test_prune_all_disabled() {
1442 let tmp = TempDir::new().unwrap();
1443 let _registry_path = tmp.path().join("registry.json");
1444
1445 let mut registry = Registry::default();
1446 let repo_path = PathBuf::from("/nonexistent/repo");
1447 registry.add_repo(repo_path.clone());
1448 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1449
1450 let results = prune_all(&mut registry, false, false);
1451 assert!(
1452 results
1453 .iter()
1454 .any(|r| matches!(r.status, PruneStatus::Disabled))
1455 );
1456 }
1457
1458 #[test]
1459 fn test_restore_project_no_adapters() {
1460 let tmp = TempDir::new().unwrap();
1461 let result = restore_project_to_depth(
1462 tmp.path(),
1463 crate::constants::DEFAULT_SCAN_DEPTH,
1464 TEST_TIMEOUT,
1465 );
1466 assert!(result.is_err());
1467 }
1468
1469 #[test]
1470 fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
1471 let tmp = TempDir::new().unwrap();
1475 let api = tmp.path().join("api");
1476 fs::create_dir_all(&api).unwrap();
1477 fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1478 let deleted = vec![("api/.venv".to_string(), "venv".to_string())];
1481 let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
1484
1485 assert_eq!(results.len(), 1);
1486 assert_eq!(results[0].0, "venv (api/.venv)");
1487 if let Err(e) = &results[0].1 {
1488 assert!(
1489 !e.to_string().contains("no longer a"),
1490 "the recorded adapter must be attempted, got: {e}"
1491 );
1492 }
1493 }
1494
1495 #[test]
1496 fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
1497 let tmp = TempDir::new().unwrap();
1500 let repo = tmp.path().join("repo");
1501 create_git_repo_with_commit(&repo);
1502 create_python_project(&repo);
1503 fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
1504
1505 let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
1506
1507 assert_eq!(results.len(), 1);
1508 let PruneStatus::DeleteError(msg) = &results[0].status else {
1509 panic!("expected a refusal, got {:?}", results[0].status);
1510 };
1511 assert!(msg.contains("git repository"), "says why: {msg}");
1512 assert!(repo.join(".venv").exists(), "nothing may be deleted");
1513 assert_eq!(results[0].size_freed, 0);
1514 }
1515
1516 fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
1517 RepoStatusEntry {
1518 path: PathBuf::from(name),
1519 entry: RepoEntry::new(),
1520 reason: SkipReason::Candidate,
1521 adapters: Vec::new(),
1522 bloat_dirs: Vec::new(),
1523 reclaimable_bytes: reclaimable,
1524 last_activity: None,
1525 idle_days: 15,
1526 }
1527 }
1528
1529 #[test]
1530 fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
1531 let repos = [
1532 status_entry("small", 10),
1533 status_entry("big", 300),
1534 status_entry("mid", 200),
1535 ];
1536 let names: Vec<String> = take_top(&repos, Some(2))
1537 .iter()
1538 .map(|e| e.path.display().to_string())
1539 .collect();
1540 assert_eq!(names, vec!["big", "mid"]);
1544 }
1545
1546 #[test]
1547 fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
1548 let repos = [status_entry("a", 1), status_entry("b", 2)];
1549 assert_eq!(take_top(&repos, None).len(), 2);
1550 assert_eq!(take_top(&repos, Some(10)).len(), 2);
1551 assert_eq!(take_top(&repos, Some(0)).len(), 0);
1552 }
1553
1554 #[test]
1555 fn test_restore_project_with_npm() {
1556 let tmp = TempDir::new().unwrap();
1557 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1558 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1559 let results = restore_project_to_depth(
1560 tmp.path(),
1561 crate::constants::DEFAULT_SCAN_DEPTH,
1562 TEST_TIMEOUT,
1563 );
1564 assert!(results.is_ok());
1566 let results = results.unwrap();
1567 assert!(!results.is_empty());
1568 assert_eq!(results[0].0, "npm");
1569 }
1570}