From 97e1982d2884cf8394162f13a6942d04e78bdcd5 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Mon, 8 Jun 2026 18:15:49 +0300 Subject: [PATCH] fix: detect enableGlobalVirtualStore toggle in workspace state check (#12147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: detect enableGlobalVirtualStore toggle in workspace state check (#12142) The optimisticRepeatInstall fast path bypasses validateModules entirely, so toggling enableGlobalVirtualStore off was not detected — pnpm printed "Already up to date" without reinstalling deps in the new virtual store location. Add enableGlobalVirtualStore to WORKSPACE_STATE_SETTING_KEYS so checkDepsStatus detects the change and falls through to the full mutateModules path (which includes the virtualStoreDir compatibility check and purge). * refactor: bidirectional settings comparison in checkDepsStatus Iterate over WORKSPACE_STATE_SETTING_KEYS instead of Object.entries(workspaceState.settings) so that settings absent from legacy state files (e.g. enableGlobalVirtualStore) are still compared against the current config. Removes the ad-hoc allowBuilds null-check block since the main loop now covers it with the ?? {} coalescing. * fix(workspace-state): track enableGlobalVirtualStore in pacquet state Port pnpm/pnpm#12147 to pacquet. pnpm's checkDepsStatus now iterates the full WORKSPACE_STATE_SETTING_KEYS list, so a key pacquet omits from .pnpm-workspace-state-v1.json is read as `undefined` and compared against pnpm's resolved config. `enableGlobalVirtualStore` resolves to a concrete default (true, or false under CI), so omitting it would make pnpm reinstall after a pacquet install — and pacquet itself never detected the toggle (its own copy of pnpm/pnpm#12142). Add `enable_global_virtual_store` to WorkspaceStateSettings; current_settings writes `then_some(true)` (omit when off, mirroring pnpm omitting its undefined default); settings_match coerces `None`/`Some(false)` as equal before comparing, matching the existing allow_builds/package_extensions handling. --------- Co-authored-by: Zoltan Kochan --- .changeset/gvs-toggle-detection.md | 7 + deps/status/src/checkDepsStatus.ts | 25 +-- deps/status/test/checkDepsStatus.test.ts | 62 ++++++ .../src/optimistic_repeat_install.rs | 94 +++++--- .../src/optimistic_repeat_install/tests.rs | 207 +++++++++++++++++- pacquet/crates/workspace-state/src/lib.rs | 27 ++- workspace/state/src/types.ts | 1 + 7 files changed, 367 insertions(+), 56 deletions(-) create mode 100644 .changeset/gvs-toggle-detection.md diff --git a/.changeset/gvs-toggle-detection.md b/.changeset/gvs-toggle-detection.md new file mode 100644 index 0000000000..be04dd5ca9 --- /dev/null +++ b/.changeset/gvs-toggle-detection.md @@ -0,0 +1,7 @@ +--- +"@pnpm/workspace.state": patch +"@pnpm/deps.status": patch +"pnpm": patch +--- + +Fix `pnpm install` ignoring `enableGlobalVirtualStore` toggle by including it in the workspace state settings check [#12142](https://github.com/pnpm/pnpm/issues/12142). diff --git a/deps/status/src/checkDepsStatus.ts b/deps/status/src/checkDepsStatus.ts index c7a61cd3b7..c29d545fe3 100644 --- a/deps/status/src/checkDepsStatus.ts +++ b/deps/status/src/checkDepsStatus.ts @@ -34,7 +34,7 @@ import type { ProjectManifest, } from '@pnpm/types' import { findWorkspaceProjectsNoCheck } from '@pnpm/workspace.projects-reader' -import { loadWorkspaceState, updateWorkspaceState, type WorkspaceState, type WorkspaceStateSettings } from '@pnpm/workspace.state' +import { loadWorkspaceState, updateWorkspaceState, WORKSPACE_STATE_SETTING_KEYS, type WorkspaceState, type WorkspaceStateSettings } from '@pnpm/workspace.state' import { readWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-reader' import { equals, filter, isEmpty, once } from 'ramda' @@ -134,13 +134,16 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W if (workspaceState.settings) { const ignoredSettings = new Set(opts.ignoredWorkspaceStateSettings) - ignoredSettings.add('catalogs') // 'catalogs' is always ignored - for (const [settingName, settingValue] of Object.entries(workspaceState.settings)) { + ignoredSettings.add('catalogs') + for (const settingName of WORKSPACE_STATE_SETTING_KEYS) { if (ignoredSettings.has(settingName as keyof WorkspaceStateSettings)) continue - const currentSettingValue = settingName === 'allowBuilds' + const storedValue = settingName === 'allowBuilds' + ? workspaceState.settings[settingName] ?? {} + : workspaceState.settings[settingName as keyof WorkspaceStateSettings] + const currentValue = settingName === 'allowBuilds' ? opts.allowBuilds ?? {} : opts[settingName as keyof WorkspaceStateSettings] - if (!equals(settingValue, currentSettingValue)) { + if (!equals(storedValue, currentValue)) { return { upToDate: false, issue: `The value of the ${settingName} setting has changed`, @@ -148,18 +151,6 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W } } } - if ( - !ignoredSettings.has('allowBuilds') && - workspaceState.settings.allowBuilds == null && - opts.allowBuilds != null && - !isEmpty(opts.allowBuilds) - ) { - return { - upToDate: false, - issue: 'The value of the allowBuilds setting has changed', - workspaceState, - } - } } if ((opts.configDependencies != null || workspaceState.configDependencies != null) && !equals(opts.configDependencies ?? {}, workspaceState.configDependencies ?? {})) { return { diff --git a/deps/status/test/checkDepsStatus.test.ts b/deps/status/test/checkDepsStatus.test.ts index abef4c9074..be64853d72 100644 --- a/deps/status/test/checkDepsStatus.test.ts +++ b/deps/status/test/checkDepsStatus.test.ts @@ -259,6 +259,65 @@ describe('checkDepsStatus - settings change detection', () => { expect(result.issue).not.toBe('The value of the allowBuilds setting has changed') }) + + it('returns upToDate: false when enableGlobalVirtualStore is toggled off', async () => { + const lastValidatedTimestamp = Date.now() - 10_000 + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + enableGlobalVirtualStore: true, + }, + projects: {}, + filteredInstall: false, + } + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + + const opts: CheckDepsStatusOptions = { + rootProjectManifest: {}, + rootProjectManifestDir: '/project', + pnpmfile: [], + ...mockWorkspaceState.settings, + enableGlobalVirtualStore: false, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe('The value of the enableGlobalVirtualStore setting has changed') + }) + + it('returns upToDate: false when enableGlobalVirtualStore is toggled on from a legacy state file that lacks the key', async () => { + const lastValidatedTimestamp = Date.now() - 10_000 + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + }, + projects: {}, + filteredInstall: false, + } + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + + const opts: CheckDepsStatusOptions = { + rootProjectManifest: {}, + rootProjectManifestDir: '/project', + pnpmfile: [], + ...mockWorkspaceState.settings, + enableGlobalVirtualStore: true, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe('The value of the enableGlobalVirtualStore setting has changed') + }) }) describe('checkDepsStatus - pnpmfile modification', () => { @@ -338,6 +397,9 @@ describe('checkDepsStatus - pnpmfile modification', () => { excludeLinksFromLockfile: false, linkWorkspacePackages: true, preferWorkspacePackages: true, + patchedDependencies: { + foo: '/project/patches/foo.patch', + }, }, projects: { [projectRootDir]: { diff --git a/pacquet/crates/package-manager/src/optimistic_repeat_install.rs b/pacquet/crates/package-manager/src/optimistic_repeat_install.rs index a1e17e1264..c19db56a12 100644 --- a/pacquet/crates/package-manager/src/optimistic_repeat_install.rs +++ b/pacquet/crates/package-manager/src/optimistic_repeat_install.rs @@ -165,27 +165,26 @@ pub fn check_optimistic_repeat_install( /// Compare today's settings against what the previous install /// recorded. /// -/// Only the fields pacquet actively populates via [`current_settings`] -/// participate in the comparison. Fields the upstream pnpm CLI writes -/// but pacquet hasn't ported yet (e.g. `excludeLinksFromLockfile`) are -/// ignored — pacquet doesn't consume them during install, so a -/// difference can't affect the materialised `node_modules`. Without -/// this carve-out a cross-package-manager scenario (pnpm wrote the -/// state, pacquet reads it next) would always reject the fast path -/// because pnpm's defaults fill those fields while pacquet's -/// `current_settings` leaves them `None`. +/// 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. /// -/// As each ported setting in pnpm/pnpm#12009 lands end-to-end and -/// gets surfaced through `current_settings`, it joins the comparison -/// here automatically. -/// -/// Mirrors pnpm's `Object.entries(workspaceState.settings)` walk in -/// [`checkDepsStatus`](https://github.com/pnpm/pnpm/blob/72d997cc34/deps/status/src/checkDepsStatus.ts): -/// pnpm iterates fields *in the state*, which by symmetry only -/// includes fields the writer cared about. The `allowBuilds` coercion -/// mirrors pnpm's [`opts.allowBuilds ?? {}`](https://github.com/pnpm/pnpm/blob/72d997cc34/deps/status/src/checkDepsStatus.ts#L141) -/// on the read side and pnpm's tolerance of an absent -/// `allowBuilds` key in the recorded state on the write side. +/// pnpm's [`checkDepsStatus`](https://github.com/pnpm/pnpm/blob/20f9362161/deps/status/src/checkDepsStatus.ts#L138) +/// 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 mirrors +/// pnpm's [`opts.allowBuilds ?? {}`](https://github.com/pnpm/pnpm/blob/20f9362161/deps/status/src/checkDepsStatus.ts#L143) +/// on the read side and pnpm's tolerance of an absent `allowBuilds` key +/// in the recorded state on the write side. fn settings_match( state: &WorkspaceState, config: &Config, @@ -202,11 +201,19 @@ fn settings_match( && recorded.dedupe_peer_dependents == live.dedupe_peer_dependents && recorded.dedupe_peers == live.dedupe_peers && recorded.dev == live.dev + && enable_global_virtual_store_match( + recorded.enable_global_virtual_store, + live.enable_global_virtual_store, + ) + && recorded.exclude_links_from_lockfile == live.exclude_links_from_lockfile && recorded.hoist_pattern == live.hoist_pattern && recorded.hoist_workspace_packages == live.hoist_workspace_packages && recorded.ignored_optional_dependencies == live.ignored_optional_dependencies && recorded.inject_workspace_packages == live.inject_workspace_packages && recorded.link_workspace_packages == live.link_workspace_packages + && recorded.minimum_release_age == live.minimum_release_age + && recorded.minimum_release_age_ignore_missing_time + == live.minimum_release_age_ignore_missing_time && recorded.node_linker == live.node_linker && recorded.optional == live.optional && recorded.overrides == live.overrides @@ -219,17 +226,36 @@ fn settings_match( && recorded.prefer_workspace_packages == live.prefer_workspace_packages && recorded.production == live.production && recorded.public_hoist_pattern == live.public_hoist_pattern - // Deliberately *not* compared (tracked at pnpm/pnpm#12009 — drop - // each from this list once `current_settings` writes its value): + // Deliberately *not* compared. pnpm leaves the first group + // `undefined` by default, so omitting them here still matches pnpm's + // all-key freshness check (`undefined == undefined`): // catalogs (pnpm always ignores; see // ignoredSettings.add('catalogs')) - // excludeLinksFromLockfile - // minimumReleaseAge* (pacquet supports it but doesn't - // round-trip through workspace state - // yet — separate follow-up). - // trustPolicy* (same situation as minimumReleaseAge) - // workspacePackagePatterns (already covered via - // pnpm-workspace.yaml `packages:`) + // minimumReleaseAgeStrict (pnpm sets it only when the user + // explicitly sets minimumReleaseAge) + // minimumReleaseAgeExclude + // trustPolicy* (all `undefined` until configured) + // workspacePackagePatterns (concrete for a multi-package + // workspace, but lives in the + // workspace manifest, not `Config`; + // threading it into `current_settings` + // is a separate follow-up. pacquet + // detects project-set changes via + // `project_structure_matches`). +} + +/// `enableGlobalVirtualStore` has no `?? default` coercion on pnpm's +/// read side, but its `undefined` default and an explicit `false` both +/// mean "global virtual store off". pnpm omits the key for the former +/// and records `false` only when CI forces it; pacquet omits both. +/// Normalize the absent and `false` forms before comparing so a +/// pnpm-written file (omitted or `false`) matches a pacquet install +/// with the store off, while a real `true`/`false` flip still trips. +fn enable_global_virtual_store_match( + state_value: Option, + current_value: Option, +) -> bool { + state_value.unwrap_or(false) == current_value.unwrap_or(false) } /// Pnpm writes `Some({})` for an empty `allowBuilds`; pacquet writes @@ -290,6 +316,12 @@ pub(crate) fn current_settings( 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(), @@ -297,6 +329,10 @@ pub(crate) fn current_settings( 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 diff --git a/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs b/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs index 0b274a5c19..a0be38e519 100644 --- a/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs +++ b/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs @@ -338,6 +338,204 @@ fn returns_skipped_when_inject_workspace_packages_drifts() { assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings"))); } +/// Drift in `enableGlobalVirtualStore` invalidates the cached state. +/// Toggling it moves the virtual store between `/links` and +/// each project's `node_modules/.pnpm`, so the previous install's +/// layout no longer matches a fresh resolution. Mirrors pnpm's fix for +/// [#12142](https://github.com/pnpm/pnpm/issues/12142): the toggle was +/// invisible to the freshness check until the key joined the comparison. +#[test] +fn returns_skipped_when_enable_global_virtual_store_drifts() { + let dir = tempdir().unwrap(); + let workspace_root = dir.path(); + let manifest_path = workspace_root.join("package.json"); + fs::write(&manifest_path, r#"{"name":"root","version":"1.0.0"}"#).unwrap(); + let manifest = PackageManifest::from_path(manifest_path).unwrap(); + + let mut config = Config::new(); + config.modules_dir = workspace_root.join("node_modules"); + fs::create_dir_all(&config.modules_dir).unwrap(); + config.enable_global_virtual_store = true; + let config = config.leak(); + + let mut stale_config = Config::new(); + stale_config.modules_dir = config.modules_dir.clone(); + stale_config.enable_global_virtual_store = false; + let stale_settings = + current_settings(&stale_config, pacquet_config::NodeLinker::Isolated, isolated_included()); + let mut projects = BTreeMap::new(); + projects.insert( + workspace_root.to_string_lossy().into_owned(), + ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) }, + ); + write_state(workspace_root, now_millis() + 60_000, stale_settings, projects); + + let decision = check_optimistic_repeat_install( + workspace_root, + config, + pacquet_config::NodeLinker::Isolated, + isolated_included(), + &[(workspace_root.to_path_buf(), &manifest)], + false, + ); + assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings"))); +} + +/// A pnpm-written state that records `enableGlobalVirtualStore: false` +/// (the value pnpm forces under CI) stays on the fast path for a pacquet +/// install with the store off, which omits the key. `false` and the +/// omitted `None` are the same "store off" state, so the coercion in +/// `enable_global_virtual_store_match` keeps the cross-package-manager +/// file from tripping a needless reinstall. +#[test] +fn returns_up_to_date_when_recorded_global_virtual_store_is_explicit_off() { + let (dir, config, manifest) = + setup_fresh_install(pacquet_config::NodeLinker::Isolated, "root", "1.0.0", ""); + + let mut settings = + current_settings(config, pacquet_config::NodeLinker::Isolated, isolated_included()); + settings.enable_global_virtual_store = Some(false); + let mut projects = BTreeMap::new(); + projects.insert( + dir.path().to_string_lossy().into_owned(), + ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) }, + ); + write_state(dir.path(), now_millis(), settings, projects); + + let decision = check_optimistic_repeat_install( + dir.path(), + config, + pacquet_config::NodeLinker::Isolated, + isolated_included(), + &[(dir.path().to_path_buf(), &manifest)], + false, + ); + assert_eq!(decision, Decision::UpToDate); +} + +/// Drift in `excludeLinksFromLockfile` invalidates the cached state. +/// pnpm resolves it to a concrete `false` default and records it, so +/// pacquet must record and compare it too — otherwise pnpm's all-key +/// freshness check reports drift on every command after a pacquet +/// install. +#[test] +fn returns_skipped_when_exclude_links_from_lockfile_drifts() { + let dir = tempdir().unwrap(); + let workspace_root = dir.path(); + let manifest_path = workspace_root.join("package.json"); + fs::write(&manifest_path, r#"{"name":"root","version":"1.0.0"}"#).unwrap(); + let manifest = PackageManifest::from_path(manifest_path).unwrap(); + + let mut config = Config::new(); + config.modules_dir = workspace_root.join("node_modules"); + fs::create_dir_all(&config.modules_dir).unwrap(); + config.exclude_links_from_lockfile = true; + let config = config.leak(); + + let mut stale_config = Config::new(); + stale_config.modules_dir = config.modules_dir.clone(); + stale_config.exclude_links_from_lockfile = false; + let stale_settings = + current_settings(&stale_config, pacquet_config::NodeLinker::Isolated, isolated_included()); + let mut projects = BTreeMap::new(); + projects.insert( + workspace_root.to_string_lossy().into_owned(), + ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) }, + ); + write_state(workspace_root, now_millis() + 60_000, stale_settings, projects); + + let decision = check_optimistic_repeat_install( + workspace_root, + config, + pacquet_config::NodeLinker::Isolated, + isolated_included(), + &[(workspace_root.to_path_buf(), &manifest)], + false, + ); + assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings"))); +} + +/// Drift in `minimumReleaseAge` invalidates the cached state. pnpm +/// resolves it to a concrete `1440` default and records it verbatim +/// (the raw value, not the `Some(0)`-disabled resolution), so pacquet +/// records and compares the raw value too. +#[test] +fn returns_skipped_when_minimum_release_age_drifts() { + let dir = tempdir().unwrap(); + let workspace_root = dir.path(); + let manifest_path = workspace_root.join("package.json"); + fs::write(&manifest_path, r#"{"name":"root","version":"1.0.0"}"#).unwrap(); + let manifest = PackageManifest::from_path(manifest_path).unwrap(); + + let mut config = Config::new(); + config.modules_dir = workspace_root.join("node_modules"); + fs::create_dir_all(&config.modules_dir).unwrap(); + config.minimum_release_age = Some(2880); + let config = config.leak(); + + let mut stale_config = Config::new(); + stale_config.modules_dir = config.modules_dir.clone(); + stale_config.minimum_release_age = Some(1440); + let stale_settings = + current_settings(&stale_config, pacquet_config::NodeLinker::Isolated, isolated_included()); + let mut projects = BTreeMap::new(); + projects.insert( + workspace_root.to_string_lossy().into_owned(), + ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) }, + ); + write_state(workspace_root, now_millis() + 60_000, stale_settings, projects); + + let decision = check_optimistic_repeat_install( + workspace_root, + config, + pacquet_config::NodeLinker::Isolated, + isolated_included(), + &[(workspace_root.to_path_buf(), &manifest)], + false, + ); + assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings"))); +} + +/// Drift in `minimumReleaseAgeIgnoreMissingTime` invalidates the cached +/// state. pnpm resolves it to a concrete `true` default and records it, +/// so pacquet records and compares it too. +#[test] +fn returns_skipped_when_minimum_release_age_ignore_missing_time_drifts() { + let dir = tempdir().unwrap(); + let workspace_root = dir.path(); + let manifest_path = workspace_root.join("package.json"); + fs::write(&manifest_path, r#"{"name":"root","version":"1.0.0"}"#).unwrap(); + let manifest = PackageManifest::from_path(manifest_path).unwrap(); + + let mut config = Config::new(); + config.modules_dir = workspace_root.join("node_modules"); + fs::create_dir_all(&config.modules_dir).unwrap(); + config.minimum_release_age_ignore_missing_time = false; + let config = config.leak(); + + let mut stale_config = Config::new(); + stale_config.modules_dir = config.modules_dir.clone(); + stale_config.minimum_release_age_ignore_missing_time = true; + let stale_settings = + current_settings(&stale_config, pacquet_config::NodeLinker::Isolated, isolated_included()); + let mut projects = BTreeMap::new(); + projects.insert( + workspace_root.to_string_lossy().into_owned(), + ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) }, + ); + write_state(workspace_root, now_millis() + 60_000, stale_settings, projects); + + let decision = check_optimistic_repeat_install( + workspace_root, + config, + pacquet_config::NodeLinker::Isolated, + isolated_included(), + &[(workspace_root.to_path_buf(), &manifest)], + false, + ); + assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings"))); +} + /// Drift in `ignoredOptionalDependencies` invalidates the cached /// state. /// @@ -721,12 +919,9 @@ fn returns_up_to_date_when_state_carries_unported_pnpm_settings() { let mut settings = current_settings(config, pacquet_config::NodeLinker::Isolated, isolated_included()); - // Populate every field pacquet doesn't surface through - // `current_settings` today. Each is an upstream pnpm setting - // listed in pnpm/pnpm#12009. - settings.exclude_links_from_lockfile = Some(false); - // `catalogs` is always ignored by pnpm itself; pacquet - // mirrors that. + // Populate fields pacquet records but `settings_match` does not + // compare, to prove a difference on them keeps the fast path. + // `catalogs` is always ignored by pnpm itself; pacquet mirrors that. settings.catalogs = Some(serde_json::json!({"default": {"react": "^18.0.0"}})); // `workspacePackagePatterns` is recorded by pnpm from // pnpm-workspace.yaml's `packages:` field, which pacquet diff --git a/pacquet/crates/workspace-state/src/lib.rs b/pacquet/crates/workspace-state/src/lib.rs index 79892f9d5b..20804c0a25 100644 --- a/pacquet/crates/workspace-state/src/lib.rs +++ b/pacquet/crates/workspace-state/src/lib.rs @@ -91,10 +91,11 @@ pub struct WorkspaceState { /// . /// /// 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. +/// track yet. pnpm iterates the full `WORKSPACE_STATE_SETTING_KEYS` +/// list and reads an omitted key as `undefined`, so a key pacquet omits +/// stays compatible only while pnpm's resolved value for it is also +/// `undefined`. Match what the install actually used — if pacquet's +/// resolved value differs from pnpm's, pnpm correctly reinstalls. #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceStateSettings { @@ -114,6 +115,13 @@ pub struct WorkspaceStateSettings { pub dedupe_peers: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dev: Option, + /// `None` and `Some(false)` both mean "global virtual store off" — + /// pnpm omits the key for its `undefined` default and only writes a + /// concrete value when `--global` (always `true`) or CI (`false`) + /// forces one. The freshness check coerces the two off-forms before + /// comparing (see `enable_global_virtual_store_match`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_global_virtual_store: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub exclude_links_from_lockfile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -126,6 +134,17 @@ pub struct WorkspaceStateSettings { pub inject_workspace_packages: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub link_workspace_packages: Option, + /// Minutes a published version must age before it may be installed. + /// pnpm resolves this to a concrete `24 * 60` default, so it must be + /// recorded for pnpm's all-key freshness check to stay on the fast + /// path after a pacquet install. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimum_release_age: Option, + /// Whether versions whose registry metadata lacks a `time` field + /// pass the maturity check. pnpm defaults this to `true`, so it is + /// recorded for the same reason as [`Self::minimum_release_age`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimum_release_age_ignore_missing_time: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub node_linker: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/workspace/state/src/types.ts b/workspace/state/src/types.ts index 289975bafb..6ff85ce2b3 100644 --- a/workspace/state/src/types.ts +++ b/workspace/state/src/types.ts @@ -16,6 +16,7 @@ export interface WorkspaceState { } export const WORKSPACE_STATE_SETTING_KEYS = [ + 'enableGlobalVirtualStore', 'allowBuilds', 'autoInstallPeers', 'catalogs',