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:
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(¤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 (`<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 ¤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<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 = ¤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<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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user