mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-29 02:15:26 -04:00
fix: detect enableGlobalVirtualStore toggle in workspace state check (#12147)
* 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 <z@kochan.io>
This commit is contained in:
7
.changeset/gvs-toggle-detection.md
Normal file
7
.changeset/gvs-toggle-detection.md
Normal file
@@ -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).
|
||||
25
deps/status/src/checkDepsStatus.ts
vendored
25
deps/status/src/checkDepsStatus.ts
vendored
@@ -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<keyof WorkspaceStateSettings>(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 {
|
||||
|
||||
62
deps/status/test/checkDepsStatus.test.ts
vendored
62
deps/status/test/checkDepsStatus.test.ts
vendored
@@ -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]: {
|
||||
|
||||
@@ -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<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
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<storeDir>/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
|
||||
|
||||
@@ -91,10 +91,11 @@ pub struct WorkspaceState {
|
||||
/// <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.
|
||||
/// 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<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dev: Option<bool>,
|
||||
/// `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<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exclude_links_from_lockfile: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -126,6 +134,17 @@ pub struct WorkspaceStateSettings {
|
||||
pub inject_workspace_packages: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link_workspace_packages: Option<serde_json::Value>,
|
||||
/// 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<u64>,
|
||||
/// 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<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub node_linker: Option<NodeLinker>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface WorkspaceState {
|
||||
}
|
||||
|
||||
export const WORKSPACE_STATE_SETTING_KEYS = [
|
||||
'enableGlobalVirtualStore',
|
||||
'allowBuilds',
|
||||
'autoInstallPeers',
|
||||
'catalogs',
|
||||
|
||||
Reference in New Issue
Block a user