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.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-08-05 12:48:15 +02:00
1 parent a92dc94042
commit 1a75bb2434
9 files changed
+1418 -1335

No files matched your search

@@ -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,
File diff suppressed because it is too large. Load diff
@@ -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<LockfileConflictCheckFailure> {
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<LockfileConflictCheckFailure> {
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),
}
}
}
@@ -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<String>,
},
}
/// 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<String> {
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)
}
@@ -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<bool, &'static str> {
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':'
}
@@ -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<Option<Lockfile>, &'static str> {
let &OptimisticRepeatInstallCheck {
workspace_root,
config,
project_manifests,
is_workspace_install,
lockfile,
catalogs,
..
} = check;
let mut loaded_current: Option<Lockfile> = 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(&current_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 (`<virtual_store_dir>/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 &current == 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<String, std::collections::HashMap<String, &'a Path>>,
}
/// 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<std::borrow::Cow<'_, Path>> = 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::<node_semver::Range>().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:<alias>@<range>` — 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::<node_semver::Version>() else { return false };
let Ok(range) = range.parse::<node_semver::Range>() 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<Vec<ManifestStat<'a>>> {
project_manifests
.iter()
.map(|(root_dir, manifest)| {
file_mtime(manifest.path()).map(|mtime| ManifestStat {
root_dir: root_dir.as_path(),
manifest,
mtime,
})
})
.collect()
}
@@ -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 = &current;
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<bool>,
current_value: Option<bool>,
) -> 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<String, serde_json::Value>>,
current_value: Option<&std::collections::BTreeMap<String, serde_json::Value>>,
) -> 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,
}
}
@@ -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;
@@ -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<FileMtime> {
let metadata = fs::metadata(path).ok()?;
file_mtime_from_metadata(&metadata)
}
pub(crate) fn file_mtime_from_metadata(metadata: &fs::Metadata) -> Option<FileMtime> {
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<i64> {
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 `<virtual_store_dir>/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<i64> {
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 `<workspace_root>/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
}
}