From 1a75bb2434fd562e3c7550dcd68947fde970fa34 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Wed, 5 Aug 2026 12:48:15 +0200 Subject: [PATCH] refactor(package-manager): split the optimistic repeat-install check (#13661) Split the 1899-line `optimistic_repeat_install.rs` into per-subject probe modules (`manifest_agreement`, `settings`, `deps_status`, `local_file_deps`, `timestamps`, `conflict_markers`), leaving the decision itself at 601 lines. Pure code motion; the crate's public surface is unchanged. --- .../package-manager/src/install/tests.rs | 12 +- .../src/optimistic_repeat_install.rs | 1350 +---------------- .../conflict_markers.rs | 109 ++ .../optimistic_repeat_install/deps_status.rs | 243 +++ .../local_file_deps.rs | 160 ++ .../manifest_agreement.rs | 360 +++++ .../src/optimistic_repeat_install/settings.rs | 360 +++++ .../src/optimistic_repeat_install/tests.rs | 13 +- .../optimistic_repeat_install/timestamps.rs | 146 ++ 9 files changed, 1418 insertions(+), 1335 deletions(-) create mode 100644 pnpm/crates/package-manager/src/optimistic_repeat_install/conflict_markers.rs create mode 100644 pnpm/crates/package-manager/src/optimistic_repeat_install/deps_status.rs create mode 100644 pnpm/crates/package-manager/src/optimistic_repeat_install/local_file_deps.rs create mode 100644 pnpm/crates/package-manager/src/optimistic_repeat_install/manifest_agreement.rs create mode 100644 pnpm/crates/package-manager/src/optimistic_repeat_install/settings.rs create mode 100644 pnpm/crates/package-manager/src/optimistic_repeat_install/timestamps.rs diff --git a/pnpm/crates/package-manager/src/install/tests.rs b/pnpm/crates/package-manager/src/install/tests.rs index 0ef325d708..8862bce8f9 100644 --- a/pnpm/crates/package-manager/src/install/tests.rs +++ b/pnpm/crates/package-manager/src/install/tests.rs @@ -7511,7 +7511,7 @@ async fn optimistic_repeat_install_skips_entire_pipeline_when_state_is_fresh() { version: Some("1.0.0".to_string()), }, ); - let settings = crate::optimistic_repeat_install::current_settings( + let settings = crate::optimistic_repeat_install::settings::current_settings( config, pacquet_config::NodeLinker::Isolated, included, @@ -7634,7 +7634,7 @@ fn sync_fast_path_matches_optimistic_short_circuit() { version: Some("1.0.0".to_string()), }, ); - let settings = crate::optimistic_repeat_install::current_settings( + let settings = crate::optimistic_repeat_install::settings::current_settings( config, pacquet_config::NodeLinker::Isolated, included, @@ -7753,7 +7753,7 @@ fn sync_fast_path_reads_the_workspace_root_wanted_lockfile_from_a_member() { pnpmfiles: Vec::new(), filtered_install: false, config_dependencies: None, - settings: crate::optimistic_repeat_install::current_settings( + settings: crate::optimistic_repeat_install::settings::current_settings( config, pacquet_config::NodeLinker::Isolated, included, @@ -7883,7 +7883,7 @@ async fn frozen_lockfile_disables_optimistic_short_circuit() { version: Some("1.0.0".to_string()), }, ); - let settings = crate::optimistic_repeat_install::current_settings( + let settings = crate::optimistic_repeat_install::settings::current_settings( config, pacquet_config::NodeLinker::Isolated, included, @@ -8037,7 +8037,7 @@ async fn partial_install_disables_optimistic_short_circuit() { version: Some("1.0.0".to_string()), }, ); - let settings = crate::optimistic_repeat_install::current_settings( + let settings = crate::optimistic_repeat_install::settings::current_settings( config, pacquet_config::NodeLinker::Isolated, included, @@ -8183,7 +8183,7 @@ async fn optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing( version: Some("1.0.0".to_string()), }, ); - let settings = crate::optimistic_repeat_install::current_settings( + let settings = crate::optimistic_repeat_install::settings::current_settings( config, pacquet_config::NodeLinker::Isolated, included, diff --git a/pnpm/crates/package-manager/src/optimistic_repeat_install.rs b/pnpm/crates/package-manager/src/optimistic_repeat_install.rs index 681682c6c1..6e48b2348a 100644 --- a/pnpm/crates/package-manager/src/optimistic_repeat_install.rs +++ b/pnpm/crates/package-manager/src/optimistic_repeat_install.rs @@ -44,6 +44,32 @@ //! crate would have to re-export wholesale. Extract it only if a //! consumer outside this crate's dependents appears. +pub(crate) mod conflict_markers; +pub(crate) mod deps_status; +pub(crate) mod local_file_deps; +pub(crate) mod manifest_agreement; +pub(crate) mod settings; +pub(crate) mod timestamps; + +pub(crate) use conflict_markers::{ + LockfileConflictCheckFailure, first_lockfile_requiring_conflict_safe_install, +}; +pub use deps_status::{RunDepsStatus, check_deps_status_before_run}; +pub(crate) use local_file_deps::{ + has_local_file_dep, has_local_file_override, has_local_file_package_extension, +}; +pub(crate) use manifest_agreement::{ + LinkedPackagesContext, ManifestStat, modified_manifests_match_lockfile, stat_manifests, +}; +pub(crate) use settings::{ + catalogs_cache_matches, current_settings_with_catalogs, first_setting_drift, + recorded_supported_architectures_match, settings_match, +}; +pub(crate) use timestamps::{ + FileMtime, file_mtime, file_mtime_from_metadata, lockfile_modified_since, modified_at_or_after, + mtime_ms, validation_baseline_ms, wanted_lockfile_modified, +}; + use std::{ fs, io::{ErrorKind, Read}, @@ -346,256 +372,6 @@ pub(crate) fn check_optimistic_repeat_install_ignoring( } } -#[derive(Clone, Copy)] -enum LockfileConflictCheckFailure { - MergeConflict, - Unsafe, -} - -fn first_lockfile_requiring_conflict_safe_install( - check: &OptimisticRepeatInstallCheck<'_>, - last_validated_timestamp: i64, -) -> Option<(PathBuf, LockfileConflictCheckFailure)> { - let shared_lockfile = check.workspace_root.join(Lockfile::FILE_NAME); - if let Some(failure) = - lockfile_conflict_check_failure(&shared_lockfile, last_validated_timestamp) - { - return Some((shared_lockfile, failure)); - } - if check.config.shared_workspace_lockfile { - return None; - } - for (root_dir, _) in check.project_manifests { - let lockfile_path = root_dir.join(Lockfile::FILE_NAME); - if lockfile_path != shared_lockfile - && let Some(failure) = - lockfile_conflict_check_failure(&lockfile_path, last_validated_timestamp) - { - return Some((lockfile_path, failure)); - } - } - None -} - -const CONFLICT_MARKER: &[u8] = b"<<<<<<<"; -const LOCKFILE_CONFLICT_SCAN_BUFFER_SIZE: usize = 8 * 1024; -const MAX_LOCKFILE_CONFLICT_SCAN_BYTES: u64 = 16 * 1024 * 1024; - -fn lockfile_conflict_check_failure( - path: &Path, - last_validated_timestamp: i64, -) -> Option { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return None, - Err(_) => return Some(LockfileConflictCheckFailure::Unsafe), - }; - if !metadata.file_type().is_file() { - return Some(LockfileConflictCheckFailure::Unsafe); - } - let Some(mtime) = file_mtime_from_metadata(&metadata) else { - return Some(LockfileConflictCheckFailure::Unsafe); - }; - if !lockfile_modified_since(mtime, last_validated_timestamp) { - return None; - } - if metadata.len() >= MAX_LOCKFILE_CONFLICT_SCAN_BYTES { - return Some(LockfileConflictCheckFailure::Unsafe); - } - modified_lockfile_conflict_check_failure(path) -} - -fn modified_lockfile_conflict_check_failure(path: &Path) -> Option { - let Ok(mut file) = fs::File::open(path) else { - return Some(LockfileConflictCheckFailure::Unsafe); - }; - let Ok(metadata) = file.metadata() else { - return Some(LockfileConflictCheckFailure::Unsafe); - }; - if !metadata.file_type().is_file() || metadata.len() >= MAX_LOCKFILE_CONFLICT_SCAN_BYTES { - return Some(LockfileConflictCheckFailure::Unsafe); - } - - let mut buffer = [0; LOCKFILE_CONFLICT_SCAN_BUFFER_SIZE + CONFLICT_MARKER.len() - 1]; - let mut carried = 0; - let mut scanned = 0_u64; - loop { - let remaining = MAX_LOCKFILE_CONFLICT_SCAN_BYTES.saturating_sub(scanned); - if remaining == 0 { - return Some(LockfileConflictCheckFailure::Unsafe); - } - let read_capacity = LOCKFILE_CONFLICT_SCAN_BUFFER_SIZE.min(remaining as usize); - match file.read(&mut buffer[carried..carried + read_capacity]) { - Ok(0) => return None, - Ok(read) => { - scanned += read as u64; - let end = carried + read; - if buffer[..end] - .windows(CONFLICT_MARKER.len()) - .any(|bytes| bytes == CONFLICT_MARKER) - { - return Some(LockfileConflictCheckFailure::MergeConflict); - } - carried = end.min(CONFLICT_MARKER.len() - 1); - buffer.copy_within(end - carried..end, 0); - } - Err(error) if error.kind() == ErrorKind::Interrupted => {} - Err(_) => return Some(LockfileConflictCheckFailure::Unsafe), - } - } -} - -/// Whether any project declares a dependency with a local file -/// specifier in `dependencies`, `devDependencies`, or -/// `optionalDependencies`. Groups excluded from the current install -/// (per `included`) are skipped. `catalog:` specs are dereferenced -/// through the workspace catalogs. -fn has_local_file_dep( - project_manifests: &[(PathBuf, &PackageManifest)], - included: IncludedDependencies, - catalogs: &Catalogs, -) -> bool { - let fields: [(&str, bool); 3] = [ - ("dependencies", included.dependencies), - ("devDependencies", included.dev_dependencies), - ("optionalDependencies", included.optional_dependencies), - ]; - project_manifests.iter().any(|(_, manifest)| { - fields.iter().any(|(field, group_included)| { - *group_included - && manifest.value().get(*field).and_then(|value| value.as_object()).is_some_and( - |deps| { - deps.iter().any(|(alias, spec)| { - spec.as_str().is_some_and(|spec| { - is_local_file_spec(spec) - || catalog_resolves_to_local_file(catalogs, alias, spec) - }) - }) - }, - ) - }) - }) -} - -/// Whether a `catalog:` spec dereferences (through the workspace -/// catalogs) to a local file specifier. A misconfigured catalog entry -/// returns `false`: it fails the full install with the proper error -/// anyway, so the fast path only needs to not report up-to-date for a -/// *valid* catalog entry holding a local path. -fn catalog_resolves_to_local_file(catalogs: &Catalogs, alias: &str, spec: &str) -> bool { - // `resolve_from_catalog` returns `Unused` for any non-`catalog:` spec, so - // short-circuit before allocating the owned `WantedDependency` it needs. - if !spec.starts_with("catalog:") { - return false; - } - match resolve_from_catalog( - catalogs, - &WantedDependency { alias: alias.to_string(), bare_specifier: spec.to_string() }, - ) { - CatalogResolutionResult::Found(found) => is_local_file_spec(&found.resolution.specifier), - _ => false, - } -} - -/// Whether any `pnpm.overrides` entry maps to a local file specifier. -/// An override redirects every matching dependency in the graph to its -/// specifier, so a local file override makes the installed contents -/// depend on that directory or tarball the same way a direct local file -/// dependency does. A parse failure returns its own distinct reason — -/// not the local-file reason, which would misattribute the cause. -fn has_local_file_override(config: &Config, catalogs: &Catalogs) -> Result { - match crate::install::parse_config_overrides(config, catalogs) { - Ok(Some(overrides)) => { - Ok(overrides.iter().any(|entry| is_local_file_spec(&entry.new_bare_specifier))) - } - Ok(None) => Ok(false), - Err(_) => Err("pnpm.overrides cannot be parsed"), - } -} - -/// Whether any `packageExtensions` entry injects a dependency with a -/// local file specifier. Package extensions are merged into matching -/// packages' manifests by the read-package hook during the full -/// install, so a `file:`/local-path/tarball spec added there has the -/// same content-change blind spot as a direct local file dependency -/// without appearing in any project manifest. Only `dependencies` and -/// `optionalDependencies` are scanned: peer dependencies are resolved -/// from the graph rather than fetched, so a local spec there is never -/// installed. -fn has_local_file_package_extension( - config: &Config, - included: IncludedDependencies, - catalogs: &Catalogs, -) -> bool { - let Some(extensions) = config.package_extensions.as_ref() else { - return false; - }; - extensions.values().any(|extension| { - let optional = included - .optional_dependencies - .then_some(extension.optional_dependencies.as_ref()) - .flatten(); - [extension.dependencies.as_ref(), optional].into_iter().flatten().any(|deps| { - deps.iter().any(|(alias, spec)| { - is_local_file_spec(spec) || catalog_resolves_to_local_file(catalogs, alias, spec) - }) - }) - }) -} - -/// Whether the specifier resolves to a local directory or tarball whose -/// contents can change without any manifest or lockfile mtime moving: -/// the `file:` protocol, path-prefixed specs (`./`, `../`, `~/`, -/// absolute POSIX paths, and Windows drive paths including -/// drive-relative ones like `c:dir`), and bare tarball file names. -/// -/// Deliberately narrower than the local resolver's bare-path matching: -/// a bare path like `user/repo` is statically indistinguishable from a -/// git shorthand at this layer, and matching it would disable the -/// repeat-install fast path for every project with git dependencies. -/// Such specs (and anything else carrying a protocol or URL) stay on -/// the fast path. `catalog:` specs also return `false` here — callers -/// dereference them through the workspace catalogs first, because a -/// catalog entry may hold a bare local path (the catalog resolver only -/// bans the `workspace:`, `link:`, and `file:` protocols). -fn is_local_file_spec(spec: &str) -> bool { - if spec.starts_with("file:") { - return true; - } - if spec.starts_with(['.', '/', '\\']) - || spec.starts_with("~/") - || spec.starts_with(r"~\") - || is_windows_drive_path(spec) - { - return true; - } - if spec.contains(':') { - return false; - } - if spec.contains('#') { - return false; - } - ends_with_ignore_ascii_case(spec, ".tgz") - || ends_with_ignore_ascii_case(spec, ".tar.gz") - || ends_with_ignore_ascii_case(spec, ".tar") -} - -/// Case-insensitive (ASCII) suffix check that, unlike -/// `spec.to_ascii_lowercase().ends_with(suffix)`, does not allocate. -fn ends_with_ignore_ascii_case(spec: &str, suffix: &str) -> bool { - let spec = spec.as_bytes(); - let suffix = suffix.as_bytes(); - spec.len() >= suffix.len() && spec[spec.len() - suffix.len()..].eq_ignore_ascii_case(suffix) -} - -/// `c:/...`, `c:\...`, or drive-relative `c:foo` — a Windows drive -/// path. No separator is required after the colon; no registry protocol -/// is a single letter, so `[a-z]:` is unambiguous. -fn is_windows_drive_path(spec: &str) -> bool { - let bytes = spec.as_bytes(); - bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' -} - /// Restore a missing `pnpm-lock.yaml` from the current lockfile before /// the fast path reports "Already up to date", so the short-circuit /// leaves the same on-disk contract a full install would (the full @@ -625,210 +401,6 @@ fn regenerate_wanted_lockfile_if_missing( .map_err(|_| "failed to regenerate pnpm-lock.yaml from the current lockfile") } -/// One project manifest's stat outcome, paired with the inputs the -/// content re-check needs. -struct ManifestStat<'a> { - root_dir: &'a Path, - manifest: &'a PackageManifest, - mtime: FileMtime, -} - -/// The modified-manifests branch: the lockfile-equality assertion plus -/// the wanted-lockfile up-to-date check (settings drift, per-importer -/// specifier match, linked-package freshness) for every project whose -/// manifest is newer than the last validation. `Err` carries the -/// `Decision::Skipped` reason. -/// -/// When `pnpm-lock.yaml` is absent, the current lockfile stands in as -/// the wanted one (see the lockfile gate in -/// [`check_optimistic_repeat_install`]); `Ok(Some(_))` then carries the -/// loaded current lockfile so the caller can regenerate -/// `pnpm-lock.yaml` from it without a second read. -fn modified_manifests_match_lockfile( - check: &OptimisticRepeatInstallCheck<'_>, - state: &WorkspaceState, - modified: &[&ManifestStat<'_>], - dedupe_peers: bool, -) -> Result, &'static str> { - let &OptimisticRepeatInstallCheck { - workspace_root, - config, - project_manifests, - is_workspace_install, - lockfile, - catalogs, - .. - } = check; - let mut loaded_current: Option = None; - let mut wanted_is_current = false; - let lockfile = lockfile.get().map_err(|_| "the wanted lockfile cannot be read or parsed")?; - let (wanted, wanted_mtime): (&Lockfile, FileMtime) = if let Some(wanted) = lockfile { - let Some(mtime) = file_mtime(&workspace_root.join(Lockfile::FILE_NAME)) else { - return Err( - "a manifest is newer than the last validation and pnpm-lock.yaml cannot be stat'd", - ); - }; - (wanted, mtime) - } else { - let current_path = config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME); - let Some(mtime) = file_mtime(¤t_path) else { - return Err("a manifest is newer than the last validation and no lockfile is loaded"); - }; - let current = Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir) - .map_err(|_| "the current lockfile cannot be loaded")? - .ok_or("a manifest is newer than the last validation and no lockfile is loaded")?; - wanted_is_current = true; - (&*loaded_current.insert(current), mtime) - }; - - // Decide which modified projects need the full content check, and - // whether the wanted lockfile must be compared against the current - // one (`/lock.yaml`). - let to_check: &[&ManifestStat<'_>] = if wanted_is_current { - // The wanted lockfile IS the current one — there's no second - // lockfile to assert equality against, and the mtime - // short-circuits below compare the two lockfile files, so they - // don't apply. Every modified project gets the content check. - modified - } else if is_workspace_install { - // Workspace branch: a wanted lockfile newer than the last - // validation must equal what the previous install materialized. - if modified_at_or_after(wanted_mtime, state.last_validated_timestamp) { - assert_wanted_lockfile_equals_current(wanted, config)?; - } - modified - } else { - // Single-project branch keys off the lockfile mtimes instead of - // `lastValidatedTimestamp`. - let current_mtime_ms = - mtime_ms(&config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME)); - if let Some(current_mtime_ms) = current_mtime_ms - && modified_at_or_after(wanted_mtime, current_mtime_ms) - { - assert_wanted_lockfile_equals_current(wanted, config)?; - } - let root = modified.first().expect("modified-manifests branch requires a modified project"); - if modified_at_or_after(root.mtime, wanted_mtime.ms) { - modified - } else if current_mtime_ms.is_some() { - // "The manifest file is not newer than the lockfile. - // Exiting check." - &[] - } else if !wanted.is_empty() { - // RUN_CHECK_DEPS_NO_DEPS: the lockfile requires - // dependencies but nothing was ever installed. - return Err("the lockfile requires dependencies but none were installed"); - } else { - &[] - } - }; - - if to_check.is_empty() { - return Ok(loaded_current); - } - - let parsed_overrides = crate::install::parse_config_overrides(config, catalogs) - .map_err(|_| "pnpm.overrides cannot be parsed")?; - if let Err(error) = crate::install::check_lockfile_settings_drift( - wanted, - config, - catalogs, - crate::install::CheckLockfileSettingsDriftOptions { - parsed_overrides: parsed_overrides.as_deref(), - // `pnpmfileChecksum` needs no comparison here: reaching this - // point means `pnpmfiles_modified_since` already proved the - // pnpmfile list and contents are what the install that wrote - // this lockfile saw. Computing the checksum instead would cost - // a Node worker on the path that exists to avoid starting one. - pnpmfile_checksum: pacquet_lockfile::PnpmfileChecksumCheck::Skip, - dedupe_peers, - }, - ) { - tracing::debug!(target: "pacquet::install", %error, "repeat-install content check: lockfile settings drift"); - return Err("a lockfile setting drifted from the current configuration"); - } - - let linked_ctx = LinkedPackagesContext::new(config, project_manifests); - let ignored_optional_matcher = pacquet_config::matcher::create_matcher( - config.ignored_optional_dependencies.as_deref().unwrap_or_default(), - ); - for project in to_check { - let importer_id = - pacquet_workspace::importer_id_from_root_dir(workspace_root, project.root_dir); - if let Err(error) = crate::install::check_importer_satisfies( - wanted, - project.manifest, - &importer_id, - config, - &ignored_optional_matcher, - parsed_overrides.as_deref(), - ) { - tracing::debug!(target: "pacquet::install", %error, importer_id, "repeat-install content check: manifest no longer satisfied"); - return Err("a modified manifest is no longer satisfied by the lockfile"); - } - let Some(importer) = wanted.importers.get(&importer_id) else { - return Err("a modified project has no importer entry in the lockfile"); - }; - if !linked_packages_are_up_to_date( - &linked_ctx, - project.root_dir, - project.manifest, - importer, - ) { - return Err("a linked package is out of date"); - } - } - Ok(loaded_current) -} - -/// Assert the wanted lockfile equals the current one: with no current -/// lockfile every importer of the wanted one must be dependency-free -/// (`RUN_CHECK_DEPS_NO_DEPS`); otherwise the two parsed lockfiles must -/// be equal (`RUN_CHECK_DEPS_OUTDATED_DEPS`). -fn assert_wanted_lockfile_equals_current( - wanted: &Lockfile, - config: &Config, -) -> Result<(), &'static str> { - let current = Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir) - .map_err(|_| "the current lockfile cannot be loaded")?; - match current { - None => { - let any_deps = wanted.importers.values().any(|snapshot| { - snapshot - .dependencies_by_groups([ - DependencyGroup::Prod, - DependencyGroup::Dev, - DependencyGroup::Optional, - ]) - .next() - .is_some() - }); - if any_deps { - Err("the lockfile requires dependencies but none were installed") - } else { - Ok(()) - } - } - Some(current) => { - if ¤t == wanted { - Ok(()) - } else { - Err("the installed dependencies are not up to date with the lockfile") - } - } - } -} - -/// Shared lookups for [`linked_packages_are_up_to_date`], built once -/// per content check. -struct LinkedPackagesContext<'a> { - link_workspace_packages: bool, - manifests_by_dir: std::collections::HashMap<&'a Path, &'a PackageManifest>, - /// `name → version → root_dir` over the workspace's projects. - workspace_packages: - std::collections::HashMap>, -} - impl<'a> LinkedPackagesContext<'a> { fn new(config: &Config, project_manifests: &'a [(PathBuf, &'a PackageManifest)]) -> Self { let mut manifests_by_dir = std::collections::HashMap::new(); @@ -865,277 +437,6 @@ impl<'a> LinkedPackagesContext<'a> { } } -/// Verify that linked packages are up to date: every importer -/// dependency that resolved to a workspace link must still link under -/// today's manifest spec, and every one that resolved to the registry -/// must not have become linkable. The local-file-dependency freshness -/// branch (a `file:` directory specifier) is not handled here — those -/// entries conservatively report "not up to date" so the full install -/// path re-evaluates them. -fn linked_packages_are_up_to_date( - ctx: &LinkedPackagesContext<'_>, - project_dir: &Path, - manifest: &PackageManifest, - snapshot: &ProjectSnapshot, -) -> bool { - const GROUPS: [(DependencyGroup, &str); 3] = [ - (DependencyGroup::Optional, "optionalDependencies"), - (DependencyGroup::Prod, "dependencies"), - (DependencyGroup::Dev, "devDependencies"), - ]; - for (group, manifest_field) in GROUPS { - let Some(lockfile_deps) = snapshot.get_map_by_group(group) else { - continue; - }; - let Some(manifest_deps) = - manifest.value().get(manifest_field).and_then(|value| value.as_object()) - else { - continue; - }; - for (dep_name, dep) in lockfile_deps { - let dep_name = dep_name.to_string(); - let Some(current_spec) = manifest_deps.get(&dep_name).and_then(|v| v.as_str()) else { - continue; - }; - if ref_is_local_directory(&dep.specifier) { - // A `file:` specifier that resolved to `link:` (e.g. an - // injected self-reference) is a local link with no - // `packages:` entry — up to date by construction. - if matches!(dep.version, ImporterDepVersion::Link(_)) { - continue; - } - return false; - } - let link_target = dep.version.as_link_target(); - let is_linked = link_target.is_some(); - if is_linked - && (current_spec.starts_with("link:") - || current_spec.starts_with("file:") - || current_spec.starts_with("workspace:.")) - { - continue; - } - // A linked dependency whose spec is a distribution tag is - // considered up to date to skip full resolution. - if is_linked && spec_is_distribution_tag(current_spec) { - continue; - } - let linked_dir: Option> = match link_target { - Some(target) => Some(std::borrow::Cow::Owned(project_dir.join(target))), - None => dep - .version - .as_regular() - .map(std::string::ToString::to_string) - .and_then(|version| ctx.workspace_packages.get(&dep_name)?.get(&version)) - .map(|dir| std::borrow::Cow::Borrowed(*dir)), - }; - let Some(linked_dir) = linked_dir else { - continue; - }; - if !ctx.link_workspace_packages && !current_spec.starts_with("workspace:") { - // A linkable dir exists, but nothing requests linking it. - continue; - } - let available_range = version_range_of_spec(current_spec); - let local_package_satisfies_range = matches!(available_range, "*" | "^" | "~") - || ctx - .linked_version(&linked_dir) - .is_some_and(|version| semver_satisfies_loosely(&version, available_range)); - if is_linked != local_package_satisfies_range { - return false; - } - } - } - true -} - -/// Whether a specifier points at a local directory: a `file:` -/// specifier that is not a tarball. -fn ref_is_local_directory(specifier: &str) -> bool { - specifier.starts_with("file:") - && !(specifier.ends_with(".tgz") - || specifier.ends_with(".tar.gz") - || specifier.ends_with(".tar")) -} - -/// Whether a bare specifier is an npm distribution tag (`latest`, -/// `beta`, ...): anything that doesn't parse as a semver range and -/// contains only characters a tag name may carry. Protocol-ish specs -/// (`workspace:^1.0.0`, `npm:foo@1`) contain `:`/`@`/`/` and therefore -/// never match. -fn spec_is_distribution_tag(spec: &str) -> bool { - !spec.is_empty() - && spec.parse::().is_err() - && spec.chars().all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.')) -} - -/// Strip the `workspace:` / `npm:` envelope so the remainder can be -/// compared as a semver range. -fn version_range_of_spec(spec: &str) -> &str { - if let Some(rest) = spec.strip_prefix("workspace:") { - return rest; - } - if let Some(rest) = spec.strip_prefix("npm:") { - // `npm:@` — the `@` search starts at index 1 so a - // leading scope `@` isn't mistaken for the separator. - return match rest.get(1..).and_then(|tail| tail.find('@')) { - Some(at) => { - let range = &rest[at + 2..]; - if range.is_empty() { "*" } else { range } - } - None => "*", - }; - } - spec -} - -/// `semver.satisfies(version, range, { loose: true })` — a version or -/// range that doesn't parse fails the match. -fn semver_satisfies_loosely(version: &str, range: &str) -> bool { - let Ok(version) = version.parse::() else { return false }; - let Ok(range) = range.parse::() else { return false }; - range.satisfies(&version) -} - -const NANOS_PER_MILLI: i64 = 1_000_000; -const NANOS_PER_SEC: i64 = 1_000_000_000; - -/// A file's mtime, kept at full nanosecond precision. The recorded -/// `lastValidatedTimestamp` is only millisecond-precise (it is a -/// filesystem mtime truncated to ms — see -/// [`crate::install::build_workspace_state`]), but the comparison against -/// it must not be: pacquet installs are fast enough that a lockfile -/// written by one install and a manifest edited moments later can share -/// the same millisecond, so a millisecond-granular comparison would miss -/// the edit and wrongly keep the fast path. Comparing the file's -/// nanosecond mtime against the truncated (rounded-down) reference -/// distinguishes them. -#[derive(Clone, Copy)] -struct FileMtime { - /// Milliseconds since the epoch. Used where the value is *recorded* as - /// the reference (the state stores `lastValidatedTimestamp` in ms, and - /// pnpm's `checkDepsStatus` compares in ms). - ms: i64, - /// Nanoseconds since the epoch. Used as the *subject* of a comparison, - /// where sub-millisecond precision matters. - ns: i64, - /// The filesystem stored no sub-second component, so the real - /// modification time lies anywhere in `[ns, ns + 1s)`. True on - /// second-granularity filesystems (ext4 with 128-byte inodes, HFS+, - /// some CI runner disks); false wherever mtimes keep sub-second - /// precision (ext4 256-byte inodes, APFS, NTFS, xfs, and so on). - whole_second: bool, -} - -/// [`FileMtime`] of `path`, `None` when it can't be stat'd. -fn file_mtime(path: &Path) -> Option { - let metadata = fs::metadata(path).ok()?; - file_mtime_from_metadata(&metadata) -} - -fn file_mtime_from_metadata(metadata: &fs::Metadata) -> Option { - let modified = metadata.modified().ok()?; - let elapsed = modified.duration_since(SystemTime::UNIX_EPOCH).ok()?; - Some(FileMtime { - ms: i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX), - ns: i64::try_from(elapsed.as_nanos()).unwrap_or(i64::MAX), - whole_second: elapsed.subsec_nanos() == 0, - }) -} - -/// Millisecond mtime of `path`, `None` when it can't be stat'd. -fn mtime_ms(path: &Path) -> Option { - file_mtime(path).map(|mtime| mtime.ms) -} - -/// Whether a file with mtime `subject` may have been modified at or after -/// `reference_ms`. The reference is millisecond-precise (a filesystem -/// mtime truncated toward zero); the subject is compared at nanosecond -/// precision, so a file whose whole-millisecond mtime equals the -/// reference's but which was really written later in that millisecond is -/// still seen as modified. On a filesystem that rounds mtimes down to -/// whole seconds the file could have been touched anywhere within its -/// second, so the whole second counts as possibly-after. Erring toward -/// "modified" only runs the authoritative content check — it never skips -/// a needed install. -fn modified_at_or_after(subject: FileMtime, reference_ms: i64) -> bool { - let reference_ns = reference_ms.saturating_mul(NANOS_PER_MILLI); - if subject.whole_second { - subject.ns.saturating_add(NANOS_PER_SEC) > reference_ns - } else { - subject.ns > reference_ns - } -} - -/// The freshness baseline recorded in the workspace state: the latest -/// mtime among the lockfile this install wrote (the wanted -/// `pnpm-lock.yaml`, or the current `/lock.yaml` when -/// the wanted one is absent) and the project manifests it validated. -/// -/// A filesystem mtime, not the wall clock, so the baseline shares a clock -/// with every file the repeat-install check later compares against it: a -/// wall-vs-mtime clock skew (observed ~2 ms on some CI microVMs, where the -/// wall clock ran ahead of the filesystem's mtime clock) would otherwise -/// let a `now()` baseline sit above the mtime of a manifest/pnpmfile -/// edited moments after the install, hiding the edit and wrongly keeping -/// the fast path. Taking the max over the manifests too means a content -/// check that passed on an already-edited manifest still blesses it, so -/// the next run can take the pure-mtime fast path. `None` when nothing can -/// be stat'd. -pub(crate) fn validation_baseline_ms( - workspace_root: &Path, - config: &Config, - project_manifests: &[(PathBuf, &PackageManifest)], -) -> Option { - let lockfile = mtime_ms(&workspace_root.join(Lockfile::FILE_NAME)) - .or_else(|| mtime_ms(&config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME))); - project_manifests - .iter() - .filter_map(|(_, manifest)| mtime_ms(manifest.path())) - .chain(lockfile) - .max() -} - -/// Whether `/pnpm-lock.yaml` has an mtime newer than the -/// last validation. A lockfile-only change leaves every manifest -/// untouched but must still defeat the manifest-mtime fast path. A -/// missing lockfile reports `false` here — it is handled by the -/// existence and stand-in gates, not treated as a modification. -/// -/// Compared at whole-millisecond precision, unlike the manifest / patch / -/// pnpmfile checks: `lastValidatedTimestamp` is itself a lockfile mtime -/// truncated to milliseconds (see -/// [`crate::install::build_workspace_state`]), so a nanosecond comparison -/// would flag the *unchanged* lockfile against its own truncated value on -/// every repeat install and force a content check each time. An external -/// lockfile edit (git checkout, manual rewrite) lands in a later -/// millisecond, so millisecond precision still catches it. -fn wanted_lockfile_modified(workspace_root: &Path, last_validated_timestamp: i64) -> bool { - file_mtime(&workspace_root.join(Lockfile::FILE_NAME)) - .is_some_and(|mtime| lockfile_modified_since(mtime, last_validated_timestamp)) -} - -/// Whether the lockfile's `subject` mtime post-dates `reference_ms`. -/// -/// On a sub-second filesystem this is a whole-*millisecond* comparison, -/// unlike the nanosecond [`modified_at_or_after`] used for -/// manifests/patches/pnpmfiles: the baseline is the lockfile's own mtime -/// truncated to milliseconds, so a nanosecond comparison would flag the -/// unchanged lockfile against its own truncated value on every repeat -/// install. An external lockfile edit lands in a later millisecond and is -/// still caught. On a whole-second-mtime filesystem the whole second is -/// treated as possibly-after (as [`modified_at_or_after`] does), because -/// there a same-second external edit is indistinguishable from the -/// install's own lockfile write by mtime alone, so it must fall through to -/// the authoritative content check. See [`wanted_lockfile_modified`]. -fn lockfile_modified_since(subject: FileMtime, reference_ms: i64) -> bool { - if subject.whole_second { - subject.ms.saturating_add(1_000) > reference_ms - } else { - subject.ms > reference_ms - } -} - fn current_lockfile_unusable_with_non_empty_wanted( check: &OptimisticRepeatInstallCheck<'_>, ) -> Result { @@ -1158,356 +459,6 @@ fn current_lockfile_file_has_content(virtual_store_dir: &Path) -> bool { .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) } -/// Compare today's settings against what the previous install -/// recorded. -/// -/// Only the fields pacquet populates via [`current_settings`] -/// participate in the comparison; the rest are listed at the end of -/// this function with the reason each is safe to skip. -/// -/// pnpm iterates the full `WORKSPACE_STATE_SETTING_KEYS` list, reading a -/// key absent from the recorded state as `undefined`. So the reverse -/// scenario (pacquet wrote the state, pnpm reads it next) stays on the -/// fast path only for keys whose pnpm-resolved value is also -/// `undefined`. Every key pnpm resolves to a concrete default — -/// `excludeLinksFromLockfile` (`false`), `minimumReleaseAge` (`1440`), -/// `minimumReleaseAgeIgnoreMissingTime` (`true`) — must therefore be -/// written by [`current_settings`] and compared here, or pnpm would -/// report drift and re-run a (no-op) install on every command after a -/// pacquet install. `enableGlobalVirtualStore` is `undefined` by -/// default (concrete only under `--global`/CI), so pacquet's omit-when- -/// off encoding already matches. The `allowBuilds` coercion treats an -/// absent value as an empty map on the read side, matching pnpm's -/// tolerance of an absent `allowBuilds` key in the recorded state on -/// the write side. -/// Whether the `supportedArchitectures` recorded by the last install -/// matches `live` (today's CLI-merged value). Read from the workspace -/// state for the frozen path's lockfile-up-to-date early return, whose -/// other guards (`wanted == current`, `.modules.yaml` consistency) cannot -/// see an architecture change: the skip set it would produce differs, so -/// the shortcut must not fire. A missing state file or an unreadable one -/// reports a match only when `live` is also unset — the conservative -/// direction is a full install. -pub(crate) fn recorded_supported_architectures_match( - workspace_root: &Path, - live: Option<&SupportedArchitectures>, -) -> bool { - let recorded = match load_workspace_state(workspace_root) { - Ok(Some(state)) => state.settings.supported_architectures, - _ => None, - }; - recorded == live.and_then(|value| serde_json::to_value(value).ok()) -} - -fn settings_match( - state: &WorkspaceState, - config: &Config, - node_linker: NodeLinker, - included: IncludedDependencies, - supported_architectures: Option<&SupportedArchitectures>, - ignored_workspace_state_settings: &[&str], -) -> bool { - first_setting_drift( - state, - config, - node_linker, - included, - supported_architectures, - ignored_workspace_state_settings, - ) - .is_none() -} - -/// The camelCase name (pnpm's workspace-state setting key) of the first -/// recorded setting that differs from today's config, or `None` when -/// they all match. `ignored_workspace_state_settings` lets callers skip -/// keys such as `dev` / `optional` / `production`: `pnpm run` / `pnpm -/// exec` always execute with the default dependency groups, so those -/// never match the state written by a `--production` / `--no-optional` -/// install (pnpm's `ignoredWorkspaceStateSettings`). -fn first_setting_drift( - state: &WorkspaceState, - config: &Config, - node_linker: NodeLinker, - included: IncludedDependencies, - supported_architectures: Option<&SupportedArchitectures>, - ignored_workspace_state_settings: &[&str], -) -> Option<&'static str> { - let current = current_settings(config, node_linker, included, supported_architectures); - let recorded = &state.settings; - let live = ¤t; - macro_rules! return_drift_if { - ($key:literal, $differs:expr $(,)?) => { - if !ignored_workspace_state_settings.contains(&$key) && $differs { - return Some($key); - } - }; - } - - let allow_builds_drift = - !allow_builds_match(recorded.allow_builds.as_ref(), live.allow_builds.as_ref()); - return_drift_if!("allowBuilds", allow_builds_drift); - return_drift_if!("autoInstallPeers", recorded.auto_install_peers != live.auto_install_peers); - return_drift_if!("dedupeDirectDeps", recorded.dedupe_direct_deps != live.dedupe_direct_deps); - return_drift_if!( - "dedupeInjectedDeps", - recorded.dedupe_injected_deps != live.dedupe_injected_deps, - ); - return_drift_if!( - "dedupePeerDependents", - recorded.dedupe_peer_dependents != live.dedupe_peer_dependents, - ); - return_drift_if!("dedupePeers", recorded.dedupe_peers != live.dedupe_peers); - return_drift_if!("dev", recorded.dev != live.dev); - let enable_global_virtual_store_drift = !enable_global_virtual_store_match( - recorded.enable_global_virtual_store, - live.enable_global_virtual_store, - ); - return_drift_if!("enableGlobalVirtualStore", enable_global_virtual_store_drift); - return_drift_if!( - "excludeLinksFromLockfile", - recorded.exclude_links_from_lockfile != live.exclude_links_from_lockfile, - ); - return_drift_if!("hoistPattern", recorded.hoist_pattern != live.hoist_pattern); - return_drift_if!( - "hoistWorkspacePackages", - recorded.hoist_workspace_packages != live.hoist_workspace_packages, - ); - return_drift_if!( - "ignoredOptionalDependencies", - recorded.ignored_optional_dependencies != live.ignored_optional_dependencies, - ); - return_drift_if!( - "injectWorkspacePackages", - recorded.inject_workspace_packages != live.inject_workspace_packages, - ); - return_drift_if!( - "linkWorkspacePackages", - recorded.link_workspace_packages != live.link_workspace_packages, - ); - return_drift_if!("minimumReleaseAge", recorded.minimum_release_age != live.minimum_release_age,); - return_drift_if!( - "minimumReleaseAgeIgnoreMissingTime", - recorded.minimum_release_age_ignore_missing_time - != live.minimum_release_age_ignore_missing_time, - ); - return_drift_if!("nodeLinker", recorded.node_linker != live.node_linker); - return_drift_if!("optional", recorded.optional != live.optional); - return_drift_if!("overrides", recorded.overrides != live.overrides); - let package_extensions_drift = !package_extensions_match( - recorded.package_extensions.as_ref(), - live.package_extensions.as_ref(), - ); - return_drift_if!("packageExtensions", package_extensions_drift); - return_drift_if!( - "patchedDependencies", - recorded.patched_dependencies != live.patched_dependencies, - ); - return_drift_if!( - "peersSuffixMaxLength", - recorded.peers_suffix_max_length != live.peers_suffix_max_length, - ); - return_drift_if!( - "preferWorkspacePackages", - recorded.prefer_workspace_packages != live.prefer_workspace_packages, - ); - return_drift_if!("production", recorded.production != live.production); - return_drift_if!( - "publicHoistPattern", - recorded.public_hoist_pattern != live.public_hoist_pattern, - ); - return_drift_if!( - "supportedArchitectures", - recorded.supported_architectures != live.supported_architectures, - ); - - None - // Deliberately *not* compared in this generic settings loop: - // `catalogs` is ignored here and checked separately in - // `check_optimistic_repeat_install` so catalogs from either - // `pnpm-workspace.yaml` or an `updateConfig` hook can invalidate - // the cache. - // - // The remaining omitted keys are left out because pnpm leaves them - // `undefined` by default, so omitting them here still matches pnpm's - // all-key freshness check (`undefined == undefined`): - // minimumReleaseAgeStrict (pnpm sets it only when the user - // explicitly sets minimumReleaseAge) - // minimumReleaseAgeExclude - // trustPolicy* (all `undefined` until configured) - // workspacePackagePatterns (concrete for a multi-package - // workspace, but lives in the - // workspace manifest, not `Config`; - // threading it into `current_settings` - // is a separate follow-up. pacquet - // detects project-set changes via - // `project_structure_matches`). -} - -/// `enableGlobalVirtualStore` has no `?? default` coercion on pnpm's -/// read side, but its `undefined` default and an explicit `false` both -/// mean "global virtual store off". pnpm omits the key for the former -/// and records `false` only when CI forces it; pacquet omits both. -/// Normalize the absent and `false` forms before comparing so a -/// pnpm-written file (omitted or `false`) matches a pacquet install -/// with the store off, while a real `true`/`false` flip still trips. -fn enable_global_virtual_store_match( - state_value: Option, - current_value: Option, -) -> bool { - state_value.unwrap_or(false) == current_value.unwrap_or(false) -} - -/// Pnpm writes `Some({})` for an empty `allowBuilds`; pacquet writes -/// `None` for the same effective value. Treat them as equivalent so -/// cross-package-manager state files don't trip the comparison. -fn allow_builds_match( - state_value: Option<&std::collections::BTreeMap>, - current_value: Option<&std::collections::BTreeMap>, -) -> bool { - match (state_value, current_value) { - (None, None) => true, - (Some(map), None) | (None, Some(map)) => map.is_empty(), - (Some(state_map), Some(current_map)) => state_map == current_map, - } -} - -/// `packageExtensions` are compared as opaque `serde_json::Value` -/// trees so the workspace-state file written by either implementation -/// round-trips through the other. Empty maps are equivalent to absent -/// — pacquet's [`pacquet_config::WorkspaceSettings::apply_to`] already collapses -/// `packageExtensions: {}` to `None`, but pnpm may write `Some({})` -/// directly, and the workspace-state file is shared across the two. -fn package_extensions_match( - state_value: Option<&serde_json::Value>, - current_value: Option<&serde_json::Value>, -) -> bool { - fn is_empty(value: &serde_json::Value) -> bool { - match value { - serde_json::Value::Object(map) => map.is_empty(), - serde_json::Value::Null => true, - _ => false, - } - } - match (state_value, current_value) { - (None, None) => true, - (Some(value), None) | (None, Some(value)) => is_empty(value), - (Some(state_value), Some(current_value)) => state_value == current_value, - } -} - -/// Build the [`WorkspaceStateSettings`] that today's install would -/// write. Shared with `install::build_workspace_state` so the -/// freshness check sees the same byte shape the writer produced — -/// when one side grows a field, the other automatically does too. -pub(crate) fn current_settings( - config: &Config, - node_linker: NodeLinker, - included: IncludedDependencies, - supported_architectures: Option<&SupportedArchitectures>, -) -> WorkspaceStateSettings { - let allow_builds = (!config.allow_builds.is_empty()).then(|| { - config.allow_builds.iter().map(|(k, v)| (k.clone(), serde_json::Value::Bool(*v))).collect() - }); - WorkspaceStateSettings { - allow_builds, - auto_install_peers: Some(config.auto_install_peers), - dedupe_direct_deps: Some(config.dedupe_direct_deps), - dedupe_injected_deps: Some(config.dedupe_injected_deps), - dedupe_peer_dependents: Some(config.dedupe_peer_dependents), - dedupe_peers: Some(config.dedupe_peers), - dev: Some(included.dev_dependencies), - // Mirror pnpm's writer, which omits the key for its `undefined` - // default and records a concrete value only when forced. pacquet - // has no `--global` flow, so the only "on" value it ever writes - // is `true`; an off store maps back to the omitted `None`. - enable_global_virtual_store: config.enable_global_virtual_store.then_some(true), - exclude_links_from_lockfile: Some(config.exclude_links_from_lockfile), - hoist_pattern: config.hoist_pattern.clone(), - hoist_workspace_packages: Some(config.hoist_workspace_packages), - ignored_optional_dependencies: config.ignored_optional_dependencies.clone(), - inject_workspace_packages: Some(config.inject_workspace_packages), - link_workspace_packages: Some(link_workspace_packages_to_json( - config.link_workspace_packages, - )), - minimum_release_age: config.minimum_release_age, - minimum_release_age_ignore_missing_time: Some( - config.minimum_release_age_ignore_missing_time, - ), - node_linker: Some(map_node_linker(node_linker)), - optional: Some(included.optional_dependencies), - overrides: config - .overrides - .as_ref() - .map(|map| map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()), - package_extensions: config - .package_extensions - .as_ref() - .and_then(|map| serde_json::to_value(map).ok()), - patched_dependencies: config.patched_dependencies.clone(), - peers_suffix_max_length: Some( - u32::try_from(config.peers_suffix_max_length).unwrap_or(u32::MAX), - ), - prefer_workspace_packages: Some(config.prefer_workspace_packages), - production: Some(included.dependencies), - public_hoist_pattern: config.public_hoist_pattern.clone(), - // The CLI-merged effective value (yaml plus `--cpu` / `--os` / - // `--libc`), like `included` above: a change through either - // channel re-evaluates the skipped optionals on the next run. - supported_architectures: supported_architectures - .and_then(|value| serde_json::to_value(value).ok()), - ..Default::default() - } -} - -pub(crate) fn current_settings_with_catalogs( - config: &Config, - node_linker: NodeLinker, - included: IncludedDependencies, - supported_architectures: Option<&SupportedArchitectures>, - catalogs: &Catalogs, -) -> WorkspaceStateSettings { - let mut settings = current_settings(config, node_linker, included, supported_architectures); - settings.catalogs = Some(catalogs_to_json(catalogs)); - settings -} - -fn catalogs_cache_matches(recorded: Option<&serde_json::Value>, current: &Catalogs) -> bool { - let recorded = recorded.cloned().map_or_else(empty_json_object, filter_null_object_values); - let current = filter_null_object_values(catalogs_to_json(current)); - recorded == current -} - -fn catalogs_to_json(catalogs: &Catalogs) -> serde_json::Value { - serde_json::to_value(catalogs).expect("Catalogs serialize to a JSON object") -} - -fn empty_json_object() -> serde_json::Value { - serde_json::Value::Object(serde_json::Map::new()) -} - -fn filter_null_object_values(value: serde_json::Value) -> serde_json::Value { - let serde_json::Value::Object(mut map) = value else { return value }; - map.retain(|_, value| !value.is_null()); - serde_json::Value::Object(map) -} - -fn link_workspace_packages_to_json(value: LinkWorkspacePackages) -> serde_json::Value { - match value { - LinkWorkspacePackages::Off => serde_json::Value::Bool(false), - LinkWorkspacePackages::DirectOnly => serde_json::Value::Bool(true), - LinkWorkspacePackages::Deep => serde_json::Value::String("deep".to_string()), - } -} - -fn map_node_linker(linker: NodeLinker) -> WorkspaceStateNodeLinker { - match linker { - NodeLinker::Isolated => WorkspaceStateNodeLinker::Isolated, - NodeLinker::Hoisted => WorkspaceStateNodeLinker::Hoisted, - NodeLinker::Pnp => WorkspaceStateNodeLinker::Pnp, - } -} - /// Project count + per-project (key, name, version) match between the /// cached state and today's walk. The key is the project's root dir; /// `build_workspace_state` and pnpm both use it as the map key, so a @@ -1646,254 +597,5 @@ fn pnpmfiles_drift(workspace_root: &Path, previous: &[String], cutoff_ms: i64) - }) } -/// Stat every project's `package.json`. `None` on any stat failure — -/// "can't prove freshness, fall through". -fn stat_manifests<'a>( - project_manifests: &'a [(PathBuf, &'a PackageManifest)], -) -> Option>> { - project_manifests - .iter() - .map(|(root_dir, manifest)| { - file_mtime(manifest.path()).map(|mtime| ManifestStat { - root_dir: root_dir.as_path(), - manifest, - mtime, - }) - }) - .collect() -} - -/// Outcome of [`check_deps_status_before_run`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RunDepsStatus { - UpToDate, - /// `node-linker: pnp` installs cannot be inspected. The caller - /// warns ("verify-deps-before-run does not work with - /// node-linker=pnp") and runs the script. - SkippedPnp, - Outdated { - /// pnpm's issue wording for the detected drift, shown by the - /// `warn` and `error` actions. - issue: String, - /// `pnpm install` arguments reproducing the dependency groups - /// the workspace state recorded (`--production` / `--dev` / - /// `--no-optional`), for the `install` and `prompt` actions. - install_args: Vec, - }, -} - -/// The verify-deps-before-run twin of -/// [`check_optimistic_repeat_install`]: the same freshness checks, with -/// the differences pnpm's run gate carries over its install fast path — -/// it runs regardless of `optimisticRepeatInstall`, never treats local -/// file dependencies as outdated, ignores `dev`/`optional`/`production` -/// drift (scripts always run with the default groups), compares -/// configuration dependencies, and reports drift with pnpm's -/// user-facing issue wording instead of a diagnostic-only reason. -/// `state` arrives from the caller, which already had to load it to -/// decide whether a check is possible at all (a missing state is -/// "Cannot check whether dependencies are outdated"). -pub fn check_deps_status_before_run( - check: &OptimisticRepeatInstallCheck<'_>, - state: &WorkspaceState, -) -> RunDepsStatus { - let &OptimisticRepeatInstallCheck { - workspace_root, - config, - node_linker, - included, - supported_architectures, - project_manifests, - is_workspace_install, - catalogs, - .. - } = check; - - let install_args = install_args_from_state(state); - let outdated = - |issue: String| RunDepsStatus::Outdated { issue, install_args: install_args.clone() }; - - if node_linker == NodeLinker::Pnp { - return RunDepsStatus::SkippedPnp; - } - - if let Some((lockfile_path, failure)) = - first_lockfile_requiring_conflict_safe_install(check, state.last_validated_timestamp) - { - let lockfile_dir = lockfile_path.parent().unwrap_or(workspace_root).display(); - let issue = match failure { - LockfileConflictCheckFailure::MergeConflict => { - format!("The lockfile in {lockfile_dir} has merge conflicts") - } - LockfileConflictCheckFailure::Unsafe => { - format!("The lockfile in {lockfile_dir} cannot be checked for merge conflicts") - } - }; - return outdated(issue); - } - - if let Some(setting) = first_setting_drift( - state, - config, - node_linker, - included, - supported_architectures, - &["dev", "optional", "production"], - ) { - return outdated(format!("The value of the {setting} setting has changed")); - } - if config_dependencies_drifted(config, state) { - return outdated("Configuration dependencies are not up to date".to_string()); - } - if !catalogs_cache_matches(state.settings.catalogs.as_ref(), catalogs) { - return outdated("Catalogs cache outdated".to_string()); - } - if !project_structure_matches(state, project_manifests) { - return outdated("The workspace structure has changed since last install".to_string()); - } - // A filtered install legitimately leaves unselected projects - // without a modules directory. - if !state.filtered_install - && let Some(id) = first_project_missing_modules_dir(config, project_manifests) - { - return outdated(format!( - "Workspace package {id} has dependencies but does not have a modules directory", - )); - } - if !is_workspace_install - && !workspace_root.join(Lockfile::FILE_NAME).exists() - && !current_lockfile_file_has_content(&config.virtual_store_dir) - { - return outdated(format!("Cannot find a lockfile in {}", workspace_root.display())); - } - if patches_modified_since(workspace_root, config, state.last_validated_timestamp) { - return outdated("Patches were modified".to_string()); - } - if let Some(issue) = - pnpmfiles_drift(workspace_root, &state.pnpmfiles, state.last_validated_timestamp) - { - return outdated(issue); - } - - let Some(manifest_stats) = stat_manifests(project_manifests) else { - return outdated("Cannot check whether dependencies are outdated".to_string()); - }; - let modified: Vec<&ManifestStat<'_>> = manifest_stats - .iter() - .filter(|stat| modified_at_or_after(stat.mtime, state.last_validated_timestamp)) - .collect(); - let lockfile_modified = - wanted_lockfile_modified(workspace_root, state.last_validated_timestamp); - - match current_lockfile_unusable_with_non_empty_wanted(check) { - Ok(true) => { - return outdated( - "The lockfile requires dependencies but none were installed".to_string(), - ); - } - Ok(false) => {} - Err(reason) => return outdated(reason.to_string()), - } - - if modified.is_empty() && !lockfile_modified { - return match missing_wanted_lockfile_stand_in_ok(check) { - Ok(()) => RunDepsStatus::UpToDate, - Err(reason) => outdated(reason), - }; - } - - let projects_to_check: Vec<&ManifestStat<'_>> = - if lockfile_modified { manifest_stats.iter().collect() } else { modified }; - // The TypeScript run/exec handler does not forward `dedupePeers` - // into `checkDepsStatus`, so its pre-run lockfile check uses the - // false default even when the workspace setting is true. - match modified_manifests_match_lockfile(check, state, &projects_to_check, false) { - Ok(_) => { - if let Err(reason) = missing_wanted_lockfile_stand_in_ok(check) { - return outdated(reason); - } - if is_workspace_install { - let mut new_state = crate::install::build_workspace_state( - workspace_root, - config, - node_linker, - included, - supported_architectures, - catalogs, - project_manifests, - state.filtered_install, - ); - // The gate ignored `dev`/`optional`/`production` drift - // above; writing today's (default-group) values here - // would clobber what the last real install recorded and - // flip its next repeat-install check into "drift". - new_state.settings.dev = state.settings.dev; - new_state.settings.optional = state.settings.optional; - new_state.settings.production = state.settings.production; - if let Err(error) = update_workspace_state(workspace_root, &new_state) { - tracing::warn!( - target: "pacquet::run", - ?error, - "Failed to refresh the workspace state after the verify-deps-before-run content check", - ); - } - } - RunDepsStatus::UpToDate - } - Err(reason) => outdated(reason.to_string()), - } -} - -/// Read-only twin of [`regenerate_wanted_lockfile_if_missing`] for the -/// run gate: pnpm's run-path check never writes `pnpm-lock.yaml` (only -/// the install command restores it from the current lockfile), so a -/// missing wanted lockfile passes exactly when the current lockfile can -/// stand in for it, and the check leaves the workspace untouched. -fn missing_wanted_lockfile_stand_in_ok( - check: &OptimisticRepeatInstallCheck<'_>, -) -> Result<(), String> { - if check.lockfile.is_loaded_or_on_disk() || !check.config.lockfile { - return Ok(()); - } - match Lockfile::load_current_from_virtual_store_dir(&check.config.virtual_store_dir) { - Ok(Some(_)) => Ok(()), - Ok(None) => Err(format!("Cannot find a lockfile in {}", check.workspace_root.display())), - Err(_) => Err("the current lockfile cannot be loaded".to_string()), - } -} - -/// The `pnpm install` arguments reproducing the dependency groups the -/// workspace state recorded, so the `install` / `prompt` actions rerun -/// the same kind of install the project last had (pnpm's -/// `createInstallArgs`). -fn install_args_from_state(state: &WorkspaceState) -> Vec { - let settings = &state.settings; - let mut args = Vec::new(); - let dev = settings.dev.unwrap_or(false); - let production = settings.production.unwrap_or(false); - if production && !dev { - args.push("--production".to_string()); - } else if dev && !production { - args.push("--dev".to_string()); - } - if !settings.optional.unwrap_or(false) { - args.push("--no-optional".to_string()); - } - args -} - -/// Whether the configuration dependencies recorded by the last install -/// differ from today's config. Both sides read an absent map as empty -/// (pnpm compares `opts.configDependencies ?? {}` against -/// `workspaceState.configDependencies ?? {}`). -fn config_dependencies_drifted(config: &Config, state: &WorkspaceState) -> bool { - if config.config_dependencies.is_none() && state.config_dependencies.is_none() { - return false; - } - let empty = std::collections::BTreeMap::new(); - config.config_dependencies.as_ref().unwrap_or(&empty) - != state.config_dependencies.as_ref().unwrap_or(&empty) -} - #[cfg(test)] mod tests; diff --git a/pnpm/crates/package-manager/src/optimistic_repeat_install/conflict_markers.rs b/pnpm/crates/package-manager/src/optimistic_repeat_install/conflict_markers.rs new file mode 100644 index 0000000000..2dcd922a58 --- /dev/null +++ b/pnpm/crates/package-manager/src/optimistic_repeat_install/conflict_markers.rs @@ -0,0 +1,109 @@ +//! Refusing an optimistic install when a lockfile still carries merge-conflict markers. + +use super::{ + ErrorKind, Lockfile, OptimisticRepeatInstallCheck, Path, PathBuf, Read, + file_mtime_from_metadata, fs, lockfile_modified_since, +}; + +#[derive(Clone, Copy)] +pub(crate) enum LockfileConflictCheckFailure { + MergeConflict, + Unsafe, +} + +pub(crate) fn first_lockfile_requiring_conflict_safe_install( + check: &OptimisticRepeatInstallCheck<'_>, + last_validated_timestamp: i64, +) -> Option<(PathBuf, LockfileConflictCheckFailure)> { + let shared_lockfile = check.workspace_root.join(Lockfile::FILE_NAME); + if let Some(failure) = + lockfile_conflict_check_failure(&shared_lockfile, last_validated_timestamp) + { + return Some((shared_lockfile, failure)); + } + if check.config.shared_workspace_lockfile { + return None; + } + for (root_dir, _) in check.project_manifests { + let lockfile_path = root_dir.join(Lockfile::FILE_NAME); + if lockfile_path != shared_lockfile + && let Some(failure) = + lockfile_conflict_check_failure(&lockfile_path, last_validated_timestamp) + { + return Some((lockfile_path, failure)); + } + } + None +} + +pub(crate) const CONFLICT_MARKER: &[u8] = b"<<<<<<<"; + +pub(crate) const LOCKFILE_CONFLICT_SCAN_BUFFER_SIZE: usize = 8 * 1024; + +pub(crate) const MAX_LOCKFILE_CONFLICT_SCAN_BYTES: u64 = 16 * 1024 * 1024; + +pub(crate) fn lockfile_conflict_check_failure( + path: &Path, + last_validated_timestamp: i64, +) -> Option { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return None, + Err(_) => return Some(LockfileConflictCheckFailure::Unsafe), + }; + if !metadata.file_type().is_file() { + return Some(LockfileConflictCheckFailure::Unsafe); + } + let Some(mtime) = file_mtime_from_metadata(&metadata) else { + return Some(LockfileConflictCheckFailure::Unsafe); + }; + if !lockfile_modified_since(mtime, last_validated_timestamp) { + return None; + } + if metadata.len() >= MAX_LOCKFILE_CONFLICT_SCAN_BYTES { + return Some(LockfileConflictCheckFailure::Unsafe); + } + modified_lockfile_conflict_check_failure(path) +} + +pub(crate) fn modified_lockfile_conflict_check_failure( + path: &Path, +) -> Option { + let Ok(mut file) = fs::File::open(path) else { + return Some(LockfileConflictCheckFailure::Unsafe); + }; + let Ok(metadata) = file.metadata() else { + return Some(LockfileConflictCheckFailure::Unsafe); + }; + if !metadata.file_type().is_file() || metadata.len() >= MAX_LOCKFILE_CONFLICT_SCAN_BYTES { + return Some(LockfileConflictCheckFailure::Unsafe); + } + + let mut buffer = [0; LOCKFILE_CONFLICT_SCAN_BUFFER_SIZE + CONFLICT_MARKER.len() - 1]; + let mut carried = 0; + let mut scanned = 0_u64; + loop { + let remaining = MAX_LOCKFILE_CONFLICT_SCAN_BYTES.saturating_sub(scanned); + if remaining == 0 { + return Some(LockfileConflictCheckFailure::Unsafe); + } + let read_capacity = LOCKFILE_CONFLICT_SCAN_BUFFER_SIZE.min(remaining as usize); + match file.read(&mut buffer[carried..carried + read_capacity]) { + Ok(0) => return None, + Ok(read) => { + scanned += read as u64; + let end = carried + read; + if buffer[..end] + .windows(CONFLICT_MARKER.len()) + .any(|bytes| bytes == CONFLICT_MARKER) + { + return Some(LockfileConflictCheckFailure::MergeConflict); + } + carried = end.min(CONFLICT_MARKER.len() - 1); + buffer.copy_within(end - carried..end, 0); + } + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(_) => return Some(LockfileConflictCheckFailure::Unsafe), + } + } +} diff --git a/pnpm/crates/package-manager/src/optimistic_repeat_install/deps_status.rs b/pnpm/crates/package-manager/src/optimistic_repeat_install/deps_status.rs new file mode 100644 index 0000000000..0ec635357c --- /dev/null +++ b/pnpm/crates/package-manager/src/optimistic_repeat_install/deps_status.rs @@ -0,0 +1,243 @@ +//! The pre-run dependency-status check behind `verifyDepsBeforeRun`. + +use super::{ + Config, Lockfile, LockfileConflictCheckFailure, ManifestStat, NodeLinker, + OptimisticRepeatInstallCheck, WorkspaceState, catalogs_cache_matches, + current_lockfile_file_has_content, current_lockfile_unusable_with_non_empty_wanted, + first_lockfile_requiring_conflict_safe_install, first_project_missing_modules_dir, + first_setting_drift, modified_at_or_after, modified_manifests_match_lockfile, + patches_modified_since, pnpmfiles_drift, project_structure_matches, stat_manifests, + update_workspace_state, wanted_lockfile_modified, +}; + +/// Outcome of [`check_deps_status_before_run`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunDepsStatus { + UpToDate, + /// `node-linker: pnp` installs cannot be inspected. The caller + /// warns ("verify-deps-before-run does not work with + /// node-linker=pnp") and runs the script. + SkippedPnp, + Outdated { + /// pnpm's issue wording for the detected drift, shown by the + /// `warn` and `error` actions. + issue: String, + /// `pnpm install` arguments reproducing the dependency groups + /// the workspace state recorded (`--production` / `--dev` / + /// `--no-optional`), for the `install` and `prompt` actions. + install_args: Vec, + }, +} + +/// The verify-deps-before-run twin of +/// [`crate::optimistic_repeat_install::check_optimistic_repeat_install`]: the same freshness checks, with +/// the differences pnpm's run gate carries over its install fast path — +/// it runs regardless of `optimisticRepeatInstall`, never treats local +/// file dependencies as outdated, ignores `dev`/`optional`/`production` +/// drift (scripts always run with the default groups), compares +/// configuration dependencies, and reports drift with pnpm's +/// user-facing issue wording instead of a diagnostic-only reason. +/// `state` arrives from the caller, which already had to load it to +/// decide whether a check is possible at all (a missing state is +/// "Cannot check whether dependencies are outdated"). +pub fn check_deps_status_before_run( + check: &OptimisticRepeatInstallCheck<'_>, + state: &WorkspaceState, +) -> RunDepsStatus { + let &OptimisticRepeatInstallCheck { + workspace_root, + config, + node_linker, + included, + supported_architectures, + project_manifests, + is_workspace_install, + catalogs, + .. + } = check; + + let install_args = install_args_from_state(state); + let outdated = + |issue: String| RunDepsStatus::Outdated { issue, install_args: install_args.clone() }; + + if node_linker == NodeLinker::Pnp { + return RunDepsStatus::SkippedPnp; + } + + if let Some((lockfile_path, failure)) = + first_lockfile_requiring_conflict_safe_install(check, state.last_validated_timestamp) + { + let lockfile_dir = lockfile_path.parent().unwrap_or(workspace_root).display(); + let issue = match failure { + LockfileConflictCheckFailure::MergeConflict => { + format!("The lockfile in {lockfile_dir} has merge conflicts") + } + LockfileConflictCheckFailure::Unsafe => { + format!("The lockfile in {lockfile_dir} cannot be checked for merge conflicts") + } + }; + return outdated(issue); + } + + if let Some(setting) = first_setting_drift( + state, + config, + node_linker, + included, + supported_architectures, + &["dev", "optional", "production"], + ) { + return outdated(format!("The value of the {setting} setting has changed")); + } + if config_dependencies_drifted(config, state) { + return outdated("Configuration dependencies are not up to date".to_string()); + } + if !catalogs_cache_matches(state.settings.catalogs.as_ref(), catalogs) { + return outdated("Catalogs cache outdated".to_string()); + } + if !project_structure_matches(state, project_manifests) { + return outdated("The workspace structure has changed since last install".to_string()); + } + // A filtered install legitimately leaves unselected projects + // without a modules directory. + if !state.filtered_install + && let Some(id) = first_project_missing_modules_dir(config, project_manifests) + { + return outdated(format!( + "Workspace package {id} has dependencies but does not have a modules directory", + )); + } + if !is_workspace_install + && !workspace_root.join(Lockfile::FILE_NAME).exists() + && !current_lockfile_file_has_content(&config.virtual_store_dir) + { + return outdated(format!("Cannot find a lockfile in {}", workspace_root.display())); + } + if patches_modified_since(workspace_root, config, state.last_validated_timestamp) { + return outdated("Patches were modified".to_string()); + } + if let Some(issue) = + pnpmfiles_drift(workspace_root, &state.pnpmfiles, state.last_validated_timestamp) + { + return outdated(issue); + } + + let Some(manifest_stats) = stat_manifests(project_manifests) else { + return outdated("Cannot check whether dependencies are outdated".to_string()); + }; + let modified: Vec<&ManifestStat<'_>> = manifest_stats + .iter() + .filter(|stat| modified_at_or_after(stat.mtime, state.last_validated_timestamp)) + .collect(); + let lockfile_modified = + wanted_lockfile_modified(workspace_root, state.last_validated_timestamp); + + match current_lockfile_unusable_with_non_empty_wanted(check) { + Ok(true) => { + return outdated( + "The lockfile requires dependencies but none were installed".to_string(), + ); + } + Ok(false) => {} + Err(reason) => return outdated(reason.to_string()), + } + + if modified.is_empty() && !lockfile_modified { + return match missing_wanted_lockfile_stand_in_ok(check) { + Ok(()) => RunDepsStatus::UpToDate, + Err(reason) => outdated(reason), + }; + } + + let projects_to_check: Vec<&ManifestStat<'_>> = + if lockfile_modified { manifest_stats.iter().collect() } else { modified }; + // The TypeScript run/exec handler does not forward `dedupePeers` + // into `checkDepsStatus`, so its pre-run lockfile check uses the + // false default even when the workspace setting is true. + match modified_manifests_match_lockfile(check, state, &projects_to_check, false) { + Ok(_) => { + if let Err(reason) = missing_wanted_lockfile_stand_in_ok(check) { + return outdated(reason); + } + if is_workspace_install { + let mut new_state = crate::install::build_workspace_state( + workspace_root, + config, + node_linker, + included, + supported_architectures, + catalogs, + project_manifests, + state.filtered_install, + ); + // The gate ignored `dev`/`optional`/`production` drift + // above; writing today's (default-group) values here + // would clobber what the last real install recorded and + // flip its next repeat-install check into "drift". + new_state.settings.dev = state.settings.dev; + new_state.settings.optional = state.settings.optional; + new_state.settings.production = state.settings.production; + if let Err(error) = update_workspace_state(workspace_root, &new_state) { + tracing::warn!( + target: "pacquet::run", + ?error, + "Failed to refresh the workspace state after the verify-deps-before-run content check", + ); + } + } + RunDepsStatus::UpToDate + } + Err(reason) => outdated(reason.to_string()), + } +} + +/// Read-only twin of [`crate::optimistic_repeat_install::regenerate_wanted_lockfile_if_missing`] for the +/// run gate: pnpm's run-path check never writes `pnpm-lock.yaml` (only +/// the install command restores it from the current lockfile), so a +/// missing wanted lockfile passes exactly when the current lockfile can +/// stand in for it, and the check leaves the workspace untouched. +pub(crate) fn missing_wanted_lockfile_stand_in_ok( + check: &OptimisticRepeatInstallCheck<'_>, +) -> Result<(), String> { + if check.lockfile.is_loaded_or_on_disk() || !check.config.lockfile { + return Ok(()); + } + match Lockfile::load_current_from_virtual_store_dir(&check.config.virtual_store_dir) { + Ok(Some(_)) => Ok(()), + Ok(None) => Err(format!("Cannot find a lockfile in {}", check.workspace_root.display())), + Err(_) => Err("the current lockfile cannot be loaded".to_string()), + } +} + +/// The `pnpm install` arguments reproducing the dependency groups the +/// workspace state recorded, so the `install` / `prompt` actions rerun +/// the same kind of install the project last had (pnpm's +/// `createInstallArgs`). +pub(crate) fn install_args_from_state(state: &WorkspaceState) -> Vec { + let settings = &state.settings; + let mut args = Vec::new(); + let dev = settings.dev.unwrap_or(false); + let production = settings.production.unwrap_or(false); + if production && !dev { + args.push("--production".to_string()); + } else if dev && !production { + args.push("--dev".to_string()); + } + if !settings.optional.unwrap_or(false) { + args.push("--no-optional".to_string()); + } + args +} + +/// Whether the configuration dependencies recorded by the last install +/// differ from today's config. Both sides read an absent map as empty +/// (pnpm compares `opts.configDependencies ?? {}` against +/// `workspaceState.configDependencies ?? {}`). +pub(crate) fn config_dependencies_drifted(config: &Config, state: &WorkspaceState) -> bool { + if config.config_dependencies.is_none() && state.config_dependencies.is_none() { + return false; + } + let empty = std::collections::BTreeMap::new(); + config.config_dependencies.as_ref().unwrap_or(&empty) + != state.config_dependencies.as_ref().unwrap_or(&empty) +} diff --git a/pnpm/crates/package-manager/src/optimistic_repeat_install/local_file_deps.rs b/pnpm/crates/package-manager/src/optimistic_repeat_install/local_file_deps.rs new file mode 100644 index 0000000000..6b8567d752 --- /dev/null +++ b/pnpm/crates/package-manager/src/optimistic_repeat_install/local_file_deps.rs @@ -0,0 +1,160 @@ +//! Detecting dependency specs that point at the local filesystem. + +use super::{ + CatalogResolutionResult, Catalogs, Config, IncludedDependencies, PackageManifest, PathBuf, + WantedDependency, resolve_from_catalog, +}; + +/// Whether any project declares a dependency with a local file +/// specifier in `dependencies`, `devDependencies`, or +/// `optionalDependencies`. Groups excluded from the current install +/// (per `included`) are skipped. `catalog:` specs are dereferenced +/// through the workspace catalogs. +pub(crate) fn has_local_file_dep( + project_manifests: &[(PathBuf, &PackageManifest)], + included: IncludedDependencies, + catalogs: &Catalogs, +) -> bool { + let fields: [(&str, bool); 3] = [ + ("dependencies", included.dependencies), + ("devDependencies", included.dev_dependencies), + ("optionalDependencies", included.optional_dependencies), + ]; + project_manifests.iter().any(|(_, manifest)| { + fields.iter().any(|(field, group_included)| { + *group_included + && manifest.value().get(*field).and_then(|value| value.as_object()).is_some_and( + |deps| { + deps.iter().any(|(alias, spec)| { + spec.as_str().is_some_and(|spec| { + is_local_file_spec(spec) + || catalog_resolves_to_local_file(catalogs, alias, spec) + }) + }) + }, + ) + }) + }) +} + +/// Whether a `catalog:` spec dereferences (through the workspace +/// catalogs) to a local file specifier. A misconfigured catalog entry +/// returns `false`: it fails the full install with the proper error +/// anyway, so the fast path only needs to not report up-to-date for a +/// *valid* catalog entry holding a local path. +pub(crate) fn catalog_resolves_to_local_file(catalogs: &Catalogs, alias: &str, spec: &str) -> bool { + // `resolve_from_catalog` returns `Unused` for any non-`catalog:` spec, so + // short-circuit before allocating the owned `WantedDependency` it needs. + if !spec.starts_with("catalog:") { + return false; + } + match resolve_from_catalog( + catalogs, + &WantedDependency { alias: alias.to_string(), bare_specifier: spec.to_string() }, + ) { + CatalogResolutionResult::Found(found) => is_local_file_spec(&found.resolution.specifier), + _ => false, + } +} + +/// Whether any `pnpm.overrides` entry maps to a local file specifier. +/// An override redirects every matching dependency in the graph to its +/// specifier, so a local file override makes the installed contents +/// depend on that directory or tarball the same way a direct local file +/// dependency does. A parse failure returns its own distinct reason — +/// not the local-file reason, which would misattribute the cause. +pub(crate) fn has_local_file_override( + config: &Config, + catalogs: &Catalogs, +) -> Result { + match crate::install::parse_config_overrides(config, catalogs) { + Ok(Some(overrides)) => { + Ok(overrides.iter().any(|entry| is_local_file_spec(&entry.new_bare_specifier))) + } + Ok(None) => Ok(false), + Err(_) => Err("pnpm.overrides cannot be parsed"), + } +} + +/// Whether any `packageExtensions` entry injects a dependency with a +/// local file specifier. Package extensions are merged into matching +/// packages' manifests by the read-package hook during the full +/// install, so a `file:`/local-path/tarball spec added there has the +/// same content-change blind spot as a direct local file dependency +/// without appearing in any project manifest. Only `dependencies` and +/// `optionalDependencies` are scanned: peer dependencies are resolved +/// from the graph rather than fetched, so a local spec there is never +/// installed. +pub(crate) fn has_local_file_package_extension( + config: &Config, + included: IncludedDependencies, + catalogs: &Catalogs, +) -> bool { + let Some(extensions) = config.package_extensions.as_ref() else { + return false; + }; + extensions.values().any(|extension| { + let optional = included + .optional_dependencies + .then_some(extension.optional_dependencies.as_ref()) + .flatten(); + [extension.dependencies.as_ref(), optional].into_iter().flatten().any(|deps| { + deps.iter().any(|(alias, spec)| { + is_local_file_spec(spec) || catalog_resolves_to_local_file(catalogs, alias, spec) + }) + }) + }) +} + +/// Whether the specifier resolves to a local directory or tarball whose +/// contents can change without any manifest or lockfile mtime moving: +/// the `file:` protocol, path-prefixed specs (`./`, `../`, `~/`, +/// absolute POSIX paths, and Windows drive paths including +/// drive-relative ones like `c:dir`), and bare tarball file names. +/// +/// Deliberately narrower than the local resolver's bare-path matching: +/// a bare path like `user/repo` is statically indistinguishable from a +/// git shorthand at this layer, and matching it would disable the +/// repeat-install fast path for every project with git dependencies. +/// Such specs (and anything else carrying a protocol or URL) stay on +/// the fast path. `catalog:` specs also return `false` here — callers +/// dereference them through the workspace catalogs first, because a +/// catalog entry may hold a bare local path (the catalog resolver only +/// bans the `workspace:`, `link:`, and `file:` protocols). +pub(crate) fn is_local_file_spec(spec: &str) -> bool { + if spec.starts_with("file:") { + return true; + } + if spec.starts_with(['.', '/', '\\']) + || spec.starts_with("~/") + || spec.starts_with(r"~\") + || is_windows_drive_path(spec) + { + return true; + } + if spec.contains(':') { + return false; + } + if spec.contains('#') { + return false; + } + ends_with_ignore_ascii_case(spec, ".tgz") + || ends_with_ignore_ascii_case(spec, ".tar.gz") + || ends_with_ignore_ascii_case(spec, ".tar") +} + +/// Case-insensitive (ASCII) suffix check that, unlike +/// `spec.to_ascii_lowercase().ends_with(suffix)`, does not allocate. +pub(crate) fn ends_with_ignore_ascii_case(spec: &str, suffix: &str) -> bool { + let spec = spec.as_bytes(); + let suffix = suffix.as_bytes(); + spec.len() >= suffix.len() && spec[spec.len() - suffix.len()..].eq_ignore_ascii_case(suffix) +} + +/// `c:/...`, `c:\...`, or drive-relative `c:foo` — a Windows drive +/// path. No separator is required after the colon; no registry protocol +/// is a single letter, so `[a-z]:` is unambiguous. +pub(crate) fn is_windows_drive_path(spec: &str) -> bool { + let bytes = spec.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} diff --git a/pnpm/crates/package-manager/src/optimistic_repeat_install/manifest_agreement.rs b/pnpm/crates/package-manager/src/optimistic_repeat_install/manifest_agreement.rs new file mode 100644 index 0000000000..da7f622a27 --- /dev/null +++ b/pnpm/crates/package-manager/src/optimistic_repeat_install/manifest_agreement.rs @@ -0,0 +1,360 @@ +//! Whether the manifests on disk still agree with the lockfile. + +use super::{ + Config, DependencyGroup, FileMtime, ImporterDepVersion, Lockfile, OptimisticRepeatInstallCheck, + PackageManifest, Path, PathBuf, ProjectSnapshot, WorkspaceState, file_mtime, + modified_at_or_after, mtime_ms, +}; + +/// One project manifest's stat outcome, paired with the inputs the +/// content re-check needs. +pub(crate) struct ManifestStat<'a> { + pub(crate) root_dir: &'a Path, + pub(crate) manifest: &'a PackageManifest, + pub(crate) mtime: FileMtime, +} + +/// The modified-manifests branch: the lockfile-equality assertion plus +/// the wanted-lockfile up-to-date check (settings drift, per-importer +/// specifier match, linked-package freshness) for every project whose +/// manifest is newer than the last validation. `Err` carries the +/// `Decision::Skipped` reason. +/// +/// When `pnpm-lock.yaml` is absent, the current lockfile stands in as +/// the wanted one (see the lockfile gate in +/// [`crate::optimistic_repeat_install::check_optimistic_repeat_install`]); `Ok(Some(_))` then carries the +/// loaded current lockfile so the caller can regenerate +/// `pnpm-lock.yaml` from it without a second read. +pub(crate) fn modified_manifests_match_lockfile( + check: &OptimisticRepeatInstallCheck<'_>, + state: &WorkspaceState, + modified: &[&ManifestStat<'_>], + dedupe_peers: bool, +) -> Result, &'static str> { + let &OptimisticRepeatInstallCheck { + workspace_root, + config, + project_manifests, + is_workspace_install, + lockfile, + catalogs, + .. + } = check; + let mut loaded_current: Option = None; + let mut wanted_is_current = false; + let lockfile = lockfile.get().map_err(|_| "the wanted lockfile cannot be read or parsed")?; + let (wanted, wanted_mtime): (&Lockfile, FileMtime) = if let Some(wanted) = lockfile { + let Some(mtime) = file_mtime(&workspace_root.join(Lockfile::FILE_NAME)) else { + return Err( + "a manifest is newer than the last validation and pnpm-lock.yaml cannot be stat'd", + ); + }; + (wanted, mtime) + } else { + let current_path = config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME); + let Some(mtime) = file_mtime(¤t_path) else { + return Err("a manifest is newer than the last validation and no lockfile is loaded"); + }; + let current = Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir) + .map_err(|_| "the current lockfile cannot be loaded")? + .ok_or("a manifest is newer than the last validation and no lockfile is loaded")?; + wanted_is_current = true; + (&*loaded_current.insert(current), mtime) + }; + + // Decide which modified projects need the full content check, and + // whether the wanted lockfile must be compared against the current + // one (`/lock.yaml`). + let to_check: &[&ManifestStat<'_>] = if wanted_is_current { + // The wanted lockfile IS the current one — there's no second + // lockfile to assert equality against, and the mtime + // short-circuits below compare the two lockfile files, so they + // don't apply. Every modified project gets the content check. + modified + } else if is_workspace_install { + // Workspace branch: a wanted lockfile newer than the last + // validation must equal what the previous install materialized. + if modified_at_or_after(wanted_mtime, state.last_validated_timestamp) { + assert_wanted_lockfile_equals_current(wanted, config)?; + } + modified + } else { + // Single-project branch keys off the lockfile mtimes instead of + // `lastValidatedTimestamp`. + let current_mtime_ms = + mtime_ms(&config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME)); + if let Some(current_mtime_ms) = current_mtime_ms + && modified_at_or_after(wanted_mtime, current_mtime_ms) + { + assert_wanted_lockfile_equals_current(wanted, config)?; + } + let root = modified.first().expect("modified-manifests branch requires a modified project"); + if modified_at_or_after(root.mtime, wanted_mtime.ms) { + modified + } else if current_mtime_ms.is_some() { + // "The manifest file is not newer than the lockfile. + // Exiting check." + &[] + } else if !wanted.is_empty() { + // RUN_CHECK_DEPS_NO_DEPS: the lockfile requires + // dependencies but nothing was ever installed. + return Err("the lockfile requires dependencies but none were installed"); + } else { + &[] + } + }; + + if to_check.is_empty() { + return Ok(loaded_current); + } + + let parsed_overrides = crate::install::parse_config_overrides(config, catalogs) + .map_err(|_| "pnpm.overrides cannot be parsed")?; + if let Err(error) = crate::install::check_lockfile_settings_drift( + wanted, + config, + catalogs, + crate::install::CheckLockfileSettingsDriftOptions { + parsed_overrides: parsed_overrides.as_deref(), + // `pnpmfileChecksum` needs no comparison here: reaching this + // point means `pnpmfiles_modified_since` already proved the + // pnpmfile list and contents are what the install that wrote + // this lockfile saw. Computing the checksum instead would cost + // a Node worker on the path that exists to avoid starting one. + pnpmfile_checksum: pacquet_lockfile::PnpmfileChecksumCheck::Skip, + dedupe_peers, + }, + ) { + tracing::debug!(target: "pacquet::install", %error, "repeat-install content check: lockfile settings drift"); + return Err("a lockfile setting drifted from the current configuration"); + } + + let linked_ctx = LinkedPackagesContext::new(config, project_manifests); + let ignored_optional_matcher = pacquet_config::matcher::create_matcher( + config.ignored_optional_dependencies.as_deref().unwrap_or_default(), + ); + for project in to_check { + let importer_id = + pacquet_workspace::importer_id_from_root_dir(workspace_root, project.root_dir); + if let Err(error) = crate::install::check_importer_satisfies( + wanted, + project.manifest, + &importer_id, + config, + &ignored_optional_matcher, + parsed_overrides.as_deref(), + ) { + tracing::debug!(target: "pacquet::install", %error, importer_id, "repeat-install content check: manifest no longer satisfied"); + return Err("a modified manifest is no longer satisfied by the lockfile"); + } + let Some(importer) = wanted.importers.get(&importer_id) else { + return Err("a modified project has no importer entry in the lockfile"); + }; + if !linked_packages_are_up_to_date( + &linked_ctx, + project.root_dir, + project.manifest, + importer, + ) { + return Err("a linked package is out of date"); + } + } + Ok(loaded_current) +} + +/// Assert the wanted lockfile equals the current one: with no current +/// lockfile every importer of the wanted one must be dependency-free +/// (`RUN_CHECK_DEPS_NO_DEPS`); otherwise the two parsed lockfiles must +/// be equal (`RUN_CHECK_DEPS_OUTDATED_DEPS`). +pub(crate) fn assert_wanted_lockfile_equals_current( + wanted: &Lockfile, + config: &Config, +) -> Result<(), &'static str> { + let current = Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir) + .map_err(|_| "the current lockfile cannot be loaded")?; + match current { + None => { + let any_deps = wanted.importers.values().any(|snapshot| { + snapshot + .dependencies_by_groups([ + DependencyGroup::Prod, + DependencyGroup::Dev, + DependencyGroup::Optional, + ]) + .next() + .is_some() + }); + if any_deps { + Err("the lockfile requires dependencies but none were installed") + } else { + Ok(()) + } + } + Some(current) => { + if ¤t == wanted { + Ok(()) + } else { + Err("the installed dependencies are not up to date with the lockfile") + } + } + } +} + +/// Shared lookups for [`linked_packages_are_up_to_date`], built once +/// per content check. +pub(crate) struct LinkedPackagesContext<'a> { + pub(crate) link_workspace_packages: bool, + pub(crate) manifests_by_dir: std::collections::HashMap<&'a Path, &'a PackageManifest>, + /// `name → version → root_dir` over the workspace's projects. + pub(crate) workspace_packages: + std::collections::HashMap>, +} + +/// Verify that linked packages are up to date: every importer +/// dependency that resolved to a workspace link must still link under +/// today's manifest spec, and every one that resolved to the registry +/// must not have become linkable. The local-file-dependency freshness +/// branch (a `file:` directory specifier) is not handled here — those +/// entries conservatively report "not up to date" so the full install +/// path re-evaluates them. +pub(crate) fn linked_packages_are_up_to_date( + ctx: &LinkedPackagesContext<'_>, + project_dir: &Path, + manifest: &PackageManifest, + snapshot: &ProjectSnapshot, +) -> bool { + const GROUPS: [(DependencyGroup, &str); 3] = [ + (DependencyGroup::Optional, "optionalDependencies"), + (DependencyGroup::Prod, "dependencies"), + (DependencyGroup::Dev, "devDependencies"), + ]; + for (group, manifest_field) in GROUPS { + let Some(lockfile_deps) = snapshot.get_map_by_group(group) else { + continue; + }; + let Some(manifest_deps) = + manifest.value().get(manifest_field).and_then(|value| value.as_object()) + else { + continue; + }; + for (dep_name, dep) in lockfile_deps { + let dep_name = dep_name.to_string(); + let Some(current_spec) = manifest_deps.get(&dep_name).and_then(|v| v.as_str()) else { + continue; + }; + if ref_is_local_directory(&dep.specifier) { + // A `file:` specifier that resolved to `link:` (e.g. an + // injected self-reference) is a local link with no + // `packages:` entry — up to date by construction. + if matches!(dep.version, ImporterDepVersion::Link(_)) { + continue; + } + return false; + } + let link_target = dep.version.as_link_target(); + let is_linked = link_target.is_some(); + if is_linked + && (current_spec.starts_with("link:") + || current_spec.starts_with("file:") + || current_spec.starts_with("workspace:.")) + { + continue; + } + // A linked dependency whose spec is a distribution tag is + // considered up to date to skip full resolution. + if is_linked && spec_is_distribution_tag(current_spec) { + continue; + } + let linked_dir: Option> = match link_target { + Some(target) => Some(std::borrow::Cow::Owned(project_dir.join(target))), + None => dep + .version + .as_regular() + .map(std::string::ToString::to_string) + .and_then(|version| ctx.workspace_packages.get(&dep_name)?.get(&version)) + .map(|dir| std::borrow::Cow::Borrowed(*dir)), + }; + let Some(linked_dir) = linked_dir else { + continue; + }; + if !ctx.link_workspace_packages && !current_spec.starts_with("workspace:") { + // A linkable dir exists, but nothing requests linking it. + continue; + } + let available_range = version_range_of_spec(current_spec); + let local_package_satisfies_range = matches!(available_range, "*" | "^" | "~") + || ctx + .linked_version(&linked_dir) + .is_some_and(|version| semver_satisfies_loosely(&version, available_range)); + if is_linked != local_package_satisfies_range { + return false; + } + } + } + true +} + +/// Whether a specifier points at a local directory: a `file:` +/// specifier that is not a tarball. +pub(crate) fn ref_is_local_directory(specifier: &str) -> bool { + specifier.starts_with("file:") + && !(specifier.ends_with(".tgz") + || specifier.ends_with(".tar.gz") + || specifier.ends_with(".tar")) +} + +/// Whether a bare specifier is an npm distribution tag (`latest`, +/// `beta`, ...): anything that doesn't parse as a semver range and +/// contains only characters a tag name may carry. Protocol-ish specs +/// (`workspace:^1.0.0`, `npm:foo@1`) contain `:`/`@`/`/` and therefore +/// never match. +pub(crate) fn spec_is_distribution_tag(spec: &str) -> bool { + !spec.is_empty() + && spec.parse::().is_err() + && spec.chars().all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.')) +} + +/// Strip the `workspace:` / `npm:` envelope so the remainder can be +/// compared as a semver range. +pub(crate) fn version_range_of_spec(spec: &str) -> &str { + if let Some(rest) = spec.strip_prefix("workspace:") { + return rest; + } + if let Some(rest) = spec.strip_prefix("npm:") { + // `npm:@` — the `@` search starts at index 1 so a + // leading scope `@` isn't mistaken for the separator. + return match rest.get(1..).and_then(|tail| tail.find('@')) { + Some(at) => { + let range = &rest[at + 2..]; + if range.is_empty() { "*" } else { range } + } + None => "*", + }; + } + spec +} + +/// `semver.satisfies(version, range, { loose: true })` — a version or +/// range that doesn't parse fails the match. +pub(crate) fn semver_satisfies_loosely(version: &str, range: &str) -> bool { + let Ok(version) = version.parse::() else { return false }; + let Ok(range) = range.parse::() else { return false }; + range.satisfies(&version) +} + +/// Stat every project's `package.json`. `None` on any stat failure — +/// "can't prove freshness, fall through". +pub(crate) fn stat_manifests<'a>( + project_manifests: &'a [(PathBuf, &'a PackageManifest)], +) -> Option>> { + project_manifests + .iter() + .map(|(root_dir, manifest)| { + file_mtime(manifest.path()).map(|mtime| ManifestStat { + root_dir: root_dir.as_path(), + manifest, + mtime, + }) + }) + .collect() +} diff --git a/pnpm/crates/package-manager/src/optimistic_repeat_install/settings.rs b/pnpm/crates/package-manager/src/optimistic_repeat_install/settings.rs new file mode 100644 index 0000000000..a1edf65d80 --- /dev/null +++ b/pnpm/crates/package-manager/src/optimistic_repeat_install/settings.rs @@ -0,0 +1,360 @@ +//! Comparing the settings a previous install recorded against the current ones. + +use super::{ + Catalogs, Config, IncludedDependencies, LinkWorkspacePackages, NodeLinker, Path, + SupportedArchitectures, WorkspaceState, WorkspaceStateNodeLinker, WorkspaceStateSettings, + load_workspace_state, +}; + +/// Whether the `supportedArchitectures` recorded by the last install +/// matches `live` (today's CLI-merged value). Read from the workspace +/// state for the frozen path's lockfile-up-to-date early return, whose +/// other guards (`wanted == current`, `.modules.yaml` consistency) cannot +/// see an architecture change: the skip set it would produce differs, so +/// the shortcut must not fire. A missing state file or an unreadable one +/// reports a match only when `live` is also unset — the conservative +/// direction is a full install. +pub(crate) fn recorded_supported_architectures_match( + workspace_root: &Path, + live: Option<&SupportedArchitectures>, +) -> bool { + let recorded = match load_workspace_state(workspace_root) { + Ok(Some(state)) => state.settings.supported_architectures, + _ => None, + }; + recorded == live.and_then(|value| serde_json::to_value(value).ok()) +} + +pub(crate) fn settings_match( + state: &WorkspaceState, + config: &Config, + node_linker: NodeLinker, + included: IncludedDependencies, + supported_architectures: Option<&SupportedArchitectures>, + ignored_workspace_state_settings: &[&str], +) -> bool { + first_setting_drift( + state, + config, + node_linker, + included, + supported_architectures, + ignored_workspace_state_settings, + ) + .is_none() +} + +/// Compare today's settings against what the previous install +/// recorded. +/// +/// Only the fields pacquet populates via [`current_settings`] +/// participate in the comparison; the rest are listed at the end of +/// this function with the reason each is safe to skip. +/// +/// pnpm iterates the full `WORKSPACE_STATE_SETTING_KEYS` list, reading a +/// key absent from the recorded state as `undefined`. So the reverse +/// scenario (pacquet wrote the state, pnpm reads it next) stays on the +/// fast path only for keys whose pnpm-resolved value is also +/// `undefined`. Every key pnpm resolves to a concrete default — +/// `excludeLinksFromLockfile` (`false`), `minimumReleaseAge` (`1440`), +/// `minimumReleaseAgeIgnoreMissingTime` (`true`) — must therefore be +/// written by [`current_settings`] and compared here, or pnpm would +/// report drift and re-run a (no-op) install on every command after a +/// pacquet install. `enableGlobalVirtualStore` is `undefined` by +/// default (concrete only under `--global`/CI), so pacquet's omit-when- +/// off encoding already matches. The `allowBuilds` coercion treats an +/// absent value as an empty map on the read side, matching pnpm's +/// tolerance of an absent `allowBuilds` key in the recorded state on +/// the write side. +/// The camelCase name (pnpm's workspace-state setting key) of the first +/// recorded setting that differs from today's config, or `None` when +/// they all match. `ignored_workspace_state_settings` lets callers skip +/// keys such as `dev` / `optional` / `production`: `pnpm run` / `pnpm +/// exec` always execute with the default dependency groups, so those +/// never match the state written by a `--production` / `--no-optional` +/// install (pnpm's `ignoredWorkspaceStateSettings`). +pub(crate) fn first_setting_drift( + state: &WorkspaceState, + config: &Config, + node_linker: NodeLinker, + included: IncludedDependencies, + supported_architectures: Option<&SupportedArchitectures>, + ignored_workspace_state_settings: &[&str], +) -> Option<&'static str> { + let current = current_settings(config, node_linker, included, supported_architectures); + let recorded = &state.settings; + let live = ¤t; + macro_rules! return_drift_if { + ($key:literal, $differs:expr $(,)?) => { + if !ignored_workspace_state_settings.contains(&$key) && $differs { + return Some($key); + } + }; + } + + let allow_builds_drift = + !allow_builds_match(recorded.allow_builds.as_ref(), live.allow_builds.as_ref()); + return_drift_if!("allowBuilds", allow_builds_drift); + return_drift_if!("autoInstallPeers", recorded.auto_install_peers != live.auto_install_peers); + return_drift_if!("dedupeDirectDeps", recorded.dedupe_direct_deps != live.dedupe_direct_deps); + return_drift_if!( + "dedupeInjectedDeps", + recorded.dedupe_injected_deps != live.dedupe_injected_deps, + ); + return_drift_if!( + "dedupePeerDependents", + recorded.dedupe_peer_dependents != live.dedupe_peer_dependents, + ); + return_drift_if!("dedupePeers", recorded.dedupe_peers != live.dedupe_peers); + return_drift_if!("dev", recorded.dev != live.dev); + let enable_global_virtual_store_drift = !enable_global_virtual_store_match( + recorded.enable_global_virtual_store, + live.enable_global_virtual_store, + ); + return_drift_if!("enableGlobalVirtualStore", enable_global_virtual_store_drift); + return_drift_if!( + "excludeLinksFromLockfile", + recorded.exclude_links_from_lockfile != live.exclude_links_from_lockfile, + ); + return_drift_if!("hoistPattern", recorded.hoist_pattern != live.hoist_pattern); + return_drift_if!( + "hoistWorkspacePackages", + recorded.hoist_workspace_packages != live.hoist_workspace_packages, + ); + return_drift_if!( + "ignoredOptionalDependencies", + recorded.ignored_optional_dependencies != live.ignored_optional_dependencies, + ); + return_drift_if!( + "injectWorkspacePackages", + recorded.inject_workspace_packages != live.inject_workspace_packages, + ); + return_drift_if!( + "linkWorkspacePackages", + recorded.link_workspace_packages != live.link_workspace_packages, + ); + return_drift_if!("minimumReleaseAge", recorded.minimum_release_age != live.minimum_release_age,); + return_drift_if!( + "minimumReleaseAgeIgnoreMissingTime", + recorded.minimum_release_age_ignore_missing_time + != live.minimum_release_age_ignore_missing_time, + ); + return_drift_if!("nodeLinker", recorded.node_linker != live.node_linker); + return_drift_if!("optional", recorded.optional != live.optional); + return_drift_if!("overrides", recorded.overrides != live.overrides); + let package_extensions_drift = !package_extensions_match( + recorded.package_extensions.as_ref(), + live.package_extensions.as_ref(), + ); + return_drift_if!("packageExtensions", package_extensions_drift); + return_drift_if!( + "patchedDependencies", + recorded.patched_dependencies != live.patched_dependencies, + ); + return_drift_if!( + "peersSuffixMaxLength", + recorded.peers_suffix_max_length != live.peers_suffix_max_length, + ); + return_drift_if!( + "preferWorkspacePackages", + recorded.prefer_workspace_packages != live.prefer_workspace_packages, + ); + return_drift_if!("production", recorded.production != live.production); + return_drift_if!( + "publicHoistPattern", + recorded.public_hoist_pattern != live.public_hoist_pattern, + ); + return_drift_if!( + "supportedArchitectures", + recorded.supported_architectures != live.supported_architectures, + ); + + None + // Deliberately *not* compared in this generic settings loop: + // `catalogs` is ignored here and checked separately in + // `check_optimistic_repeat_install` so catalogs from either + // `pnpm-workspace.yaml` or an `updateConfig` hook can invalidate + // the cache. + // + // The remaining omitted keys are left out because pnpm leaves them + // `undefined` by default, so omitting them here still matches pnpm's + // all-key freshness check (`undefined == undefined`): + // minimumReleaseAgeStrict (pnpm sets it only when the user + // explicitly sets minimumReleaseAge) + // minimumReleaseAgeExclude + // trustPolicy* (all `undefined` until configured) + // workspacePackagePatterns (concrete for a multi-package + // workspace, but lives in the + // workspace manifest, not `Config`; + // threading it into `current_settings` + // is a separate follow-up. pacquet + // detects project-set changes via + // `project_structure_matches`). +} + +/// `enableGlobalVirtualStore` has no `?? default` coercion on pnpm's +/// read side, but its `undefined` default and an explicit `false` both +/// mean "global virtual store off". pnpm omits the key for the former +/// and records `false` only when CI forces it; pacquet omits both. +/// Normalize the absent and `false` forms before comparing so a +/// pnpm-written file (omitted or `false`) matches a pacquet install +/// with the store off, while a real `true`/`false` flip still trips. +pub(crate) fn enable_global_virtual_store_match( + state_value: Option, + current_value: Option, +) -> bool { + state_value.unwrap_or(false) == current_value.unwrap_or(false) +} + +/// Pnpm writes `Some({})` for an empty `allowBuilds`; pacquet writes +/// `None` for the same effective value. Treat them as equivalent so +/// cross-package-manager state files don't trip the comparison. +pub(crate) fn allow_builds_match( + state_value: Option<&std::collections::BTreeMap>, + current_value: Option<&std::collections::BTreeMap>, +) -> bool { + match (state_value, current_value) { + (None, None) => true, + (Some(map), None) | (None, Some(map)) => map.is_empty(), + (Some(state_map), Some(current_map)) => state_map == current_map, + } +} + +/// `packageExtensions` are compared as opaque `serde_json::Value` +/// trees so the workspace-state file written by either implementation +/// round-trips through the other. Empty maps are equivalent to absent +/// — pacquet's [`pacquet_config::WorkspaceSettings::apply_to`] already collapses +/// `packageExtensions: {}` to `None`, but pnpm may write `Some({})` +/// directly, and the workspace-state file is shared across the two. +pub(crate) fn package_extensions_match( + state_value: Option<&serde_json::Value>, + current_value: Option<&serde_json::Value>, +) -> bool { + fn is_empty(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(map) => map.is_empty(), + serde_json::Value::Null => true, + _ => false, + } + } + match (state_value, current_value) { + (None, None) => true, + (Some(value), None) | (None, Some(value)) => is_empty(value), + (Some(state_value), Some(current_value)) => state_value == current_value, + } +} + +/// Build the [`WorkspaceStateSettings`] that today's install would +/// write. Shared with `install::build_workspace_state` so the +/// freshness check sees the same byte shape the writer produced — +/// when one side grows a field, the other automatically does too. +pub(crate) fn current_settings( + config: &Config, + node_linker: NodeLinker, + included: IncludedDependencies, + supported_architectures: Option<&SupportedArchitectures>, +) -> WorkspaceStateSettings { + let allow_builds = (!config.allow_builds.is_empty()).then(|| { + config.allow_builds.iter().map(|(k, v)| (k.clone(), serde_json::Value::Bool(*v))).collect() + }); + WorkspaceStateSettings { + allow_builds, + auto_install_peers: Some(config.auto_install_peers), + dedupe_direct_deps: Some(config.dedupe_direct_deps), + dedupe_injected_deps: Some(config.dedupe_injected_deps), + dedupe_peer_dependents: Some(config.dedupe_peer_dependents), + dedupe_peers: Some(config.dedupe_peers), + dev: Some(included.dev_dependencies), + // Mirror pnpm's writer, which omits the key for its `undefined` + // default and records a concrete value only when forced. pacquet + // has no `--global` flow, so the only "on" value it ever writes + // is `true`; an off store maps back to the omitted `None`. + enable_global_virtual_store: config.enable_global_virtual_store.then_some(true), + exclude_links_from_lockfile: Some(config.exclude_links_from_lockfile), + hoist_pattern: config.hoist_pattern.clone(), + hoist_workspace_packages: Some(config.hoist_workspace_packages), + ignored_optional_dependencies: config.ignored_optional_dependencies.clone(), + inject_workspace_packages: Some(config.inject_workspace_packages), + link_workspace_packages: Some(link_workspace_packages_to_json( + config.link_workspace_packages, + )), + minimum_release_age: config.minimum_release_age, + minimum_release_age_ignore_missing_time: Some( + config.minimum_release_age_ignore_missing_time, + ), + node_linker: Some(map_node_linker(node_linker)), + optional: Some(included.optional_dependencies), + overrides: config + .overrides + .as_ref() + .map(|map| map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()), + package_extensions: config + .package_extensions + .as_ref() + .and_then(|map| serde_json::to_value(map).ok()), + patched_dependencies: config.patched_dependencies.clone(), + peers_suffix_max_length: Some( + u32::try_from(config.peers_suffix_max_length).unwrap_or(u32::MAX), + ), + prefer_workspace_packages: Some(config.prefer_workspace_packages), + production: Some(included.dependencies), + public_hoist_pattern: config.public_hoist_pattern.clone(), + // The CLI-merged effective value (yaml plus `--cpu` / `--os` / + // `--libc`), like `included` above: a change through either + // channel re-evaluates the skipped optionals on the next run. + supported_architectures: supported_architectures + .and_then(|value| serde_json::to_value(value).ok()), + ..Default::default() + } +} + +pub(crate) fn current_settings_with_catalogs( + config: &Config, + node_linker: NodeLinker, + included: IncludedDependencies, + supported_architectures: Option<&SupportedArchitectures>, + catalogs: &Catalogs, +) -> WorkspaceStateSettings { + let mut settings = current_settings(config, node_linker, included, supported_architectures); + settings.catalogs = Some(catalogs_to_json(catalogs)); + settings +} + +pub(crate) fn catalogs_cache_matches( + recorded: Option<&serde_json::Value>, + current: &Catalogs, +) -> bool { + let recorded = recorded.cloned().map_or_else(empty_json_object, filter_null_object_values); + let current = filter_null_object_values(catalogs_to_json(current)); + recorded == current +} + +pub(crate) fn catalogs_to_json(catalogs: &Catalogs) -> serde_json::Value { + serde_json::to_value(catalogs).expect("Catalogs serialize to a JSON object") +} + +pub(crate) fn empty_json_object() -> serde_json::Value { + serde_json::Value::Object(serde_json::Map::new()) +} + +pub(crate) fn filter_null_object_values(value: serde_json::Value) -> serde_json::Value { + let serde_json::Value::Object(mut map) = value else { return value }; + map.retain(|_, value| !value.is_null()); + serde_json::Value::Object(map) +} + +pub(crate) fn link_workspace_packages_to_json(value: LinkWorkspacePackages) -> serde_json::Value { + match value { + LinkWorkspacePackages::Off => serde_json::Value::Bool(false), + LinkWorkspacePackages::DirectOnly => serde_json::Value::Bool(true), + LinkWorkspacePackages::Deep => serde_json::Value::String("deep".to_string()), + } +} + +pub(crate) fn map_node_linker(linker: NodeLinker) -> WorkspaceStateNodeLinker { + match linker { + NodeLinker::Isolated => WorkspaceStateNodeLinker::Isolated, + NodeLinker::Hoisted => WorkspaceStateNodeLinker::Hoisted, + NodeLinker::Pnp => WorkspaceStateNodeLinker::Pnp, + } +} diff --git a/pnpm/crates/package-manager/src/optimistic_repeat_install/tests.rs b/pnpm/crates/package-manager/src/optimistic_repeat_install/tests.rs index a0353f0cd5..b6e298e254 100644 --- a/pnpm/crates/package-manager/src/optimistic_repeat_install/tests.rs +++ b/pnpm/crates/package-manager/src/optimistic_repeat_install/tests.rs @@ -1,9 +1,12 @@ use super::{ - Decision, FileMtime, LinkedPackagesContext, MAX_LOCKFILE_CONFLICT_SCAN_BYTES, - OptimisticRepeatInstallCheck, RunDepsStatus, check_deps_status_before_run, - check_optimistic_repeat_install, check_optimistic_repeat_install_ignoring, current_pnpmfiles, - current_settings, current_settings_with_catalogs, linked_packages_are_up_to_date, - lockfile_modified_since, modified_at_or_after, + Decision, OptimisticRepeatInstallCheck, check_optimistic_repeat_install, + check_optimistic_repeat_install_ignoring, + conflict_markers::MAX_LOCKFILE_CONFLICT_SCAN_BYTES, + current_pnpmfiles, + deps_status::{RunDepsStatus, check_deps_status_before_run}, + manifest_agreement::{LinkedPackagesContext, linked_packages_are_up_to_date}, + settings::{current_settings, current_settings_with_catalogs}, + timestamps::{FileMtime, lockfile_modified_since, modified_at_or_after}, }; use indexmap::IndexMap; use pacquet_catalogs_types::Catalogs; diff --git a/pnpm/crates/package-manager/src/optimistic_repeat_install/timestamps.rs b/pnpm/crates/package-manager/src/optimistic_repeat_install/timestamps.rs new file mode 100644 index 0000000000..a9eee08b22 --- /dev/null +++ b/pnpm/crates/package-manager/src/optimistic_repeat_install/timestamps.rs @@ -0,0 +1,146 @@ +//! Modification times, and the baseline a recorded validation is compared against. + +use super::{Config, Lockfile, PackageManifest, Path, PathBuf, SystemTime, fs}; + +pub(crate) const NANOS_PER_MILLI: i64 = 1_000_000; + +pub(crate) const NANOS_PER_SEC: i64 = 1_000_000_000; + +/// A file's mtime, kept at full nanosecond precision. The recorded +/// `lastValidatedTimestamp` is only millisecond-precise (it is a +/// filesystem mtime truncated to ms — see +/// [`crate::install::build_workspace_state`]), but the comparison against +/// it must not be: pacquet installs are fast enough that a lockfile +/// written by one install and a manifest edited moments later can share +/// the same millisecond, so a millisecond-granular comparison would miss +/// the edit and wrongly keep the fast path. Comparing the file's +/// nanosecond mtime against the truncated (rounded-down) reference +/// distinguishes them. +#[derive(Clone, Copy)] +pub(crate) struct FileMtime { + /// Milliseconds since the epoch. Used where the value is *recorded* as + /// the reference (the state stores `lastValidatedTimestamp` in ms, and + /// pnpm's `checkDepsStatus` compares in ms). + pub(crate) ms: i64, + /// Nanoseconds since the epoch. Used as the *subject* of a comparison, + /// where sub-millisecond precision matters. + pub(crate) ns: i64, + /// The filesystem stored no sub-second component, so the real + /// modification time lies anywhere in `[ns, ns + 1s)`. True on + /// second-granularity filesystems (ext4 with 128-byte inodes, HFS+, + /// some CI runner disks); false wherever mtimes keep sub-second + /// precision (ext4 256-byte inodes, APFS, NTFS, xfs, and so on). + pub(crate) whole_second: bool, +} + +/// [`FileMtime`] of `path`, `None` when it can't be stat'd. +pub(crate) fn file_mtime(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + file_mtime_from_metadata(&metadata) +} + +pub(crate) fn file_mtime_from_metadata(metadata: &fs::Metadata) -> Option { + let modified = metadata.modified().ok()?; + let elapsed = modified.duration_since(SystemTime::UNIX_EPOCH).ok()?; + Some(FileMtime { + ms: i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX), + ns: i64::try_from(elapsed.as_nanos()).unwrap_or(i64::MAX), + whole_second: elapsed.subsec_nanos() == 0, + }) +} + +/// Millisecond mtime of `path`, `None` when it can't be stat'd. +pub(crate) fn mtime_ms(path: &Path) -> Option { + file_mtime(path).map(|mtime| mtime.ms) +} + +/// Whether a file with mtime `subject` may have been modified at or after +/// `reference_ms`. The reference is millisecond-precise (a filesystem +/// mtime truncated toward zero); the subject is compared at nanosecond +/// precision, so a file whose whole-millisecond mtime equals the +/// reference's but which was really written later in that millisecond is +/// still seen as modified. On a filesystem that rounds mtimes down to +/// whole seconds the file could have been touched anywhere within its +/// second, so the whole second counts as possibly-after. Erring toward +/// "modified" only runs the authoritative content check — it never skips +/// a needed install. +pub(crate) fn modified_at_or_after(subject: FileMtime, reference_ms: i64) -> bool { + let reference_ns = reference_ms.saturating_mul(NANOS_PER_MILLI); + if subject.whole_second { + subject.ns.saturating_add(NANOS_PER_SEC) > reference_ns + } else { + subject.ns > reference_ns + } +} + +/// The freshness baseline recorded in the workspace state: the latest +/// mtime among the lockfile this install wrote (the wanted +/// `pnpm-lock.yaml`, or the current `/lock.yaml` when +/// the wanted one is absent) and the project manifests it validated. +/// +/// A filesystem mtime, not the wall clock, so the baseline shares a clock +/// with every file the repeat-install check later compares against it: a +/// wall-vs-mtime clock skew (observed ~2 ms on some CI microVMs, where the +/// wall clock ran ahead of the filesystem's mtime clock) would otherwise +/// let a `now()` baseline sit above the mtime of a manifest/pnpmfile +/// edited moments after the install, hiding the edit and wrongly keeping +/// the fast path. Taking the max over the manifests too means a content +/// check that passed on an already-edited manifest still blesses it, so +/// the next run can take the pure-mtime fast path. `None` when nothing can +/// be stat'd. +pub(crate) fn validation_baseline_ms( + workspace_root: &Path, + config: &Config, + project_manifests: &[(PathBuf, &PackageManifest)], +) -> Option { + let lockfile = mtime_ms(&workspace_root.join(Lockfile::FILE_NAME)) + .or_else(|| mtime_ms(&config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME))); + project_manifests + .iter() + .filter_map(|(_, manifest)| mtime_ms(manifest.path())) + .chain(lockfile) + .max() +} + +/// Whether `/pnpm-lock.yaml` has an mtime newer than the +/// last validation. A lockfile-only change leaves every manifest +/// untouched but must still defeat the manifest-mtime fast path. A +/// missing lockfile reports `false` here — it is handled by the +/// existence and stand-in gates, not treated as a modification. +/// +/// Compared at whole-millisecond precision, unlike the manifest / patch / +/// pnpmfile checks: `lastValidatedTimestamp` is itself a lockfile mtime +/// truncated to milliseconds (see +/// [`crate::install::build_workspace_state`]), so a nanosecond comparison +/// would flag the *unchanged* lockfile against its own truncated value on +/// every repeat install and force a content check each time. An external +/// lockfile edit (git checkout, manual rewrite) lands in a later +/// millisecond, so millisecond precision still catches it. +pub(crate) fn wanted_lockfile_modified( + workspace_root: &Path, + last_validated_timestamp: i64, +) -> bool { + file_mtime(&workspace_root.join(Lockfile::FILE_NAME)) + .is_some_and(|mtime| lockfile_modified_since(mtime, last_validated_timestamp)) +} + +/// Whether the lockfile's `subject` mtime post-dates `reference_ms`. +/// +/// On a sub-second filesystem this is a whole-*millisecond* comparison, +/// unlike the nanosecond [`modified_at_or_after`] used for +/// manifests/patches/pnpmfiles: the baseline is the lockfile's own mtime +/// truncated to milliseconds, so a nanosecond comparison would flag the +/// unchanged lockfile against its own truncated value on every repeat +/// install. An external lockfile edit lands in a later millisecond and is +/// still caught. On a whole-second-mtime filesystem the whole second is +/// treated as possibly-after (as [`modified_at_or_after`] does), because +/// there a same-second external edit is indistinguishable from the +/// install's own lockfile write by mtime alone, so it must fall through to +/// the authoritative content check. See [`wanted_lockfile_modified`]. +pub(crate) fn lockfile_modified_since(subject: FileMtime, reference_ms: i64) -> bool { + if subject.whole_second { + subject.ms.saturating_add(1_000) > reference_ms + } else { + subject.ms > reference_ms + } +}