From 7a0be7955a7391bfd85f2589685340236c85b345 Mon Sep 17 00:00:00 2001 From: "chenzhelong.sirius" Date: Thu, 11 Jun 2026 20:20:16 +0800 Subject: [PATCH 1/6] feat: support pnpm global virtual store settings --- README.md | 3 +- .../README.md | 15 +- .../package.json | 2 +- .../src/TestHelper.ts | 105 +++++++- .../src/runTests.ts | 18 +- .../src/testPnpmGlobalVirtualStore.ts | 102 ++++++++ ...global-virtual-store_2026-06-11-15-20.json | 11 + common/config/rush/pnpm-config.json | 18 ++ common/reviews/api/rush-lib.api.md | 2 + .../common/config/rush/pnpm-config.json | 18 ++ .../logic/installManager/InstallHelpers.ts | 111 ++++++--- .../installManager/WorkspaceInstallManager.ts | 72 +++++- .../logic/pnpm/PnpmOptionsConfiguration.ts | 25 ++ .../src/logic/pnpm/PnpmWorkspaceFile.ts | 22 +- .../test/PnpmOptionsConfiguration.test.ts | 18 ++ .../logic/pnpm/test/PnpmWorkspaceFile.test.ts | 44 ++++ .../PnpmWorkspaceFile.test.ts.snap | 19 ++ .../pnpm-config-globalVirtualStore.json | 4 + .../src/logic/test/InstallHelpers.test.ts | 74 +++++- .../test/WorkspaceInstallManager.test.ts | 224 ++++++++++++++++++ .../src/schemas/pnpm-config.schema.json | 5 + 21 files changed, 863 insertions(+), 49 deletions(-) create mode 100644 build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts create mode 100644 common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-globalVirtualStore.json create mode 100644 libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts diff --git a/README.md b/README.md index 8a54e1dea4e..f0040001cd1 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/build-tests/rush-amazon-s3-build-cache-plugin-integration-test](./build-tests/rush-amazon-s3-build-cache-plugin-integration-test/) | Tests connecting to an amazon S3 endpoint | | [/build-tests/rush-lib-declaration-paths-test](./build-tests/rush-lib-declaration-paths-test/) | This project ensures all of the paths in rush-lib/lib/... have imports that resolve correctly. If this project builds, all `lib/**/*.d.ts` files in the `@microsoft/rush-lib` package are valid. | | [/build-tests/rush-mcp-example-plugin](./build-tests/rush-mcp-example-plugin/) | Example showing how to create a plugin for @rushstack/mcp-server | -| [/build-tests/rush-package-manager-integration-test](./build-tests/rush-package-manager-integration-test/) | Integration tests for non-pnpm package managers in Rush. | +| [/build-tests/rush-package-manager-integration-test](./build-tests/rush-package-manager-integration-test/) | Integration tests for package managers in Rush. | | [/build-tests/rush-project-change-analyzer-test](./build-tests/rush-project-change-analyzer-test/) | This is an example project that uses rush-lib's ProjectChangeAnalyzer to | | [/build-tests/rush-redis-cobuild-plugin-integration-test](./build-tests/rush-redis-cobuild-plugin-integration-test/) | Tests connecting to an redis server | | [/build-tests/set-webpack-public-path-plugin-test](./build-tests/set-webpack-public-path-plugin-test/) | Building this project tests the set-webpack-public-path-plugin | @@ -258,4 +258,3 @@ provided by the bot. You will only need to do this once across all repos using o This repo has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. - diff --git a/build-tests/rush-package-manager-integration-test/README.md b/build-tests/rush-package-manager-integration-test/README.md index 5563850776f..264c96b7881 100644 --- a/build-tests/rush-package-manager-integration-test/README.md +++ b/build-tests/rush-package-manager-integration-test/README.md @@ -1,6 +1,6 @@ # Rush Package Manager Integration Tests -This directory contains integration tests for verifying Rush works correctly with different package managers after the tar 7.x upgrade. +This directory contains integration tests for verifying Rush works correctly with different package managers. ## Background @@ -10,6 +10,8 @@ Rush's npm and yarn modes use temp project tarballs (stored in `common/temp/proj These tests ensure the tar 7.x upgrade works correctly with these workflows. +The PNPM test additionally verifies Rush's workspace install integration with PNPM's global virtual store. + ## Tests The test suite is written in TypeScript using `@rushstack/node-core-library` for cross-platform compatibility. @@ -30,6 +32,14 @@ Tests Rush yarn mode by: - Running `rush install` - Running `rush build` (verifies everything works end-to-end) +### testPnpmGlobalVirtualStore.ts +Tests Rush pnpm workspace mode with global virtual store by: +- Initializing a Rush repo with `pnpmVersion` configured +- Enabling `useWorkspaces` and `enableGlobalVirtualStore` +- Running `rush update` +- Running `rush install` +- Verifying the generated workspace file, shared PNPM store, dependency links, and build output + ## Prerequisites Before running these tests: @@ -62,6 +72,7 @@ These integration tests verify: - ✓ Tarballs are extracted correctly during `rush install` - ✓ File permissions are preserved (tar filter function works) - ✓ Dependencies are linked properly between projects +- ✓ PNPM global virtual store is passed through to a real workspace install - ✓ The complete workflow (update → install → build) succeeds - ✓ Built code executes correctly @@ -70,6 +81,7 @@ These integration tests verify: Each test creates a temporary Rush repository in `/tmp/rush-package-manager-test/`: - `/tmp/rush-package-manager-test/npm-test-repo/` - npm mode test repository - `/tmp/rush-package-manager-test/yarn-test-repo/` - yarn mode test repository +- `/tmp/rush-package-manager-test/pnpm-global-virtual-store-test-repo/` - pnpm global virtual store test repository These directories are cleaned up at the start of each test run. @@ -86,6 +98,7 @@ The tests use: The tar library is used in: - `libraries/rush-lib/src/logic/TempProjectHelper.ts` - Creates tarballs - `libraries/rush-lib/src/logic/npm/NpmLinkManager.ts` - Extracts tarballs +- `libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts` - Generates PNPM workspace files ## Troubleshooting diff --git a/build-tests/rush-package-manager-integration-test/package.json b/build-tests/rush-package-manager-integration-test/package.json index a812e495c73..d53e12bd3d1 100644 --- a/build-tests/rush-package-manager-integration-test/package.json +++ b/build-tests/rush-package-manager-integration-test/package.json @@ -2,7 +2,7 @@ "name": "rush-package-manager-integration-test", "version": "1.0.0", "private": true, - "description": "Integration tests for non-pnpm package managers in Rush.", + "description": "Integration tests for package managers in Rush.", "license": "MIT", "scripts": { "_phase:build": "heft build --clean", diff --git a/build-tests/rush-package-manager-integration-test/src/TestHelper.ts b/build-tests/rush-package-manager-integration-test/src/TestHelper.ts index e711014973d..55c4f2d7c74 100644 --- a/build-tests/rush-package-manager-integration-test/src/TestHelper.ts +++ b/build-tests/rush-package-manager-integration-test/src/TestHelper.ts @@ -4,7 +4,13 @@ import * as path from 'node:path'; import type * as child_process from 'node:child_process'; -import { FileSystem, Executable, JsonFile, type JsonObject } from '@rushstack/node-core-library'; +import { + FileSystem, + Executable, + JsonFile, + type FileSystemStats, + type JsonObject +} from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; /** @@ -23,7 +29,11 @@ export class TestHelper { /** * Execute a Rush command using the locally-built Rush */ - public async executeRushAsync(args: string[], workingDirectory: string): Promise { + public async executeRushAsync( + args: string[], + workingDirectory: string, + environment?: NodeJS.ProcessEnv + ): Promise { this._terminal.writeLine(`Executing: ${process.argv0} ${this._rushBinPath} ${args.join(' ')}`); const childProcess: child_process.ChildProcess = Executable.spawn( @@ -31,6 +41,7 @@ export class TestHelper { [this._rushBinPath, ...args], { currentWorkingDirectory: workingDirectory, + environment, stdio: 'inherit' } ); @@ -45,7 +56,7 @@ export class TestHelper { */ public async createTestRepoAsync( testRepoPath: string, - packageManagerType: 'npm' | 'yarn', + packageManagerType: 'npm' | 'pnpm' | 'yarn', packageManagerVersion: string ): Promise { // Clean up previous test run and create empty test repo directory @@ -66,6 +77,10 @@ export class TestHelper { delete rushJson.pnpmVersion; delete rushJson.yarnVersion; rushJson.npmVersion = packageManagerVersion; + } else if (packageManagerType === 'pnpm') { + delete rushJson.npmVersion; + delete rushJson.yarnVersion; + rushJson.pnpmVersion = packageManagerVersion; } else if (packageManagerType === 'yarn') { delete rushJson.pnpmVersion; delete rushJson.npmVersion; @@ -151,8 +166,9 @@ export class TestHelper { // Verify symlinks resolve correctly for local dependencies if (dep.startsWith('test-project-')) { const depRealPath: string = await FileSystem.getRealPathAsync(depPath); - const expectedRealPath: string = path.join(testRepoPath, 'projects', dep); - if (depRealPath !== expectedRealPath) { + const expectedPath: string = path.join(testRepoPath, 'projects', dep); + const expectedRealPath: string = await FileSystem.getRealPathAsync(expectedPath); + if (!(await this._doPathsReferToSameObjectAsync(depPath, expectedPath))) { throw new Error( `ERROR: Symlink for ${dep} does not resolve correctly!\n` + `Expected: ${expectedRealPath}\n` + @@ -164,6 +180,85 @@ export class TestHelper { this._terminal.writeLine('✓ Dependencies installed correctly'); } + private async _doPathsReferToSameObjectAsync(path1: string, path2: string): Promise { + const path1Stats: FileSystemStats = await FileSystem.getStatisticsAsync(path1); + const path2Stats: FileSystemStats = await FileSystem.getStatisticsAsync(path2); + return path1Stats.dev === path2Stats.dev && path1Stats.ino === path2Stats.ino; + } + + /** + * Verify that PNPM's global virtual store was enabled and moved out of the workspace node_modules folder. + */ + public async verifyPnpmGlobalVirtualStoreAsync( + testRepoPath: string, + sharedStorePath: string + ): Promise { + this._terminal.writeLine('\nVerifying PNPM global virtual store structure...'); + + const workspaceFilePath: string = path.join(testRepoPath, 'common/temp/pnpm-workspace.yaml'); + const workspaceFileContents: string = await FileSystem.readFileAsync(workspaceFilePath); + if (!workspaceFileContents.includes('enableGlobalVirtualStore: true')) { + throw new Error(`ERROR: enableGlobalVirtualStore was not written to ${workspaceFilePath}`); + } + + const localVirtualStorePath: string = path.join(testRepoPath, 'common/temp/node_modules/.pnpm'); + if (await FileSystem.existsAsync(localVirtualStorePath)) { + const localVirtualStoreItemNames: string[] = + await FileSystem.readFolderItemNamesAsync(localVirtualStorePath); + const unexpectedLocalPackageFolders: string[] = localVirtualStoreItemNames.filter( + (itemName) => itemName !== 'lock.yaml' && itemName !== 'node_modules' + ); + if (unexpectedLocalPackageFolders.length > 0) { + throw new Error( + `ERROR: Expected ${localVirtualStorePath} to omit package instance folders, but found: ` + + unexpectedLocalPackageFolders.join(', ') + ); + } + } + + if (!(await FileSystem.existsAsync(sharedStorePath))) { + throw new Error(`ERROR: Shared PNPM store was not created at ${sharedStorePath}`); + } + + const sharedStoreItemNames: string[] = await FileSystem.readFolderItemNamesAsync(sharedStorePath); + if (sharedStoreItemNames.length === 0) { + throw new Error(`ERROR: Shared PNPM store is empty at ${sharedStorePath}`); + } + + const globalVirtualStorePath: string = await this._findPnpmGlobalVirtualStorePathAsync(sharedStorePath); + if (!(await FileSystem.existsAsync(globalVirtualStorePath))) { + throw new Error( + `ERROR: Expected PNPM global virtual store package links under ${sharedStorePath}, ` + + `but ${globalVirtualStorePath} was not found.` + ); + } + + const globalVirtualStoreItemNames: string[] = + await FileSystem.readFolderItemNamesAsync(globalVirtualStorePath); + if (globalVirtualStoreItemNames.length === 0) { + throw new Error( + `ERROR: PNPM global virtual store package links folder is empty at ${globalVirtualStorePath}` + ); + } + + this._terminal.writeLine('✓ PNPM global virtual store structure verified'); + } + + private async _findPnpmGlobalVirtualStorePathAsync(sharedStorePath: string): Promise { + const sharedStoreVersionFolderNames: string[] = + await FileSystem.readFolderItemNamesAsync(sharedStorePath); + for (const folderName of sharedStoreVersionFolderNames) { + if (folderName.startsWith('v')) { + const linksPath: string = path.join(sharedStorePath, folderName, 'links'); + if (await FileSystem.existsAsync(linksPath)) { + return linksPath; + } + } + } + + return path.join(sharedStorePath, '', 'links'); + } + /** * Verify that build outputs were created */ diff --git a/build-tests/rush-package-manager-integration-test/src/runTests.ts b/build-tests/rush-package-manager-integration-test/src/runTests.ts index e531c6251bb..ee76c1a14f1 100644 --- a/build-tests/rush-package-manager-integration-test/src/runTests.ts +++ b/build-tests/rush-package-manager-integration-test/src/runTests.ts @@ -4,6 +4,7 @@ import { Terminal, ConsoleTerminalProvider } from '@rushstack/terminal'; import { testNpmModeAsync } from './testNpmMode'; +import { testPnpmGlobalVirtualStoreAsync } from './testPnpmGlobalVirtualStore'; import { testYarnModeAsync } from './testYarnMode'; /** @@ -17,7 +18,7 @@ async function runTestsAsync(): Promise { terminal.writeLine('=========================================='); terminal.writeLine(''); terminal.writeLine('These tests verify that the tar 7.x upgrade works correctly'); - terminal.writeLine('with different Rush package managers (npm, yarn).'); + terminal.writeLine('with different Rush package managers (npm, pnpm, yarn).'); terminal.writeLine(''); terminal.writeLine('Tests will:'); terminal.writeLine(' 1. Create Rush repos using locally-built Rush'); @@ -45,6 +46,20 @@ async function runTestsAsync(): Promise { terminal.writeErrorLine(String(error)); } + // Run pnpm global virtual store test + terminal.writeLine('=========================================='); + terminal.writeLine('Running PNPM global virtual store test...'); + terminal.writeLine('=========================================='); + try { + await testPnpmGlobalVirtualStoreAsync(terminal); + testsPassed++; + } catch (error) { + testsFailed++; + failedTests.push('PNPM global virtual store'); + terminal.writeErrorLine('⚠️ PNPM global virtual store test FAILED'); + terminal.writeErrorLine(String(error)); + } + // Run yarn mode test terminal.writeLine('=========================================='); terminal.writeLine('Running Yarn mode test...'); @@ -81,6 +96,7 @@ async function runTestsAsync(): Promise { terminal.writeLine(''); terminal.writeLine('The tar 7.x upgrade is working correctly with:'); terminal.writeLine(' - NPM package manager'); + terminal.writeLine(' - PNPM global virtual store'); terminal.writeLine(' - Yarn package manager'); terminal.writeLine(''); process.exit(0); diff --git a/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts b/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts new file mode 100644 index 00000000000..59f0936774b --- /dev/null +++ b/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { FileSystem, JsonFile, type JsonObject } from '@rushstack/node-core-library'; +import type { ITerminal } from '@rushstack/terminal'; + +import { TestHelper } from './TestHelper'; + +/** + * Integration test for Rush PNPM workspace mode with PNPM's global virtual store. + * This test verifies that Rush passes enableGlobalVirtualStore through to a real PNPM install. + */ +export async function testPnpmGlobalVirtualStoreAsync(terminal: ITerminal): Promise { + const helper: TestHelper = new TestHelper(terminal); + // Use system temp directory to avoid rush init detecting parent rush.json + const testRepoPath: string = path.join( + os.tmpdir(), + 'rush-package-manager-test', + 'pnpm-global-virtual-store-test-repo' + ); + const sharedStorePath: string = path.join(os.tmpdir(), 'rush-package-manager-test', 'shared-pnpm-store'); + const rushEnvironment: NodeJS.ProcessEnv = { + ...process.env, + CI: 'false', + PNPM_CONFIG_CI: 'false', + RUSH_PNPM_STORE_PATH: sharedStorePath + }; + + terminal.writeLine('=========================================='); + terminal.writeLine('Rush PNPM Global Virtual Store Integration Test'); + terminal.writeLine('=========================================='); + terminal.writeLine(''); + terminal.writeLine( + 'This test verifies that Rush can enable PNPM global virtual store during workspace installs.' + ); + terminal.writeLine(''); + + await helper.createTestRepoAsync(testRepoPath, 'pnpm', '10.12.1'); + + const pnpmConfigPath: string = path.join(testRepoPath, 'common/config/rush/pnpm-config.json'); + const pnpmConfigJson: JsonObject = await JsonFile.loadAsync(pnpmConfigPath); + pnpmConfigJson.useWorkspaces = true; + pnpmConfigJson.enableGlobalVirtualStore = true; + await JsonFile.saveAsync(pnpmConfigJson, pnpmConfigPath, { updateExistingFile: true }); + + terminal.writeLine('Creating test-project-a...'); + await helper.createTestProjectAsync( + testRepoPath, + 'test-project-a', + '1.0.0', + { semver: '^7.5.4' }, + `node -e "const fs = require('fs'); fs.mkdirSync('lib', {recursive: true}); fs.writeFileSync('lib/index.js', 'module.exports = { greet: () => \\"Hello from A\\" };');"` + ); + + terminal.writeLine('Creating test-project-b...'); + await helper.createTestProjectAsync( + testRepoPath, + 'test-project-b', + '1.0.0', + { + 'test-project-a': 'workspace:*', + moment: '^2.29.4' + }, + `node -e "const fs = require('fs'); fs.mkdirSync('lib', {recursive: true}); fs.writeFileSync('lib/index.js', 'module.exports = { test: () => \\"Using: \\" + require(\\'test-project-a\\').greet() };');"` + ); + + await FileSystem.ensureEmptyFolderAsync(sharedStorePath); + + terminal.writeLine(''); + terminal.writeLine("Running 'rush update' with PNPM global virtual store enabled..."); + await helper.executeRushAsync(['update'], testRepoPath, rushEnvironment); + + terminal.writeLine(''); + terminal.writeLine("Running 'rush install' with PNPM global virtual store enabled..."); + await helper.executeRushAsync(['install'], testRepoPath, rushEnvironment); + + await helper.verifyPnpmGlobalVirtualStoreAsync(testRepoPath, sharedStorePath); + await helper.verifyDependenciesAsync(testRepoPath, 'test-project-a', ['semver']); + await helper.verifyDependenciesAsync(testRepoPath, 'test-project-b', ['test-project-a']); + + terminal.writeLine(''); + terminal.writeLine("Running 'rush build'..."); + await helper.executeRushAsync(['build'], testRepoPath, rushEnvironment); + + await helper.verifyBuildOutputsAsync(testRepoPath, ['test-project-a', 'test-project-b']); + await helper.testBuiltCodeAsync(testRepoPath, 'test-project-b'); + + terminal.writeLine(''); + terminal.writeLine('=========================================='); + terminal.writeLine('✓ PNPM Global Virtual Store Integration Test PASSED'); + terminal.writeLine('=========================================='); + terminal.writeLine(''); + terminal.writeLine('PNPM global virtual store works correctly with Rush workspace installs:'); + terminal.writeLine(' - Workspace file includes enableGlobalVirtualStore'); + terminal.writeLine(' - Shared PNPM store is populated'); + terminal.writeLine(' - Dependencies link and resolve correctly'); + terminal.writeLine(' - Build completed successfully'); + terminal.writeLine(''); +} diff --git a/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json b/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json new file mode 100644 index 00000000000..292b5a20db3 --- /dev/null +++ b/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add PNPM global virtual store support for workspace installs and avoid unnecessary global package manager lock acquisition.", + "type": "minor" + } + ], + "packageName": "@microsoft/rush", + "email": "EscapeB@users.noreply.github.com" +} diff --git a/common/config/rush/pnpm-config.json b/common/config/rush/pnpm-config.json index c55351814e3..ca9902d8d2d 100644 --- a/common/config/rush/pnpm-config.json +++ b/common/config/rush/pnpm-config.json @@ -190,6 +190,24 @@ */ // "pnpmStore": "global", + /** + * If true, Rush will configure PNPM workspace installs to use PNPM's global virtual store. + * This places the virtual store under the configured PNPM store instead of under + * `node_modules/.pnpm` in the workspace. This can significantly reduce setup and cleanup costs + * when multiple Git worktrees share the same PNPM store. + * + * This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared + * PNPM store, configured using either `"pnpmStore": "global"` or the `RUSH_PNPM_STORE_PATH` + * environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. + * It is not currently compatible with the + * `usePnpmSyncForInjectedDependencies` experiment. + * + * PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore + * + * The default value is false. + */ + // "enableGlobalVirtualStore": true, + /** * If true, then `rush install` will report an error if manual modifications * were made to the PNPM shrinkwrap file without running `rush update` afterwards. diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 24c5a6606ac..74f7b4659e8 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -752,6 +752,7 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { alwaysFullInstall?: boolean; alwaysInjectDependenciesFromOtherSubspaces?: boolean; autoInstallPeers?: boolean; + enableGlobalVirtualStore?: boolean; globalAllowBuilds?: Record; globalAllowedDeprecatedVersions?: Record; globalCatalogs?: Record>; @@ -1178,6 +1179,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly alwaysFullInstall: boolean | undefined; readonly alwaysInjectDependenciesFromOtherSubspaces: boolean | undefined; readonly autoInstallPeers: boolean | undefined; + readonly enableGlobalVirtualStore: boolean; readonly globalAllowBuilds: Record | undefined; readonly globalAllowedDeprecatedVersions: Record | undefined; readonly globalCatalogs: Record> | undefined; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json index 9d5f764bc67..f9d8b550da6 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json @@ -192,6 +192,24 @@ */ /*[LINE "HYPOTHETICAL"]*/ "pnpmStore": "global", + /** + * If true, Rush will configure PNPM workspace installs to use PNPM's global virtual store. + * This places the virtual store under the configured PNPM store instead of under + * `node_modules/.pnpm` in the workspace. This can significantly reduce setup and cleanup costs + * when multiple Git worktrees share the same PNPM store. + * + * This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared + * PNPM store, configured using either `"pnpmStore": "global"` or the `RUSH_PNPM_STORE_PATH` + * environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. + * It is not currently compatible with the + * `usePnpmSyncForInjectedDependencies` experiment. + * + * PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore + * + * The default value is false. + */ + /*[LINE "HYPOTHETICAL"]*/ "enableGlobalVirtualStore": true, + /** * If true, then `rush install` will report an error if manual modifications * were made to the PNPM shrinkwrap file without running `rush update` afterwards. diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 1296125a985..af687c59bb5 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -298,47 +298,81 @@ export class InstallHelpers { node: process.versions.node }); + if ( + (await packageManagerMarker.isValidAsync()) && + !InstallHelpers._doesPackageManagerInstallLockFileExist(rushUserFolder, packageManagerAndVersion) + ) { + logIfConsoleOutputIsNotRestricted( + `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` + ); + InstallHelpers._ensureLocalPackageManagerSymlink( + rushConfiguration, + packageManager, + packageManagerToolFolder, + logIfConsoleOutputIsNotRestricted + ); + return; + } + logIfConsoleOutputIsNotRestricted(`Trying to acquire lock for ${packageManagerAndVersion}`); const lock: LockFile = await LockFile.acquireAsync(rushUserFolder, packageManagerAndVersion); logIfConsoleOutputIsNotRestricted(`Acquired lock for ${packageManagerAndVersion}`); - if (!(await packageManagerMarker.isValidAsync()) || lock.dirtyWhenAcquired) { - logIfConsoleOutputIsNotRestricted( - Colorize.bold(`Installing ${packageManager} version ${packageManagerVersion}\n`) - ); + try { + if (!(await packageManagerMarker.isValidAsync()) || lock.dirtyWhenAcquired) { + logIfConsoleOutputIsNotRestricted( + Colorize.bold(`Installing ${packageManager} version ${packageManagerVersion}\n`) + ); + + // note that this will remove the last-install flag from the directory + await Utilities.installPackageInDirectoryAsync({ + directory: packageManagerToolFolder, + packageName: packageManager, + version: rushConfiguration.packageManagerToolVersion, + tempPackageTitle: `${packageManager}-local-install`, + maxInstallAttempts: maxInstallAttempts, + // This is using a local configuration to install a package in a shared global location. + // Generally that's a bad practice, but in this case if we can successfully install + // the package at all, we can reasonably assume it's good for all the repositories. + // In particular, we'll assume that two different NPM registries cannot have two + // different implementations of the same version of the same package. + // This was needed for: https://github.com/microsoft/rushstack/issues/691 + commonRushConfigFolder: rushConfiguration.commonRushConfigFolder, + // Only filter npm-incompatible properties when the repo uses pnpm or yarn. + // If the repo uses npm, the .npmrc is already configured for npm, so don't filter. + filterNpmIncompatibleProperties: rushConfiguration.packageManager !== 'npm' + }); + + logIfConsoleOutputIsNotRestricted( + `Successfully installed ${packageManager} version ${packageManagerVersion}` + ); + } else { + logIfConsoleOutputIsNotRestricted( + `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` + ); + } - // note that this will remove the last-install flag from the directory - await Utilities.installPackageInDirectoryAsync({ - directory: packageManagerToolFolder, - packageName: packageManager, - version: rushConfiguration.packageManagerToolVersion, - tempPackageTitle: `${packageManager}-local-install`, - maxInstallAttempts: maxInstallAttempts, - // This is using a local configuration to install a package in a shared global location. - // Generally that's a bad practice, but in this case if we can successfully install - // the package at all, we can reasonably assume it's good for all the repositories. - // In particular, we'll assume that two different NPM registries cannot have two - // different implementations of the same version of the same package. - // This was needed for: https://github.com/microsoft/rushstack/issues/691 - commonRushConfigFolder: rushConfiguration.commonRushConfigFolder, - // Only filter npm-incompatible properties when the repo uses pnpm or yarn. - // If the repo uses npm, the .npmrc is already configured for npm, so don't filter. - filterNpmIncompatibleProperties: rushConfiguration.packageManager !== 'npm' - }); + await packageManagerMarker.createAsync(); - logIfConsoleOutputIsNotRestricted( - `Successfully installed ${packageManager} version ${packageManagerVersion}` - ); - } else { - logIfConsoleOutputIsNotRestricted( - `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` + InstallHelpers._ensureLocalPackageManagerSymlink( + rushConfiguration, + packageManager, + packageManagerToolFolder, + logIfConsoleOutputIsNotRestricted ); + } finally { + lock.release(); } + } - await packageManagerMarker.createAsync(); - + private static _ensureLocalPackageManagerSymlink( + rushConfiguration: RushConfiguration, + packageManager: PackageManagerName, + packageManagerToolFolder: string, + logIfConsoleOutputIsNotRestricted: (message?: string) => void + ): void { // Example: "C:\MyRepo\common\temp" FileSystem.ensureFolder(rushConfiguration.commonTempFolder); @@ -365,8 +399,23 @@ export class InstallHelpers { linkTargetPath: packageManagerToolFolder, newLinkPath: localPackageManagerToolFolder }); + } + + private static _doesPackageManagerInstallLockFileExist( + rushUserFolder: string, + packageManagerAndVersion: string + ): boolean { + for (const itemName of FileSystem.readFolderItemNames(rushUserFolder)) { + if (itemName === `${packageManagerAndVersion}.lock`) { + return true; + } + + if (itemName.startsWith(`${packageManagerAndVersion}#`) && itemName.endsWith('.lock')) { + return true; + } + } - lock.release(); + return false; } // Helper for getPackageManagerEnvironment diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index 222dd451419..2dbc215a3e0 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -35,7 +35,7 @@ import { Utilities } from '../../utilities/Utilities'; import { InstallHelpers } from './InstallHelpers'; import type { CommonVersionsConfiguration } from '../../api/CommonVersionsConfiguration'; import type { RepoStateFile } from '../RepoStateFile'; -import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; import { ShrinkwrapFileFactory } from '../ShrinkwrapFileFactory'; import { BaseProjectShrinkwrapFile } from '../base/BaseProjectShrinkwrapFile'; import { type CustomTipId, type ICustomTipInfo, PNPM_CUSTOM_TIPS } from '../../api/CustomTipsConfiguration'; @@ -44,12 +44,22 @@ import type { Subspace } from '../../api/Subspace'; import { BaseLinkManager, SymlinkKind } from '../base/BaseLinkManager'; import { FlagFile } from '../../api/FlagFile'; import { Stopwatch } from '../../utilities/Stopwatch'; -import type { PnpmOptionsConfiguration } from '../pnpm/PnpmOptionsConfiguration'; +import type { PnpmOptionsConfiguration, PnpmStoreLocation } from '../pnpm/PnpmOptionsConfiguration'; export interface IPnpmModules { hoistedDependencies: { [dep in string]: { [depPath in string]: string } }; } +interface IGlobalVirtualStoreValidationOptions { + pnpmVersion: string; + pnpmConfigFilename: string; + rushJsonFolder: string; + pnpmStore: PnpmStoreLocation; + pnpmStorePath: string; + pnpmStorePathOverride: string | undefined; + usePnpmSyncForInjectedDependencies: boolean | undefined; +} + /** * This class implements common logic between "rush install" and "rush update". */ @@ -476,10 +486,7 @@ export class WorkspaceInstallManager extends BaseInstallManager { ) { if (pnpmOptions.globalAllowBuilds) { workspaceFile.setAllowBuilds(pnpmOptions.globalAllowBuilds); - } else if ( - pnpmOptions.globalOnlyBuiltDependencies || - pnpmOptions.globalNeverBuiltDependencies - ) { + } else if (pnpmOptions.globalOnlyBuiltDependencies || pnpmOptions.globalNeverBuiltDependencies) { // Backward compatibility: convert globalOnlyBuiltDependencies/globalNeverBuiltDependencies // to allowBuilds format for pnpm 11+ const allowBuilds: Record = {}; @@ -509,6 +516,22 @@ export class WorkspaceInstallManager extends BaseInstallManager { ); } + if (pnpmOptions.enableGlobalVirtualStore) { + WorkspaceInstallManager._validateGlobalVirtualStoreOptions({ + pnpmVersion: this.rushConfiguration.packageManagerToolVersion, + pnpmConfigFilename: + pnpmOptions.jsonFilename || + `${this.rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}`, + rushJsonFolder: this.rushConfiguration.rushJsonFolder, + pnpmStore: this.rushConfiguration.pnpmOptions.pnpmStore, + pnpmStorePath: this.rushConfiguration.pnpmOptions.pnpmStorePath, + pnpmStorePathOverride: EnvironmentConfiguration.pnpmStorePathOverride, + usePnpmSyncForInjectedDependencies: + this.rushConfiguration.experimentsConfiguration.configuration?.usePnpmSyncForInjectedDependencies + }); + workspaceFile.setEnableGlobalVirtualStore(true); + } + // Save the generated workspace file. Don't update the file timestamp unless the content has changed, // since "rush install" will consider this timestamp workspaceFile.save(workspaceFile.workspaceFilename, { onlyIfChanged: true }); @@ -516,6 +539,43 @@ export class WorkspaceInstallManager extends BaseInstallManager { return { shrinkwrapIsUpToDate, shrinkwrapWarnings }; } + private static _validateGlobalVirtualStoreOptions(options: IGlobalVirtualStoreValidationOptions): void { + if (semver.lt(options.pnpmVersion, '10.12.1')) { + throw new Error( + `Your version of PNPM (${options.pnpmVersion}) doesn't support the ` + + `"enableGlobalVirtualStore" field in ${options.pnpmConfigFilename}. ` + + 'Remove this field or upgrade to PNPM 10.12.1 or newer.' + ); + } + + if (options.pnpmStore === 'local') { + if (!options.pnpmStorePathOverride) { + throw new Error( + `The "enableGlobalVirtualStore" setting requires a shared PNPM store. ` + + `The current "pnpmStore" setting resolves to a worktree-local store under ` + + `${options.pnpmStorePath}. Set "pnpmStore" to "global" or use ` + + `${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH}.` + ); + } + + if (Path.isUnderOrEqual(path.resolve(options.pnpmStorePathOverride), options.rushJsonFolder)) { + throw new Error( + `The "enableGlobalVirtualStore" setting requires a PNPM store that can be shared by ` + + `multiple worktrees. The ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH} environment ` + + `variable points inside the Rush repo: ${options.pnpmStorePathOverride}.` + ); + } + } + + if (options.usePnpmSyncForInjectedDependencies) { + throw new Error( + `The "enableGlobalVirtualStore" setting is not compatible with the ` + + `"usePnpmSyncForInjectedDependencies" experiment. PNPM global virtual store moves the ` + + `virtual store out of "node_modules/.pnpm", but pnpm-sync currently requires that folder.` + ); + } + } + protected async canSkipInstallAsync( lastModifiedDate: Date, subspace: Subspace, diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index dbaf9027c02..c54e2882a9e 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -188,6 +188,10 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { * {@inheritDoc PnpmOptionsConfiguration.alwaysFullInstall} */ alwaysFullInstall?: boolean; + /** + * {@inheritDoc PnpmOptionsConfiguration.enableGlobalVirtualStore} + */ + enableGlobalVirtualStore?: boolean; /** * {@inheritDoc PnpmOptionsConfiguration.pnpmLockfilePolicies} */ @@ -530,6 +534,26 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration /*[LINE "DEMO"]*/ public readonly alwaysFullInstall: boolean | undefined; + /** + * When true, Rush will configure PNPM to use a global virtual store for workspace installs. + * + * @remarks + * This causes PNPM to place the virtual store under the configured PNPM store instead of under + * `node_modules/.pnpm` in the workspace. This can significantly reduce setup and cleanup costs + * when multiple Git worktrees share the same PNPM store. + * + * This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared + * PNPM store, configured using either `pnpmStore: "global"` or the `RUSH_PNPM_STORE_PATH` + * environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. + * It is not currently compatible with the + * `usePnpmSyncForInjectedDependencies` experiment. + * + * PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore + * + * The default value is false. + */ + public readonly enableGlobalVirtualStore: boolean; + /** * The `globalCatalogs` setting provides named catalogs for organizing dependency versions. * Each catalog can be referenced using the `catalog:catalogName` protocol in package.json files @@ -602,6 +626,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration this.trustPolicyIgnoreAfterMinutes = json.trustPolicyIgnoreAfterMinutes; this.alwaysInjectDependenciesFromOtherSubspaces = json.alwaysInjectDependenciesFromOtherSubspaces; this.alwaysFullInstall = json.alwaysFullInstall; + this.enableGlobalVirtualStore = !!json.enableGlobalVirtualStore; this.pnpmLockfilePolicies = json.pnpmLockfilePolicies; this.globalCatalogs = json.globalCatalogs; } diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts index 850aaf1687b..8701f0a22bd 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts @@ -27,7 +27,8 @@ const yamlModule: typeof import('js-yaml') = Import.lazy('js-yaml', require); * "allowBuilds": { * "esbuild": true, * "fsevents": false - * } + * }, + * "enableGlobalVirtualStore": true * } */ interface IPnpmWorkspaceYaml { @@ -42,6 +43,12 @@ interface IPnpmWorkspaceYaml { * (SUPPORTED ONLY IN PNPM 11.0.0 AND NEWER) */ allowBuilds?: Record; + /** + * Places the virtual store under the configured PNPM store instead of under the workspace + * node_modules folder. + * (SUPPORTED ONLY IN PNPM 10.12.1 AND NEWER) + */ + enableGlobalVirtualStore?: boolean; } export class PnpmWorkspaceFile extends BaseWorkspaceFile { @@ -53,6 +60,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { private _workspacePackages: Set; private _catalogs: Record> | undefined; private _allowBuilds: Record | undefined; + private _enableGlobalVirtualStore: boolean | undefined; /** * The PNPM workspace file is used to specify the location of workspaces relative to the root @@ -67,6 +75,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { this._workspacePackages = new Set(); this._catalogs = undefined; this._allowBuilds = undefined; + this._enableGlobalVirtualStore = undefined; } /** @@ -86,6 +95,13 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { this._allowBuilds = allowBuilds; } + /** + * Sets whether PNPM should use the global virtual store for this workspace. + */ + public setEnableGlobalVirtualStore(enableGlobalVirtualStore: boolean | undefined): void { + this._enableGlobalVirtualStore = enableGlobalVirtualStore; + } + /** @override */ public addPackage(packagePath: string): void { // Ensure the path is relative to the pnpm-workspace.yaml file @@ -115,6 +131,10 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { workspaceYaml.allowBuilds = this._allowBuilds; } + if (this._enableGlobalVirtualStore) { + workspaceYaml.enableGlobalVirtualStore = true; + } + return yamlModule.dump(workspaceYaml, PNPM_SHRINKWRAP_YAML_FORMAT); } } diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts index eb546fdc357..8af1042c2bb 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts @@ -100,6 +100,24 @@ describe(PnpmOptionsConfiguration.name, () => { }); }); + it('loads enableGlobalVirtualStore', () => { + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + `${__dirname}/jsonFiles/pnpm-config-globalVirtualStore.json`, + fakeCommonTempFolder + ); + + expect(pnpmConfiguration.enableGlobalVirtualStore).toEqual(true); + }); + + it('defaults enableGlobalVirtualStore to false', () => { + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonObject( + {}, + fakeCommonTempFolder + ); + + expect(pnpmConfiguration.enableGlobalVirtualStore).toEqual(false); + }); + it('loads minimumReleaseAgeMinutes', () => { const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( `${__dirname}/jsonFiles/pnpm-config-minimumReleaseAge.json`, diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts index a113bcc0b8e..c9684f392d5 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts @@ -242,4 +242,48 @@ describe(PnpmWorkspaceFile.name, () => { expect(content).not.toContain('allowBuilds'); }); }); + + describe('global virtual store functionality', () => { + it('generates workspace file with enableGlobalVirtualStore', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + workspaceFile.setEnableGlobalVirtualStore(true); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('generates workspace file with enableGlobalVirtualStore, allowBuilds, and catalogs', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + + workspaceFile.setCatalogs({ + default: { + react: '^18.0.0' + } + }); + workspaceFile.setAllowBuilds({ + esbuild: true + }); + workspaceFile.setEnableGlobalVirtualStore(true); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('omits enableGlobalVirtualStore when disabled', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + workspaceFile.setEnableGlobalVirtualStore(false); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).not.toContain('enableGlobalVirtualStore'); + }); + }); }); diff --git a/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap b/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap index 18f15e74569..8f52904eab3 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap +++ b/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap @@ -86,3 +86,22 @@ exports[`PnpmWorkspaceFile catalog functionality handles undefined catalog 1`] = - projects/app1 " `; + +exports[`PnpmWorkspaceFile global virtual store functionality generates workspace file with enableGlobalVirtualStore 1`] = ` +"enableGlobalVirtualStore: true +packages: + - projects/app1 +" +`; + +exports[`PnpmWorkspaceFile global virtual store functionality generates workspace file with enableGlobalVirtualStore, allowBuilds, and catalogs 1`] = ` +"allowBuilds: + esbuild: true +catalogs: + default: + react: ^18.0.0 +enableGlobalVirtualStore: true +packages: + - projects/app1 +" +`; diff --git a/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-globalVirtualStore.json b/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-globalVirtualStore.json new file mode 100644 index 00000000000..f0daf6d6a9b --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-globalVirtualStore.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "enableGlobalVirtualStore": true +} diff --git a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts index c6441d74840..e57f55f923e 100644 --- a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts +++ b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts @@ -1,12 +1,17 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { type IPackageJson, JsonFile } from '@rushstack/node-core-library'; +import * as path from 'node:path'; + +import { FileSystem, type IPackageJson, JsonFile, LockFile } from '@rushstack/node-core-library'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import { TestUtilities } from '@rushstack/heft-config-file'; import { InstallHelpers } from '../installManager/InstallHelpers'; import { RushConfiguration } from '../../api/RushConfiguration'; +import { LastInstallFlag } from '../../api/LastInstallFlag'; +import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; +import { Utilities } from '../../utilities/Utilities'; describe('InstallHelpers', () => { describe('generateCommonPackageJson', () => { @@ -73,4 +78,71 @@ describe('InstallHelpers', () => { ); }); }); + + describe(InstallHelpers.ensureLocalPackageManagerAsync.name, () => { + const tempFolderPath: string = `${__dirname}/temp/${InstallHelpers.name}`; + + beforeEach(() => { + FileSystem.ensureEmptyFolder(tempFolderPath); + }); + + afterEach(() => { + FileSystem.deleteFolder(tempFolderPath); + jest.restoreAllMocks(); + }); + + it('does not acquire the global lock when the package manager is already installed', async () => { + const rushGlobalFolder: RushGlobalFolder = { + path: `${tempFolderPath}/rush-global`, + nodeSpecificPath: `${tempFolderPath}/rush-global/node-${process.version}` + } as RushGlobalFolder; + const packageManagerToolFolder: string = path.join(rushGlobalFolder.nodeSpecificPath, 'pnpm-10.27.0'); + await new LastInstallFlag(packageManagerToolFolder, { node: process.versions.node }).createAsync(); + + const lockAcquireSpy: jest.SpyInstance = jest.spyOn(LockFile, 'acquireAsync'); + const rushConfiguration: RushConfiguration = { + commonRushConfigFolder: `${tempFolderPath}/common/config/rush`, + commonTempFolder: `${tempFolderPath}/common/temp`, + packageManager: 'pnpm', + packageManagerToolVersion: '10.27.0' + } as RushConfiguration; + + await InstallHelpers.ensureLocalPackageManagerAsync(rushConfiguration, rushGlobalFolder, 1, true); + + expect(lockAcquireSpy).not.toHaveBeenCalled(); + expect(FileSystem.exists(`${rushConfiguration.commonTempFolder}/pnpm-local`)).toEqual(true); + }); + + it('acquires the global lock if an install lock file is present', async () => { + const rushGlobalFolder: RushGlobalFolder = { + path: `${tempFolderPath}/rush-global`, + nodeSpecificPath: `${tempFolderPath}/rush-global/node-${process.version}` + } as RushGlobalFolder; + const packageManagerToolFolder: string = path.join(rushGlobalFolder.nodeSpecificPath, 'pnpm-10.27.0'); + await new LastInstallFlag(packageManagerToolFolder, { node: process.versions.node }).createAsync(); + FileSystem.writeFile(`${rushGlobalFolder.nodeSpecificPath}/pnpm-10.27.0#123.lock`, ''); + + const releaseAsync: jest.Mock = jest.fn(); + const lockAcquireSpy: jest.SpyInstance = jest.spyOn(LockFile, 'acquireAsync').mockResolvedValue({ + dirtyWhenAcquired: true, + release: releaseAsync + } as unknown as LockFile); + const installSpy: jest.SpyInstance = jest + .spyOn(Utilities, 'installPackageInDirectoryAsync') + .mockResolvedValue(); + const rushConfiguration: RushConfiguration = { + commonRushConfigFolder: `${tempFolderPath}/common/config/rush`, + commonTempFolder: `${tempFolderPath}/common/temp`, + packageManager: 'pnpm', + packageManagerToolVersion: '10.27.0' + } as RushConfiguration; + + await InstallHelpers.ensureLocalPackageManagerAsync(rushConfiguration, rushGlobalFolder, 1, true); + + expect(lockAcquireSpy).toHaveBeenCalledTimes(1); + expect(installSpy).toHaveBeenCalledTimes(1); + expect(releaseAsync).toHaveBeenCalledTimes(1); + expect(FileSystem.exists(`${rushConfiguration.commonTempFolder}/pnpm-local`)).toEqual(true); + }); + }); }); diff --git a/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts b/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts new file mode 100644 index 00000000000..8d75ecbfb35 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; +import { RushConfiguration } from '../../api/RushConfiguration'; +import { RushGlobalFolder } from '../../api/RushGlobalFolder'; +import type { Subspace } from '../../api/Subspace'; +import { WorkspaceInstallManager } from '../installManager/WorkspaceInstallManager'; +import { PurgeManager } from '../PurgeManager'; +import type { IInstallManagerOptions } from '../base/BaseInstallManagerTypes'; +import type { PnpmStoreLocation } from '../pnpm/PnpmOptionsConfiguration'; + +interface IGlobalVirtualStoreValidationOptions { + pnpmVersion: string; + pnpmConfigFilename: string; + rushJsonFolder: string; + pnpmStore: PnpmStoreLocation; + pnpmStorePath: string; + pnpmStorePathOverride: string | undefined; + usePnpmSyncForInjectedDependencies: boolean | undefined; +} + +interface IWorkspaceInstallManagerWithValidation { + _validateGlobalVirtualStoreOptions(options: IGlobalVirtualStoreValidationOptions): void; +} + +class TestWorkspaceInstallManager extends WorkspaceInstallManager { + public async prepareCommonTempForTestAsync(subspace: Subspace): Promise { + await super.prepareCommonTempAsync(subspace, undefined); + } +} + +describe(WorkspaceInstallManager.name, () => { + describe('enableGlobalVirtualStore validation', () => { + const validateGlobalVirtualStoreOptions: (options: IGlobalVirtualStoreValidationOptions) => void = ( + WorkspaceInstallManager as unknown as IWorkspaceInstallManagerWithValidation + )._validateGlobalVirtualStoreOptions; + + it('throws if the configured PNPM version does not support global virtual store', () => { + expect(() => + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.0', + pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', + rushJsonFolder: '/repo', + pnpmStore: 'global', + pnpmStorePath: '', + pnpmStorePathOverride: undefined, + usePnpmSyncForInjectedDependencies: undefined + }) + ).toThrow('Your version of PNPM (10.12.0) doesn\'t support the "enableGlobalVirtualStore" field'); + }); + + it('throws if global virtual store is enabled with a worktree-local PNPM store', () => { + expect(() => + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.1', + pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', + rushJsonFolder: '/repo', + pnpmStore: 'local', + pnpmStorePath: '/repo/common/temp/pnpm-store', + pnpmStorePathOverride: undefined, + usePnpmSyncForInjectedDependencies: undefined + }) + ).toThrow(`Set "pnpmStore" to "global" or use ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH}.`); + }); + + it('throws if global virtual store is enabled with pnpm-sync for injected dependencies', () => { + expect(() => + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.1', + pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', + rushJsonFolder: '/repo', + pnpmStore: 'global', + pnpmStorePath: '', + pnpmStorePathOverride: undefined, + usePnpmSyncForInjectedDependencies: true + }) + ).toThrow( + 'The "enableGlobalVirtualStore" setting is not compatible with the ' + + '"usePnpmSyncForInjectedDependencies" experiment' + ); + }); + + it('throws if the PNPM store path override points inside the Rush repo', () => { + expect(() => + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.1', + pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', + rushJsonFolder: '/repo', + pnpmStore: 'local', + pnpmStorePath: '/repo/common/temp/pnpm-store', + pnpmStorePathOverride: '/repo/common/temp/shared-pnpm-store', + usePnpmSyncForInjectedDependencies: undefined + }) + ).toThrow( + `The ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH} environment variable points inside the Rush repo` + ); + }); + + it('allows global virtual store with a PNPM store path override', () => { + expect(() => + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.1', + pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', + rushJsonFolder: '/repo', + pnpmStore: 'local', + pnpmStorePath: '/repo/common/temp/pnpm-store', + pnpmStorePathOverride: '/shared/pnpm-store', + usePnpmSyncForInjectedDependencies: undefined + }) + ).not.toThrow(); + }); + }); + + describe('prepareCommonTempAsync', () => { + const fixtureRepoPath: string = path.resolve(__dirname, 'repoWithSubspacesCatalogs'); + const tempFolderPath: string = `${__dirname}/temp/${WorkspaceInstallManager.name}`; + let originalPnpmStorePathOverride: string | undefined; + let originalPnpmStorePathEnvValue: string | undefined; + + beforeEach(() => { + originalPnpmStorePathEnvValue = process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + EnvironmentConfiguration.reset(); + EnvironmentConfiguration.validate({ doNotNormalizePaths: true }); + originalPnpmStorePathOverride = EnvironmentConfiguration.pnpmStorePathOverride; + EnvironmentConfiguration['_pnpmStorePathOverride'] = undefined; + FileSystem.ensureEmptyFolder(tempFolderPath); + }); + + afterEach(() => { + if (originalPnpmStorePathEnvValue === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = originalPnpmStorePathEnvValue; + } + EnvironmentConfiguration['_pnpmStorePathOverride'] = originalPnpmStorePathOverride; + EnvironmentConfiguration.reset(); + FileSystem.deleteFolder(tempFolderPath); + }); + + function prepareFixtureRepo(options: { pnpmStore?: PnpmStoreLocation }): RushConfiguration { + const repoPath: string = `${tempFolderPath}/repo`; + FileSystem.copyFiles({ + sourcePath: fixtureRepoPath, + destinationPath: repoPath + }); + + const rushJsonPath: string = `${repoPath}/rush.json`; + const rushJson: Record = JsonFile.load(rushJsonPath); + rushJson.pnpmVersion = '10.12.1'; + JsonFile.save(rushJson, rushJsonPath, { updateExistingFile: true }); + + const commonPnpmConfigPath: string = `${repoPath}/common/config/rush/pnpm-config.json`; + const commonPnpmConfigJson: Record = JsonFile.load(commonPnpmConfigPath); + if (options.pnpmStore) { + commonPnpmConfigJson.pnpmStore = options.pnpmStore; + } else { + delete commonPnpmConfigJson.pnpmStore; + } + JsonFile.save(commonPnpmConfigJson, commonPnpmConfigPath, { updateExistingFile: true }); + + const subspacePnpmConfigPath: string = `${repoPath}/common/config/subspaces/default/pnpm-config.json`; + const subspacePnpmConfigJson: Record = JsonFile.load(subspacePnpmConfigPath); + subspacePnpmConfigJson.enableGlobalVirtualStore = true; + JsonFile.save(subspacePnpmConfigJson, subspacePnpmConfigPath, { updateExistingFile: true }); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonPath); + FileSystem.ensureFolder(rushConfiguration.defaultSubspace.getSubspaceTempFolderPath()); + return rushConfiguration; + } + + function createInstallManager(rushConfiguration: RushConfiguration): TestWorkspaceInstallManager { + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider()); + const options: IInstallManagerOptions = { + allowShrinkwrapUpdates: true, + fullUpgrade: false, + variant: undefined, + subspace: rushConfiguration.defaultSubspace, + terminal + } as unknown as IInstallManagerOptions; + const rushGlobalFolder: RushGlobalFolder = new RushGlobalFolder(); + + return new TestWorkspaceInstallManager( + rushConfiguration, + rushGlobalFolder, + new PurgeManager(rushConfiguration, rushGlobalFolder), + options + ); + } + + it('writes enableGlobalVirtualStore through the workspace install prepare path', async () => { + const rushConfiguration: RushConfiguration = prepareFixtureRepo({ pnpmStore: 'global' }); + const installManager: TestWorkspaceInstallManager = createInstallManager(rushConfiguration); + + await installManager.prepareCommonTempForTestAsync(rushConfiguration.defaultSubspace); + + const workspaceYaml: string = FileSystem.readFile( + `${rushConfiguration.defaultSubspace.getSubspaceTempFolderPath()}/pnpm-workspace.yaml` + ); + expect(workspaceYaml).toContain('enableGlobalVirtualStore: true'); + expect(Path.convertToSlashes(workspaceYaml)).toContain('../../../a'); + }); + + it('throws from the workspace install prepare path when using a worktree-local PNPM store', async () => { + const rushConfiguration: RushConfiguration = prepareFixtureRepo({}); + const installManager: TestWorkspaceInstallManager = createInstallManager(rushConfiguration); + + expect(rushConfiguration.pnpmOptions.pnpmStore).toEqual('local'); + expect(rushConfiguration.defaultSubspace.getPnpmOptions()?.enableGlobalVirtualStore).toEqual(true); + + await expect( + installManager.prepareCommonTempForTestAsync(rushConfiguration.defaultSubspace) + ).rejects.toThrow( + `Set "pnpmStore" to "global" or use ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH}.` + ); + }); + }); +}); diff --git a/libraries/rush-lib/src/schemas/pnpm-config.schema.json b/libraries/rush-lib/src/schemas/pnpm-config.schema.json index 93be4e62642..13ca9d8c3b4 100644 --- a/libraries/rush-lib/src/schemas/pnpm-config.schema.json +++ b/libraries/rush-lib/src/schemas/pnpm-config.schema.json @@ -252,6 +252,11 @@ "type": "boolean" }, + "enableGlobalVirtualStore": { + "description": "(EXPERIMENTAL) If true, Rush will configure PNPM workspace installs to use PNPM's global virtual store. This places the virtual store under the configured PNPM store instead of under `node_modules/.pnpm` in the workspace. This can significantly reduce setup and cleanup costs when multiple Git worktrees share the same PNPM store.\n\nThis option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared PNPM store, configured using either `pnpmStore: \"global\"` or the `RUSH_PNPM_STORE_PATH` environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. It is not currently compatible with the `usePnpmSyncForInjectedDependencies` experiment.\n\nPNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore\n\nThe default value is false.", + "type": "boolean" + }, + "pnpmLockfilePolicies": { "description": "This setting defines the policies that govern the `pnpm-lock.yaml` file.", "type": "object", From 051accc77985c6ec46e444d2a6041b9af19420c8 Mon Sep 17 00:00:00 2001 From: EscapeB <463355954@qq.com> Date: Tue, 23 Jun 2026 13:01:33 +0800 Subject: [PATCH 2/6] Update libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json Co-authored-by: Pete Gonzalez <4673363+octogonz@users.noreply.github.com> --- .../assets/rush-init/common/config/rush/pnpm-config.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json index f9d8b550da6..c408d5173f7 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json @@ -201,8 +201,7 @@ * This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared * PNPM store, configured using either `"pnpmStore": "global"` or the `RUSH_PNPM_STORE_PATH` * environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. - * It is not currently compatible with the - * `usePnpmSyncForInjectedDependencies` experiment. + * It is not compatible with the `usePnpmSyncForInjectedDependencies` experiment. * * PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore * From ce3f1f0d88e18e152cfe75fc2f51d921ba58d8f0 Mon Sep 17 00:00:00 2001 From: EscapeB <463355954@qq.com> Date: Tue, 23 Jun 2026 13:02:12 +0800 Subject: [PATCH 3/6] Update common/config/rush/pnpm-config.json Co-authored-by: Pete Gonzalez <4673363+octogonz@users.noreply.github.com> --- common/config/rush/pnpm-config.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/config/rush/pnpm-config.json b/common/config/rush/pnpm-config.json index ca9902d8d2d..13622674f0f 100644 --- a/common/config/rush/pnpm-config.json +++ b/common/config/rush/pnpm-config.json @@ -199,8 +199,7 @@ * This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared * PNPM store, configured using either `"pnpmStore": "global"` or the `RUSH_PNPM_STORE_PATH` * environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. - * It is not currently compatible with the - * `usePnpmSyncForInjectedDependencies` experiment. + * It is not compatible with the `usePnpmSyncForInjectedDependencies` experiment. * * PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore * From 614bf78da6d7bdca519d8f861f5deb9ca2941d5b Mon Sep 17 00:00:00 2001 From: EscapeB <463355954@qq.com> Date: Tue, 23 Jun 2026 13:02:25 +0800 Subject: [PATCH 4/6] Update libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts Co-authored-by: Pete Gonzalez <4673363+octogonz@users.noreply.github.com> --- libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index c54e2882a9e..00c3c95bcd7 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -545,8 +545,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration * This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared * PNPM store, configured using either `pnpmStore: "global"` or the `RUSH_PNPM_STORE_PATH` * environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. - * It is not currently compatible with the - * `usePnpmSyncForInjectedDependencies` experiment. + * It is not compatible with the `usePnpmSyncForInjectedDependencies` experiment. * * PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore * From 1047d9fe26efda18a6f51c6045e7f589144d0f4c Mon Sep 17 00:00:00 2001 From: "chenzhelong.sirius" Date: Tue, 23 Jun 2026 13:32:25 +0800 Subject: [PATCH 5/6] chore: split package manager install lock fix --- ...global-virtual-store_2026-06-11-15-20.json | 2 +- .../logic/installManager/InstallHelpers.ts | 111 +++++------------- .../src/logic/test/InstallHelpers.test.ts | 74 +----------- 3 files changed, 33 insertions(+), 154 deletions(-) diff --git a/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json b/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json index 292b5a20db3..d8e72d08384 100644 --- a/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json +++ b/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "Add PNPM global virtual store support for workspace installs and avoid unnecessary global package manager lock acquisition.", + "comment": "Add PNPM global virtual store support for workspace installs.", "type": "minor" } ], diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index af687c59bb5..1296125a985 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -298,81 +298,47 @@ export class InstallHelpers { node: process.versions.node }); - if ( - (await packageManagerMarker.isValidAsync()) && - !InstallHelpers._doesPackageManagerInstallLockFileExist(rushUserFolder, packageManagerAndVersion) - ) { - logIfConsoleOutputIsNotRestricted( - `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` - ); - InstallHelpers._ensureLocalPackageManagerSymlink( - rushConfiguration, - packageManager, - packageManagerToolFolder, - logIfConsoleOutputIsNotRestricted - ); - return; - } - logIfConsoleOutputIsNotRestricted(`Trying to acquire lock for ${packageManagerAndVersion}`); const lock: LockFile = await LockFile.acquireAsync(rushUserFolder, packageManagerAndVersion); logIfConsoleOutputIsNotRestricted(`Acquired lock for ${packageManagerAndVersion}`); - try { - if (!(await packageManagerMarker.isValidAsync()) || lock.dirtyWhenAcquired) { - logIfConsoleOutputIsNotRestricted( - Colorize.bold(`Installing ${packageManager} version ${packageManagerVersion}\n`) - ); - - // note that this will remove the last-install flag from the directory - await Utilities.installPackageInDirectoryAsync({ - directory: packageManagerToolFolder, - packageName: packageManager, - version: rushConfiguration.packageManagerToolVersion, - tempPackageTitle: `${packageManager}-local-install`, - maxInstallAttempts: maxInstallAttempts, - // This is using a local configuration to install a package in a shared global location. - // Generally that's a bad practice, but in this case if we can successfully install - // the package at all, we can reasonably assume it's good for all the repositories. - // In particular, we'll assume that two different NPM registries cannot have two - // different implementations of the same version of the same package. - // This was needed for: https://github.com/microsoft/rushstack/issues/691 - commonRushConfigFolder: rushConfiguration.commonRushConfigFolder, - // Only filter npm-incompatible properties when the repo uses pnpm or yarn. - // If the repo uses npm, the .npmrc is already configured for npm, so don't filter. - filterNpmIncompatibleProperties: rushConfiguration.packageManager !== 'npm' - }); - - logIfConsoleOutputIsNotRestricted( - `Successfully installed ${packageManager} version ${packageManagerVersion}` - ); - } else { - logIfConsoleOutputIsNotRestricted( - `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` - ); - } + if (!(await packageManagerMarker.isValidAsync()) || lock.dirtyWhenAcquired) { + logIfConsoleOutputIsNotRestricted( + Colorize.bold(`Installing ${packageManager} version ${packageManagerVersion}\n`) + ); - await packageManagerMarker.createAsync(); + // note that this will remove the last-install flag from the directory + await Utilities.installPackageInDirectoryAsync({ + directory: packageManagerToolFolder, + packageName: packageManager, + version: rushConfiguration.packageManagerToolVersion, + tempPackageTitle: `${packageManager}-local-install`, + maxInstallAttempts: maxInstallAttempts, + // This is using a local configuration to install a package in a shared global location. + // Generally that's a bad practice, but in this case if we can successfully install + // the package at all, we can reasonably assume it's good for all the repositories. + // In particular, we'll assume that two different NPM registries cannot have two + // different implementations of the same version of the same package. + // This was needed for: https://github.com/microsoft/rushstack/issues/691 + commonRushConfigFolder: rushConfiguration.commonRushConfigFolder, + // Only filter npm-incompatible properties when the repo uses pnpm or yarn. + // If the repo uses npm, the .npmrc is already configured for npm, so don't filter. + filterNpmIncompatibleProperties: rushConfiguration.packageManager !== 'npm' + }); - InstallHelpers._ensureLocalPackageManagerSymlink( - rushConfiguration, - packageManager, - packageManagerToolFolder, - logIfConsoleOutputIsNotRestricted + logIfConsoleOutputIsNotRestricted( + `Successfully installed ${packageManager} version ${packageManagerVersion}` + ); + } else { + logIfConsoleOutputIsNotRestricted( + `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` ); - } finally { - lock.release(); } - } - private static _ensureLocalPackageManagerSymlink( - rushConfiguration: RushConfiguration, - packageManager: PackageManagerName, - packageManagerToolFolder: string, - logIfConsoleOutputIsNotRestricted: (message?: string) => void - ): void { + await packageManagerMarker.createAsync(); + // Example: "C:\MyRepo\common\temp" FileSystem.ensureFolder(rushConfiguration.commonTempFolder); @@ -399,23 +365,8 @@ export class InstallHelpers { linkTargetPath: packageManagerToolFolder, newLinkPath: localPackageManagerToolFolder }); - } - - private static _doesPackageManagerInstallLockFileExist( - rushUserFolder: string, - packageManagerAndVersion: string - ): boolean { - for (const itemName of FileSystem.readFolderItemNames(rushUserFolder)) { - if (itemName === `${packageManagerAndVersion}.lock`) { - return true; - } - - if (itemName.startsWith(`${packageManagerAndVersion}#`) && itemName.endsWith('.lock')) { - return true; - } - } - return false; + lock.release(); } // Helper for getPackageManagerEnvironment diff --git a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts index e57f55f923e..c6441d74840 100644 --- a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts +++ b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts @@ -1,17 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import * as path from 'node:path'; - -import { FileSystem, type IPackageJson, JsonFile, LockFile } from '@rushstack/node-core-library'; +import { type IPackageJson, JsonFile } from '@rushstack/node-core-library'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import { TestUtilities } from '@rushstack/heft-config-file'; import { InstallHelpers } from '../installManager/InstallHelpers'; import { RushConfiguration } from '../../api/RushConfiguration'; -import { LastInstallFlag } from '../../api/LastInstallFlag'; -import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; -import { Utilities } from '../../utilities/Utilities'; describe('InstallHelpers', () => { describe('generateCommonPackageJson', () => { @@ -78,71 +73,4 @@ describe('InstallHelpers', () => { ); }); }); - - describe(InstallHelpers.ensureLocalPackageManagerAsync.name, () => { - const tempFolderPath: string = `${__dirname}/temp/${InstallHelpers.name}`; - - beforeEach(() => { - FileSystem.ensureEmptyFolder(tempFolderPath); - }); - - afterEach(() => { - FileSystem.deleteFolder(tempFolderPath); - jest.restoreAllMocks(); - }); - - it('does not acquire the global lock when the package manager is already installed', async () => { - const rushGlobalFolder: RushGlobalFolder = { - path: `${tempFolderPath}/rush-global`, - nodeSpecificPath: `${tempFolderPath}/rush-global/node-${process.version}` - } as RushGlobalFolder; - const packageManagerToolFolder: string = path.join(rushGlobalFolder.nodeSpecificPath, 'pnpm-10.27.0'); - await new LastInstallFlag(packageManagerToolFolder, { node: process.versions.node }).createAsync(); - - const lockAcquireSpy: jest.SpyInstance = jest.spyOn(LockFile, 'acquireAsync'); - const rushConfiguration: RushConfiguration = { - commonRushConfigFolder: `${tempFolderPath}/common/config/rush`, - commonTempFolder: `${tempFolderPath}/common/temp`, - packageManager: 'pnpm', - packageManagerToolVersion: '10.27.0' - } as RushConfiguration; - - await InstallHelpers.ensureLocalPackageManagerAsync(rushConfiguration, rushGlobalFolder, 1, true); - - expect(lockAcquireSpy).not.toHaveBeenCalled(); - expect(FileSystem.exists(`${rushConfiguration.commonTempFolder}/pnpm-local`)).toEqual(true); - }); - - it('acquires the global lock if an install lock file is present', async () => { - const rushGlobalFolder: RushGlobalFolder = { - path: `${tempFolderPath}/rush-global`, - nodeSpecificPath: `${tempFolderPath}/rush-global/node-${process.version}` - } as RushGlobalFolder; - const packageManagerToolFolder: string = path.join(rushGlobalFolder.nodeSpecificPath, 'pnpm-10.27.0'); - await new LastInstallFlag(packageManagerToolFolder, { node: process.versions.node }).createAsync(); - FileSystem.writeFile(`${rushGlobalFolder.nodeSpecificPath}/pnpm-10.27.0#123.lock`, ''); - - const releaseAsync: jest.Mock = jest.fn(); - const lockAcquireSpy: jest.SpyInstance = jest.spyOn(LockFile, 'acquireAsync').mockResolvedValue({ - dirtyWhenAcquired: true, - release: releaseAsync - } as unknown as LockFile); - const installSpy: jest.SpyInstance = jest - .spyOn(Utilities, 'installPackageInDirectoryAsync') - .mockResolvedValue(); - const rushConfiguration: RushConfiguration = { - commonRushConfigFolder: `${tempFolderPath}/common/config/rush`, - commonTempFolder: `${tempFolderPath}/common/temp`, - packageManager: 'pnpm', - packageManagerToolVersion: '10.27.0' - } as RushConfiguration; - - await InstallHelpers.ensureLocalPackageManagerAsync(rushConfiguration, rushGlobalFolder, 1, true); - - expect(lockAcquireSpy).toHaveBeenCalledTimes(1); - expect(installSpy).toHaveBeenCalledTimes(1); - expect(releaseAsync).toHaveBeenCalledTimes(1); - expect(FileSystem.exists(`${rushConfiguration.commonTempFolder}/pnpm-local`)).toEqual(true); - }); - }); }); From 8aad71aec4f33b02072f22e0754974879dd7fcca Mon Sep 17 00:00:00 2001 From: "chenzhelong.sirius" Date: Tue, 23 Jun 2026 14:50:07 +0800 Subject: [PATCH 6/6] Support PNPM global virtual store via environment --- .../README.md | 2 +- .../src/testPnpmGlobalVirtualStore.ts | 4 +- ...global-virtual-store_2026-06-11-15-20.json | 2 +- common/config/rush/pnpm-config.json | 17 -- common/reviews/api/rush-lib.api.md | 4 +- .../common/config/rush/pnpm-config.json | 17 -- .../src/api/EnvironmentConfiguration.ts | 30 ++++ libraries/rush-lib/src/api/LastInstallFlag.ts | 24 +++ .../api/test/EnvironmentConfiguration.test.ts | 32 ++++ .../src/api/test/LastInstallFlag.test.ts | 35 ++++ .../src/api/test/RushConfiguration.test.ts | 20 ++- .../installManager/WorkspaceInstallManager.ts | 68 ++++---- .../logic/pnpm/PnpmOptionsConfiguration.ts | 24 --- .../test/PnpmOptionsConfiguration.test.ts | 18 --- .../pnpm-config-globalVirtualStore.json | 4 - .../src/logic/test/BaseInstallManager.test.ts | 151 ++++++++++++++++++ .../test/WorkspaceInstallManager.test.ts | 61 ++++--- .../src/schemas/pnpm-config.schema.json | 5 - 18 files changed, 370 insertions(+), 148 deletions(-) delete mode 100644 libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-globalVirtualStore.json diff --git a/build-tests/rush-package-manager-integration-test/README.md b/build-tests/rush-package-manager-integration-test/README.md index 264c96b7881..444c8cdbf5e 100644 --- a/build-tests/rush-package-manager-integration-test/README.md +++ b/build-tests/rush-package-manager-integration-test/README.md @@ -35,7 +35,7 @@ Tests Rush yarn mode by: ### testPnpmGlobalVirtualStore.ts Tests Rush pnpm workspace mode with global virtual store by: - Initializing a Rush repo with `pnpmVersion` configured -- Enabling `useWorkspaces` and `enableGlobalVirtualStore` +- Enabling `useWorkspaces` and setting `RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE=1` - Running `rush update` - Running `rush install` - Verifying the generated workspace file, shared PNPM store, dependency links, and build output diff --git a/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts b/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts index 59f0936774b..cf581ee345e 100644 --- a/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts +++ b/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts @@ -26,7 +26,8 @@ export async function testPnpmGlobalVirtualStoreAsync(terminal: ITerminal): Prom ...process.env, CI: 'false', PNPM_CONFIG_CI: 'false', - RUSH_PNPM_STORE_PATH: sharedStorePath + RUSH_PNPM_STORE_PATH: sharedStorePath, + RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE: '1' }; terminal.writeLine('=========================================='); @@ -43,7 +44,6 @@ export async function testPnpmGlobalVirtualStoreAsync(terminal: ITerminal): Prom const pnpmConfigPath: string = path.join(testRepoPath, 'common/config/rush/pnpm-config.json'); const pnpmConfigJson: JsonObject = await JsonFile.loadAsync(pnpmConfigPath); pnpmConfigJson.useWorkspaces = true; - pnpmConfigJson.enableGlobalVirtualStore = true; await JsonFile.saveAsync(pnpmConfigJson, pnpmConfigPath, { updateExistingFile: true }); terminal.writeLine('Creating test-project-a...'); diff --git a/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json b/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json index d8e72d08384..db66cc29520 100644 --- a/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json +++ b/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "Add PNPM global virtual store support for workspace installs.", + "comment": "Add PNPM global virtual store support for workspace installs via RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE.", "type": "minor" } ], diff --git a/common/config/rush/pnpm-config.json b/common/config/rush/pnpm-config.json index 13622674f0f..c55351814e3 100644 --- a/common/config/rush/pnpm-config.json +++ b/common/config/rush/pnpm-config.json @@ -190,23 +190,6 @@ */ // "pnpmStore": "global", - /** - * If true, Rush will configure PNPM workspace installs to use PNPM's global virtual store. - * This places the virtual store under the configured PNPM store instead of under - * `node_modules/.pnpm` in the workspace. This can significantly reduce setup and cleanup costs - * when multiple Git worktrees share the same PNPM store. - * - * This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared - * PNPM store, configured using either `"pnpmStore": "global"` or the `RUSH_PNPM_STORE_PATH` - * environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. - * It is not compatible with the `usePnpmSyncForInjectedDependencies` experiment. - * - * PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore - * - * The default value is false. - */ - // "enableGlobalVirtualStore": true, - /** * If true, then `rush install` will report an error if manual modifications * were made to the PNPM shrinkwrap file without running `rush update` afterwards. diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 74f7b4659e8..bb78e2f281c 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -241,6 +241,7 @@ export class EnvironmentConfiguration { static get hasBeenValidated(): boolean; // (undocumented) static parseBooleanEnvironmentVariable(name: string, value: string | undefined): boolean | undefined; + static get pnpmGlobalVirtualStore(): boolean; static get pnpmStorePathOverride(): string | undefined; static get pnpmVerifyStoreIntegrity(): boolean | undefined; static get quietMode(): boolean; @@ -261,6 +262,7 @@ export const EnvironmentVariableNames: { readonly RUSH_PARALLELISM: "RUSH_PARALLELISM"; readonly RUSH_ABSOLUTE_SYMLINKS: "RUSH_ABSOLUTE_SYMLINKS"; readonly RUSH_PNPM_STORE_PATH: "RUSH_PNPM_STORE_PATH"; + readonly RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE: "RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE"; readonly RUSH_PNPM_VERIFY_STORE_INTEGRITY: "RUSH_PNPM_VERIFY_STORE_INTEGRITY"; readonly RUSH_DEPLOY_TARGET_FOLDER: "RUSH_DEPLOY_TARGET_FOLDER"; readonly RUSH_GLOBAL_FOLDER: "RUSH_GLOBAL_FOLDER"; @@ -752,7 +754,6 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { alwaysFullInstall?: boolean; alwaysInjectDependenciesFromOtherSubspaces?: boolean; autoInstallPeers?: boolean; - enableGlobalVirtualStore?: boolean; globalAllowBuilds?: Record; globalAllowedDeprecatedVersions?: Record; globalCatalogs?: Record>; @@ -1179,7 +1180,6 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly alwaysFullInstall: boolean | undefined; readonly alwaysInjectDependenciesFromOtherSubspaces: boolean | undefined; readonly autoInstallPeers: boolean | undefined; - readonly enableGlobalVirtualStore: boolean; readonly globalAllowBuilds: Record | undefined; readonly globalAllowedDeprecatedVersions: Record | undefined; readonly globalCatalogs: Record> | undefined; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json index c408d5173f7..9d5f764bc67 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json @@ -192,23 +192,6 @@ */ /*[LINE "HYPOTHETICAL"]*/ "pnpmStore": "global", - /** - * If true, Rush will configure PNPM workspace installs to use PNPM's global virtual store. - * This places the virtual store under the configured PNPM store instead of under - * `node_modules/.pnpm` in the workspace. This can significantly reduce setup and cleanup costs - * when multiple Git worktrees share the same PNPM store. - * - * This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared - * PNPM store, configured using either `"pnpmStore": "global"` or the `RUSH_PNPM_STORE_PATH` - * environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. - * It is not compatible with the `usePnpmSyncForInjectedDependencies` experiment. - * - * PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore - * - * The default value is false. - */ - /*[LINE "HYPOTHETICAL"]*/ "enableGlobalVirtualStore": true, - /** * If true, then `rush install` will report an error if manual modifications * were made to the PNPM shrinkwrap file without running `rush update` afterwards. diff --git a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts index de8c8d2d2ff..cff3fa85f46 100644 --- a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts +++ b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts @@ -81,6 +81,13 @@ export const EnvironmentVariableNames = { */ RUSH_PNPM_STORE_PATH: 'RUSH_PNPM_STORE_PATH', + /** + * When using PNPM as the package manager, this variable can be used to enable PNPM's global + * virtual store for workspace installs. The value of this environment variable must be `1` (for + * true) or `0` (for false). If not specified, PNPM's global virtual store is not enabled by Rush. + */ + RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE: 'RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE', + /** * When using PNPM as the package manager, this variable can be used to control whether or not PNPM * validates the integrity of the PNPM store during installation. The value of this environment variable must be @@ -276,6 +283,8 @@ export class EnvironmentConfiguration { private static _pnpmStorePathOverride: string | undefined; + private static _pnpmGlobalVirtualStore: boolean = false; + private static _pnpmVerifyStoreIntegrity: boolean | undefined; private static _rushGlobalFolderOverride: string | undefined; @@ -357,6 +366,15 @@ export class EnvironmentConfiguration { return EnvironmentConfiguration._pnpmStorePathOverride; } + /** + * If true, enables PNPM's global virtual store during workspace installs. + * See {@link EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} + */ + public static get pnpmGlobalVirtualStore(): boolean { + EnvironmentConfiguration._ensureValidated(); + return EnvironmentConfiguration._pnpmGlobalVirtualStore; + } + /** * If specified, enables or disables integrity verification of the pnpm store during install. * See {@link EnvironmentVariableNames.RUSH_PNPM_VERIFY_STORE_INTEGRITY} @@ -551,6 +569,15 @@ export class EnvironmentConfiguration { break; } + case EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE: { + EnvironmentConfiguration._pnpmGlobalVirtualStore = + EnvironmentConfiguration.parseBooleanEnvironmentVariable( + EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE, + value + ) ?? false; + break; + } + case EnvironmentVariableNames.RUSH_PNPM_VERIFY_STORE_INTEGRITY: { EnvironmentConfiguration._pnpmVerifyStoreIntegrity = value === '1' ? true : value === '0' ? false : undefined; @@ -694,6 +721,9 @@ export class EnvironmentConfiguration { public static reset(): void { EnvironmentConfiguration._rushTempFolderOverride = undefined; EnvironmentConfiguration._quietMode = false; + EnvironmentConfiguration._pnpmStorePathOverride = undefined; + EnvironmentConfiguration._pnpmGlobalVirtualStore = false; + EnvironmentConfiguration._pnpmVerifyStoreIntegrity = undefined; EnvironmentConfiguration._gitBinaryPath = undefined; EnvironmentConfiguration._tarBinaryPath = undefined; EnvironmentConfiguration._hasBeenValidated = false; diff --git a/libraries/rush-lib/src/api/LastInstallFlag.ts b/libraries/rush-lib/src/api/LastInstallFlag.ts index ab854ac6ef7..345542bb1d0 100644 --- a/libraries/rush-lib/src/api/LastInstallFlag.ts +++ b/libraries/rush-lib/src/api/LastInstallFlag.ts @@ -7,6 +7,7 @@ import { JsonFile, type JsonObject, Path, type IPackageJson, Objects } from '@ru import type { PackageManagerName } from './packageManager/PackageManager'; import type { RushConfiguration } from './RushConfiguration'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from './EnvironmentConfiguration'; import * as objectUtilities from '../utilities/objectUtilities'; import type { Subspace } from './Subspace'; import { Selection } from '../logic/Selection'; @@ -42,6 +43,10 @@ export interface ILastInstallFlagJson { * Same with pnpmOptions.pnpmStorePath in rush.json */ storePath?: string; + /** + * True when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE is enabled. + */ + pnpmGlobalVirtualStore?: boolean; /** * An experimental flag used by cleanInstallAfterNpmrcChanges */ @@ -149,6 +154,22 @@ export class LastInstallFlag extends FlagFile> { `New Path: ${normalizedNewStorePath}` ); } + const oldPnpmGlobalVirtualStore: boolean = oldState.pnpmGlobalVirtualStore === true; + const newPnpmGlobalVirtualStore: boolean = newState.pnpmGlobalVirtualStore === true; + if (oldPnpmGlobalVirtualStore !== newPnpmGlobalVirtualStore) { + throw new Error( + 'Current PNPM global virtual store setting does not match the last one used. ' + + 'This may cause inconsistency in your builds.\n\n' + + `If you wish to install with the new global virtual store setting, please run ` + + `"rush ${rushVerb} --purge"\n\n` + + `Old ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE}: ${ + oldPnpmGlobalVirtualStore ? '1' : '0' + }\n` + + `New ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE}: ${ + newPnpmGlobalVirtualStore ? '1' : '0' + }` + ); + } } // check whether new selected projects are installed if (newState.selectedProjectNames) { @@ -207,6 +228,9 @@ export function getCommonTempFlag( if (currentState.packageManager === 'pnpm' && rushConfiguration.pnpmOptions) { currentState.storePath = rushConfiguration.pnpmOptions.pnpmStorePath; + if (EnvironmentConfiguration.pnpmGlobalVirtualStore) { + currentState.pnpmGlobalVirtualStore = true; + } if (rushConfiguration.pnpmOptions.useWorkspaces) { currentState.workspaces = rushConfiguration.pnpmOptions.useWorkspaces; } diff --git a/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts b/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts index 78f597c2a3d..3d160a08bbe 100644 --- a/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts @@ -108,4 +108,36 @@ describe(EnvironmentConfiguration.name, () => { expect(EnvironmentConfiguration.pnpmStorePathOverride).toEqual(expectedValue); }); }); + + describe('pnpmGlobalVirtualStore', () => { + const ENV_VAR: string = 'RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE'; + + it('returns false for unset environment variable', () => { + EnvironmentConfiguration.validate(); + + expect(EnvironmentConfiguration.pnpmGlobalVirtualStore).toEqual(false); + }); + + it('returns true when environment variable is set to 1', () => { + process.env[ENV_VAR] = '1'; + EnvironmentConfiguration.validate(); + + expect(EnvironmentConfiguration.pnpmGlobalVirtualStore).toEqual(true); + }); + + it('returns false when environment variable is set to 0', () => { + process.env[ENV_VAR] = '0'; + EnvironmentConfiguration.validate(); + + expect(EnvironmentConfiguration.pnpmGlobalVirtualStore).toEqual(false); + }); + + it('rejects unsupported environment variable values', () => { + process.env[ENV_VAR] = 'true'; + + expect(EnvironmentConfiguration.validate).toThrow( + `Invalid value "true" for the environment variable ${ENV_VAR}` + ); + }); + }); }); diff --git a/libraries/rush-lib/src/api/test/LastInstallFlag.test.ts b/libraries/rush-lib/src/api/test/LastInstallFlag.test.ts index 11aa23f09a5..08e7300a8d6 100644 --- a/libraries/rush-lib/src/api/test/LastInstallFlag.test.ts +++ b/libraries/rush-lib/src/api/test/LastInstallFlag.test.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { FileSystem } from '@rushstack/node-core-library'; +import { EnvironmentVariableNames } from '../EnvironmentConfiguration'; import { LastInstallFlag } from '../LastInstallFlag'; const TEMP_DIR_PATH: string = `${__dirname}/temp`; @@ -92,6 +93,40 @@ describe(LastInstallFlag.name, () => { }).rejects.toThrow(/PNPM store path/); }); + it("throws an error if the PNPM global virtual store setting doesn't match the old one", async () => { + const flag1: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { + packageManager: 'pnpm', + storePath: `${TEMP_DIR_PATH}/pnpm-store` + }); + const flag2: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { + packageManager: 'pnpm', + storePath: `${TEMP_DIR_PATH}/pnpm-store`, + pnpmGlobalVirtualStore: true + }); + + await flag1.createAsync(); + await expect(async () => { + await flag2.checkValidAndReportStoreIssuesAsync({ rushVerb: 'install' }); + }).rejects.toThrow(EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE); + }); + + it("throws an error if the PNPM global virtual store setting was previously enabled but isn't now", async () => { + const flag1: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { + packageManager: 'pnpm', + storePath: `${TEMP_DIR_PATH}/pnpm-store`, + pnpmGlobalVirtualStore: true + }); + const flag2: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { + packageManager: 'pnpm', + storePath: `${TEMP_DIR_PATH}/pnpm-store` + }); + + await flag1.createAsync(); + await expect(async () => { + await flag2.checkValidAndReportStoreIssuesAsync({ rushVerb: 'install' }); + }).rejects.toThrow(EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE); + }); + it("doesn't throw an error if conditions for error aren't met", async () => { const flag1: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { packageManager: 'pnpm', diff --git a/libraries/rush-lib/src/api/test/RushConfiguration.test.ts b/libraries/rush-lib/src/api/test/RushConfiguration.test.ts index 6b90c8c2a10..b7d33b64af7 100644 --- a/libraries/rush-lib/src/api/test/RushConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/RushConfiguration.test.ts @@ -7,7 +7,7 @@ import { JsonFile, Path, Text } from '@rushstack/node-core-library'; import { RushConfiguration } from '../RushConfiguration'; import type { ApprovedPackagesPolicy } from '../ApprovedPackagesPolicy'; import { RushConfigurationProject } from '../RushConfigurationProject'; -import { EnvironmentConfiguration } from '../EnvironmentConfiguration'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../EnvironmentConfiguration'; import { DependencyType } from '../PackageJsonEditor'; function normalizePathForComparison(pathToNormalize: string): string { @@ -227,9 +227,11 @@ describe(RushConfiguration.name, () => { describe('PNPM Store Paths', () => { afterEach(() => { EnvironmentConfiguration['_pnpmStorePathOverride'] = undefined; + EnvironmentConfiguration['_pnpmGlobalVirtualStore'] = false; + EnvironmentConfiguration.reset(); }); - const PNPM_STORE_PATH_ENV: string = 'RUSH_PNPM_STORE_PATH'; + const PNPM_STORE_PATH_ENV: string = EnvironmentVariableNames.RUSH_PNPM_STORE_PATH; describe('Loading repo/rush-pnpm-local.json', () => { const RUSH_JSON_FILENAME: string = path.resolve(__dirname, 'repo', 'rush-pnpm-local.json'); @@ -259,6 +261,20 @@ describe(RushConfiguration.name, () => { expect(rushConfiguration.pnpmOptions.pnpmStorePath).toEqual(EXPECT_STORE_PATH); expect(path.isAbsolute(rushConfiguration.pnpmOptions.pnpmStorePath)).toEqual(true); }); + + it(`loads the local store path when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE is defined`, () => { + const EXPECT_STORE_PATH: string = path.resolve(__dirname, 'repo', 'common', 'temp', 'pnpm-store'); + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(RUSH_JSON_FILENAME); + + expect(rushConfiguration.packageManager).toEqual('pnpm'); + expect(rushConfiguration.pnpmOptions.pnpmStore).toEqual('local'); + expect(Path.convertToSlashes(rushConfiguration.pnpmOptions.pnpmStorePath)).toEqual( + Path.convertToSlashes(EXPECT_STORE_PATH) + ); + }); }); describe('Loading repo/rush-pnpm-global.json', () => { diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index 2dbc215a3e0..089944f71a1 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -52,7 +52,6 @@ export interface IPnpmModules { interface IGlobalVirtualStoreValidationOptions { pnpmVersion: string; - pnpmConfigFilename: string; rushJsonFolder: string; pnpmStore: PnpmStoreLocation; pnpmStorePath: string; @@ -516,19 +515,20 @@ export class WorkspaceInstallManager extends BaseInstallManager { ); } - if (pnpmOptions.enableGlobalVirtualStore) { - WorkspaceInstallManager._validateGlobalVirtualStoreOptions({ - pnpmVersion: this.rushConfiguration.packageManagerToolVersion, - pnpmConfigFilename: - pnpmOptions.jsonFilename || - `${this.rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}`, - rushJsonFolder: this.rushConfiguration.rushJsonFolder, - pnpmStore: this.rushConfiguration.pnpmOptions.pnpmStore, - pnpmStorePath: this.rushConfiguration.pnpmOptions.pnpmStorePath, - pnpmStorePathOverride: EnvironmentConfiguration.pnpmStorePathOverride, - usePnpmSyncForInjectedDependencies: - this.rushConfiguration.experimentsConfiguration.configuration?.usePnpmSyncForInjectedDependencies - }); + if (EnvironmentConfiguration.pnpmGlobalVirtualStore) { + const globalVirtualStoreWarning: string | undefined = + WorkspaceInstallManager._validateGlobalVirtualStoreOptions({ + pnpmVersion: this.rushConfiguration.packageManagerToolVersion, + rushJsonFolder: this.rushConfiguration.rushJsonFolder, + pnpmStore: this.rushConfiguration.pnpmOptions.pnpmStore, + pnpmStorePath: this.rushConfiguration.pnpmOptions.pnpmStorePath, + pnpmStorePathOverride: EnvironmentConfiguration.pnpmStorePathOverride, + usePnpmSyncForInjectedDependencies: + this.rushConfiguration.experimentsConfiguration.configuration?.usePnpmSyncForInjectedDependencies + }); + if (globalVirtualStoreWarning) { + this._terminal.writeWarningLine(Colorize.yellow(globalVirtualStoreWarning)); + } workspaceFile.setEnableGlobalVirtualStore(true); } @@ -539,41 +539,47 @@ export class WorkspaceInstallManager extends BaseInstallManager { return { shrinkwrapIsUpToDate, shrinkwrapWarnings }; } - private static _validateGlobalVirtualStoreOptions(options: IGlobalVirtualStoreValidationOptions): void { + private static _validateGlobalVirtualStoreOptions( + options: IGlobalVirtualStoreValidationOptions + ): string | undefined { if (semver.lt(options.pnpmVersion, '10.12.1')) { throw new Error( `Your version of PNPM (${options.pnpmVersion}) doesn't support the ` + - `"enableGlobalVirtualStore" field in ${options.pnpmConfigFilename}. ` + - 'Remove this field or upgrade to PNPM 10.12.1 or newer.' + `${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment variable. ` + + 'Unset this environment variable or upgrade to PNPM 10.12.1 or newer.' ); } - if (options.pnpmStore === 'local') { - if (!options.pnpmStorePathOverride) { - throw new Error( - `The "enableGlobalVirtualStore" setting requires a shared PNPM store. ` + - `The current "pnpmStore" setting resolves to a worktree-local store under ` + - `${options.pnpmStorePath}. Set "pnpmStore" to "global" or use ` + - `${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH}.` - ); - } + if (options.pnpmStore === 'local' && !options.pnpmStorePathOverride) { + throw new Error( + `The ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment ` + + `variable requires a shared PNPM store. The current "pnpmStore" setting resolves to ` + + `a worktree-local store under ${options.pnpmStorePath}. Set "pnpmStore" to "global" ` + + `or use ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH}.` + ); + } + if (options.pnpmStorePathOverride) { if (Path.isUnderOrEqual(path.resolve(options.pnpmStorePathOverride), options.rushJsonFolder)) { - throw new Error( - `The "enableGlobalVirtualStore" setting requires a PNPM store that can be shared by ` + - `multiple worktrees. The ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH} environment ` + - `variable points inside the Rush repo: ${options.pnpmStorePathOverride}.` + return ( + `The ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH} environment variable points inside ` + + `the Rush repo: ${options.pnpmStorePathOverride}. PNPM global virtual store will still ` + + `be enabled, but the store will remain worktree-local and will not reduce setup or ` + + `cleanup costs across multiple worktrees.` ); } } if (options.usePnpmSyncForInjectedDependencies) { throw new Error( - `The "enableGlobalVirtualStore" setting is not compatible with the ` + + `The ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment ` + + `variable is not compatible with the ` + `"usePnpmSyncForInjectedDependencies" experiment. PNPM global virtual store moves the ` + `virtual store out of "node_modules/.pnpm", but pnpm-sync currently requires that folder.` ); } + + return undefined; } protected async canSkipInstallAsync( diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index 00c3c95bcd7..dbaf9027c02 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -188,10 +188,6 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { * {@inheritDoc PnpmOptionsConfiguration.alwaysFullInstall} */ alwaysFullInstall?: boolean; - /** - * {@inheritDoc PnpmOptionsConfiguration.enableGlobalVirtualStore} - */ - enableGlobalVirtualStore?: boolean; /** * {@inheritDoc PnpmOptionsConfiguration.pnpmLockfilePolicies} */ @@ -534,25 +530,6 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration /*[LINE "DEMO"]*/ public readonly alwaysFullInstall: boolean | undefined; - /** - * When true, Rush will configure PNPM to use a global virtual store for workspace installs. - * - * @remarks - * This causes PNPM to place the virtual store under the configured PNPM store instead of under - * `node_modules/.pnpm` in the workspace. This can significantly reduce setup and cleanup costs - * when multiple Git worktrees share the same PNPM store. - * - * This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared - * PNPM store, configured using either `pnpmStore: "global"` or the `RUSH_PNPM_STORE_PATH` - * environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. - * It is not compatible with the `usePnpmSyncForInjectedDependencies` experiment. - * - * PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore - * - * The default value is false. - */ - public readonly enableGlobalVirtualStore: boolean; - /** * The `globalCatalogs` setting provides named catalogs for organizing dependency versions. * Each catalog can be referenced using the `catalog:catalogName` protocol in package.json files @@ -625,7 +602,6 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration this.trustPolicyIgnoreAfterMinutes = json.trustPolicyIgnoreAfterMinutes; this.alwaysInjectDependenciesFromOtherSubspaces = json.alwaysInjectDependenciesFromOtherSubspaces; this.alwaysFullInstall = json.alwaysFullInstall; - this.enableGlobalVirtualStore = !!json.enableGlobalVirtualStore; this.pnpmLockfilePolicies = json.pnpmLockfilePolicies; this.globalCatalogs = json.globalCatalogs; } diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts index 8af1042c2bb..eb546fdc357 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts @@ -100,24 +100,6 @@ describe(PnpmOptionsConfiguration.name, () => { }); }); - it('loads enableGlobalVirtualStore', () => { - const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( - `${__dirname}/jsonFiles/pnpm-config-globalVirtualStore.json`, - fakeCommonTempFolder - ); - - expect(pnpmConfiguration.enableGlobalVirtualStore).toEqual(true); - }); - - it('defaults enableGlobalVirtualStore to false', () => { - const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonObject( - {}, - fakeCommonTempFolder - ); - - expect(pnpmConfiguration.enableGlobalVirtualStore).toEqual(false); - }); - it('loads minimumReleaseAgeMinutes', () => { const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( `${__dirname}/jsonFiles/pnpm-config-minimumReleaseAge.json`, diff --git a/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-globalVirtualStore.json b/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-globalVirtualStore.json deleted file mode 100644 index f0daf6d6a9b..00000000000 --- a/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-globalVirtualStore.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", - "enableGlobalVirtualStore": true -} diff --git a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts index 21a2f460d3c..e201c429d47 100644 --- a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts +++ b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts @@ -11,6 +11,7 @@ import type { IInstallManagerOptions } from '../base/BaseInstallManagerTypes'; import { RushConfiguration } from '../../api/RushConfiguration'; import { RushGlobalFolder } from '../../api/RushGlobalFolder'; import type { Subspace } from '../../api/Subspace'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; class FakeBaseInstallManager extends BaseInstallManager { public constructor( @@ -44,6 +45,10 @@ class FakeBaseInstallManager extends BaseInstallManager { describe('BaseInstallManager Test', () => { const rushGlobalFolder: RushGlobalFolder = new RushGlobalFolder(); + afterEach(() => { + EnvironmentConfiguration.reset(); + }); + it('pnpm version in 6.32.12 - 6.33.x || 7.0.1 - 7.8.x should output warning', () => { const rushJsonFilePnpmV6: string = path.resolve(__dirname, 'ignoreCompatibilityDb/rush1.json'); const rushJsonFilePnpmV7: string = path.resolve(__dirname, 'ignoreCompatibilityDb/rush2.json'); @@ -129,4 +134,150 @@ describe('BaseInstallManager Test', () => { ); } }); + + it('passes --store when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE is used with a local PNPM store', () => { + const originalPnpmGlobalVirtualStore: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + const originalPnpmStorePath: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + try { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + EnvironmentConfiguration.reset(); + + const rushJsonFile: string = path.resolve(__dirname, 'ignoreCompatibilityDb/rush3.json'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + const purgeManager: typeof PurgeManager.prototype = new PurgeManager( + rushConfiguration, + rushGlobalFolder + ); + const options: IInstallManagerOptions = { + subspace: rushConfiguration.defaultSubspace + } as IInstallManagerOptions; + const fakeBaseInstallManager: FakeBaseInstallManager = new FakeBaseInstallManager( + rushConfiguration, + rushGlobalFolder, + purgeManager, + options + ); + + const args: string[] = []; + fakeBaseInstallManager.pushConfigurationArgs(args, options, rushConfiguration.defaultSubspace); + + expect(rushConfiguration.pnpmOptions.pnpmStore).toEqual('local'); + expect(rushConfiguration.pnpmOptions.pnpmStorePath).not.toEqual(''); + expect(args).toContain('--store'); + expect(args).toContain(rushConfiguration.pnpmOptions.pnpmStorePath); + } finally { + if (originalPnpmGlobalVirtualStore === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = + originalPnpmGlobalVirtualStore; + } + if (originalPnpmStorePath === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = originalPnpmStorePath; + } + EnvironmentConfiguration.reset(); + } + }); + + it('does not pass --store when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE is used with a global PNPM store', () => { + const originalPnpmGlobalVirtualStore: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + const originalPnpmStorePath: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + try { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + EnvironmentConfiguration.reset(); + + const rushJsonFile: string = path.resolve(__dirname, '../../api/test/repo/rush-pnpm-global.json'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + const purgeManager: typeof PurgeManager.prototype = new PurgeManager( + rushConfiguration, + rushGlobalFolder + ); + const options: IInstallManagerOptions = { + subspace: rushConfiguration.defaultSubspace + } as IInstallManagerOptions; + const fakeBaseInstallManager: FakeBaseInstallManager = new FakeBaseInstallManager( + rushConfiguration, + rushGlobalFolder, + purgeManager, + options + ); + + const args: string[] = []; + fakeBaseInstallManager.pushConfigurationArgs(args, options, rushConfiguration.defaultSubspace); + + expect(rushConfiguration.pnpmOptions.pnpmStore).toEqual('global'); + expect(rushConfiguration.pnpmOptions.pnpmStorePath).toEqual(''); + expect(args).not.toContain('--store'); + } finally { + if (originalPnpmGlobalVirtualStore === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = + originalPnpmGlobalVirtualStore; + } + if (originalPnpmStorePath === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = originalPnpmStorePath; + } + EnvironmentConfiguration.reset(); + } + }); + + it('passes --store when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE uses an explicit PNPM store path', () => { + const originalPnpmGlobalVirtualStore: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + const originalPnpmStorePath: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + try { + const expectedStorePath: string = path.resolve('/var/temp/pnpm-store'); + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = expectedStorePath; + EnvironmentConfiguration.reset(); + + const rushJsonFile: string = path.resolve(__dirname, 'ignoreCompatibilityDb/rush3.json'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + const purgeManager: typeof PurgeManager.prototype = new PurgeManager( + rushConfiguration, + rushGlobalFolder + ); + const options: IInstallManagerOptions = { + subspace: rushConfiguration.defaultSubspace + } as IInstallManagerOptions; + const fakeBaseInstallManager: FakeBaseInstallManager = new FakeBaseInstallManager( + rushConfiguration, + rushGlobalFolder, + purgeManager, + options + ); + + const args: string[] = []; + fakeBaseInstallManager.pushConfigurationArgs(args, options, rushConfiguration.defaultSubspace); + + expect(rushConfiguration.pnpmOptions.pnpmStorePath).toEqual(expectedStorePath); + expect(args).toContain('--store'); + expect(args).toContain(expectedStorePath); + } finally { + if (originalPnpmGlobalVirtualStore === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = + originalPnpmGlobalVirtualStore; + } + if (originalPnpmStorePath === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = originalPnpmStorePath; + } + EnvironmentConfiguration.reset(); + } + }); }); diff --git a/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts b/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts index 8d75ecbfb35..78f845a9d3d 100644 --- a/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts +++ b/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts @@ -17,7 +17,6 @@ import type { PnpmStoreLocation } from '../pnpm/PnpmOptionsConfiguration'; interface IGlobalVirtualStoreValidationOptions { pnpmVersion: string; - pnpmConfigFilename: string; rushJsonFolder: string; pnpmStore: PnpmStoreLocation; pnpmStorePath: string; @@ -26,7 +25,7 @@ interface IGlobalVirtualStoreValidationOptions { } interface IWorkspaceInstallManagerWithValidation { - _validateGlobalVirtualStoreOptions(options: IGlobalVirtualStoreValidationOptions): void; + _validateGlobalVirtualStoreOptions(options: IGlobalVirtualStoreValidationOptions): string | undefined; } class TestWorkspaceInstallManager extends WorkspaceInstallManager { @@ -37,29 +36,31 @@ class TestWorkspaceInstallManager extends WorkspaceInstallManager { describe(WorkspaceInstallManager.name, () => { describe('enableGlobalVirtualStore validation', () => { - const validateGlobalVirtualStoreOptions: (options: IGlobalVirtualStoreValidationOptions) => void = ( - WorkspaceInstallManager as unknown as IWorkspaceInstallManagerWithValidation - )._validateGlobalVirtualStoreOptions; + const validateGlobalVirtualStoreOptions: ( + options: IGlobalVirtualStoreValidationOptions + ) => string | undefined = (WorkspaceInstallManager as unknown as IWorkspaceInstallManagerWithValidation) + ._validateGlobalVirtualStoreOptions; it('throws if the configured PNPM version does not support global virtual store', () => { expect(() => validateGlobalVirtualStoreOptions({ pnpmVersion: '10.12.0', - pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', rushJsonFolder: '/repo', pnpmStore: 'global', pnpmStorePath: '', pnpmStorePathOverride: undefined, usePnpmSyncForInjectedDependencies: undefined }) - ).toThrow('Your version of PNPM (10.12.0) doesn\'t support the "enableGlobalVirtualStore" field'); + ).toThrow( + `Your version of PNPM (10.12.0) doesn't support the ` + + `${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment variable` + ); }); it('throws if global virtual store is enabled with a worktree-local PNPM store', () => { expect(() => validateGlobalVirtualStoreOptions({ pnpmVersion: '10.12.1', - pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', rushJsonFolder: '/repo', pnpmStore: 'local', pnpmStorePath: '/repo/common/temp/pnpm-store', @@ -73,7 +74,6 @@ describe(WorkspaceInstallManager.name, () => { expect(() => validateGlobalVirtualStoreOptions({ pnpmVersion: '10.12.1', - pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', rushJsonFolder: '/repo', pnpmStore: 'global', pnpmStorePath: '', @@ -81,39 +81,39 @@ describe(WorkspaceInstallManager.name, () => { usePnpmSyncForInjectedDependencies: true }) ).toThrow( - 'The "enableGlobalVirtualStore" setting is not compatible with the ' + + `The ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment ` + + `variable is not compatible with the ` + '"usePnpmSyncForInjectedDependencies" experiment' ); }); - it('throws if the PNPM store path override points inside the Rush repo', () => { - expect(() => + it('warns if the PNPM store path override points inside the Rush repo', () => { + expect( validateGlobalVirtualStoreOptions({ pnpmVersion: '10.12.1', - pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', rushJsonFolder: '/repo', pnpmStore: 'local', pnpmStorePath: '/repo/common/temp/pnpm-store', pnpmStorePathOverride: '/repo/common/temp/shared-pnpm-store', usePnpmSyncForInjectedDependencies: undefined }) - ).toThrow( - `The ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH} environment variable points inside the Rush repo` + ).toContain( + `The ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH} environment variable points inside ` + + `the Rush repo` ); }); it('allows global virtual store with a PNPM store path override', () => { - expect(() => + expect( validateGlobalVirtualStoreOptions({ pnpmVersion: '10.12.1', - pnpmConfigFilename: '/repo/common/config/rush/pnpm-config.json', rushJsonFolder: '/repo', pnpmStore: 'local', pnpmStorePath: '/repo/common/temp/pnpm-store', pnpmStorePathOverride: '/shared/pnpm-store', usePnpmSyncForInjectedDependencies: undefined }) - ).not.toThrow(); + ).toBeUndefined(); }); }); @@ -121,15 +121,22 @@ describe(WorkspaceInstallManager.name, () => { const fixtureRepoPath: string = path.resolve(__dirname, 'repoWithSubspacesCatalogs'); const tempFolderPath: string = `${__dirname}/temp/${WorkspaceInstallManager.name}`; let originalPnpmStorePathOverride: string | undefined; + let originalPnpmGlobalVirtualStore: boolean; let originalPnpmStorePathEnvValue: string | undefined; + let originalPnpmGlobalVirtualStoreEnvValue: string | undefined; beforeEach(() => { originalPnpmStorePathEnvValue = process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + originalPnpmGlobalVirtualStoreEnvValue = + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; EnvironmentConfiguration.reset(); EnvironmentConfiguration.validate({ doNotNormalizePaths: true }); originalPnpmStorePathOverride = EnvironmentConfiguration.pnpmStorePathOverride; + originalPnpmGlobalVirtualStore = EnvironmentConfiguration.pnpmGlobalVirtualStore; EnvironmentConfiguration['_pnpmStorePathOverride'] = undefined; + EnvironmentConfiguration['_pnpmGlobalVirtualStore'] = false; FileSystem.ensureEmptyFolder(tempFolderPath); }); @@ -139,7 +146,14 @@ describe(WorkspaceInstallManager.name, () => { } else { process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = originalPnpmStorePathEnvValue; } + if (originalPnpmGlobalVirtualStoreEnvValue === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = + originalPnpmGlobalVirtualStoreEnvValue; + } EnvironmentConfiguration['_pnpmStorePathOverride'] = originalPnpmStorePathOverride; + EnvironmentConfiguration['_pnpmGlobalVirtualStore'] = originalPnpmGlobalVirtualStore; EnvironmentConfiguration.reset(); FileSystem.deleteFolder(tempFolderPath); }); @@ -165,11 +179,6 @@ describe(WorkspaceInstallManager.name, () => { } JsonFile.save(commonPnpmConfigJson, commonPnpmConfigPath, { updateExistingFile: true }); - const subspacePnpmConfigPath: string = `${repoPath}/common/config/subspaces/default/pnpm-config.json`; - const subspacePnpmConfigJson: Record = JsonFile.load(subspacePnpmConfigPath); - subspacePnpmConfigJson.enableGlobalVirtualStore = true; - JsonFile.save(subspacePnpmConfigJson, subspacePnpmConfigPath, { updateExistingFile: true }); - const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonPath); FileSystem.ensureFolder(rushConfiguration.defaultSubspace.getSubspaceTempFolderPath()); return rushConfiguration; @@ -195,6 +204,8 @@ describe(WorkspaceInstallManager.name, () => { } it('writes enableGlobalVirtualStore through the workspace install prepare path', async () => { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + EnvironmentConfiguration.reset(); const rushConfiguration: RushConfiguration = prepareFixtureRepo({ pnpmStore: 'global' }); const installManager: TestWorkspaceInstallManager = createInstallManager(rushConfiguration); @@ -208,11 +219,13 @@ describe(WorkspaceInstallManager.name, () => { }); it('throws from the workspace install prepare path when using a worktree-local PNPM store', async () => { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + EnvironmentConfiguration.reset(); const rushConfiguration: RushConfiguration = prepareFixtureRepo({}); const installManager: TestWorkspaceInstallManager = createInstallManager(rushConfiguration); expect(rushConfiguration.pnpmOptions.pnpmStore).toEqual('local'); - expect(rushConfiguration.defaultSubspace.getPnpmOptions()?.enableGlobalVirtualStore).toEqual(true); + expect(rushConfiguration.pnpmOptions.pnpmStorePath).not.toEqual(''); await expect( installManager.prepareCommonTempForTestAsync(rushConfiguration.defaultSubspace) diff --git a/libraries/rush-lib/src/schemas/pnpm-config.schema.json b/libraries/rush-lib/src/schemas/pnpm-config.schema.json index 13ca9d8c3b4..93be4e62642 100644 --- a/libraries/rush-lib/src/schemas/pnpm-config.schema.json +++ b/libraries/rush-lib/src/schemas/pnpm-config.schema.json @@ -252,11 +252,6 @@ "type": "boolean" }, - "enableGlobalVirtualStore": { - "description": "(EXPERIMENTAL) If true, Rush will configure PNPM workspace installs to use PNPM's global virtual store. This places the virtual store under the configured PNPM store instead of under `node_modules/.pnpm` in the workspace. This can significantly reduce setup and cleanup costs when multiple Git worktrees share the same PNPM store.\n\nThis option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared PNPM store, configured using either `pnpmStore: \"global\"` or the `RUSH_PNPM_STORE_PATH` environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo. It is not currently compatible with the `usePnpmSyncForInjectedDependencies` experiment.\n\nPNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore\n\nThe default value is false.", - "type": "boolean" - }, - "pnpmLockfilePolicies": { "description": "This setting defines the policies that govern the `pnpm-lock.yaml` file.", "type": "object",