diff --git a/src/bin/cargo/commands/clean.rs b/src/bin/cargo/commands/clean.rs index e9ee35fdf20..f556369c9da 100644 --- a/src/bin/cargo/commands/clean.rs +++ b/src/bin/cargo/commands/clean.rs @@ -127,6 +127,24 @@ pub fn cli() -> Command { ) .value_name("SIZE") .value_parser(parse_human_size), + ) + .arg( + opt( + "max-target-dir-age", + "Deletes target directories that have not been used \ + since the given age (unstable)", + ) + .value_name("DURATION") + .value_parser(parse_time_span), + ) + .arg( + opt( + "max-target-dir-size", + "Deletes target directories until the total size is under \ + the given size (unstable)", + ) + .value_name("SIZE") + .value_parser(parse_human_size), ), ) .after_help(color_print::cstr!( @@ -190,6 +208,8 @@ fn gc(gctx: &GlobalContext, args: &ArgMatches) -> CliResult { max_crate_size: size_opt("max-crate-size"), max_git_size: size_opt("max-git-size"), max_download_size: size_opt("max-download-size"), + max_target_dir_age: duration_opt("max-target-dir-age"), + max_target_dir_size: size_opt("max-target-dir-size"), }; if let Some(age) = duration_opt("max-download-age") { gc_opts.set_max_download_age(age); diff --git a/src/cargo/core/gc.rs b/src/cargo/core/gc.rs index 324cede649b..715ebd4c137 100644 --- a/src/cargo/core/gc.rs +++ b/src/cargo/core/gc.rs @@ -130,6 +130,10 @@ pub struct GcOpts { pub max_git_size: Option, /// The `--max-download-size` CLI option. pub max_download_size: Option, + /// The `--max-target-dir-age` CLI option. + pub max_target_dir_age: Option, + /// The `--max-target-dir-size` CLI option. + pub max_target_dir_size: Option, } impl GcOpts { diff --git a/src/cargo/core/global_cache_tracker.rs b/src/cargo/core/global_cache_tracker.rs index 8465f18b1bd..b47a2bbb2f0 100644 --- a/src/cargo/core/global_cache_tracker.rs +++ b/src/cargo/core/global_cache_tracker.rs @@ -116,6 +116,7 @@ use crate::core::gc::GcOpts; use crate::ops::CleanContext; +use crate::ops::cargo_clean::validate_target_dir_tag; use crate::util::cache_lock::CacheLockMode; use crate::util::interning::InternedString; use crate::util::sqlite::{self, Migration, basic_migration}; @@ -209,6 +210,15 @@ pub struct GitCheckout { pub size: Option, } +/// The key for a target directory entry stored in the database. +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub struct TargetDirectory { + /// The workspace manifest path (Cargo.toml or cargo script). + pub workspace_manifest: InternedString, + /// The target directory path. + pub target_dir: InternedString, +} + /// Filesystem paths in the global cache. /// /// Accessing these assumes a lock has already been acquired. @@ -303,6 +313,20 @@ fn migrations() -> Vec { )?; Ok(()) }), + // Target directory tracking + // + // Tracks target directories associated with workspaces. Neither field alone + // is unique: CARGO_TARGET_DIR can be shared across workspaces, and one + // workspace can have multiple target dirs (e.g., rust-analyzer). + basic_migration( + "CREATE TABLE target_directory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_manifest TEXT NOT NULL, + target_dir TEXT NOT NULL, + timestamp INTEGER NOT NULL, + UNIQUE (workspace_manifest, target_dir) + )", + ), ] } @@ -564,6 +588,7 @@ impl GlobalCacheTracker { trace!(target: "gc", "cleaning {gc_opts:?}"); let tx = self.conn.transaction()?; let mut delete_paths = Vec::new(); + let mut target_dir_delete_groups = Vec::new(); // This can be an expensive operation, so only perform it if necessary. if gc_opts.is_download_cache_opt_set() { // TODO: Investigate how slow this might be. @@ -609,6 +634,19 @@ impl GlobalCacheTracker { let max_age = now - max_age.as_secs(); Self::get_git_co_items_to_clean(&tx, max_age, &base.git_co, &mut delete_paths)?; } + if let Some(max_age) = gc_opts.max_target_dir_age { + if max_age == Duration::ZERO { + // Special case: max_age=0 means delete all entries + Self::get_target_dirs_to_clean_age( + &tx, + i64::MAX as Timestamp, + &mut target_dir_delete_groups, + )?; + } else { + let max_age = now - max_age.as_secs(); + Self::get_target_dirs_to_clean_age(&tx, max_age, &mut target_dir_delete_groups)?; + } + } // Size collection must happen after date collection so that dates // have precedence, since size constraints are a more blunt // instrument. @@ -646,6 +684,18 @@ impl GlobalCacheTracker { if let Some(max_size) = gc_opts.max_download_size { Self::get_registry_items_to_clean_size_both(&tx, max_size, &base, &mut delete_paths)?; } + if let Some(max_size) = gc_opts.max_target_dir_size { + Self::get_target_dirs_to_clean_size(&tx, max_size, &mut target_dir_delete_groups)?; + } + + for grouped in target_dir_delete_groups { + // Match `cargo clean` behavior for non-explicit target dirs: unsafe target + // directories are skipped instead of aborting the whole GC operation. + if validate_target_dir_tag(&grouped.path).is_ok() { + Self::delete_grouped_target_directory_rows(&tx, &grouped)?; + delete_paths.push(grouped.path); + } + } clean_ctx.remove_paths(&delete_paths)?; @@ -1354,6 +1404,211 @@ impl GlobalCacheTracker { } Ok(()) } + + /// Returns all target directories from the database. + pub fn target_directory_all(&self) -> CargoResult> { + let mut stmt = self + .conn + .prepare("SELECT workspace_manifest, target_dir, timestamp FROM target_directory")?; + let rows = stmt.query_map([], |row| { + let workspace_manifest: String = row.get_unwrap(0); + let target_dir: String = row.get_unwrap(1); + let timestamp: Timestamp = row.get_unwrap(2); + Ok(( + TargetDirectory { + workspace_manifest: InternedString::new(&workspace_manifest), + target_dir: InternedString::new(&target_dir), + }, + timestamp, + )) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Loads all target-directory association rows. + fn target_directory_rows(conn: &Connection) -> CargoResult> { + let mut stmt = conn.prepare_cached( + "SELECT workspace_manifest, target_dir, timestamp FROM target_directory", + )?; + stmt.query_map([], |row| { + let workspace_manifest: String = row.get_unwrap(0); + let target_dir: String = row.get_unwrap(1); + let timestamp: Timestamp = row.get_unwrap(2); + Ok((workspace_manifest, target_dir, timestamp)) + })? + .collect::, _>>() + .map_err(Into::into) + } + + fn delete_target_directory_row( + conn: &Connection, + workspace_manifest: &Path, + target_dir: &Path, + ) -> CargoResult<()> { + conn.execute( + "DELETE FROM target_directory WHERE workspace_manifest = ?1 AND target_dir = ?2", + [ + workspace_manifest.to_string_lossy().to_string(), + target_dir.to_string_lossy().to_string(), + ], + )?; + Ok(()) + } + + fn delete_grouped_target_directory_rows( + conn: &Connection, + grouped: &GroupedTargetDirectory, + ) -> CargoResult<()> { + for assoc in &grouped.associations { + Self::delete_target_directory_row( + conn, + &assoc.workspace_manifest, + &assoc.raw_target_dir, + )?; + } + Ok(()) + } + + /// Groups target-directory association rows by physical target dir path. + fn grouped_target_directories(conn: &Connection) -> CargoResult> { + let mut grouped = HashMap::>::new(); + for (workspace_manifest, target_dir, timestamp) in Self::target_directory_rows(conn)? { + let raw_target_dir = PathBuf::from(target_dir); + let normalized_target_dir = paths::normalize_path(&raw_target_dir); + grouped + .entry(normalized_target_dir) + .or_default() + .push(TargetDirectoryAssociation { + workspace_manifest: PathBuf::from(workspace_manifest), + raw_target_dir, + timestamp, + }); + } + Ok(grouped + .into_iter() + .map(|(path, associations)| GroupedTargetDirectory { path, associations }) + .collect()) + } + + /// Adds paths to delete from `target_directory` whose effective last use is + /// older than the given timestamp, while preserving a shared target dir if + /// any valid recent association remains. + fn get_target_dirs_to_clean_age( + conn: &Connection, + max_age: Timestamp, + delete_groups: &mut Vec, + ) -> CargoResult<()> { + debug!(target: "gc", "cleaning target_directory since {max_age:?}"); + for grouped in Self::grouped_target_directories(conn)? { + let (valid, leaked): (Vec<_>, Vec<_>) = grouped + .associations + .iter() + .cloned() + .partition(|assoc| assoc.workspace_manifest.exists()); + + let effective_timestamp = valid + .iter() + .map(|assoc| assoc.timestamp) + .max() + .or_else(|| leaked.iter().map(|assoc| assoc.timestamp).max()) + .unwrap(); + + if effective_timestamp < max_age { + delete_groups.push(grouped); + continue; + } + + for assoc in leaked { + Self::delete_target_directory_row( + conn, + &assoc.workspace_manifest, + &assoc.raw_target_dir, + )?; + } + } + Ok(()) + } + + /// Adds paths to delete from target_directory to keep total size under max_size. + fn get_target_dirs_to_clean_size( + conn: &Connection, + max_size: u64, + delete_groups: &mut Vec, + ) -> CargoResult<()> { + debug!(target: "gc", "cleaning target_directory till under {max_size:?}"); + + let mut grouped = Vec::new(); + for grouped_target in Self::grouped_target_directories(conn)? { + let (valid, leaked): (Vec<_>, Vec<_>) = grouped_target + .associations + .iter() + .cloned() + .partition(|assoc| assoc.workspace_manifest.exists()); + + let effective_timestamp = valid + .iter() + .map(|assoc| assoc.timestamp) + .max() + .or_else(|| leaked.iter().map(|assoc| assoc.timestamp).max()) + .unwrap(); + + if !valid.is_empty() { + for assoc in leaked { + Self::delete_target_directory_row( + conn, + &assoc.workspace_manifest, + &assoc.raw_target_dir, + )?; + } + } + + let size = cargo_util::du(&grouped_target.path, &[]).unwrap_or(0); + grouped.push(TargetDirectorySizeEntry { + grouped: grouped_target, + effective_timestamp, + size, + }); + } + + grouped.sort_by(|a, b| a.effective_timestamp.cmp(&b.effective_timestamp)); + + let mut total_size: u64 = grouped.iter().map(|entry| entry.size).sum(); + debug!(target: "gc", "total target_directory size appears to be {total_size}"); + + if total_size <= max_size { + return Ok(()); + } + + for entry in grouped { + if total_size <= max_size { + break; + } + delete_groups.push(entry.grouped); + total_size = total_size.saturating_sub(entry.size); + } + + Ok(()) + } +} + +#[derive(Clone, Debug)] +struct TargetDirectoryAssociation { + workspace_manifest: PathBuf, + raw_target_dir: PathBuf, + timestamp: Timestamp, +} + +#[derive(Debug)] +struct GroupedTargetDirectory { + path: PathBuf, + associations: Vec, +} + +#[derive(Debug)] +struct TargetDirectorySizeEntry { + grouped: GroupedTargetDirectory, + effective_timestamp: Timestamp, + size: u64, } /// Helper to generate the upsert for the parent tables. @@ -1442,6 +1697,8 @@ pub struct DeferredGlobalLastUse { git_db_timestamps: HashMap, /// New git checkout entries to insert. git_checkout_timestamps: HashMap, + /// New target directory entries to insert. + target_directory_timestamps: HashMap, /// This is used so that a warning about failing to update the database is /// only displayed once. save_err_has_warned: bool, @@ -1460,6 +1717,7 @@ impl DeferredGlobalLastUse { registry_src_timestamps: HashMap::new(), git_db_timestamps: HashMap::new(), git_checkout_timestamps: HashMap::new(), + target_directory_timestamps: HashMap::new(), save_err_has_warned: false, now: now(), } @@ -1471,6 +1729,7 @@ impl DeferredGlobalLastUse { && self.registry_src_timestamps.is_empty() && self.git_db_timestamps.is_empty() && self.git_checkout_timestamps.is_empty() + && self.target_directory_timestamps.is_empty() } fn clear(&mut self) { @@ -1479,6 +1738,7 @@ impl DeferredGlobalLastUse { self.registry_src_timestamps.clear(); self.git_db_timestamps.clear(); self.git_checkout_timestamps.clear(); + self.target_directory_timestamps.clear(); } /// Indicates the given [`RegistryIndex`] has been used right now. @@ -1571,6 +1831,23 @@ impl DeferredGlobalLastUse { self.git_checkout_timestamps.insert(git_checkout, timestamp); } + /// Indicates the given [`TargetDirectory`] has been used right now. + pub fn mark_target_directory_used(&mut self, target_dir: TargetDirectory) { + self.mark_target_directory_used_stamp(target_dir, None); + } + + /// Indicates the given [`TargetDirectory`] has been used with the given + /// time (or "now" if `None`). + pub fn mark_target_directory_used_stamp( + &mut self, + target_dir: TargetDirectory, + timestamp: Option<&SystemTime>, + ) { + let timestamp = timestamp.map_or(self.now, to_timestamp); + self.target_directory_timestamps + .insert(target_dir, timestamp); + } + /// Saves all of the deferred information to the database. /// /// This will also clear the state of `self`. @@ -1587,6 +1864,7 @@ impl DeferredGlobalLastUse { self.insert_registry_crate_from_cache(&tx)?; self.insert_registry_src_from_cache(&tx)?; self.insert_git_checkout_from_cache(&tx)?; + self.insert_target_directory_from_cache(&tx)?; tx.commit()?; trace!(target: "gc", "last-use save complete"); Ok(()) @@ -1725,6 +2003,29 @@ impl DeferredGlobalLastUse { Ok(()) } + /// Flushes all of the `target_directory_timestamps` to the database, + /// clearing `target_directory_timestamps`. + fn insert_target_directory_from_cache(&mut self, conn: &Connection) -> CargoResult<()> { + let target_directory_timestamps = std::mem::take(&mut self.target_directory_timestamps); + for (target_dir, timestamp) in target_directory_timestamps { + trace!(target: "gc", "insert target directory {target_dir:?} {timestamp}"); + let mut stmt = conn.prepare_cached( + "INSERT INTO target_directory (workspace_manifest, target_dir, timestamp) + VALUES (?1, ?2, ?3) + ON CONFLICT DO UPDATE SET timestamp=excluded.timestamp + WHERE timestamp < ?4", + )?; + stmt.execute(params![ + target_dir.workspace_manifest, + target_dir.target_dir, + timestamp, + timestamp - UPDATE_RESOLUTION + ])?; + } + + Ok(()) + } + /// Returns the numeric ID of the registry, either fetching from the local /// cache, or getting it from the database. /// diff --git a/src/cargo/core/mod.rs b/src/cargo/core/mod.rs index f7ee6a7b758..ea2d61d9696 100644 --- a/src/cargo/core/mod.rs +++ b/src/cargo/core/mod.rs @@ -1,5 +1,6 @@ pub use self::dependency::{Dependency, Patch, PatchLocation, SerializedDependency}; pub use self::features::{CliUnstable, Edition, Feature, Features}; +pub use self::global_cache_tracker::TargetDirectory; pub use self::manifest::{EitherManifest, VirtualManifest}; pub use self::manifest::{Manifest, Target, TargetKind}; pub use self::package::{Package, PackageSet}; diff --git a/src/cargo/ops/cargo_clean.rs b/src/cargo/ops/cargo_clean.rs index 34c74afed5b..4c3aa32dfbf 100644 --- a/src/cargo/ops/cargo_clean.rs +++ b/src/cargo/ops/cargo_clean.rs @@ -141,7 +141,7 @@ pub fn clean(ws: &Workspace<'_>, opts: &CleanOptions<'_>) -> CargoResult<()> { Ok(()) } -fn validate_target_dir_tag(target_dir_path: &Path) -> CargoResult<()> { +pub(crate) fn validate_target_dir_tag(target_dir_path: &Path) -> CargoResult<()> { const TAG_SIGNATURE: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55"; let tag_path = target_dir_path.join("CACHEDIR.TAG"); diff --git a/src/cargo/ops/cargo_compile/mod.rs b/src/cargo/ops/cargo_compile/mod.rs index acc5239ec9e..215f7bcbdf9 100644 --- a/src/cargo/ops/cargo_compile/mod.rs +++ b/src/cargo/ops/cargo_compile/mod.rs @@ -50,7 +50,7 @@ use crate::core::compiler::{DepKindSet, UnitIndex}; use crate::core::profiles::Profiles; use crate::core::resolver::features::{self, CliFeatures, FeaturesFor}; use crate::core::resolver::{ForceAllTargets, HasDevUnits, Resolve}; -use crate::core::{PackageId, PackageSet, SourceId, TargetKind, Workspace}; +use crate::core::{PackageId, PackageSet, SourceId, TargetDirectory, TargetKind, Workspace}; use crate::drop_println; use crate::ops; use crate::ops::resolve::{SpecsAndResolvedFeatures, WorkspaceResolve}; @@ -61,6 +61,7 @@ use crate::util::log_message::LogMessage; use crate::util::{CargoResult, StableHasher}; mod compile_filter; +use cargo_util::paths; use cargo_util_terminal::report::{Group, Level, Origin}; pub use compile_filter::{CompileFilter, FilterRule, LibRule}; @@ -353,6 +354,14 @@ pub fn create_bcx<'a, 'gctx>( // passed in with `-p` or the defaults from the workspace), and convert // Vec to a Vec. let to_build_ids = resolve.specs_to_ids(&specs)?; + { + let mut deferred = gctx.deferred_global_last_use()?; + let target_dir_path = paths::normalize_path(&ws.target_dir().into_path_unlocked()); + deferred.mark_target_directory_used(TargetDirectory { + workspace_manifest: InternedString::new(&ws.root_manifest().to_string_lossy()), + target_dir: InternedString::new(&target_dir_path.to_string_lossy()), + }); + } // Now get the `Package` for each `PackageId`. This may trigger a download // if the user specified `-p` for a dependency that is not downloaded. // Dependencies will be downloaded during build_unit_dependencies. diff --git a/src/cargo/ops/mod.rs b/src/cargo/ops/mod.rs index b98560f422d..040b5c07412 100644 --- a/src/cargo/ops/mod.rs +++ b/src/cargo/ops/mod.rs @@ -55,7 +55,7 @@ pub use self::resolve::{ pub use self::vendor::{VendorOptions, vendor}; pub mod cargo_add; -mod cargo_clean; +pub(crate) mod cargo_clean; pub(crate) mod cargo_compile; pub mod cargo_config; mod cargo_doc; diff --git a/tests/testsuite/global_cache_tracker.rs b/tests/testsuite/global_cache_tracker.rs index 4c489c6bb37..5537fc9a7d1 100644 --- a/tests/testsuite/global_cache_tracker.rs +++ b/tests/testsuite/global_cache_tracker.rs @@ -2127,3 +2127,948 @@ fn resilient_to_unexpected_files() { "#]]) .run(); } + +fn target_dir_timestamps() -> Vec<(String, String, u64)> { + let gctx = GlobalContextBuilder::new().build(); + let _lock = gctx + .acquire_package_cache_lock(CacheLockMode::MutateExclusive) + .unwrap(); + let tracker = GlobalCacheTracker::new(&gctx).unwrap(); + let mut rows = tracker + .target_directory_all() + .unwrap() + .into_iter() + .map(|(td, ts)| { + ( + td.workspace_manifest.as_str().to_owned(), + td.target_dir.as_str().to_owned(), + ts, + ) + }) + .collect::>(); + rows.sort(); + rows +} + +fn target_dir_rows_for_path(path: &Path) -> Vec<(String, String, u64)> { + let path = path.to_string_lossy().to_string(); + target_dir_timestamps() + .into_iter() + .filter(|(_, target_dir, _)| target_dir == &path) + .collect() +} + +#[cargo_test] +fn tracks_target_dir_on_build() { + // Verifies that building a project creates a target_directory entry + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build to create the target directory + p.cargo("build").run(); + + let target_dirs = target_dir_timestamps(); + let has_entry = target_dirs.iter().any(|(_, target_dir, _)| { + target_dir.ends_with("/target") || target_dir.ends_with("\\target") + }); + assert!( + has_entry, + "Expected target directory entry but found: {:?}", + target_dirs + ); +} + +#[cargo_test] +fn tracks_target_dir_on_check() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + p.cargo("check") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + let rows = target_dir_timestamps(); + let first = rows + .iter() + .find(|(_, target_dir, _)| { + target_dir.ends_with("/target") || target_dir.ends_with("\\target") + }) + .cloned() + .expect("missing target dir row after check"); + + p.cargo("check") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(1)) + .run(); + let rows = target_dir_timestamps(); + let second = rows + .iter() + .find(|(_, target_dir, _)| target_dir == &first.1) + .cloned() + .expect("missing target dir row after second check"); + + assert!( + second.2 > first.2, + "expected check to refresh target dir timestamp: {first:?} -> {second:?}" + ); +} + +#[cargo_test] +fn tracks_target_dir_on_doc() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + p.cargo("doc") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + let rows = target_dir_timestamps(); + let first = rows + .iter() + .find(|(_, target_dir, _)| { + target_dir.ends_with("/target") || target_dir.ends_with("\\target") + }) + .cloned() + .expect("missing target dir row after doc"); + + p.cargo("doc") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(1)) + .run(); + let rows = target_dir_timestamps(); + let second = rows + .iter() + .find(|(_, target_dir, _)| target_dir == &first.1) + .cloned() + .expect("missing target dir row after second doc"); + + assert!( + second.2 > first.2, + "expected doc to refresh target dir timestamp: {first:?} -> {second:?}" + ); +} + +#[cargo_test] +fn does_not_track_target_dir_on_fetch() { + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + edition = "2015" + + [dependencies] + bar = "1.0" + "#, + ) + .file("src/lib.rs", "") + .build(); + Package::new("bar", "1.0.0").publish(); + + p.cargo("fetch") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + let rows = target_dir_timestamps(); + assert!( + rows.is_empty(), + "fetch should not create target-directory entries, found: {:?}", + rows + ); +} + +#[cargo_test] +fn does_not_track_target_dir_on_metadata() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + p.cargo("metadata --format-version=1") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + let rows = target_dir_timestamps(); + assert!( + rows.is_empty(), + "metadata should not create target-directory entries, found: {:?}", + rows + ); +} + +#[cargo_test] +fn clean_target_dir_gc_skips_invalid_cachedir_tag() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + p.cargo("build") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + let target_dir = p.root().join("target"); + assert!(target_dir.exists()); + std::fs::write(target_dir.join("CACHEDIR.TAG"), "Signature: 1234").unwrap(); + + p.cargo("clean gc -v -Zgc") + .arg("--max-target-dir-age=3 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVED] 0 files + +"#]]) + .run(); + + assert!( + target_dir.exists(), + "target dir with invalid CACHEDIR.TAG should be skipped by gc" + ); + let rows = target_dir_rows_for_path(&target_dir); + assert_eq!( + rows.len(), + 1, + "tracking row should remain when unsafe target dir is skipped: {rows:?}" + ); +} + +#[cargo_test] +fn clean_target_dir_age() { + // --max-target-dir-age flag deletes dirs older than threshold + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build to create target directory and mark as 4 days old + p.cargo("build") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + // Verify target dir was created + assert!(p.root().join("target").exists()); + + // Should delete the target dir + p.cargo("clean gc -v -Zgc") + .arg("--max-target-dir-age=3 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVING] [ROOT]/foo/target +[REMOVED] [FILE_NUM] files, [FILE_SIZE]B total + +"#]]) + .run(); + + // Target dir should be deleted + assert!(!p.root().join("target").exists()); +} + +#[cargo_test] +fn clean_target_dir_age_preserves_recent() { + // Target dirs accessed within threshold are NOT deleted + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build to create target directory and mark as 2 days old + p.cargo("build") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(2)) + .run(); + + // Verify target dir was created + assert!(p.root().join("target").exists()); + + // Should NOT delete the target dir (it's only 2 days old) + p.cargo("clean gc -v -Zgc") + .arg("--max-target-dir-age=3 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVED] 0 files + +"#]]) + .run(); + + // Target dir should still exist + assert!(p.root().join("target").exists()); +} + +#[cargo_test] +fn clean_target_dir_leaked() { + // Target dir deleted when workspace manifest no longer exists + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build to create target directory and mark as old + p.cargo("build") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + assert!(p.root().join("target").exists()); + + // Remove the workspace manifest + std::fs::remove_file(p.root().join("Cargo.toml")).unwrap(); + + // Clean should still delete the leaked target dir + p.cargo("clean gc -v -Zgc") + .arg("--max-target-dir-age=3 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVING] [ROOT]/foo/target +[REMOVED] [FILE_NUM] files, [FILE_SIZE]B total + +"#]]) + .run(); + + // Target dir should be deleted + assert!(!p.root().join("target").exists()); +} + +#[cargo_test] +fn clean_target_dir_dry_run() { + // --dry-run shows paths without deleting + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build to create target directory and mark as old + p.cargo("build") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + assert!(p.root().join("target").exists()); + + // Dry run should NOT delete the target dir + p.cargo("clean gc --dry-run -Zgc") + .arg("--max-target-dir-age=3 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[SUMMARY] [FILE_NUM] files, [FILE_SIZE]B total +[WARNING] no files deleted due to --dry-run + +"#]]) + .run(); + + // Target dir should still exist + assert!(p.root().join("target").exists()); +} + +#[cargo_test] +fn clean_target_dir_all() { + // --max-target-dir-age with age > 0 should delete target dirs marked as old + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build to create target directory and mark as 4 days old + p.cargo("build") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + // Verify target dir was created + assert!(p.root().join("target").exists()); + + // Should delete the target dir (age=0 means delete anything older than now) + p.cargo("clean gc -v -Zgc") + .arg("--max-target-dir-age=0 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVING] [ROOT]/foo/target +[REMOVED] [FILE_NUM] files, [FILE_SIZE]B total + +"#]]) + .run(); + + // Target dir should be deleted + assert!(!p.root().join("target").exists()); +} + +#[cargo_test] +fn clean_target_dir_age_with_shared_target() { + // Multiple workspaces sharing CARGO_TARGET_DIR are tracked correctly + let p1 = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + let p2 = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + let shared_target = p1.root().parent().unwrap().join("shared-target"); + + // Build first project with shared target dir + p1.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .run(); + + // Build second project with same shared target dir + p2.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .run(); + + // With 3 day age limit, should delete the shared target + // Note: Since p1 is built first and we can't update its timestamp via fetch alone, + // and p2's build may share the same target dir entry, we just verify the GC works + p1.cargo("clean gc -v -Zgc") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .arg("--max-target-dir-age=0 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVING] [ROOT]/shared-target +[REMOVED] [FILE_NUM] files, [FILE_SIZE]B total + +"#]]) + .run(); +} + +#[cargo_test] +fn clean_target_dir_leaked_preserves_valid() { + // Valid workspaces with same target dir are NOT deleted when another workspace is deleted + let p1 = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + let p2 = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + let shared_target = p1.root().parent().unwrap().join("shared-target"); + + // Build first project with shared target dir, mark as 4 days old + p1.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + // Build second project with same shared target dir, mark as recent (1 day old) + p2.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(1)) + .run(); + + let all_rows = target_dir_timestamps(); + let before = target_dir_rows_for_path(&shared_target); + assert_eq!( + before.len(), + 2, + "expected two associations before gc: {before:?}; all rows: {all_rows:?}" + ); + + // Verify shared target exists + assert!(shared_target.exists()); + + // With 3 day age limit, should NOT delete because p2 is recent + p1.cargo("clean gc -v -Zgc") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .arg("--max-target-dir-age=3 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVED] 0 files + +"#]]) + .run(); + + // Shared target should still exist and valid associations should remain intact. + assert!(shared_target.exists()); + let after = target_dir_rows_for_path(&shared_target); + assert_eq!( + after.len(), + 2, + "expected valid shared-target associations to remain after gc: {after:?}" + ); + assert!( + after + .iter() + .any(|(manifest, _, _)| manifest.ends_with("/foo/Cargo.toml") + || manifest.ends_with("\\foo\\Cargo.toml")), + "expected foo workspace association to remain: {after:?}" + ); + assert!( + after + .iter() + .any(|(manifest, _, _)| manifest.ends_with("/bar/Cargo.toml") + || manifest.ends_with("\\bar\\Cargo.toml")), + "expected bar workspace association to remain: {after:?}" + ); +} + +#[cargo_test] +fn clean_target_dir_age_shared_deletes_when_all_stale() { + let p1 = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + let p2 = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + let shared_target = p1.root().parent().unwrap().join("shared-target-all-stale"); + + p1.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(5)) + .run(); + p2.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + let before = target_dir_rows_for_path(&shared_target); + assert_eq!( + before.len(), + 2, + "expected two associations before gc: {before:?}" + ); + assert!(shared_target.exists()); + + p1.cargo("clean gc -v -Zgc") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .arg("--max-target-dir-age=3 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVING] [ROOT]/shared-target-all-stale +[REMOVED] [FILE_NUM] files, [FILE_SIZE]B total + +"#]]) + .run(); + + assert!(!shared_target.exists()); + let after = target_dir_rows_for_path(&shared_target); + assert!( + after.is_empty(), + "expected rows for deleted shared target to be removed: {after:?}" + ); +} + +#[cargo_test] +fn clean_target_dir_size_counts_shared_dir_once() { + let p1 = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + let p2 = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.1")) + .file("src/lib.rs", "") + .build(); + let p3 = project() + .at("baz") + .file("Cargo.toml", &basic_manifest("baz", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + let shared_target = p1.root().parent().unwrap().join("shared-target-size-once"); + + p1.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(5)) + .run(); + p2.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + p3.cargo("build") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(3)) + .run(); + + assert!(shared_target.exists()); + assert!(p3.root().join("target").exists()); + let shared_size = cargo_util::du(&shared_target, &[]).unwrap(); + let other_size = cargo_util::du(&p3.root().join("target"), &[]).unwrap(); + + let threshold = shared_size + other_size + 1; + assert!( + threshold < shared_size * 2 + other_size, + "test setup requires threshold between single-count and double-count totals" + ); + + p1.cargo("clean gc -v -Zgc") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .arg(format!("--max-target-dir-size={threshold}")) + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVED] 0 files + +"#]]) + .run(); + + assert!( + shared_target.exists(), + "shared target should remain if counted once" + ); + assert!( + p3.root().join("target").exists(), + "other target should remain" + ); + let rows = target_dir_rows_for_path(&shared_target); + assert_eq!( + rows.len(), + 2, + "shared target rows should remain intact: {rows:?}" + ); +} + +#[cargo_test] +fn clean_target_dir_size_shared_deletes_once_when_over_threshold() { + let p1 = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + let p2 = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + let shared_target = p1 + .root() + .parent() + .unwrap() + .join("shared-target-size-delete"); + + p1.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(5)) + .run(); + p2.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + let before = target_dir_rows_for_path(&shared_target); + assert_eq!( + before.len(), + 2, + "expected two associations before gc: {before:?}" + ); + assert!(shared_target.exists()); + let shared_size = cargo_util::du(&shared_target, &[]).unwrap(); + + p1.cargo("clean gc -v -Zgc") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .arg(format!( + "--max-target-dir-size={}", + shared_size.saturating_sub(1) + )) + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVING] [ROOT]/shared-target-size-delete +[REMOVED] [FILE_NUM] files, [FILE_SIZE]B total + +"#]]) + .run(); + + assert!(!shared_target.exists()); + let after = target_dir_rows_for_path(&shared_target); + assert!( + after.is_empty(), + "expected rows for deleted shared target to be removed: {after:?}" + ); +} + +#[cargo_test] +fn clean_target_dir_leaked_row_cleanup_is_scoped_to_target_dir() { + let p1 = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + let p2 = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + let shared_target = p1 + .root() + .parent() + .unwrap() + .join("shared-target-scoped-leak"); + let leaked_target = p1 + .root() + .parent() + .unwrap() + .join("leaked-target-scoped-leak"); + + p1.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + p2.cargo("build") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(1)) + .run(); + p1.cargo("build") + .env("CARGO_TARGET_DIR", leaked_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + assert!(shared_target.exists()); + assert!(leaked_target.exists()); + assert_eq!(target_dir_rows_for_path(&shared_target).len(), 2); + assert_eq!(target_dir_rows_for_path(&leaked_target).len(), 1); + + std::fs::remove_file(p1.root().join("Cargo.toml")).unwrap(); + + p2.cargo("clean gc -v -Zgc") + .env("CARGO_TARGET_DIR", shared_target.as_os_str()) + .arg("--max-target-dir-age=3 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVING] [ROOT]/leaked-target-scoped-leak +[REMOVED] [FILE_NUM] files, [FILE_SIZE]B total + +"#]]) + .run(); + + assert!( + shared_target.exists(), + "shared target should be preserved by bar association" + ); + assert!( + !leaked_target.exists(), + "leaked solo target should be deleted" + ); + + let shared_rows = target_dir_rows_for_path(&shared_target); + assert_eq!( + shared_rows.len(), + 1, + "only bar association should remain for shared target: {shared_rows:?}" + ); + assert!( + shared_rows[0].0.ends_with("/bar/Cargo.toml") + || shared_rows[0].0.ends_with("\\bar\\Cargo.toml"), + "expected remaining shared-target row to belong to bar: {shared_rows:?}" + ); + let leaked_rows = target_dir_rows_for_path(&leaked_target); + assert!( + leaked_rows.is_empty(), + "leaked target rows should be removed after deletion: {leaked_rows:?}" + ); +} + +#[cargo_test] +fn clean_target_dir_shared_path_spelling_is_grouped() { + let p1 = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + let p2 = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + let normalized_shared_target = p1 + .root() + .parent() + .unwrap() + .join("shared-target-path-spelling"); + let dotted_shared_target = p2.root().join("../shared-target-path-spelling"); + + p1.cargo("build") + .env("CARGO_TARGET_DIR", normalized_shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + p2.cargo("build") + .env("CARGO_TARGET_DIR", dotted_shared_target.as_os_str()) + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(1)) + .run(); + + assert!(normalized_shared_target.exists()); + let rows = target_dir_timestamps(); + assert_eq!( + rows.len(), + 2, + "expected both associations to be recorded: {rows:?}" + ); + + p1.cargo("clean gc -v -Zgc") + .env("CARGO_TARGET_DIR", normalized_shared_target.as_os_str()) + .arg("--max-target-dir-age=3 days") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVED] 0 files + +"#]]) + .run(); + + assert!( + normalized_shared_target.exists(), + "recent association via alternate path spelling should protect the shared target dir" + ); +} + +#[cargo_test] +fn clean_target_dir_size() { + // --max-target-dir-size with very large value means no limit (keeps all) + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build to create target directory + p.cargo("build").run(); + + // Verify target dir was created + assert!(p.root().join("target").exists()); + + // Use a very large threshold (effectively no limit) + p.cargo("clean gc -v -Zgc") + .arg("--max-target-dir-size=1000000000") + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVED] 0 files + +"#]]) + .run(); + + // Target dir should still exist + assert!(p.root().join("target").exists()); +} + +#[cargo_test] +fn clean_target_dir_size_under_threshold() { + // Target dir is kept when total size is under threshold + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build to create target directory + p.cargo("build").run(); + + // Verify target dir was created + assert!(p.root().join("target").exists()); + + // Get the target dir size + let target_size = cargo_util::du(&p.root().join("target"), &[]).unwrap(); + + // Use a threshold larger than the target dir size + p.cargo("clean gc -v -Zgc") + .arg(format!("--max-target-dir-size={}", target_size + 1000)) + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVED] 0 files + +"#]]) + .run(); + + // Target dir should still exist + assert!(p.root().join("target").exists()); +} + +#[cargo_test] +fn clean_target_dir_size_over_threshold() { + // Target dir is deleted when total size exceeds threshold + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build to create target directory + p.cargo("build").run(); + + // Verify target dir was created + assert!(p.root().join("target").exists()); + + // Get the target dir size + let target_size = cargo_util::du(&p.root().join("target"), &[]).unwrap(); + + // Use a threshold smaller than the target dir size + p.cargo("clean gc -v -Zgc") + .arg(format!("--max-target-dir-size={}", target_size / 2)) + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVING] [ROOT]/foo/target +[REMOVED] [FILE_NUM] files, [FILE_SIZE]B total + +"#]]) + .run(); + + // Target dir should be deleted + assert!(!p.root().join("target").exists()); +} + +#[cargo_test] +fn clean_target_dir_size_multiple_dirs() { + // Multiple target dirs - oldest is deleted first + let p1 = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.1")) + .file("src/lib.rs", "") + .build(); + let p2 = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.1")) + .file("src/lib.rs", "") + .build(); + + // Build both projects to create target directories with deterministic ages. + p1.cargo("build") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(5)) + .run(); + p2.cargo("build") + .env("__CARGO_TEST_LAST_USE_NOW", days_ago_unix(4)) + .run(); + + // Verify both target dirs exist + assert!(p1.root().join("target").exists()); + assert!(p2.root().join("target").exists()); + + // Get sizes + let size1 = cargo_util::du(&p1.root().join("target"), &[]).unwrap(); + let size2 = cargo_util::du(&p2.root().join("target"), &[]).unwrap(); + + // Set threshold above either individual dir but below their combined size. + let threshold = if size1 > size2 { + size1 + 100 + } else { + size2 + 100 + }; + assert!( + size1 + size2 > threshold, + "test setup requires combined size over threshold" + ); + + // Clean with threshold that should delete the oldest target dir first. + p1.cargo("clean gc -v -Zgc") + .arg(format!("--max-target-dir-size={}", threshold)) + .masquerade_as_nightly_cargo(&["gc"]) + .with_stderr_data(str![[r#" +[REMOVING] [ROOT]/foo/target +[REMOVED] [FILE_NUM] files, [FILE_SIZE]B total + +"#]]) + .run(); + + assert!( + !p1.root().join("target").exists(), + "oldest target dir should be removed first" + ); + assert!( + p2.root().join("target").exists(), + "newer target dir should remain" + ); +}