From 7a0be7955a7391bfd85f2589685340236c85b345 Mon Sep 17 00:00:00 2001 From: "chenzhelong.sirius" Date: Thu, 11 Jun 2026 20:20:16 +0800 Subject: [PATCH] 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",