diff --git a/aube.usage.kdl b/aube.usage.kdl index b47ff2aa6..3aa6a0fa7 100644 --- a/aube.usage.kdl +++ b/aube.usage.kdl @@ -3788,6 +3788,7 @@ It removes global virtual-store graph entries not referenced by any registered p On reflink filesystems such as APFS or btrfs, link counts cannot prove project reachability, so content-store pruning relies on cached package indexes. Global virtual-store reachability comes from project links. """# flag --dry-run help="Do not actually delete anything; report what would be pruned" + flag --json help="Emit the dry-run plan as one machine-readable JSON document (requires --dry-run)" } cmd status help="Verify the store against cached package indexes" effect=read { long_help #""" diff --git a/crates/aube-codes/src/errors.rs b/crates/aube-codes/src/errors.rs index 37ff7c2ed..e87eca020 100644 --- a/crates/aube-codes/src/errors.rs +++ b/crates/aube-codes/src/errors.rs @@ -62,6 +62,8 @@ pub const ERR_AUBE_NO_HOME: &str = "ERR_AUBE_NO_HOME"; pub const ERR_AUBE_GIT_ERROR: &str = "ERR_AUBE_GIT_ERROR"; pub const ERR_AUBE_STORE_INDEX_SCAN_FAILED: &str = "ERR_AUBE_STORE_INDEX_SCAN_FAILED"; pub const ERR_AUBE_GVS_PRUNE_FAILED: &str = "ERR_AUBE_GVS_PRUNE_FAILED"; +pub const ERR_AUBE_STORE_PRUNE_LOCK_FAILED: &str = "ERR_AUBE_STORE_PRUNE_LOCK_FAILED"; +pub const ERR_AUBE_STORE_PRUNE_FAILED: &str = "ERR_AUBE_STORE_PRUNE_FAILED"; // ── linker ────────────────────────────────────────────────────────── pub const ERR_AUBE_LINK_FAILED: &str = "ERR_AUBE_LINK_FAILED"; @@ -305,6 +307,18 @@ pub const ALL: &[CodeMeta] = &[ description: "The global virtual store project registry or prune walk failed.", exit_code: None, }, + CodeMeta { + name: ERR_AUBE_STORE_PRUNE_LOCK_FAILED, + category: category::TARBALL_STORE, + description: "Aube couldn't acquire the store-wide maintenance lock for pruning.", + exit_code: None, + }, + CodeMeta { + name: ERR_AUBE_STORE_PRUNE_FAILED, + category: category::TARBALL_STORE, + description: "A planned content-store file couldn't be removed during pruning.", + exit_code: None, + }, // Registry / network CodeMeta { name: ERR_AUBE_PACKAGE_NOT_FOUND, diff --git a/crates/aube-codes/src/warnings.rs b/crates/aube-codes/src/warnings.rs index 7d3b23575..b9fb6c409 100644 --- a/crates/aube-codes/src/warnings.rs +++ b/crates/aube-codes/src/warnings.rs @@ -30,6 +30,7 @@ pub const WARN_AUBE_DELTA_INVALIDATE_FAILED: &str = "WARN_AUBE_DELTA_INVALIDATE_ pub const WARN_AUBE_GVS_INCOMPATIBLE: &str = "WARN_AUBE_GVS_INCOMPATIBLE"; pub const WARN_AUBE_GVS_MODE_CHANGED: &str = "WARN_AUBE_GVS_MODE_CHANGED"; pub const WARN_AUBE_GVS_CROSS_VOLUME: &str = "WARN_AUBE_GVS_CROSS_VOLUME"; +pub const WARN_AUBE_STORE_PRUNE_ENTRY_DISAPPEARED: &str = "WARN_AUBE_STORE_PRUNE_ENTRY_DISAPPEARED"; // ── settings / config validation ──────────────────────────────────── pub const WARN_AUBE_INVALID_CONCURRENCY: &str = "WARN_AUBE_INVALID_CONCURRENCY"; @@ -125,6 +126,7 @@ pub const WARN_AUBE_RUNTIME_MISE_FALLBACK: &str = "WARN_AUBE_RUNTIME_MISE_FALLBA pub mod category { pub const PNPMFILE_HOOKS: &str = "pnpmfile / hooks"; pub const INSTALL_LIFECYCLE: &str = "Install lifecycle"; + pub const STORE: &str = "Store maintenance"; pub const SETTINGS_CONFIG: &str = "Settings / config validation"; pub const UPDATE_PRERELEASE: &str = "Update / prerelease"; pub const AUDIT_NPMRC: &str = "Audit / npmrc"; @@ -266,6 +268,12 @@ pub const ALL: &[CodeMeta] = &[ description: "`cacheDir` (global virtual store) and `storeDir` are on different volumes, so linking falls back to per-file copy.", exit_code: None, }, + CodeMeta { + name: WARN_AUBE_STORE_PRUNE_ENTRY_DISAPPEARED, + category: category::STORE, + description: "A global virtual-store entry disappeared while a prune plan was being built; the preview skipped it.", + exit_code: None, + }, // Settings / config validation CodeMeta { name: WARN_AUBE_INVALID_CONCURRENCY, diff --git a/crates/aube-store/src/cas.rs b/crates/aube-store/src/cas.rs index 740aeea3e..0e542c408 100644 --- a/crates/aube-store/src/cas.rs +++ b/crates/aube-store/src/cas.rs @@ -185,6 +185,7 @@ impl Store { /// the directory already exists, but callers should still hoist the /// call out of tight loops. pub fn ensure_shards_exist(&self) -> Result<(), Error> { + self.prepare_for_write()?; std::fs::create_dir_all(&self.root).map_err(|e| Error::Io(self.root.clone(), e))?; // Windows Defender and Search both touch every file in the // store on default installs. Setting this attribute makes @@ -572,6 +573,7 @@ impl Store { /// exist yet, the `create_new` open will fail with `NotFound`; we /// fall back to the slow path for correctness. pub fn import_bytes(&self, content: &[u8], executable: bool) -> Result { + self.prepare_for_write()?; let hash_t0 = std::time::Instant::now(); let hex_hash = blake3_hex(content); if aube_util::diag::enabled() { @@ -733,6 +735,7 @@ impl Store { executable: bool, gate: Option<&Gate>, ) -> Result { + self.prepare_for_write()?; let Some(gate) = gate else { return self.import_bytes(content, executable); }; diff --git a/crates/aube-store/src/index.rs b/crates/aube-store/src/index.rs index 5219ce720..0c44e735b 100644 --- a/crates/aube-store/src/index.rs +++ b/crates/aube-store/src/index.rs @@ -210,7 +210,9 @@ impl Store { let index: PackageIndex = sonic_rs::from_slice(&buf).ok()?; if !index_files_match_metadata(&index, verify_files) { trace!("cache stale: {name}@{version}"); - let _ = xx::file::remove_file(&index_path); + if self.prepare_for_write().is_ok() { + let _ = xx::file::remove_file(&index_path); + } return None; } trace!("cache hit: {name}@{version}"); @@ -232,6 +234,7 @@ impl Store { version: &str, integrity: Option<&str>, ) -> Result { + self.prepare_for_write()?; let Some(index_path) = self.index_path(name, version, integrity) else { return Ok(false); }; @@ -253,6 +256,7 @@ impl Store { integrity: Option<&str>, index: &PackageIndex, ) -> Result<(), Error> { + self.prepare_for_write()?; let index_path = self.index_path(name, version, integrity).ok_or_else(|| { Error::Tar(format!( "refusing to cache: invalid coordinate {name:?}@{version:?} or integrity {integrity:?}" diff --git a/crates/aube-store/src/lib.rs b/crates/aube-store/src/lib.rs index be08c720d..6fe90e557 100644 --- a/crates/aube-store/src/lib.rs +++ b/crates/aube-store/src/lib.rs @@ -45,16 +45,35 @@ use sha1::Sha1; #[cfg(test)] use sha2::{Digest as _, Sha256, Sha384, Sha512}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::atomic::AtomicBool; #[cfg(target_os = "macos")] use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex, OnceLock}; pub const CACHE_DIR_NAME: &str = "aube-cache"; pub const INDEX_SUBDIR: &str = "index"; pub const VIRTUAL_STORE_SUBDIR: &str = "virtual-store"; pub const PACKUMENT_CACHE_SUBDIR: &str = "packuments-v1"; pub const PACKUMENT_FULL_CACHE_SUBDIR: &str = "packuments-full-v1"; +pub const MAINTENANCE_LOCK_FILE: &str = ".maintenance.lock"; + +#[derive(Default)] +struct MaintenanceState { + shared: Mutex>, +} + +/// Exclusive store-maintenance lease held by `aube store prune`. +/// +/// Every CAS/index writer takes the corresponding shared lease through +/// [`Store::prepare_for_write`], so holding this guard freezes one complete +/// prune snapshot across the GVS, cached indexes, and CAS files. +pub struct StoreMaintenanceGuard(std::fs::File); + +impl Drop for StoreMaintenanceGuard { + fn drop(&mut self) { + let _ = self.0.unlock(); + } +} /// The global content-addressable store, owned by aube. /// @@ -83,6 +102,8 @@ pub struct Store { /// `storeDir` volume so materialized packages can be hardlinked /// out of the CAS. virtual_store_dir: PathBuf, + maintenance: Arc, + migration_done: Arc>, /// When set, `create_cas_file` writes directly to the final /// content-addressed path on non-Linux platforms instead of the /// tempfile-then-rename dance. Caller must guarantee no concurrent @@ -125,14 +146,14 @@ impl Store { /// user-facing store dir. The global virtual store lands under /// `cache_dir` unless [`Store::with_virtual_store_dir`] moves it. pub fn with_dirs(root: PathBuf, cache_dir: PathBuf) -> Self { - let store = Self { + Self { root, virtual_store_dir: cache_dir.join(VIRTUAL_STORE_SUBDIR), cache_dir, + maintenance: Arc::new(MaintenanceState::default()), + migration_done: Arc::new(OnceLock::new()), fast_path: Arc::new(AtomicBool::new(false)), - }; - store.migrate_legacy_index_dir(); - store + } } /// Point the global virtual store somewhere other than @@ -153,6 +174,8 @@ impl Store { root, virtual_store_dir: cache_dir.join(VIRTUAL_STORE_SUBDIR), cache_dir, + maintenance: Arc::new(MaintenanceState::default()), + migration_done: Arc::new(OnceLock::new()), fast_path: Arc::new(AtomicBool::new(false)), } } @@ -210,12 +233,18 @@ impl Store { /// aube wrote cached package indexes before they were moved next /// to the CAS files. Used only by [`migrate_legacy_index_dir`]; new /// code should always go through [`index_dir`]. - fn legacy_index_dir(&self) -> PathBuf { + pub fn legacy_index_dir(&self) -> PathBuf { self.cache_dir.join(INDEX_SUBDIR) } + /// Whether opening this store for writes would migrate the legacy index. + pub fn legacy_index_migration_needed(&self) -> bool { + self.legacy_index_dir().exists() && !self.index_dir().exists() + } + /// One-shot migration from the legacy XDG-cache index location to - /// the in-store `v1/index/` directory. Runs at `Store::open`-time. + /// the in-store `v1/index/` directory. Runs when the store first prepares + /// for a write, after its shared maintenance lease is acquired. /// /// The legacy location was a footgun under Docker BuildKit cache /// mounts: users would mount the CAS files dir, the indexes would @@ -288,6 +317,81 @@ impl Store { } } + pub fn maintenance_lock_path(&self) -> PathBuf { + self.store_v1_dir().join(MAINTENANCE_LOCK_FILE) + } + + fn open_maintenance_lock(&self) -> Result { + let path = self.maintenance_lock_path(); + let Some(parent) = path.parent() else { + return Err(Error::Io( + path, + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "store maintenance lock has no parent", + ), + )); + }; + std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_path_buf(), e))?; + std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path) + .map_err(|e| Error::Io(path, e)) + } + + /// Acquire the shared writer lease and perform any pending legacy-index + /// migration. The lease is retained by this `Store` and all of its clones. + pub fn prepare_for_write(&self) -> Result<(), Error> { + let mut shared = self.maintenance.shared.lock().map_err(|_| { + Error::Io( + self.maintenance_lock_path(), + std::io::Error::other("store maintenance lock state is poisoned"), + ) + })?; + if shared.is_none() { + let file = self.open_maintenance_lock()?; + file.lock_shared() + .map_err(|e| Error::Io(self.maintenance_lock_path(), e))?; + *shared = Some(file); + } + drop(shared); + self.migration_done.get_or_init(|| { + self.migrate_legacy_index_dir(); + }); + Ok(()) + } + + /// Acquire an exclusive lease for a complete prune plan/apply operation. + pub fn lock_for_maintenance(&self) -> Result { + let shared = self.maintenance.shared.lock().map_err(|_| { + Error::Io( + self.maintenance_lock_path(), + std::io::Error::other("store maintenance lock state is poisoned"), + ) + })?; + if shared.is_some() { + return Err(Error::Io( + self.maintenance_lock_path(), + std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "this Store already holds a writer lease", + ), + )); + } + let file = self.open_maintenance_lock()?; + file.lock() + .map_err(|e| Error::Io(self.maintenance_lock_path(), e))?; + Ok(StoreMaintenanceGuard(file)) + } + + /// Apply the legacy-index migration while an exclusive maintenance lease + /// is held. Used by real prune after its candidate plan is complete. + pub fn migrate_legacy_index_for_maintenance(&self, _guard: &StoreMaintenanceGuard) { + self.migrate_legacy_index_dir(); + } + /// Directory for the global virtual store (materialized packages). /// `/virtual-store/` unless `globalVirtualStoreDir` /// moved it, so it follows `cacheDir` by default. @@ -368,6 +472,8 @@ mod tests { root, virtual_store_dir: cache_dir.join(VIRTUAL_STORE_SUBDIR), cache_dir, + maintenance: Arc::new(MaintenanceState::default()), + migration_done: Arc::new(OnceLock::new()), fast_path: Arc::new(AtomicBool::new(false)), } } @@ -453,6 +559,32 @@ mod tests { ); } + #[test] + fn maintenance_lock_waits_for_writer_lease() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("store/v1/files"); + let cache = tmp.path().join("cache"); + let writer = Store::with_dirs(root.clone(), cache.clone()); + writer.prepare_for_write().unwrap(); + + let maintenance = Store::with_dirs(root, cache); + let (tx, rx) = std::sync::mpsc::channel(); + let handle = std::thread::spawn(move || { + let guard = maintenance.lock_for_maintenance().unwrap(); + tx.send(()).unwrap(); + drop(guard); + }); + assert!( + rx.recv_timeout(std::time::Duration::from_millis(50)) + .is_err(), + "maintenance must wait while a writer lease is live" + ); + drop(writer); + rx.recv_timeout(std::time::Duration::from_secs(2)) + .expect("maintenance should proceed after the writer exits"); + handle.join().unwrap(); + } + #[test] fn store_v1_dir_is_parent_of_files() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/aube/src/commands/gvs_registry.rs b/crates/aube/src/commands/gvs_registry.rs index e99709713..2de143ad5 100644 --- a/crates/aube/src/commands/gvs_registry.rs +++ b/crates/aube/src/commands/gvs_registry.rs @@ -4,8 +4,8 @@ use std::collections::HashSet; use std::ffi::OsString; use std::path::{Path, PathBuf}; -const PROJECTS_DIR: &str = ".projects"; -const LOCK_FILE: &str = ".prune.lock"; +pub(crate) const PROJECTS_DIR: &str = ".projects"; +pub(crate) const LOCK_FILE: &str = ".prune.lock"; #[derive(Debug, Serialize, Deserialize)] struct RegisteredProject { @@ -56,14 +56,22 @@ pub(crate) fn lock_for_install(global_virtual_store: &Path) -> miette::Result miette::Result { +pub(crate) fn lock_for_prune( + global_virtual_store: &Path, + quiet: bool, +) -> miette::Result> { + if !global_virtual_store.exists() { + return Ok(None); + } let file = open_lock(global_virtual_store)?; match file.try_lock() { Ok(()) => {} Err(std::fs::TryLockError::WouldBlock) => { - crate::progress::safe_eprintln( - "Waiting for a running global virtual store install to finish before pruning", - ); + if !quiet { + crate::progress::safe_eprintln( + "Waiting for a running global virtual store install to finish before pruning", + ); + } file.lock() .map_err(|e| lock_error(global_virtual_store, e))?; } @@ -71,7 +79,7 @@ fn lock_for_prune(global_virtual_store: &Path) -> miette::Result { return Err(lock_error(global_virtual_store, e)); } } - Ok(GvsLock(file)) + Ok(Some(GvsLock(file))) } fn lock_error(global_virtual_store: &Path, error: std::io::Error) -> miette::Report { @@ -148,26 +156,53 @@ pub(crate) fn register_fast_path_project( register_project(global_virtual_store, project_dir, aube_dir) } -pub(crate) fn prune(global_virtual_store: &Path, dry_run: bool) -> miette::Result { +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) enum FileIdentity { + #[cfg(unix)] + Unix { device: u64, inode: u64 }, + #[cfg(not(unix))] + Path(PathBuf), +} + +#[derive(Clone, Debug)] +pub(crate) struct CandidateFile { + pub identity: FileIdentity, + pub bytes: u64, +} + +#[derive(Debug, Default)] +pub(crate) struct GvsPrunePlan { + pub entries: Vec, + pub stale_records: Vec, + pub files: Vec, + pub vanished_files: Vec, +} + +impl GvsPrunePlan { + pub fn bytes(&self) -> u64 { + let mut identities = HashSet::new(); + self.files + .iter() + .filter(|file| identities.insert(file.identity.clone())) + .map(|file| file.bytes) + .sum() + } +} + +pub(crate) fn plan_prune(global_virtual_store: &Path) -> miette::Result { if !global_virtual_store.exists() { - return Ok(0); + return Ok(GvsPrunePlan::default()); } - let _lock = lock_for_prune(global_virtual_store)?; let mut reachable = HashSet::new(); - let mut stale_records = Vec::new(); + let mut plan = GvsPrunePlan::default(); let projects_dir = global_virtual_store.join(PROJECTS_DIR); - if !projects_dir.exists() { - if !graph_entries(global_virtual_store)?.is_empty() { - return Err(miette!( - code = aube_codes::errors::ERR_AUBE_GVS_PRUNE_FAILED, - "global virtual store project registry {} is missing while entries exist\nhelp: run {} in active projects before pruning again", - projects_dir.display(), - aube_util::cmd("install") - )); - } - if !dry_run { - std::fs::create_dir_all(&projects_dir).map_err(|e| registry_error(&projects_dir, e))?; - } + if !projects_dir.exists() && !graph_entries(global_virtual_store)?.is_empty() { + return Err(miette!( + code = aube_codes::errors::ERR_AUBE_GVS_PRUNE_FAILED, + "global virtual store project registry {} is missing while entries exist\nhelp: run {} in active projects before pruning again", + projects_dir.display(), + aube_util::cmd("install") + )); } if projects_dir.exists() { @@ -180,9 +215,7 @@ pub(crate) fn prune(global_virtual_store: &Path, dry_run: bool) -> miette::Resul let project: RegisteredProject = serde_json::from_slice(&bytes).map_err(|e| invalid_registry_error(&path, e))?; if !project.project_dir.exists() || !project.aube_dir.exists() { - if !dry_run { - stale_records.push(path); - } + plan.stale_records.push(path); continue; } let current = project_entries(global_virtual_store, &project.aube_dir)?; @@ -190,7 +223,6 @@ pub(crate) fn prune(global_virtual_store: &Path, dry_run: bool) -> miette::Resul } } - let mut removed = 0; for entry in read_dir(global_virtual_store)? { let name = entry.file_name(); if !is_graph_entry_name(&name) { @@ -202,14 +234,74 @@ pub(crate) fn prune(global_virtual_store: &Path, dry_run: bool) -> miette::Resul if !file_type.is_dir() || reachable.contains(&name) { continue; } - if !dry_run { - aube_linker::remove_dir_all_with_retry(&entry.path()) - .map_err(|e| prune_error(&entry.path(), e))?; + let path = entry.path(); + collect_candidate_files(&path, &mut plan.files, &mut plan.vanished_files)?; + plan.entries.push(path); + } + Ok(plan) +} + +pub(crate) fn apply_prune(global_virtual_store: &Path, plan: &GvsPrunePlan) -> miette::Result<()> { + if !global_virtual_store.exists() { + return Ok(()); + } + for path in &plan.entries { + aube_linker::remove_dir_all_with_retry(path).map_err(|e| prune_error(path, e))?; + } + for path in &plan.stale_records { + std::fs::remove_file(path).map_err(|e| prune_error(path, e))?; + } + let projects_dir = global_virtual_store.join(PROJECTS_DIR); + if !projects_dir.exists() { + std::fs::create_dir_all(&projects_dir).map_err(|e| registry_error(&projects_dir, e))?; + } + Ok(()) +} + +fn collect_candidate_files( + path: &Path, + files: &mut Vec, + vanished_files: &mut Vec, +) -> miette::Result<()> { + for entry in read_dir(path)? { + let entry_path = entry.path(); + let metadata = match std::fs::symlink_metadata(&entry_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + vanished_files.push(entry_path); + continue; + } + Err(error) => return Err(prune_error(&entry_path, error)), + }; + if metadata.is_dir() { + collect_candidate_files(&entry_path, files, vanished_files)?; + } else if metadata.is_file() { + #[cfg(unix)] + let identity = { + use std::os::unix::fs::MetadataExt; + FileIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + } + }; + #[cfg(not(unix))] + let identity = FileIdentity::Path(entry_path.clone()); + files.push(CandidateFile { + identity, + bytes: metadata.len(), + }); } - removed += 1; } - for path in stale_records { - std::fs::remove_file(&path).map_err(|e| prune_error(&path, e))?; + Ok(()) +} + +#[cfg(test)] +pub(crate) fn prune(global_virtual_store: &Path, dry_run: bool) -> miette::Result { + let _lock = lock_for_prune(global_virtual_store, false)?; + let plan = plan_prune(global_virtual_store)?; + let removed = plan.entries.len(); + if !dry_run { + apply_prune(global_virtual_store, &plan)?; } Ok(removed) } diff --git a/crates/aube/src/commands/mod.rs b/crates/aube/src/commands/mod.rs index a5fb4d3ea..1ec0e2b81 100644 --- a/crates/aube/src/commands/mod.rs +++ b/crates/aube/src/commands/mod.rs @@ -142,14 +142,15 @@ pub(crate) use settings_context::{ global_output_flags, global_virtual_store_dir, global_virtual_store_dir_with_ctx, global_virtual_store_flags, has_embedder_store_override, load_npm_config, lockfile_kind_for_write, lockfile_kind_for_write_with_ctx, make_client, metadata_cache_anchor, - open_store, open_store_with_ctx, packument_cache_dir, packument_cache_dir_for_cwd, - packument_full_cache_dir, packument_full_cache_dir_for_cwd, project_modules_dir, - resolve_fetch_policy, resolve_modules_dir_name_for_cwd, resolve_virtual_store_dir, - resolve_virtual_store_dir_for_cwd, resolve_virtual_store_dir_max_length, - resolve_virtual_store_dir_max_length_for_cwd, resolved_cache_dir, resolved_cache_dir_with_ctx, - resolved_store_dir, resolved_store_dir_with_ctx, run_pnpmfile_pre_resolution, - scope_embedder_install_overrides, set_fetch_cli_overrides, set_global_frozen_override, - set_global_output_flags, set_global_virtual_store_flags, set_registry_override, + open_store, open_store_for_maintenance, open_store_with_ctx, packument_cache_dir, + packument_cache_dir_for_cwd, packument_full_cache_dir, packument_full_cache_dir_for_cwd, + project_modules_dir, resolve_fetch_policy, resolve_modules_dir_name_for_cwd, + resolve_virtual_store_dir, resolve_virtual_store_dir_for_cwd, + resolve_virtual_store_dir_max_length, resolve_virtual_store_dir_max_length_for_cwd, + resolved_cache_dir, resolved_cache_dir_with_ctx, resolved_store_dir, + resolved_store_dir_with_ctx, run_pnpmfile_pre_resolution, scope_embedder_install_overrides, + set_fetch_cli_overrides, set_global_frozen_override, set_global_output_flags, + set_global_virtual_store_flags, set_registry_override, set_skip_auto_install_on_package_manager_mismatch, skip_auto_install_on_package_manager_mismatch, with_settings_ctx, with_settings_ctx_and_cli, }; diff --git a/crates/aube/src/commands/settings_context.rs b/crates/aube/src/commands/settings_context.rs index fab5e1e14..79ccaaea4 100644 --- a/crates/aube/src/commands/settings_context.rs +++ b/crates/aube/src/commands/settings_context.rs @@ -250,7 +250,12 @@ pub(crate) fn ensure_registry_auth_for_package( /// across versions of aube and never collides with a pnpm store rooted /// at the same path. pub(crate) fn open_store(cwd: &std::path::Path) -> miette::Result { - with_settings_ctx(cwd, |ctx| open_store_with_ctx(cwd, ctx)) + let store = with_settings_ctx(cwd, |ctx| open_store_for_maintenance_with_ctx(cwd, ctx))?; + store + .prepare_for_write() + .into_diagnostic() + .wrap_err("failed to prepare store for writing")?; + Ok(store) } /// Open the content store using an already-resolved invocation context. @@ -259,6 +264,27 @@ pub(crate) fn open_store(cwd: &std::path::Path) -> miette::Result, +) -> miette::Result { + let store = open_store_for_maintenance_with_ctx(cwd, ctx)?; + store + .prepare_for_write() + .into_diagnostic() + .wrap_err("failed to prepare store for writing")?; + Ok(store) +} + +/// Resolve a Store without taking its writer lease or migrating legacy data. +/// Store maintenance uses this constructor so dry-run can plan migrations +/// without applying them before it acquires the exclusive maintenance lease. +pub(crate) fn open_store_for_maintenance( + cwd: &std::path::Path, +) -> miette::Result { + with_settings_ctx(cwd, |ctx| open_store_for_maintenance_with_ctx(cwd, ctx)) +} + +fn open_store_for_maintenance_with_ctx( + cwd: &std::path::Path, + ctx: &aube_settings::ResolveCtx<'_>, ) -> miette::Result { let root = match resolved_store_dir_with_ctx(cwd, ctx) { Some(custom) => custom.join("v1").join("files"), diff --git a/crates/aube/src/commands/store.rs b/crates/aube/src/commands/store.rs index 9a5f94ec0..1420705b6 100644 --- a/crates/aube/src/commands/store.rs +++ b/crates/aube/src/commands/store.rs @@ -26,9 +26,12 @@ //! auto-install check. use crate::commands::{make_client, packument_full_cache_dir, resolve_version, split_name_spec}; -use clap::{Args, Subcommand}; +use clap::{Arg, ArgAction, ArgMatches, Args, Command, Error, FromArgMatches, Subcommand}; use miette::{IntoDiagnostic, miette}; +use serde::Serialize; +use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; #[derive(Debug, Args)] pub struct StoreArgs { @@ -72,13 +75,116 @@ pub enum StoreCommand { Status, } -#[derive(Debug, Args)] +// `PruneArgs` is part of the published Rust API, so keep its original +// constructible shape while exposing JSON as CLI-only state. +static PRUNE_JSON_REQUESTED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug)] pub struct PruneArgs { /// Do not actually delete anything; report what would be pruned. - #[arg(long)] pub dry_run: bool, } +impl FromArgMatches for PruneArgs { + fn from_arg_matches(matches: &ArgMatches) -> Result { + PRUNE_JSON_REQUESTED.store(matches.get_flag("json"), Ordering::Relaxed); + Ok(Self { + dry_run: matches.get_flag("dry_run"), + }) + } + + fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), Error> { + PRUNE_JSON_REQUESTED.store(matches.get_flag("json"), Ordering::Relaxed); + self.dry_run = matches.get_flag("dry_run"); + Ok(()) + } +} + +impl Args for PruneArgs { + fn augment_args(command: Command) -> Command { + command + .arg( + Arg::new("dry_run") + .long("dry-run") + .action(ArgAction::SetTrue) + .help("Do not actually delete anything; report what would be pruned"), + ) + .arg( + Arg::new("json") + .long("json") + .action(ArgAction::SetTrue) + .requires("dry_run") + .help( + "Emit the dry-run plan as one machine-readable JSON document (requires --dry-run)", + ), + ) + } + + fn augment_args_for_update(command: Command) -> Command { + Self::augment_args(command) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PruneReport { + schema_version: u32, + dry_run: bool, + mutation_roots: Vec, + actions: Vec, + global_virtual_store: GvsStats, + content_store: CasStats, + reclaimable_bytes_upper_bound: u64, + warnings: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MutationRoot { + kind: &'static str, + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + resolved_path: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlannedAction { + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + to: Option, + count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GvsStats { + entries: usize, + bytes_upper_bound: u64, + stale_project_records: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CasStats { + files: usize, + bytes_upper_bound: u64, +} + +#[derive(Debug, Serialize)] +struct StructuredWarning { + code: &'static str, + message: String, +} + +#[derive(Debug, Default)] +struct CasPrunePlan { + paths: Vec, + files: Vec, +} + pub async fn run(args: StoreArgs) -> miette::Result<()> { match args.command { StoreCommand::Add { packages } => add(packages).await, @@ -93,8 +199,13 @@ fn open_store() -> miette::Result { crate::commands::open_store(&cwd) } +fn open_store_for_maintenance() -> miette::Result { + let cwd = crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from(".")); + crate::commands::open_store_for_maintenance(&cwd) +} + fn path() -> miette::Result<()> { - let store = open_store()?; + let store = open_store_for_maintenance()?; println!("{}", store.store_v1_dir().display()); Ok(()) } @@ -177,11 +288,9 @@ async fn add(specs: Vec) -> miette::Result<()> { /// Collect the set of hex hashes referenced by every cached package index. /// Pruning must fail closed if this scan is incomplete: a skipped index would /// otherwise make its live CAS files look unreferenced. -fn referenced_hashes( - store: &aube_store::Store, -) -> miette::Result> { +fn referenced_hashes(index_dir: &Path) -> miette::Result> { let mut seen = std::collections::HashSet::new(); - visit_cached_indices(store, |_, index| { + visit_cached_indices_at(index_dir, |_, index| { for stored in index.values() { seen.insert(stored.hex_hash.clone()); } @@ -193,9 +302,15 @@ fn referenced_hashes( /// A missing index root is an empty cache; every other scan failure is fatal. fn visit_cached_indices( store: &aube_store::Store, + visit: impl FnMut(&Path, aube_store::PackageIndex), +) -> miette::Result<()> { + visit_cached_indices_at(&store.index_dir(), visit) +} + +fn visit_cached_indices_at( + index_dir: &Path, mut visit: impl FnMut(&Path, aube_store::PackageIndex), ) -> miette::Result<()> { - let index_dir = store.index_dir(); if !index_dir.try_exists().map_err(|e| { miette!( code = aube_codes::errors::ERR_AUBE_STORE_INDEX_SCAN_FAILED, @@ -205,7 +320,7 @@ fn visit_cached_indices( })? { return Ok(()); } - visit_indices_in_dir(&index_dir, true, &mut visit) + visit_indices_in_dir(index_dir, true, &mut visit) } fn visit_indices_in_dir( @@ -265,124 +380,335 @@ fn visit_indices_in_dir( } fn prune(args: PruneArgs) -> miette::Result<()> { - let store = open_store()?; - let removed_gvs = super::gvs_registry::prune(&store.virtual_store_dir(), args.dry_run)?; - if removed_gvs > 0 { - let gvs_verb = if args.dry_run { - "Would prune" - } else { - "Pruned" - }; - eprintln!( - "{gvs_verb} {} from the global virtual store", - pluralizer::pluralize("package", removed_gvs as isize, true) - ); + let json = PRUNE_JSON_REQUESTED.swap(false, Ordering::Relaxed); + let store = open_store_for_maintenance()?; + let maintenance_lock = store + .lock_for_maintenance() + .into_diagnostic() + .map_err(|e| { + miette!( + code = aube_codes::errors::ERR_AUBE_STORE_PRUNE_LOCK_FAILED, + "failed to lock the store for pruning: {e}" + ) + })?; + let _gvs_lock = super::gvs_registry::lock_for_prune(&store.virtual_store_dir(), json)?; + let gvs_plan = super::gvs_registry::plan_prune(&store.virtual_store_dir())?; + let current_index_dir = store.index_dir(); + let legacy_index_dir = store.legacy_index_dir(); + let mut referenced = referenced_hashes(¤t_index_dir)?; + if legacy_index_dir != current_index_dir { + referenced.extend(referenced_hashes(&legacy_index_dir)?); } - let root = store.root().to_path_buf(); - if !root.exists() { - eprintln!("Store is empty: nothing to prune"); + let cas_plan = plan_cas_prune(store.root(), &referenced, &gvs_plan)?; + let report = build_prune_report(&store, &gvs_plan, &cas_plan); + + if json { + let output = serde_json::to_string_pretty(&report).into_diagnostic()?; + println!("{output}"); return Ok(()); } - let referenced = referenced_hashes(&store)?; - let mut removed_files = 0u64; - let mut removed_bytes = 0u64; + if !args.dry_run { + if store.legacy_index_migration_needed() { + store.migrate_legacy_index_for_maintenance(&maintenance_lock); + } + super::gvs_registry::apply_prune(&store.virtual_store_dir(), &gvs_plan)?; + for path in &cas_plan.paths { + std::fs::remove_file(path).map_err(|e| { + miette!( + code = aube_codes::errors::ERR_AUBE_STORE_PRUNE_FAILED, + "failed to prune store file {}: {e}", + path.display() + ) + })?; + } + } - // NamedTempFile removes these on every normal exit, but SIGKILL or a - // machine crash can strand a large streamed tar entry at the CAS root. - // They are never referenced by an index and are safe prune candidates. - for entry in std::fs::read_dir(&root).into_diagnostic()?.flatten() { + let verb = if args.dry_run { + "Would prune" + } else { + "Pruned" + }; + if !gvs_plan.entries.is_empty() { + eprintln!( + "{verb} {} ({:.1} MB) from the global virtual store", + pluralizer::pluralize("package", gvs_plan.entries.len() as isize, true), + gvs_plan.bytes() as f64 / 1_048_576.0 + ); + } + if !gvs_plan.stale_records.is_empty() { + eprintln!( + "{verb} {} from the global virtual store registry", + pluralizer::pluralize( + "stale project record", + gvs_plan.stale_records.len() as isize, + true + ) + ); + } + if !cas_plan.files.is_empty() { + let size_prefix = if args.dry_run { "up to " } else { "" }; + eprintln!( + "{verb} {} ({size_prefix}{:.1} MB) from the store", + pluralizer::pluralize("file", cas_plan.files.len() as isize, true), + candidate_bytes(&cas_plan.files) as f64 / 1_048_576.0 + ); + } + if gvs_plan.entries.is_empty() && gvs_plan.stale_records.is_empty() && cas_plan.files.is_empty() + { + eprintln!("Nothing to prune"); + } + for path in &gvs_plan.vanished_files { + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_STORE_PRUNE_ENTRY_DISAPPEARED, + path = %path.display(), + "global virtual-store file disappeared while building the prune plan" + ); + } + Ok(()) +} + +fn plan_cas_prune( + root: &Path, + referenced: &HashSet, + gvs_plan: &super::gvs_registry::GvsPrunePlan, +) -> miette::Result { + if !root.try_exists().into_diagnostic()? { + return Ok(CasPrunePlan::default()); + } + let mut removed_gvs_links: HashMap = HashMap::new(); + for file in &gvs_plan.files { + *removed_gvs_links.entry(file.identity.clone()).or_default() += 1; + } + let mut plan = CasPrunePlan::default(); + let mut content_paths = HashSet::new(); + let mut markers = Vec::new(); + let root_entries = read_dir_complete(root)?; + for entry in &root_entries { let path = entry.path(); let is_stream_temp = entry .file_name() .to_str() .is_some_and(|name| name.starts_with(".aube-stream-")); - if !is_stream_temp || !path.is_file() { + if !is_stream_temp { continue; } - let len = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0); - if args.dry_run || std::fs::remove_file(&path).is_ok() { - removed_files += 1; - removed_bytes += len; + let metadata = entry.metadata().into_diagnostic()?; + if metadata.is_file() { + plan.paths.push(path.clone()); + plan.files.push(super::gvs_registry::CandidateFile { + identity: candidate_identity(&path, &metadata), + bytes: metadata.len(), + }); } } - // Walk every 2-char shard directory. Store layout is // //[-exec]. - for shard in std::fs::read_dir(&root).into_diagnostic()?.flatten() { + for shard in root_entries { let shard_path = shard.path(); - if !shard_path.is_dir() { + if !shard.file_type().into_diagnostic()?.is_dir() { continue; } - let shard_name = match shard_path.file_name().and_then(|s| s.to_str()) { - Some(s) if s.len() == 2 => s.to_string(), - _ => continue, + let Some(shard_name) = shard_path.file_name().and_then(|s| s.to_str()) else { + continue; }; - for file in std::fs::read_dir(&shard_path).into_diagnostic()?.flatten() { + if shard_name.len() != 2 { + continue; + } + for file in read_dir_complete(&shard_path)? { let file_path = file.path(); + let metadata = file.metadata().into_diagnostic()?; + if !metadata.is_file() { + continue; + } let Some(fname) = file_path.file_name().and_then(|s| s.to_str()) else { continue; }; - // Skip the `-exec` marker; it gets removed alongside its target. - let is_exec_marker = fname.ends_with("-exec"); - let base = fname.strip_suffix("-exec").unwrap_or(fname); - let hex = format!("{shard_name}{base}"); - + if let Some(base) = fname.strip_suffix("-exec") { + let content_path = shard_path.join(base); + markers.push((file_path, content_path)); + continue; + } + let hex = format!("{shard_name}{fname}"); if referenced.contains(&hex) { continue; } - - // On hardlink filesystems, files with nlink > 1 are referenced - // by at least one virtual-store entry — don't touch them. Exec - // markers are never hardlinked, so we can't check them directly; - // instead we delete a marker only when its companion content - // file is *also* going away, otherwise we'd silently strip the - // executable bit from a file pnpm still references. - let content_len = match file.metadata() { - Ok(meta) => { - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if is_exec_marker { - let content_path = shard_path.join(base); - if let Ok(content_meta) = std::fs::metadata(&content_path) - && content_meta.nlink() > 1 - { - continue; - } - } else if meta.nlink() > 1 { - continue; - } - } - meta.len() + let identity = candidate_identity(&file_path, &metadata); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let removed_links = removed_gvs_links.get(&identity).copied().unwrap_or(0); + if metadata.nlink() > removed_links + 1 { + continue; } - Err(_) => 0, - }; - - // Only credit the byte counter after the unlink actually - // succeeds, otherwise a permission-denied failure would - // inflate the "freed" number in the summary. A dry run has no - // unlink to check, so it credits every candidate and its total - // is an upper bound — hence the "up to" in the summary below. - let unlinked = args.dry_run || std::fs::remove_file(&file_path).is_ok(); - if unlinked && !is_exec_marker { - removed_files += 1; - removed_bytes += content_len; } + content_paths.insert(file_path.clone()); + plan.paths.push(file_path); + plan.files.push(super::gvs_registry::CandidateFile { + identity, + bytes: metadata.len(), + }); } } + for (marker, content) in markers { + if content_paths.contains(&content) { + plan.paths.push(marker); + } + } + Ok(plan) +} - let (verb, size_prefix) = if args.dry_run { - ("Would prune", "up to ") - } else { - ("Pruned", "") - }; - eprintln!( - "{verb} {} ({size_prefix}{:.1} MB) from the store", - pluralizer::pluralize("file", removed_files as isize, true), - removed_bytes as f64 / 1_048_576.0 - ); - Ok(()) +fn candidate_identity( + _path: &Path, + metadata: &std::fs::Metadata, +) -> super::gvs_registry::FileIdentity { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + super::gvs_registry::FileIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + } + } + #[cfg(not(unix))] + { + let _ = metadata; + super::gvs_registry::FileIdentity::Path(_path.to_path_buf()) + } +} + +fn read_dir_complete(path: &Path) -> miette::Result> { + std::fs::read_dir(path) + .into_diagnostic()? + .collect::, _>>() + .into_diagnostic() +} + +fn build_prune_report( + store: &aube_store::Store, + gvs_plan: &super::gvs_registry::GvsPrunePlan, + cas_plan: &CasPrunePlan, +) -> PruneReport { + let mut mutation_roots = vec![ + mutation_root("store", store.store_v1_dir()), + mutation_root("contentStore", store.root().to_path_buf()), + mutation_root("packageIndex", store.index_dir()), + mutation_root("globalVirtualStore", store.virtual_store_dir()), + mutation_root( + "projectRegistry", + store + .virtual_store_dir() + .join(super::gvs_registry::PROJECTS_DIR), + ), + mutation_root("maintenanceLock", store.maintenance_lock_path()), + mutation_root( + "globalVirtualStoreLock", + store + .virtual_store_dir() + .join(super::gvs_registry::LOCK_FILE), + ), + ]; + let mut actions = Vec::new(); + if store.legacy_index_migration_needed() { + mutation_roots.push(mutation_root( + "legacyPackageIndex", + store.legacy_index_dir(), + )); + actions.push(PlannedAction { + kind: "migrateLegacyPackageIndex", + from: Some(json_path(store.legacy_index_dir())), + to: Some(json_path(store.index_dir())), + count: 1, + }); + } + actions.extend([ + PlannedAction { + kind: "pruneGlobalVirtualStoreEntries", + from: None, + to: None, + count: gvs_plan.entries.len(), + }, + PlannedAction { + kind: "removeStaleProjectRecords", + from: None, + to: None, + count: gvs_plan.stale_records.len(), + }, + PlannedAction { + kind: "pruneContentStoreFiles", + from: None, + to: None, + count: cas_plan.files.len(), + }, + ]); + let mut unique = HashMap::new(); + for file in gvs_plan.files.iter().chain(&cas_plan.files) { + unique.entry(file.identity.clone()).or_insert(file.bytes); + } + PruneReport { + schema_version: 1, + dry_run: true, + mutation_roots, + actions, + global_virtual_store: GvsStats { + entries: gvs_plan.entries.len(), + bytes_upper_bound: gvs_plan.bytes(), + stale_project_records: gvs_plan.stale_records.len(), + }, + content_store: CasStats { + files: cas_plan.files.len(), + bytes_upper_bound: candidate_bytes(&cas_plan.files), + }, + reclaimable_bytes_upper_bound: unique.into_values().sum(), + warnings: gvs_plan + .vanished_files + .iter() + .map(|path| StructuredWarning { + code: aube_codes::warnings::WARN_AUBE_STORE_PRUNE_ENTRY_DISAPPEARED, + message: format!( + "global virtual-store file {} disappeared while building the prune plan", + path.display() + ), + }) + .collect(), + } +} + +fn candidate_bytes(files: &[super::gvs_registry::CandidateFile]) -> u64 { + let mut identities = HashSet::new(); + files + .iter() + .filter(|file| identities.insert(file.identity.clone())) + .map(|file| file.bytes) + .sum() +} + +fn mutation_root(kind: &'static str, path: std::path::PathBuf) -> MutationRoot { + let resolved = resolve_physical_path(&path); + let resolved_path = resolved.filter(|resolved| resolved != &path).map(json_path); + MutationRoot { + kind, + path: json_path(path), + resolved_path, + } +} + +fn resolve_physical_path(path: &Path) -> Option { + let mut existing = path; + let mut tail = Vec::new(); + while !existing.exists() { + tail.push(existing.file_name()?.to_os_string()); + existing = existing.parent()?; + } + let mut resolved = std::fs::canonicalize(existing).ok()?; + for component in tail.into_iter().rev() { + resolved.push(component); + } + Some(resolved) +} + +fn json_path(path: std::path::PathBuf) -> String { + path.to_string_lossy().into_owned() } fn status() -> miette::Result<()> { diff --git a/docs/cli/commands.json b/docs/cli/commands.json index 93d4be467..f417f77f7 100644 --- a/docs/cli/commands.json +++ b/docs/cli/commands.json @@ -11260,7 +11260,7 @@ "store", "prune" ], - "usage": "store prune [--dry-run]", + "usage": "store prune [--dry-run [--json]]", "subcommands": {}, "args": [], "flags": [ @@ -11275,6 +11275,18 @@ ], "hide": false, "global": false + }, + { + "name": "json", + "usage": "--json", + "help": "Emit the dry-run plan as one machine-readable JSON document (requires --dry-run)", + "help_first_line": "Emit the dry-run plan as one machine-readable JSON document (requires --dry-run)", + "short": [], + "long": [ + "json" + ], + "hide": false, + "global": false } ], "mounts": [], diff --git a/docs/cli/index.md b/docs/cli/index.md index 7e170be0f..43a0f4fbc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -204,7 +204,7 @@ Run from the workspace root regardless of the current package - [`aube store `](/cli/store.md) - [`aube store add …`](/cli/store/add.md) - [`aube store path`](/cli/store/path.md) -- [`aube store prune [--dry-run]`](/cli/store/prune.md) +- [`aube store prune [--dry-run [--json]]`](/cli/store/prune.md) - [`aube store status`](/cli/store/status.md) - [`aube test [FLAGS] [ARGS]…`](/cli/test.md) - [`aube trust `](/cli/trust.md) diff --git a/docs/cli/store.md b/docs/cli/store.md index c5716b11e..239c04de0 100644 --- a/docs/cli/store.md +++ b/docs/cli/store.md @@ -10,5 +10,5 @@ Manage the global store - [`aube store add …`](/cli/store/add.md) - [`aube store path`](/cli/store/path.md) -- [`aube store prune [--dry-run]`](/cli/store/prune.md) +- [`aube store prune [--dry-run [--json]]`](/cli/store/prune.md) - [`aube store status`](/cli/store/status.md) diff --git a/docs/cli/store/prune.md b/docs/cli/store/prune.md index 2c55cd796..66fad7fb3 100644 --- a/docs/cli/store/prune.md +++ b/docs/cli/store/prune.md @@ -1,7 +1,7 @@ # `aube store prune` -- **Usage**: `aube store prune [--dry-run]` +- **Usage**: `aube store prune [--dry-run [--json]]` - **Effect**: modifies state Remove unreferenced packages from the global store. @@ -17,3 +17,7 @@ On reflink filesystems such as APFS or btrfs, link counts cannot prove project r ### `--dry-run` Do not actually delete anything; report what would be pruned + +### `--json` + +Emit the dry-run plan as one machine-readable JSON document (requires --dry-run) diff --git a/docs/error-codes.data.json b/docs/error-codes.data.json index 310f91395..7de9462ce 100644 --- a/docs/error-codes.data.json +++ b/docs/error-codes.data.json @@ -150,6 +150,18 @@ "description": "The global virtual store project registry or prune walk failed.", "exit_code": null }, + { + "name": "ERR_AUBE_STORE_PRUNE_LOCK_FAILED", + "category": "Tarball / store", + "description": "Aube couldn't acquire the store-wide maintenance lock for pruning.", + "exit_code": null + }, + { + "name": "ERR_AUBE_STORE_PRUNE_FAILED", + "category": "Tarball / store", + "description": "A planned content-store file couldn't be removed during pruning.", + "exit_code": null + }, { "name": "ERR_AUBE_PACKAGE_NOT_FOUND", "category": "Registry / network", @@ -680,6 +692,12 @@ "description": "`cacheDir` (global virtual store) and `storeDir` are on different volumes, so linking falls back to per-file copy.", "exit_code": null }, + { + "name": "WARN_AUBE_STORE_PRUNE_ENTRY_DISAPPEARED", + "category": "Store maintenance", + "description": "A global virtual-store entry disappeared while a prune plan was being built; the preview skipped it.", + "exit_code": null + }, { "name": "WARN_AUBE_INVALID_CONCURRENCY", "category": "Settings / config validation", @@ -1045,6 +1063,7 @@ "warnings": [ "pnpmfile / hooks", "Install lifecycle", + "Store maintenance", "Settings / config validation", "Update / prerelease", "Audit / npmrc", diff --git a/mise.toml b/mise.toml index 9a8effe26..e89e3da3b 100644 --- a/mise.toml +++ b/mise.toml @@ -158,6 +158,7 @@ run = [ "rm -rf docs/cli && mkdir -p docs/cli", "usage g markdown -mf aube.usage.kdl --out-dir docs/cli --url-prefix /cli", "usage g json -f aube.usage.kdl > docs/cli/commands.json", + "node scripts/patch-generated-cli-docs.mjs", "cargo run -p aube-settings --bin generate-settings-docs", "cargo run -p aube-codes --bin generate-error-codes-docs", ] diff --git a/scripts/patch-generated-cli-docs.mjs b/scripts/patch-generated-cli-docs.mjs new file mode 100644 index 000000000..8e7851291 --- /dev/null +++ b/scripts/patch-generated-cli-docs.mjs @@ -0,0 +1,35 @@ +import { readFile, writeFile } from 'node:fs/promises' + +// usage-lib does not model relationships between flags, so correct generated +// usage strings whose optionality depends on another flag. +const replacements = [ + { + path: 'docs/cli/index.md', + from: 'aube store prune [--dry-run] [--json]', + to: 'aube store prune [--dry-run [--json]]', + }, + { + path: 'docs/cli/store.md', + from: 'aube store prune [--dry-run] [--json]', + to: 'aube store prune [--dry-run [--json]]', + }, + { + path: 'docs/cli/store/prune.md', + from: 'aube store prune [--dry-run] [--json]', + to: 'aube store prune [--dry-run [--json]]', + }, + { + path: 'docs/cli/commands.json', + from: '"usage": "store prune [--dry-run] [--json]"', + to: '"usage": "store prune [--dry-run [--json]]"', + }, +] + +for (const { path, from, to } of replacements) { + const source = await readFile(path, 'utf8') + const occurrences = source.split(from).length - 1 + if (occurrences !== 1) { + throw new Error(`expected exactly one generated usage string in ${path}, found ${occurrences}`) + } + await writeFile(path, source.replace(from, to)) +} diff --git a/test/store.bats b/test/store.bats index 635c1f7c5..b5b4a527f 100644 --- a/test/store.bats +++ b/test/store.bats @@ -28,6 +28,7 @@ teardown() { # temp dir and XDG_DATA_HOME points inside it, so the resolved # path must match exactly. assert_output "$XDG_DATA_HOME/aube/store/v1" + [ ! -e "$XDG_DATA_HOME/aube/store/v1" ] } @test "aube store path honors store-dir from .npmrc and appends v1" { @@ -175,7 +176,18 @@ EOF @test "aube store prune runs cleanly on an empty store" { run aube store prune assert_success - assert_output --partial "empty" + assert_output --partial "Nothing to prune" + [ ! -d "$AUBE_GLOBAL_VIRTUAL_STORE_DIR/v1" ] +} + +@test "aube store prune does not call a populated store empty" { + run aube store add is-odd@3.0.1 + assert_success + + run aube store prune + assert_success + assert_output --partial "Nothing to prune" + refute_output --partial "empty" } @test "aube store prune actually deletes unreferenced files" { @@ -211,6 +223,7 @@ EOF assert_success assert_file_not_exists "$stream_temp" assert_output --partial "Pruned 1 file" + refute_output --partial "up to" } @test "aube store prune removes entries from deleted registered projects" { @@ -302,5 +315,80 @@ JSON @test "aube store prune --dry-run on an empty store" { run aube store prune --dry-run assert_success - assert_output --partial "empty" + assert_output --partial "Nothing to prune" +} + +@test "aube store prune --json requires --dry-run" { + run aube store prune --json + assert_failure + assert_output --partial "--dry-run" +} + +@test "aube store prune retains files referenced only by a legacy index" { + run aube store add is-odd@3.0.1 + assert_success + + store_v1="$(aube store path)" + current_index="$(find "$store_v1/index" -name 'is-odd@3.0.1.json' -print -quit)" + legacy_index="$XDG_CACHE_HOME/aube/index/legacy/is-odd@3.0.1.json" + mkdir -p "$(dirname "$legacy_index")" + cp "$current_index" "$legacy_index" + legacy_store_path="$(grep -o '"store_path":"[^"]*"' "$legacy_index" | head -n1 | sed 's/.*":"//;s/"$//')" + rm "$current_index" + + # Keep the current index directory populated too, reproducing the state + # where an interrupted or partial migration left live records in both. + run aube store add is-even@1.0.0 + assert_success + assert_file_exists "$legacy_store_path" + + run aube store prune + assert_success + assert_file_exists "$legacy_store_path" +} + +@test "aube store prune JSON reports every root and leaves legacy indexes unmigrated" { + legacy="$XDG_CACHE_HOME/aube/index" + mkdir -p "$legacy" + echo '{}' >"$legacy/legacy@1.0.0.json" + + run aube store prune --dry-run --json + assert_success + echo "$output" | jq -e ' + .schemaVersion == 1 and + .dryRun == true and + ([.mutationRoots[].kind] | index("contentStore") != null) and + ([.mutationRoots[].kind] | index("globalVirtualStore") != null) and + ([.mutationRoots[].kind] | index("legacyPackageIndex") != null) and + ([.actions[].kind] | index("migrateLegacyPackageIndex") != null) + ' >/dev/null + assert_file_exists "$legacy/legacy@1.0.0.json" + [ ! -d "$XDG_DATA_HOME/aube/store/v1/index" ] +} + +@test "aube store prune JSON models GVS hardlink removal before CAS pruning" { + store_v1="$(aube store path)" + cas="$store_v1/files/aa" + gvs="$AUBE_GLOBAL_VIRTUAL_STORE_DIR/v1" + mkdir -p "$cas" "$gvs/.projects" "$gvs/orphan/node_modules/pkg" + content="$cas/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + printf 'shared-content' >"$content" + ln "$content" "$gvs/orphan/node_modules/pkg/index.js" + + run aube store prune --dry-run --json + assert_success + echo "$output" | jq -e ' + .globalVirtualStore.entries == 1 and + .contentStore.files == 1 and + .globalVirtualStore.bytesUpperBound == 14 and + .contentStore.bytesUpperBound == 14 and + .reclaimableBytesUpperBound == 14 + ' >/dev/null + assert_file_exists "$content" + assert_dir_exists "$gvs/orphan" + + run aube store prune + assert_success + [ ! -f "$content" ] + [ ! -d "$gvs/orphan" ] }