mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-02 20:05:14 -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,
|
ProjectManifest,
|
||||||
} from '@pnpm/types'
|
} from '@pnpm/types'
|
||||||
import { findWorkspaceProjectsNoCheck } from '@pnpm/workspace.projects-reader'
|
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 { readWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-reader'
|
||||||
import { equals, filter, isEmpty, once } from 'ramda'
|
import { equals, filter, isEmpty, once } from 'ramda'
|
||||||
|
|
||||||
@@ -134,13 +134,16 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
|
|||||||
|
|
||||||
if (workspaceState.settings) {
|
if (workspaceState.settings) {
|
||||||
const ignoredSettings = new Set<keyof WorkspaceStateSettings>(opts.ignoredWorkspaceStateSettings)
|
const ignoredSettings = new Set<keyof WorkspaceStateSettings>(opts.ignoredWorkspaceStateSettings)
|
||||||
ignoredSettings.add('catalogs') // 'catalogs' is always ignored
|
ignoredSettings.add('catalogs')
|
||||||
for (const [settingName, settingValue] of Object.entries(workspaceState.settings)) {
|
for (const settingName of WORKSPACE_STATE_SETTING_KEYS) {
|
||||||
if (ignoredSettings.has(settingName as keyof WorkspaceStateSettings)) continue
|
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.allowBuilds ?? {}
|
||||||
: opts[settingName as keyof WorkspaceStateSettings]
|
: opts[settingName as keyof WorkspaceStateSettings]
|
||||||
if (!equals(settingValue, currentSettingValue)) {
|
if (!equals(storedValue, currentValue)) {
|
||||||
return {
|
return {
|
||||||
upToDate: false,
|
upToDate: false,
|
||||||
issue: `The value of the ${settingName} setting has changed`,
|
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 ?? {})) {
|
if ((opts.configDependencies != null || workspaceState.configDependencies != null) && !equals(opts.configDependencies ?? {}, workspaceState.configDependencies ?? {})) {
|
||||||
return {
|
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')
|
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', () => {
|
describe('checkDepsStatus - pnpmfile modification', () => {
|
||||||
@@ -338,6 +397,9 @@ describe('checkDepsStatus - pnpmfile modification', () => {
|
|||||||
excludeLinksFromLockfile: false,
|
excludeLinksFromLockfile: false,
|
||||||
linkWorkspacePackages: true,
|
linkWorkspacePackages: true,
|
||||||
preferWorkspacePackages: true,
|
preferWorkspacePackages: true,
|
||||||
|
patchedDependencies: {
|
||||||
|
foo: '/project/patches/foo.patch',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
[projectRootDir]: {
|
[projectRootDir]: {
|
||||||
|
|||||||
@@ -165,27 +165,26 @@ pub fn check_optimistic_repeat_install(
|
|||||||
/// Compare today's settings against what the previous install
|
/// Compare today's settings against what the previous install
|
||||||
/// recorded.
|
/// recorded.
|
||||||
///
|
///
|
||||||
/// Only the fields pacquet actively populates via [`current_settings`]
|
/// Only the fields pacquet populates via [`current_settings`]
|
||||||
/// participate in the comparison. Fields the upstream pnpm CLI writes
|
/// participate in the comparison; the rest are listed at the end of
|
||||||
/// but pacquet hasn't ported yet (e.g. `excludeLinksFromLockfile`) are
|
/// this function with the reason each is safe to skip.
|
||||||
/// 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`.
|
|
||||||
///
|
///
|
||||||
/// As each ported setting in pnpm/pnpm#12009 lands end-to-end and
|
/// pnpm's [`checkDepsStatus`](https://github.com/pnpm/pnpm/blob/20f9362161/deps/status/src/checkDepsStatus.ts#L138)
|
||||||
/// gets surfaced through `current_settings`, it joins the comparison
|
/// iterates the full `WORKSPACE_STATE_SETTING_KEYS` list, reading a key
|
||||||
/// here automatically.
|
/// absent from the recorded state as `undefined`. So the reverse
|
||||||
///
|
/// scenario (pacquet wrote the state, pnpm reads it next) stays on the
|
||||||
/// Mirrors pnpm's `Object.entries(workspaceState.settings)` walk in
|
/// fast path only for keys whose pnpm-resolved value is also
|
||||||
/// [`checkDepsStatus`](https://github.com/pnpm/pnpm/blob/72d997cc34/deps/status/src/checkDepsStatus.ts):
|
/// `undefined`. Every key pnpm resolves to a concrete default —
|
||||||
/// pnpm iterates fields *in the state*, which by symmetry only
|
/// `excludeLinksFromLockfile` (`false`), `minimumReleaseAge` (`1440`),
|
||||||
/// includes fields the writer cared about. The `allowBuilds` coercion
|
/// `minimumReleaseAgeIgnoreMissingTime` (`true`) — must therefore be
|
||||||
/// mirrors pnpm's [`opts.allowBuilds ?? {}`](https://github.com/pnpm/pnpm/blob/72d997cc34/deps/status/src/checkDepsStatus.ts#L141)
|
/// written by [`current_settings`] and compared here, or pnpm would
|
||||||
/// on the read side and pnpm's tolerance of an absent
|
/// report drift and re-run a (no-op) install on every command after a
|
||||||
/// `allowBuilds` key in the recorded state on the write side.
|
/// 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(
|
fn settings_match(
|
||||||
state: &WorkspaceState,
|
state: &WorkspaceState,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
@@ -202,11 +201,19 @@ fn settings_match(
|
|||||||
&& recorded.dedupe_peer_dependents == live.dedupe_peer_dependents
|
&& recorded.dedupe_peer_dependents == live.dedupe_peer_dependents
|
||||||
&& recorded.dedupe_peers == live.dedupe_peers
|
&& recorded.dedupe_peers == live.dedupe_peers
|
||||||
&& recorded.dev == live.dev
|
&& 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_pattern == live.hoist_pattern
|
||||||
&& recorded.hoist_workspace_packages == live.hoist_workspace_packages
|
&& recorded.hoist_workspace_packages == live.hoist_workspace_packages
|
||||||
&& recorded.ignored_optional_dependencies == live.ignored_optional_dependencies
|
&& recorded.ignored_optional_dependencies == live.ignored_optional_dependencies
|
||||||
&& recorded.inject_workspace_packages == live.inject_workspace_packages
|
&& recorded.inject_workspace_packages == live.inject_workspace_packages
|
||||||
&& recorded.link_workspace_packages == live.link_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.node_linker == live.node_linker
|
||||||
&& recorded.optional == live.optional
|
&& recorded.optional == live.optional
|
||||||
&& recorded.overrides == live.overrides
|
&& recorded.overrides == live.overrides
|
||||||
@@ -219,17 +226,36 @@ fn settings_match(
|
|||||||
&& recorded.prefer_workspace_packages == live.prefer_workspace_packages
|
&& recorded.prefer_workspace_packages == live.prefer_workspace_packages
|
||||||
&& recorded.production == live.production
|
&& recorded.production == live.production
|
||||||
&& recorded.public_hoist_pattern == live.public_hoist_pattern
|
&& recorded.public_hoist_pattern == live.public_hoist_pattern
|
||||||
// Deliberately *not* compared (tracked at pnpm/pnpm#12009 — drop
|
// Deliberately *not* compared. pnpm leaves the first group
|
||||||
// each from this list once `current_settings` writes its value):
|
// `undefined` by default, so omitting them here still matches pnpm's
|
||||||
|
// all-key freshness check (`undefined == undefined`):
|
||||||
// catalogs (pnpm always ignores; see
|
// catalogs (pnpm always ignores; see
|
||||||
// ignoredSettings.add('catalogs'))
|
// ignoredSettings.add('catalogs'))
|
||||||
// excludeLinksFromLockfile
|
// minimumReleaseAgeStrict (pnpm sets it only when the user
|
||||||
// minimumReleaseAge* (pacquet supports it but doesn't
|
// explicitly sets minimumReleaseAge)
|
||||||
// round-trip through workspace state
|
// minimumReleaseAgeExclude
|
||||||
// yet — separate follow-up).
|
// trustPolicy* (all `undefined` until configured)
|
||||||
// trustPolicy* (same situation as minimumReleaseAge)
|
// workspacePackagePatterns (concrete for a multi-package
|
||||||
// workspacePackagePatterns (already covered via
|
// workspace, but lives in the
|
||||||
// pnpm-workspace.yaml `packages:`)
|
// 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
|
/// 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_peer_dependents: Some(config.dedupe_peer_dependents),
|
||||||
dedupe_peers: Some(config.dedupe_peers),
|
dedupe_peers: Some(config.dedupe_peers),
|
||||||
dev: Some(included.dev_dependencies),
|
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_pattern: config.hoist_pattern.clone(),
|
||||||
hoist_workspace_packages: Some(config.hoist_workspace_packages),
|
hoist_workspace_packages: Some(config.hoist_workspace_packages),
|
||||||
ignored_optional_dependencies: config.ignored_optional_dependencies.clone(),
|
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(
|
link_workspace_packages: Some(link_workspace_packages_to_json(
|
||||||
config.link_workspace_packages,
|
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)),
|
node_linker: Some(map_node_linker(node_linker)),
|
||||||
optional: Some(included.optional_dependencies),
|
optional: Some(included.optional_dependencies),
|
||||||
overrides: config
|
overrides: config
|
||||||
|
|||||||
@@ -338,6 +338,204 @@ fn returns_skipped_when_inject_workspace_packages_drifts() {
|
|||||||
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
|
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
|
/// Drift in `ignoredOptionalDependencies` invalidates the cached
|
||||||
/// state.
|
/// state.
|
||||||
///
|
///
|
||||||
@@ -721,12 +919,9 @@ fn returns_up_to_date_when_state_carries_unported_pnpm_settings() {
|
|||||||
|
|
||||||
let mut settings =
|
let mut settings =
|
||||||
current_settings(config, pacquet_config::NodeLinker::Isolated, isolated_included());
|
current_settings(config, pacquet_config::NodeLinker::Isolated, isolated_included());
|
||||||
// Populate every field pacquet doesn't surface through
|
// Populate fields pacquet records but `settings_match` does not
|
||||||
// `current_settings` today. Each is an upstream pnpm setting
|
// compare, to prove a difference on them keeps the fast path.
|
||||||
// listed in pnpm/pnpm#12009.
|
// `catalogs` is always ignored by pnpm itself; pacquet mirrors that.
|
||||||
settings.exclude_links_from_lockfile = Some(false);
|
|
||||||
// `catalogs` is always ignored by pnpm itself; pacquet
|
|
||||||
// mirrors that.
|
|
||||||
settings.catalogs = Some(serde_json::json!({"default": {"react": "^18.0.0"}}));
|
settings.catalogs = Some(serde_json::json!({"default": {"react": "^18.0.0"}}));
|
||||||
// `workspacePackagePatterns` is recorded by pnpm from
|
// `workspacePackagePatterns` is recorded by pnpm from
|
||||||
// pnpm-workspace.yaml's `packages:` field, which pacquet
|
// 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>.
|
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/types.ts>.
|
||||||
///
|
///
|
||||||
/// Every field is `Option` so pacquet can omit settings it does not
|
/// Every field is `Option` so pacquet can omit settings it does not
|
||||||
/// track yet; missing keys round-trip as JSON `undefined`, which pnpm's
|
/// track yet. pnpm iterates the full `WORKSPACE_STATE_SETTING_KEYS`
|
||||||
/// `Object.entries(workspaceState.settings)` loop simply skips. Match
|
/// list and reads an omitted key as `undefined`, so a key pacquet omits
|
||||||
/// what the install actually used — if pacquet's resolved value differs
|
/// stays compatible only while pnpm's resolved value for it is also
|
||||||
/// from pnpm's, pnpm correctly reinstalls.
|
/// `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)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct WorkspaceStateSettings {
|
pub struct WorkspaceStateSettings {
|
||||||
@@ -114,6 +115,13 @@ pub struct WorkspaceStateSettings {
|
|||||||
pub dedupe_peers: Option<bool>,
|
pub dedupe_peers: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub dev: Option<bool>,
|
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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub exclude_links_from_lockfile: Option<bool>,
|
pub exclude_links_from_lockfile: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -126,6 +134,17 @@ pub struct WorkspaceStateSettings {
|
|||||||
pub inject_workspace_packages: Option<bool>,
|
pub inject_workspace_packages: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub link_workspace_packages: Option<serde_json::Value>,
|
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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub node_linker: Option<NodeLinker>,
|
pub node_linker: Option<NodeLinker>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface WorkspaceState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const WORKSPACE_STATE_SETTING_KEYS = [
|
export const WORKSPACE_STATE_SETTING_KEYS = [
|
||||||
|
'enableGlobalVirtualStore',
|
||||||
'allowBuilds',
|
'allowBuilds',
|
||||||
'autoInstallPeers',
|
'autoInstallPeers',
|
||||||
'catalogs',
|
'catalogs',
|
||||||
|
|||||||
Reference in New Issue
Block a user