mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-01 04:28:22 -04:00
feat(package-manager): write .pnpm-workspace-state-v1.json after install (#11665)
* feat(package-manager): write .pnpm-workspace-state-v1.json after install
pnpm's verifyDepsBeforeRun gate bails out with "Cannot check whether
dependencies are outdated" as soon as the workspace state file is
missing, so a node_modules tree materialized by pacquet always tripped
the check and forced a reinstall. Port @pnpm/workspace.state to a new
pacquet-workspace-state crate and write the file at the end of
Install::run so pnpm can fast-path the freshness check after pacquet
has done the install.
Closes the gap behind the pnpm_config_verify_deps_before_run: false
workaround in 7ff112bac6.
* chore(workspace-state): apply taplo formatting
Re-align the dependency-block padding to taplo's expected width — CI's
`taplo format --check` flagged the 13-character padding the manual draft
shipped with.
* chore(workspace-state): drop trailing comma in single-line assert_eq!
Dylint's `perfectionist::macro_trailing_comma` flags single-line macro
invocations that end with a trailing comma. Rustfmt's earlier collapse
of the multi-line assertion left the comma intact; remove it so the
nightly dylint check passes.
This commit is contained in:
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -2262,6 +2262,7 @@ dependencies = [
|
||||
"pacquet-tarball",
|
||||
"pacquet-testing-utils",
|
||||
"pacquet-workspace",
|
||||
"pacquet-workspace-state",
|
||||
"pipe-trait",
|
||||
"pretty_assertions",
|
||||
"rayon",
|
||||
@@ -2445,6 +2446,19 @@ dependencies = [
|
||||
"wax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-workspace-state"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"indexmap",
|
||||
"pacquet-diagnostics",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
|
||||
@@ -35,6 +35,7 @@ pacquet-reporter = { path = "pacquet/crates/reporter" }
|
||||
pacquet-patching = { path = "pacquet/crates/patching" }
|
||||
pacquet-real-hoist = { path = "pacquet/crates/real-hoist" }
|
||||
pacquet-workspace = { path = "pacquet/crates/workspace" }
|
||||
pacquet-workspace-state = { path = "pacquet/crates/workspace-state" }
|
||||
|
||||
# Tasks
|
||||
pacquet-registry-mock = { path = "pacquet/tasks/registry-mock" }
|
||||
|
||||
@@ -29,6 +29,7 @@ pacquet-reporter = { workspace = true }
|
||||
pacquet-store-dir = { workspace = true }
|
||||
pacquet-tarball = { workspace = true }
|
||||
pacquet-workspace = { workspace = true }
|
||||
pacquet-workspace-state = { workspace = true }
|
||||
|
||||
async-recursion = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
|
||||
@@ -21,6 +21,10 @@ use pacquet_reporter::{
|
||||
StageLog, SummaryLog,
|
||||
};
|
||||
use pacquet_tarball::MemCache;
|
||||
use pacquet_workspace_state::{
|
||||
NodeLinker as WorkspaceStateNodeLinker, ProjectEntry, UpdateWorkspaceStateError,
|
||||
WorkspaceState, WorkspaceStateSettings, now_millis, update_workspace_state,
|
||||
};
|
||||
|
||||
/// This subroutine does everything `pacquet install` is supposed to do.
|
||||
#[must_use]
|
||||
@@ -140,6 +144,15 @@ pub enum InstallError {
|
||||
|
||||
#[diagnostic(transparent)]
|
||||
FindWorkspaceDir(#[error(source)] pacquet_workspace::FindWorkspaceDirError),
|
||||
|
||||
/// Surfaces a failure to persist `.pnpm-workspace-state-v1.json`.
|
||||
/// Missing or unreadable state forces `pnpm run`'s
|
||||
/// `verifyDepsBeforeRun` check to fall back to "outdated", which
|
||||
/// is exactly the regression CI hits when pacquet runs the
|
||||
/// install — fail the install rather than letting a silent write
|
||||
/// error compound into spurious reinstalls.
|
||||
#[diagnostic(transparent)]
|
||||
WriteWorkspaceState(#[error(source)] UpdateWorkspaceStateError),
|
||||
}
|
||||
|
||||
impl<'a, DependencyGroupList> Install<'a, DependencyGroupList>
|
||||
@@ -504,6 +517,22 @@ where
|
||||
.map_err(InstallError::SaveCurrentLockfile)?;
|
||||
}
|
||||
|
||||
// Write `node_modules/.pnpm-workspace-state-v1.json`. Mirrors
|
||||
// upstream's `updateWorkspaceState` call at
|
||||
// <https://github.com/pnpm/pnpm/blob/7ff112bac6/installing/commands/src/installDeps.ts#L447-L454>.
|
||||
// pnpm's `verifyDepsBeforeRun` gate at
|
||||
// <https://github.com/pnpm/pnpm/blob/7ff112bac6/deps/status/src/checkDepsStatus.ts#L80-L86>
|
||||
// bails to "outdated" the moment this file is missing,
|
||||
// forcing `pnpm install` to rerun. Writing it after both the
|
||||
// `.modules.yaml` and the current lockfile succeed mirrors
|
||||
// pnpm's ordering and keeps the file pointing at a fully
|
||||
// committed install.
|
||||
update_workspace_state(
|
||||
&workspace_root,
|
||||
&build_workspace_state(config, node_linker, included, manifest, lockfile),
|
||||
)
|
||||
.map_err(InstallError::WriteWorkspaceState)?;
|
||||
|
||||
// `pnpm:summary` closes the install and lets the reporter render
|
||||
// the accumulated `pnpm:root` events as a "+N -M" block. Must
|
||||
// come after `importing_done`, matching pnpm's ordering at
|
||||
@@ -603,5 +632,134 @@ fn build_modules_manifest(
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate pacquet's `Config::node_linker` into the on-disk variant
|
||||
/// shared with the workspace-state writer. Same three-way set as
|
||||
/// [`map_node_linker`] but targeting [`WorkspaceStateNodeLinker`].
|
||||
fn map_workspace_state_node_linker(linker: &NodeLinker) -> WorkspaceStateNodeLinker {
|
||||
match linker {
|
||||
NodeLinker::Isolated => WorkspaceStateNodeLinker::Isolated,
|
||||
NodeLinker::Hoisted => WorkspaceStateNodeLinker::Hoisted,
|
||||
NodeLinker::Pnp => WorkspaceStateNodeLinker::Pnp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a string field off a project manifest, returning `None` when
|
||||
/// the field is missing or not a JSON string. Pnpm tolerates either
|
||||
/// shape — `name`/`version` are advisory metadata in this context, so
|
||||
/// pacquet matches by silently dropping non-string values.
|
||||
fn manifest_string_field(manifest: &PackageManifest, key: &str) -> Option<String> {
|
||||
manifest.value().get(key).and_then(|v| v.as_str()).map(ToString::to_string)
|
||||
}
|
||||
|
||||
/// Build the `projects` map for [`WorkspaceState`]. Mirrors upstream's
|
||||
/// `Object.fromEntries(opts.allProjects.map(...))` at
|
||||
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/createWorkspaceState.ts>.
|
||||
///
|
||||
/// For workspace installs (frozen-lockfile with sub-importers), pacquet
|
||||
/// reads each sub-importer's `package.json` to capture `name` / `version`
|
||||
/// the same way pnpm's `find_workspace_projects` does. The root
|
||||
/// importer (`.`) reuses the already-loaded `manifest` — re-reading it
|
||||
/// would double the I/O for no behavior change. A missing or unreadable
|
||||
/// sub-manifest is logged and skipped: pnpm would already correctly
|
||||
/// re-run install in that case (the project count won't match), so a
|
||||
/// best-effort entry beats failing the install over a transient read.
|
||||
fn build_projects_map(
|
||||
workspace_root: &std::path::Path,
|
||||
manifest: &PackageManifest,
|
||||
lockfile: Option<&Lockfile>,
|
||||
) -> BTreeMap<String, ProjectEntry> {
|
||||
let mut projects: BTreeMap<String, ProjectEntry> = BTreeMap::new();
|
||||
let root_entry = ProjectEntry {
|
||||
name: manifest_string_field(manifest, "name"),
|
||||
version: manifest_string_field(manifest, "version"),
|
||||
};
|
||||
let importer_ids: Vec<String> = match lockfile {
|
||||
Some(lf) => lf.importers.keys().cloned().collect(),
|
||||
None => vec![Lockfile::ROOT_IMPORTER_KEY.to_string()],
|
||||
};
|
||||
for importer_id in importer_ids {
|
||||
let project_dir =
|
||||
crate::symlink_direct_dependencies::importer_root_dir(workspace_root, &importer_id);
|
||||
let entry = if importer_id == Lockfile::ROOT_IMPORTER_KEY {
|
||||
root_entry.clone()
|
||||
} else {
|
||||
match PackageManifest::from_path(project_dir.join("package.json")) {
|
||||
Ok(sub_manifest) => ProjectEntry {
|
||||
name: manifest_string_field(&sub_manifest, "name"),
|
||||
version: manifest_string_field(&sub_manifest, "version"),
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
target: "pacquet::install",
|
||||
?error,
|
||||
importer_id = %importer_id,
|
||||
"Failed to read sub-importer manifest while recording workspace state",
|
||||
);
|
||||
ProjectEntry::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
projects.insert(project_dir.to_string_lossy().into_owned(), entry);
|
||||
}
|
||||
projects
|
||||
}
|
||||
|
||||
/// Assemble the [`WorkspaceState`] payload for [`update_workspace_state`].
|
||||
///
|
||||
/// Records the projects pacquet just materialized plus the resolved
|
||||
/// settings the install used. Mirrors upstream's `createWorkspaceState`
|
||||
/// at <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/createWorkspaceState.ts>.
|
||||
/// Settings pacquet does not track yet (e.g. `dedupeDirectDeps`,
|
||||
/// `peersSuffixMaxLength`, `overrides`) are omitted; pnpm's
|
||||
/// `checkDepsStatus` only iterates fields present in the serialized
|
||||
/// object, so an absent key is silently skipped rather than treated as
|
||||
/// a drift.
|
||||
fn build_workspace_state(
|
||||
config: &Config,
|
||||
node_linker: NodeLinker,
|
||||
included: IncludedDependencies,
|
||||
manifest: &PackageManifest,
|
||||
lockfile: Option<&Lockfile>,
|
||||
) -> WorkspaceState {
|
||||
let manifest_dir = manifest.path().parent().expect("manifest path always has a parent dir");
|
||||
let workspace_root = pacquet_workspace::find_workspace_dir(manifest_dir)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| manifest_dir.to_path_buf());
|
||||
|
||||
let allow_builds = (!config.allow_builds.is_empty()).then(|| {
|
||||
config.allow_builds.iter().map(|(k, v)| (k.clone(), serde_json::Value::Bool(*v))).collect()
|
||||
});
|
||||
|
||||
WorkspaceState {
|
||||
last_validated_timestamp: now_millis(),
|
||||
projects: build_projects_map(&workspace_root, manifest, lockfile),
|
||||
// Pacquet doesn't run pnpmfiles yet; record the empty list so
|
||||
// pnpm's `patchesOrHooksAreModified` doesn't trip on a missing
|
||||
// field.
|
||||
pnpmfiles: Vec::new(),
|
||||
// Pacquet has no `--filter` yet (issue #299 stage 2). Hard-code
|
||||
// `false` so pnpm doesn't treat the install as partial and
|
||||
// skip the cache.
|
||||
filtered_install: false,
|
||||
config_dependencies: None,
|
||||
settings: WorkspaceStateSettings {
|
||||
allow_builds,
|
||||
auto_install_peers: Some(config.auto_install_peers),
|
||||
dedupe_peer_dependents: Some(config.dedupe_peer_dependents),
|
||||
dev: Some(included.dev_dependencies),
|
||||
hoist_pattern: config.hoist_pattern.clone(),
|
||||
hoist_workspace_packages: Some(config.hoist_workspace_packages),
|
||||
ignored_optional_dependencies: config.ignored_optional_dependencies.clone(),
|
||||
node_linker: Some(map_workspace_state_node_linker(&node_linker)),
|
||||
optional: Some(included.optional_dependencies),
|
||||
patched_dependencies: config.patched_dependencies.clone(),
|
||||
production: Some(included.dependencies),
|
||||
public_hoist_pattern: config.public_hoist_pattern.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -13,6 +13,9 @@ use pacquet_reporter::{
|
||||
StageLog, StatsLog, StatsMessage, SummaryLog,
|
||||
};
|
||||
use pacquet_testing_utils::fs::{get_all_folders, is_symlink_or_junction};
|
||||
use pacquet_workspace_state::{
|
||||
self as workspace_state, NodeLinker as WorkspaceStateNodeLinker, load_workspace_state,
|
||||
};
|
||||
use pipe_trait::Pipe;
|
||||
use std::{path::PathBuf, sync::Mutex};
|
||||
use tempfile::tempdir;
|
||||
@@ -680,6 +683,118 @@ async fn install_writes_modules_yaml() {
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
/// `pnpm run`'s `verifyDepsBeforeRun` gate at
|
||||
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/deps/status/src/checkDepsStatus.ts#L80-L86>
|
||||
/// bails to "outdated" the moment
|
||||
/// `<workspaceDir>/node_modules/.pnpm-workspace-state-v1.json` is
|
||||
/// missing. Pacquet must write it on every install so pnpm can fast-path
|
||||
/// the check after pacquet has materialized the modules tree — that's
|
||||
/// the gap behind the
|
||||
/// [`pnpm_config_verify_deps_before_run: false`](https://github.com/pnpm/pnpm/commit/7ff112bac6)
|
||||
/// workaround in pnpm's own CI.
|
||||
#[tokio::test]
|
||||
async fn install_writes_workspace_state() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store_dir = dir.path().join("pacquet-store");
|
||||
let project_root = dir.path().join("project");
|
||||
let modules_dir = project_root.join("node_modules");
|
||||
let virtual_store_dir = modules_dir.join(".pacquet");
|
||||
|
||||
let manifest_path = dir.path().join("package.json");
|
||||
let manifest = PackageManifest::create_if_needed(manifest_path).unwrap();
|
||||
|
||||
let mut config = Config::new();
|
||||
config.lockfile = false;
|
||||
config.store_dir = store_dir.clone().into();
|
||||
config.modules_dir = modules_dir.clone();
|
||||
config.virtual_store_dir = virtual_store_dir.clone();
|
||||
let config = config.leak();
|
||||
|
||||
let lockfile: Lockfile = serde_saphyr::from_str(text_block! {
|
||||
"lockfileVersion: '9.0'"
|
||||
"importers:"
|
||||
" .:"
|
||||
" dependencies: {}"
|
||||
"packages: {}"
|
||||
"snapshots: {}"
|
||||
})
|
||||
.expect("parse minimal v9 lockfile");
|
||||
|
||||
Install {
|
||||
tarball_mem_cache: &Default::default(),
|
||||
http_client: &Default::default(),
|
||||
config,
|
||||
manifest: &manifest,
|
||||
lockfile: Some(&lockfile),
|
||||
// Same `included` shape as `install_writes_modules_yaml` so the
|
||||
// dev/optional/production assertions below line up with the
|
||||
// dispatched groups.
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
|
||||
frozen_lockfile: true,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
node_linker: pacquet_config::NodeLinker::default(),
|
||||
resolved_packages: &Default::default(),
|
||||
}
|
||||
.run::<SilentReporter>()
|
||||
.await
|
||||
.expect("frozen-lockfile install should succeed");
|
||||
|
||||
let state = load_workspace_state(dir.path())
|
||||
.expect("read workspace state")
|
||||
.expect("workspace state file exists after install");
|
||||
|
||||
assert!(
|
||||
state.last_validated_timestamp > 0,
|
||||
"lastValidatedTimestamp should be populated, got {}",
|
||||
state.last_validated_timestamp,
|
||||
);
|
||||
|
||||
// The state must record the project that pacquet just installed
|
||||
// so pnpm's `allProjects.length !== Object.keys(projects).length`
|
||||
// check passes. Single-project install → exactly one entry, keyed
|
||||
// on the workspace dir.
|
||||
assert_eq!(state.projects.len(), 1);
|
||||
let project_key = dir.path().to_string_lossy().into_owned();
|
||||
let project = state
|
||||
.projects
|
||||
.get(&project_key)
|
||||
.unwrap_or_else(|| panic!("project entry for {project_key:?} should exist"));
|
||||
assert_eq!(
|
||||
project,
|
||||
&workspace_state::ProjectEntry {
|
||||
// `PackageManifest::create_if_needed` seeds `name` from the
|
||||
// parent dir's basename and `version` from `"1.0.0"`. The
|
||||
// test pins the round-trip of both fields so a regression
|
||||
// that loses them (e.g. switching to a non-string serde
|
||||
// shape) trips here.
|
||||
name: Some(
|
||||
dir.path()
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.expect("tmpdir has a UTF-8 basename")
|
||||
.to_string()
|
||||
),
|
||||
version: Some("1.0.0".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(!state.filtered_install);
|
||||
assert!(state.pnpmfiles.is_empty());
|
||||
|
||||
let settings = &state.settings;
|
||||
assert_eq!(settings.node_linker, Some(WorkspaceStateNodeLinker::Isolated));
|
||||
assert_eq!(settings.dev, Some(false));
|
||||
assert_eq!(settings.optional, Some(true));
|
||||
assert_eq!(settings.production, Some(true));
|
||||
assert_eq!(settings.auto_install_peers, Some(true));
|
||||
assert_eq!(settings.dedupe_peer_dependents, Some(true));
|
||||
assert_eq!(settings.hoist_workspace_packages, Some(true));
|
||||
assert_eq!(settings.hoist_pattern.as_deref(), Some(&["*".to_string()][..]));
|
||||
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
/// Ports `'do not fail on an optional dependency that has a non-optional
|
||||
/// dependency with a failing postinstall script'` at
|
||||
/// <https://github.com/pnpm/pnpm/blob/b4f8f47ac2/installing/deps-installer/test/install/optionalDependencies.ts#L563-L572>.
|
||||
|
||||
23
pacquet/crates/workspace-state/Cargo.toml
Normal file
23
pacquet/crates/workspace-state/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "pacquet-workspace-state"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
authors.workspace = true
|
||||
description.workspace = true
|
||||
edition.workspace = true
|
||||
homepage.workspace = true
|
||||
keywords.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pacquet-diagnostics = { workspace = true }
|
||||
|
||||
derive_more = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
224
pacquet/crates/workspace-state/src/lib.rs
Normal file
224
pacquet/crates/workspace-state/src/lib.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
//! Read and write pnpm's `node_modules/.pnpm-workspace-state-v1.json`.
|
||||
//!
|
||||
//! Mirrors pnpm v11's `@pnpm/workspace.state` package. See upstream
|
||||
//! <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/index.ts>.
|
||||
//!
|
||||
//! The file records what an install actually used (project list,
|
||||
//! resolved settings, pnpmfiles, …) so the next `pnpm run` invocation
|
||||
//! can decide whether `node_modules` is still up to date without
|
||||
//! re-resolving anything. Mirroring the on-disk shape byte-for-byte
|
||||
//! lets pnpm read state written by pacquet — that's what closes the
|
||||
//! gap that forced
|
||||
//! [`verify-deps-before-run=false`](https://github.com/pnpm/pnpm/commit/7ff112bac6).
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use indexmap::IndexMap;
|
||||
use pacquet_diagnostics::miette::{self, Diagnostic};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// Basename of the workspace-state file, written inside `node_modules/`.
|
||||
///
|
||||
/// Matches upstream's filename at
|
||||
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/filePath.ts>.
|
||||
pub const WORKSPACE_STATE_FILENAME: &str = ".pnpm-workspace-state-v1.json";
|
||||
|
||||
/// `<workspace_dir>/node_modules/.pnpm-workspace-state-v1.json`. Same
|
||||
/// resolution as upstream's [`getFilePath`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/filePath.ts).
|
||||
pub fn get_file_path(workspace_dir: &Path) -> PathBuf {
|
||||
workspace_dir.join("node_modules").join(WORKSPACE_STATE_FILENAME)
|
||||
}
|
||||
|
||||
/// Per-project entry inside [`WorkspaceState::projects`]. Mirrors
|
||||
/// upstream's `{ name?, version? }` shape at
|
||||
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/types.ts>.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct ProjectEntry {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Typed view of `.pnpm-workspace-state-v1.json`.
|
||||
///
|
||||
/// Mirrors upstream's [`WorkspaceState`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/types.ts).
|
||||
/// `lastValidatedTimestamp` is JS `Date.now()` — milliseconds since the
|
||||
/// Unix epoch — so pnpm's freshness checks (`mtime > lastValidated`)
|
||||
/// stay consistent across the two implementations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceState {
|
||||
pub last_validated_timestamp: i64,
|
||||
pub projects: BTreeMap<String, ProjectEntry>,
|
||||
pub pnpmfiles: Vec<String>,
|
||||
pub filtered_install: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_dependencies: Option<BTreeMap<String, String>>,
|
||||
pub settings: WorkspaceStateSettings,
|
||||
}
|
||||
|
||||
/// Subset of pnpm's `Config` keys that `checkDepsStatus` compares to
|
||||
/// the live config before allowing the fast-path. Listed at
|
||||
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/types.ts>.
|
||||
///
|
||||
/// Every field is `Option` so pacquet can omit settings it does not
|
||||
/// track yet; missing keys round-trip as JSON `undefined`, which pnpm's
|
||||
/// `Object.entries(workspaceState.settings)` loop simply skips. Match
|
||||
/// what the install actually used — if pacquet's resolved value differs
|
||||
/// from pnpm's, pnpm correctly reinstalls.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceStateSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allow_builds: Option<BTreeMap<String, serde_json::Value>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_install_peers: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub catalogs: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedupe_direct_deps: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedupe_injected_deps: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedupe_peer_dependents: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedupe_peers: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dev: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exclude_links_from_lockfile: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hoist_pattern: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hoist_workspace_packages: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ignored_optional_dependencies: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub inject_workspace_packages: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link_workspace_packages: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub node_linker: Option<NodeLinker>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub optional: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub overrides: Option<BTreeMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub package_extensions: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub patched_dependencies: Option<IndexMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub peers_suffix_max_length: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prefer_workspace_packages: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub production: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub public_hoist_pattern: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_package_patterns: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Mirrors pnpm's `nodeLinker: 'hoisted' | 'isolated' | 'pnp'`. Same
|
||||
/// wire format as [`pacquet_modules_yaml::NodeLinker`](https://github.com/pnpm/pnpm/blob/7ff112bac6/installing/modules-yaml/src/index.ts);
|
||||
/// duplicated here rather than depending on `pacquet-modules-yaml` so
|
||||
/// `workspace-state` stays independent of the install pipeline.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NodeLinker {
|
||||
Hoisted,
|
||||
Isolated,
|
||||
Pnp,
|
||||
}
|
||||
|
||||
/// Error returned by [`update_workspace_state`].
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
#[non_exhaustive]
|
||||
pub enum UpdateWorkspaceStateError {
|
||||
#[display("Failed to create directory {path:?}: {source}")]
|
||||
#[diagnostic(code(pacquet_workspace_state::create_dir))]
|
||||
CreateDir { path: PathBuf, source: io::Error },
|
||||
|
||||
#[display("Failed to serialize workspace state: {_0}")]
|
||||
#[diagnostic(code(pacquet_workspace_state::serialize_json))]
|
||||
SerializeJson(serde_json::Error),
|
||||
|
||||
#[display("Failed to write {path:?}: {source}")]
|
||||
#[diagnostic(code(pacquet_workspace_state::write_io))]
|
||||
WriteFile { path: PathBuf, source: io::Error },
|
||||
}
|
||||
|
||||
/// Write `state` to `<workspace_dir>/node_modules/.pnpm-workspace-state-v1.json`.
|
||||
///
|
||||
/// Mirrors upstream's [`updateWorkspaceState`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/updateWorkspaceState.ts):
|
||||
/// `JSON.stringify(state, undefined, 2) + '\n'`. `serde_json`'s pretty
|
||||
/// printer uses the same 2-space indent and `": "` separator as JS, so
|
||||
/// the on-disk bytes round-trip cleanly between the two writers.
|
||||
pub fn update_workspace_state(
|
||||
workspace_dir: &Path,
|
||||
state: &WorkspaceState,
|
||||
) -> Result<(), UpdateWorkspaceStateError> {
|
||||
let file_path = get_file_path(workspace_dir);
|
||||
let parent = file_path.parent().expect("workspace-state path always has a parent");
|
||||
fs::create_dir_all(parent).map_err(|source| UpdateWorkspaceStateError::CreateDir {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let mut serialized =
|
||||
serde_json::to_string_pretty(state).map_err(UpdateWorkspaceStateError::SerializeJson)?;
|
||||
serialized.push('\n');
|
||||
fs::write(&file_path, serialized.as_bytes())
|
||||
.map_err(|source| UpdateWorkspaceStateError::WriteFile { path: file_path, source })
|
||||
}
|
||||
|
||||
/// Read the workspace state file at `<workspace_dir>/node_modules/.pnpm-workspace-state-v1.json`.
|
||||
///
|
||||
/// Returns `Ok(None)` when the file does not exist, matching upstream's
|
||||
/// [`loadWorkspaceState`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/loadWorkspaceState.ts).
|
||||
pub fn load_workspace_state(
|
||||
workspace_dir: &Path,
|
||||
) -> Result<Option<WorkspaceState>, LoadWorkspaceStateError> {
|
||||
let file_path = get_file_path(workspace_dir);
|
||||
let text = match fs::read_to_string(&file_path) {
|
||||
Ok(text) => text,
|
||||
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(source) => {
|
||||
return Err(LoadWorkspaceStateError::ReadFile { path: file_path, source });
|
||||
}
|
||||
};
|
||||
serde_json::from_str(&text)
|
||||
.map(Some)
|
||||
.map_err(|source| LoadWorkspaceStateError::ParseJson { path: file_path, source })
|
||||
}
|
||||
|
||||
/// Error returned by [`load_workspace_state`].
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
#[non_exhaustive]
|
||||
pub enum LoadWorkspaceStateError {
|
||||
#[display("Failed to read {path:?}: {source}")]
|
||||
#[diagnostic(code(pacquet_workspace_state::read_io))]
|
||||
ReadFile { path: PathBuf, source: io::Error },
|
||||
|
||||
#[display("Failed to parse {path:?}: {source}")]
|
||||
#[diagnostic(code(pacquet_workspace_state::parse_json))]
|
||||
ParseJson { path: PathBuf, source: serde_json::Error },
|
||||
}
|
||||
|
||||
/// Wall-clock milliseconds since the Unix epoch, matching JS
|
||||
/// `Date.now()` and the `lastValidatedTimestamp` value pnpm writes at
|
||||
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/createWorkspaceState.ts>.
|
||||
///
|
||||
/// Truncates to `i64` because the JSON field is signed and the year
|
||||
/// 2038-pre-292277026596 range is the only one that matters.
|
||||
pub fn now_millis() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
97
pacquet/crates/workspace-state/src/tests.rs
Normal file
97
pacquet/crates/workspace-state/src/tests.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use super::{
|
||||
NodeLinker, ProjectEntry, WorkspaceState, WorkspaceStateSettings, get_file_path,
|
||||
load_workspace_state, now_millis, update_workspace_state,
|
||||
};
|
||||
use indexmap::IndexMap;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn file_path_matches_upstream() {
|
||||
let dir = std::path::Path::new("/tmp/example");
|
||||
assert_eq!(get_file_path(dir), dir.join("node_modules").join(".pnpm-workspace-state-v1.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_load_round_trip() {
|
||||
let tmp = tempdir().expect("create temp dir");
|
||||
let workspace_dir = tmp.path();
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
workspace_dir.to_string_lossy().into_owned(),
|
||||
ProjectEntry { name: Some("my-pkg".into()), version: Some("1.2.3".into()) },
|
||||
);
|
||||
|
||||
let mut patched = IndexMap::new();
|
||||
patched.insert("some-pkg".to_string(), "patches/some-pkg.patch".to_string());
|
||||
|
||||
let state = WorkspaceState {
|
||||
last_validated_timestamp: now_millis(),
|
||||
projects,
|
||||
pnpmfiles: vec![],
|
||||
filtered_install: false,
|
||||
config_dependencies: None,
|
||||
settings: WorkspaceStateSettings {
|
||||
auto_install_peers: Some(true),
|
||||
dedupe_peer_dependents: Some(true),
|
||||
dev: Some(true),
|
||||
hoist_pattern: Some(vec!["*".into()]),
|
||||
hoist_workspace_packages: Some(true),
|
||||
node_linker: Some(NodeLinker::Isolated),
|
||||
optional: Some(true),
|
||||
patched_dependencies: Some(patched.clone()),
|
||||
production: Some(true),
|
||||
public_hoist_pattern: Some(vec![]),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
update_workspace_state(workspace_dir, &state).expect("write state");
|
||||
|
||||
let path = get_file_path(workspace_dir);
|
||||
assert!(path.is_file(), "state file should exist at {path:?}");
|
||||
|
||||
let on_disk = std::fs::read_to_string(&path).expect("read state");
|
||||
assert!(on_disk.ends_with('\n'), "upstream appends a trailing newline");
|
||||
|
||||
let loaded = load_workspace_state(workspace_dir).expect("load state").expect("file present");
|
||||
assert_eq!(loaded, state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_none_when_missing() {
|
||||
let tmp = tempdir().expect("create temp dir");
|
||||
let loaded = load_workspace_state(tmp.path()).expect("missing state is not an error");
|
||||
assert!(loaded.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_settings_that_are_none() {
|
||||
let state = WorkspaceState {
|
||||
last_validated_timestamp: 0,
|
||||
projects: BTreeMap::new(),
|
||||
pnpmfiles: vec![],
|
||||
filtered_install: false,
|
||||
config_dependencies: None,
|
||||
settings: WorkspaceStateSettings { auto_install_peers: Some(true), ..Default::default() },
|
||||
};
|
||||
let serialized = serde_json::to_string(&state).expect("serialize");
|
||||
// Only the populated setting should show up.
|
||||
assert!(serialized.contains("\"autoInstallPeers\":true"), "got: {serialized}");
|
||||
assert!(!serialized.contains("dedupePeerDependents"), "got: {serialized}");
|
||||
assert!(!serialized.contains("nodeLinker"), "got: {serialized}");
|
||||
// Top-level optional keys should also be omitted.
|
||||
assert!(!serialized.contains("configDependencies"), "got: {serialized}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_linker_serializes_lowercase() {
|
||||
let value = serde_json::to_value(NodeLinker::Isolated).expect("serialize");
|
||||
assert_eq!(value, serde_json::Value::from("isolated"));
|
||||
let value = serde_json::to_value(NodeLinker::Hoisted).expect("serialize");
|
||||
assert_eq!(value, serde_json::Value::from("hoisted"));
|
||||
let value = serde_json::to_value(NodeLinker::Pnp).expect("serialize");
|
||||
assert_eq!(value, serde_json::Value::from("pnp"));
|
||||
}
|
||||
Reference in New Issue
Block a user