diff --git a/common/changes/@microsoft/rush/autoinstaller-move-node-modules-to-recycler_2026-04-07-18-05.json b/common/changes/@microsoft/rush/autoinstaller-move-node-modules-to-recycler_2026-04-07-18-05.json new file mode 100644 index 00000000000..74e96cdb0f0 --- /dev/null +++ b/common/changes/@microsoft/rush/autoinstaller-move-node-modules-to-recycler_2026-04-07-18-05.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Move stale autoinstaller `node_modules` folders into Rush's recycler before asynchronously deleting them, instead of synchronously deleting them in place.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/libraries/rush-lib/src/cli/test/Autoinstaller.test.ts b/libraries/rush-lib/src/cli/test/Autoinstaller.test.ts new file mode 100644 index 00000000000..f975427f50a --- /dev/null +++ b/libraries/rush-lib/src/cli/test/Autoinstaller.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import './mockRushCommandLineParser'; + +import { FileSystem } from '@rushstack/node-core-library'; + +import { Autoinstaller } from '../../logic/Autoinstaller'; +import { InstallHelpers } from '../../logic/installManager/InstallHelpers'; +import { RushConstants } from '../../logic/RushConstants'; +import { Utilities } from '../../utilities/Utilities'; +import { + getCommandLineParserInstanceAsync, + isolateEnvironmentConfigurationForTests, + type IEnvironmentConfigIsolation +} from './TestUtils'; + +describe(Autoinstaller.name, () => { + let _envIsolation: IEnvironmentConfigIsolation; + + beforeEach(() => { + _envIsolation = isolateEnvironmentConfigurationForTests(); + }); + + afterEach(() => { + _envIsolation.restore(); + jest.restoreAllMocks(); + }); + + it('moves an existing node_modules folder into the Rush recycler before reinstalling', async () => { + const { parser, repoPath, spawnMock } = await getCommandLineParserInstanceAsync( + 'pluginWithBuildCommandRepo', + 'update' + ); + const autoinstallerPath: string = `${repoPath}/common/autoinstallers/plugins`; + const nodeModulesFolder: string = `${autoinstallerPath}/${RushConstants.nodeModulesFolderName}`; + const staleFilePath: string = `${nodeModulesFolder}/stale-package/index.js`; + const recyclerFolder: string = `${parser.rushConfiguration.commonTempFolder}/${RushConstants.rushRecyclerFolderName}`; + + await FileSystem.writeFileAsync(staleFilePath, 'stale', { + ensureFolderExists: true + }); + + let recyclerEntriesBefore: Set; + try { + recyclerEntriesBefore = new Set(await FileSystem.readFolderItemNamesAsync(recyclerFolder)); + } catch (error) { + if (FileSystem.isNotExistError(error)) { + recyclerEntriesBefore = new Set(); + } else { + throw error; + } + } + + jest.spyOn(InstallHelpers, 'ensureLocalPackageManagerAsync').mockResolvedValue(undefined); + jest.spyOn(Utilities, 'syncNpmrc').mockImplementation(() => undefined); + jest + .spyOn(Utilities, 'executeCommandAsync') + .mockImplementation(async (options: Parameters[0]) => { + await FileSystem.ensureFolderAsync( + `${options.workingDirectory}/${RushConstants.nodeModulesFolderName}` + ); + }); + + const autoinstaller: Autoinstaller = new Autoinstaller({ + autoinstallerName: 'plugins', + rushConfiguration: parser.rushConfiguration, + rushGlobalFolder: parser.rushGlobalFolder + }); + + await autoinstaller.prepareAsync(); + + const recyclerEntriesAfter: string[] = (await FileSystem.readFolderItemNamesAsync(recyclerFolder)).filter( + (entry: string) => !recyclerEntriesBefore.has(entry) + ); + + expect(recyclerEntriesAfter).toHaveLength(1); + await expect( + FileSystem.existsAsync(`${recyclerFolder}/${recyclerEntriesAfter[0]}/stale-package/index.js`) + ).resolves.toBe(true); + await expect(FileSystem.existsAsync(staleFilePath)).resolves.toBe(false); + await expect(FileSystem.existsAsync(`${nodeModulesFolder}/rush-autoinstaller.flag`)).resolves.toBe(true); + + if (process.platform === 'win32') { + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + expect.arrayContaining(['/c']), + expect.objectContaining({ + detached: true, + stdio: 'ignore', + windowsVerbatimArguments: true + }) + ); + } else { + expect(spawnMock).toHaveBeenCalledWith( + 'rm', + expect.arrayContaining(['-rf']), + expect.objectContaining({ + detached: true, + stdio: 'ignore' + }) + ); + } + }); +}); diff --git a/libraries/rush-lib/src/logic/Autoinstaller.ts b/libraries/rush-lib/src/logic/Autoinstaller.ts index ae55a8c361b..a47dd0d89be 100644 --- a/libraries/rush-lib/src/logic/Autoinstaller.ts +++ b/libraries/rush-lib/src/logic/Autoinstaller.ts @@ -14,6 +14,7 @@ import { } from '@rushstack/node-core-library'; import { Colorize } from '@rushstack/terminal'; +import { AsyncRecycler } from '../utilities/AsyncRecycler'; import { Utilities } from '../utilities/Utilities'; import type { RushConfiguration } from '../api/RushConfiguration'; import { PackageJsonEditor } from '../api/PackageJsonEditor'; @@ -131,7 +132,11 @@ export class Autoinstaller { if (isLastInstallFlagDirty || lock.dirtyWhenAcquired) { if (FileSystem.exists(nodeModulesFolder)) { this._logIfConsoleOutputIsNotRestricted('Deleting old files from ' + nodeModulesFolder); - FileSystem.ensureEmptyFolder(nodeModulesFolder); + const recycler: AsyncRecycler = new AsyncRecycler( + `${this._rushConfiguration.commonTempFolder}/${RushConstants.rushRecyclerFolderName}` + ); + recycler.moveFolder(nodeModulesFolder); + await recycler.startDeleteAllAsync(); } // Copy: .../common/autoinstallers/my-task/.npmrc