Skip to content

Commit 47bdb9f

Browse files
committed
feat: support pnpm global virtual store settings
1 parent 71943f2 commit 47bdb9f

20 files changed

Lines changed: 849 additions & 46 deletions

build-tests/rush-package-manager-integration-test/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Rush Package Manager Integration Tests
22

3-
This directory contains integration tests for verifying Rush works correctly with different package managers after the tar 7.x upgrade.
3+
This directory contains integration tests for verifying Rush works correctly with different package managers.
44

55
## Background
66

@@ -10,6 +10,8 @@ Rush's npm and yarn modes use temp project tarballs (stored in `common/temp/proj
1010

1111
These tests ensure the tar 7.x upgrade works correctly with these workflows.
1212

13+
The PNPM test additionally verifies Rush's workspace install integration with PNPM's global virtual store.
14+
1315
## Tests
1416

1517
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:
3032
- Running `rush install`
3133
- Running `rush build` (verifies everything works end-to-end)
3234

35+
### testPnpmGlobalVirtualStore.ts
36+
Tests Rush pnpm workspace mode with global virtual store by:
37+
- Initializing a Rush repo with `pnpmVersion` configured
38+
- Enabling `useWorkspaces` and `enableGlobalVirtualStore`
39+
- Running `rush update`
40+
- Running `rush install`
41+
- Verifying the generated workspace file, shared PNPM store, dependency links, and build output
42+
3343
## Prerequisites
3444

3545
Before running these tests:
@@ -62,6 +72,7 @@ These integration tests verify:
6272
- ✓ Tarballs are extracted correctly during `rush install`
6373
- ✓ File permissions are preserved (tar filter function works)
6474
- ✓ Dependencies are linked properly between projects
75+
- ✓ PNPM global virtual store is passed through to a real workspace install
6576
- ✓ The complete workflow (update → install → build) succeeds
6677
- ✓ Built code executes correctly
6778

@@ -70,6 +81,7 @@ These integration tests verify:
7081
Each test creates a temporary Rush repository in `/tmp/rush-package-manager-test/`:
7182
- `/tmp/rush-package-manager-test/npm-test-repo/` - npm mode test repository
7283
- `/tmp/rush-package-manager-test/yarn-test-repo/` - yarn mode test repository
84+
- `/tmp/rush-package-manager-test/pnpm-global-virtual-store-test-repo/` - pnpm global virtual store test repository
7385

7486
These directories are cleaned up at the start of each test run.
7587

@@ -86,6 +98,7 @@ The tests use:
8698
The tar library is used in:
8799
- `libraries/rush-lib/src/logic/TempProjectHelper.ts` - Creates tarballs
88100
- `libraries/rush-lib/src/logic/npm/NpmLinkManager.ts` - Extracts tarballs
101+
- `libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts` - Generates PNPM workspace files
89102

90103
## Troubleshooting
91104

build-tests/rush-package-manager-integration-test/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "rush-package-manager-integration-test",
33
"version": "1.0.0",
44
"private": true,
5-
"description": "Integration tests for non-pnpm package managers in Rush.",
5+
"description": "Integration tests for package managers in Rush.",
66
"license": "MIT",
77
"scripts": {
88
"_phase:build": "heft build --clean",

build-tests/rush-package-manager-integration-test/src/TestHelper.ts

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import * as path from 'node:path';
55
import type * as child_process from 'node:child_process';
66

7-
import { FileSystem, Executable, JsonFile, type JsonObject } from '@rushstack/node-core-library';
7+
import { FileSystem, Executable, JsonFile, Path, type JsonObject } from '@rushstack/node-core-library';
88
import type { ITerminal } from '@rushstack/terminal';
99

1010
/**
@@ -23,14 +23,19 @@ export class TestHelper {
2323
/**
2424
* Execute a Rush command using the locally-built Rush
2525
*/
26-
public async executeRushAsync(args: string[], workingDirectory: string): Promise<void> {
26+
public async executeRushAsync(
27+
args: string[],
28+
workingDirectory: string,
29+
environment?: NodeJS.ProcessEnv
30+
): Promise<void> {
2731
this._terminal.writeLine(`Executing: ${process.argv0} ${this._rushBinPath} ${args.join(' ')}`);
2832

2933
const childProcess: child_process.ChildProcess = Executable.spawn(
3034
process.argv0,
3135
[this._rushBinPath, ...args],
3236
{
3337
currentWorkingDirectory: workingDirectory,
38+
environment,
3439
stdio: 'inherit'
3540
}
3641
);
@@ -45,7 +50,7 @@ export class TestHelper {
4550
*/
4651
public async createTestRepoAsync(
4752
testRepoPath: string,
48-
packageManagerType: 'npm' | 'yarn',
53+
packageManagerType: 'npm' | 'pnpm' | 'yarn',
4954
packageManagerVersion: string
5055
): Promise<void> {
5156
// Clean up previous test run and create empty test repo directory
@@ -66,6 +71,10 @@ export class TestHelper {
6671
delete rushJson.pnpmVersion;
6772
delete rushJson.yarnVersion;
6873
rushJson.npmVersion = packageManagerVersion;
74+
} else if (packageManagerType === 'pnpm') {
75+
delete rushJson.npmVersion;
76+
delete rushJson.yarnVersion;
77+
rushJson.pnpmVersion = packageManagerVersion;
6978
} else if (packageManagerType === 'yarn') {
7079
delete rushJson.pnpmVersion;
7180
delete rushJson.npmVersion;
@@ -151,7 +160,9 @@ export class TestHelper {
151160
// Verify symlinks resolve correctly for local dependencies
152161
if (dep.startsWith('test-project-')) {
153162
const depRealPath: string = await FileSystem.getRealPathAsync(depPath);
154-
const expectedRealPath: string = path.join(testRepoPath, 'projects', dep);
163+
const expectedRealPath: string = await FileSystem.getRealPathAsync(
164+
path.join(testRepoPath, 'projects', dep)
165+
);
155166
if (depRealPath !== expectedRealPath) {
156167
throw new Error(
157168
`ERROR: Symlink for ${dep} does not resolve correctly!\n` +
@@ -164,6 +175,75 @@ export class TestHelper {
164175
this._terminal.writeLine('✓ Dependencies installed correctly');
165176
}
166177

178+
/**
179+
* Verify that PNPM's global virtual store was enabled and moved out of the workspace node_modules folder.
180+
*/
181+
public async verifyPnpmGlobalVirtualStoreAsync(
182+
testRepoPath: string,
183+
sharedStorePath: string,
184+
externalDependencyPaths: string[]
185+
): Promise<void> {
186+
this._terminal.writeLine('\nVerifying PNPM global virtual store structure...');
187+
188+
const workspaceFilePath: string = path.join(testRepoPath, 'common/temp/pnpm-workspace.yaml');
189+
const workspaceFileContents: string = await FileSystem.readFileAsync(workspaceFilePath);
190+
if (!workspaceFileContents.includes('enableGlobalVirtualStore: true')) {
191+
throw new Error(`ERROR: enableGlobalVirtualStore was not written to ${workspaceFilePath}`);
192+
}
193+
194+
const localVirtualStorePath: string = path.join(testRepoPath, 'common/temp/node_modules/.pnpm');
195+
if (await FileSystem.existsAsync(localVirtualStorePath)) {
196+
const localVirtualStoreItemNames: string[] =
197+
await FileSystem.readFolderItemNamesAsync(localVirtualStorePath);
198+
const unexpectedLocalPackageFolders: string[] = localVirtualStoreItemNames.filter(
199+
(itemName) => itemName !== 'lock.yaml' && itemName !== 'node_modules'
200+
);
201+
if (unexpectedLocalPackageFolders.length > 0) {
202+
throw new Error(
203+
`ERROR: Expected ${localVirtualStorePath} to omit package instance folders, but found: ` +
204+
unexpectedLocalPackageFolders.join(', ')
205+
);
206+
}
207+
}
208+
209+
if (!(await FileSystem.existsAsync(sharedStorePath))) {
210+
throw new Error(`ERROR: Shared PNPM store was not created at ${sharedStorePath}`);
211+
}
212+
213+
const sharedStoreItemNames: string[] = await FileSystem.readFolderItemNamesAsync(sharedStorePath);
214+
if (sharedStoreItemNames.length === 0) {
215+
throw new Error(`ERROR: Shared PNPM store is empty at ${sharedStorePath}`);
216+
}
217+
218+
const sharedStoreRealPath: string = await FileSystem.getRealPathAsync(sharedStorePath);
219+
const localVirtualStoreRealPath: string | undefined = (await FileSystem.existsAsync(
220+
localVirtualStorePath
221+
))
222+
? await FileSystem.getRealPathAsync(localVirtualStorePath)
223+
: undefined;
224+
for (const externalDependencyPath of externalDependencyPaths) {
225+
const dependencyPath: string = path.join(testRepoPath, externalDependencyPath);
226+
const dependencyRealPath: string = await FileSystem.getRealPathAsync(dependencyPath);
227+
if (!Path.isUnderOrEqual(dependencyRealPath, sharedStoreRealPath)) {
228+
throw new Error(
229+
`ERROR: Expected ${dependencyPath} to resolve under the shared PNPM store.\n` +
230+
`Shared store: ${sharedStoreRealPath}\n` +
231+
`Actual: ${dependencyRealPath}`
232+
);
233+
}
234+
235+
if (localVirtualStoreRealPath && Path.isUnderOrEqual(dependencyRealPath, localVirtualStoreRealPath)) {
236+
throw new Error(
237+
`ERROR: Expected ${dependencyPath} to resolve outside the local virtual store.\n` +
238+
`Local virtual store: ${localVirtualStoreRealPath}\n` +
239+
`Actual: ${dependencyRealPath}`
240+
);
241+
}
242+
}
243+
244+
this._terminal.writeLine('✓ PNPM global virtual store structure verified');
245+
}
246+
167247
/**
168248
* Verify that build outputs were created
169249
*/

build-tests/rush-package-manager-integration-test/src/runTests.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { Terminal, ConsoleTerminalProvider } from '@rushstack/terminal';
55

66
import { testNpmModeAsync } from './testNpmMode';
7+
import { testPnpmGlobalVirtualStoreAsync } from './testPnpmGlobalVirtualStore';
78
import { testYarnModeAsync } from './testYarnMode';
89

910
/**
@@ -17,7 +18,7 @@ async function runTestsAsync(): Promise<void> {
1718
terminal.writeLine('==========================================');
1819
terminal.writeLine('');
1920
terminal.writeLine('These tests verify that the tar 7.x upgrade works correctly');
20-
terminal.writeLine('with different Rush package managers (npm, yarn).');
21+
terminal.writeLine('with different Rush package managers (npm, pnpm, yarn).');
2122
terminal.writeLine('');
2223
terminal.writeLine('Tests will:');
2324
terminal.writeLine(' 1. Create Rush repos using locally-built Rush');
@@ -45,6 +46,20 @@ async function runTestsAsync(): Promise<void> {
4546
terminal.writeErrorLine(String(error));
4647
}
4748

49+
// Run pnpm global virtual store test
50+
terminal.writeLine('==========================================');
51+
terminal.writeLine('Running PNPM global virtual store test...');
52+
terminal.writeLine('==========================================');
53+
try {
54+
await testPnpmGlobalVirtualStoreAsync(terminal);
55+
testsPassed++;
56+
} catch (error) {
57+
testsFailed++;
58+
failedTests.push('PNPM global virtual store');
59+
terminal.writeErrorLine('⚠️ PNPM global virtual store test FAILED');
60+
terminal.writeErrorLine(String(error));
61+
}
62+
4863
// Run yarn mode test
4964
terminal.writeLine('==========================================');
5065
terminal.writeLine('Running Yarn mode test...');
@@ -81,6 +96,7 @@ async function runTestsAsync(): Promise<void> {
8196
terminal.writeLine('');
8297
terminal.writeLine('The tar 7.x upgrade is working correctly with:');
8398
terminal.writeLine(' - NPM package manager');
99+
terminal.writeLine(' - PNPM global virtual store');
84100
terminal.writeLine(' - Yarn package manager');
85101
terminal.writeLine('');
86102
process.exit(0);
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as os from 'node:os';
5+
import * as path from 'node:path';
6+
7+
import { FileSystem, JsonFile, type JsonObject } from '@rushstack/node-core-library';
8+
import type { ITerminal } from '@rushstack/terminal';
9+
10+
import { TestHelper } from './TestHelper';
11+
12+
/**
13+
* Integration test for Rush PNPM workspace mode with PNPM's global virtual store.
14+
* This test verifies that Rush passes enableGlobalVirtualStore through to a real PNPM install.
15+
*/
16+
export async function testPnpmGlobalVirtualStoreAsync(terminal: ITerminal): Promise<void> {
17+
const helper: TestHelper = new TestHelper(terminal);
18+
// Use system temp directory to avoid rush init detecting parent rush.json
19+
const testRepoPath: string = path.join(
20+
os.tmpdir(),
21+
'rush-package-manager-test',
22+
'pnpm-global-virtual-store-test-repo'
23+
);
24+
const sharedStorePath: string = path.join(os.tmpdir(), 'rush-package-manager-test', 'shared-pnpm-store');
25+
const rushEnvironment: NodeJS.ProcessEnv = {
26+
...process.env,
27+
CI: 'false',
28+
PNPM_CONFIG_CI: 'false',
29+
RUSH_PNPM_STORE_PATH: sharedStorePath
30+
};
31+
32+
terminal.writeLine('==========================================');
33+
terminal.writeLine('Rush PNPM Global Virtual Store Integration Test');
34+
terminal.writeLine('==========================================');
35+
terminal.writeLine('');
36+
terminal.writeLine(
37+
'This test verifies that Rush can enable PNPM global virtual store during workspace installs.'
38+
);
39+
terminal.writeLine('');
40+
41+
await helper.createTestRepoAsync(testRepoPath, 'pnpm', '10.12.1');
42+
43+
const pnpmConfigPath: string = path.join(testRepoPath, 'common/config/rush/pnpm-config.json');
44+
const pnpmConfigJson: JsonObject = await JsonFile.loadAsync(pnpmConfigPath);
45+
pnpmConfigJson.useWorkspaces = true;
46+
pnpmConfigJson.enableGlobalVirtualStore = true;
47+
await JsonFile.saveAsync(pnpmConfigJson, pnpmConfigPath, { updateExistingFile: true });
48+
49+
terminal.writeLine('Creating test-project-a...');
50+
await helper.createTestProjectAsync(
51+
testRepoPath,
52+
'test-project-a',
53+
'1.0.0',
54+
{ semver: '^7.5.4' },
55+
`node -e "const fs = require('fs'); fs.mkdirSync('lib', {recursive: true}); fs.writeFileSync('lib/index.js', 'module.exports = { greet: () => \\"Hello from A\\" };');"`
56+
);
57+
58+
terminal.writeLine('Creating test-project-b...');
59+
await helper.createTestProjectAsync(
60+
testRepoPath,
61+
'test-project-b',
62+
'1.0.0',
63+
{
64+
'test-project-a': 'workspace:*',
65+
moment: '^2.29.4'
66+
},
67+
`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() };');"`
68+
);
69+
70+
await FileSystem.ensureEmptyFolderAsync(sharedStorePath);
71+
72+
terminal.writeLine('');
73+
terminal.writeLine("Running 'rush update' with PNPM global virtual store enabled...");
74+
await helper.executeRushAsync(['update'], testRepoPath, rushEnvironment);
75+
76+
terminal.writeLine('');
77+
terminal.writeLine("Running 'rush install' with PNPM global virtual store enabled...");
78+
await helper.executeRushAsync(['install'], testRepoPath, rushEnvironment);
79+
80+
await helper.verifyPnpmGlobalVirtualStoreAsync(testRepoPath, sharedStorePath, [
81+
'projects/test-project-a/node_modules/semver',
82+
'projects/test-project-b/node_modules/moment'
83+
]);
84+
await helper.verifyDependenciesAsync(testRepoPath, 'test-project-a', ['semver']);
85+
await helper.verifyDependenciesAsync(testRepoPath, 'test-project-b', ['test-project-a']);
86+
87+
terminal.writeLine('');
88+
terminal.writeLine("Running 'rush build'...");
89+
await helper.executeRushAsync(['build'], testRepoPath, rushEnvironment);
90+
91+
await helper.verifyBuildOutputsAsync(testRepoPath, ['test-project-a', 'test-project-b']);
92+
await helper.testBuiltCodeAsync(testRepoPath, 'test-project-b');
93+
94+
terminal.writeLine('');
95+
terminal.writeLine('==========================================');
96+
terminal.writeLine('✓ PNPM Global Virtual Store Integration Test PASSED');
97+
terminal.writeLine('==========================================');
98+
terminal.writeLine('');
99+
terminal.writeLine('PNPM global virtual store works correctly with Rush workspace installs:');
100+
terminal.writeLine(' - Workspace file includes enableGlobalVirtualStore');
101+
terminal.writeLine(' - Shared PNPM store is populated');
102+
terminal.writeLine(' - Dependencies link and resolve correctly');
103+
terminal.writeLine(' - Build completed successfully');
104+
terminal.writeLine('');
105+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush-lib",
5+
"comment": "Add PNPM global virtual store support for workspace installs and avoid unnecessary global package manager lock acquisition.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@microsoft/rush-lib",
10+
"email": "EscapeB@users.noreply.github.com"
11+
}

common/config/rush/pnpm-config.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,24 @@
190190
*/
191191
// "pnpmStore": "global",
192192

193+
/**
194+
* If true, Rush will configure PNPM workspace installs to use PNPM's global virtual store.
195+
* This places the virtual store under the configured PNPM store instead of under
196+
* `node_modules/.pnpm` in the workspace. This can significantly reduce setup and cleanup costs
197+
* when multiple Git worktrees share the same PNPM store.
198+
*
199+
* This option only affects workspace installs. It requires PNPM 10.12.1 or newer and a shared
200+
* PNPM store, configured using either `"pnpmStore": "global"` or the `RUSH_PNPM_STORE_PATH`
201+
* environment variable. If `RUSH_PNPM_STORE_PATH` is used, it must point outside the Rush repo.
202+
* It is not currently compatible with the
203+
* `usePnpmSyncForInjectedDependencies` experiment.
204+
*
205+
* PNPM documentation: https://pnpm.io/settings#enableglobalvirtualstore
206+
*
207+
* The default value is false.
208+
*/
209+
// "enableGlobalVirtualStore": true,
210+
193211
/**
194212
* If true, then `rush install` will report an error if manual modifications
195213
* were made to the PNPM shrinkwrap file without running `rush update` afterwards.

0 commit comments

Comments
 (0)