diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 5f376e94a4cec..ea645b0ca9249 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -8,9 +8,9 @@ const promiseAllRejectLate = require('promise-all-reject-late') const runScript = require('@npmcli/run-script') const { callLimit: promiseCallLimit } = require('promise-call-limit') const { depth: dfwalk } = require('treeverse') -const { dirname, resolve, relative, join } = require('node:path') +const { dirname, resolve, relative, join, sep } = require('node:path') const { log, time } = require('proc-log') -const { lstat, mkdir, rm, symlink } = require('node:fs/promises') +const { lstat, mkdir, readdir, rm, symlink } = require('node:fs/promises') const { moveFile } = require('@npmcli/fs') const { subset, intersects } = require('semver') const { walkUp } = require('walk-up-path') @@ -120,6 +120,7 @@ module.exports = cls => class Reifier extends cls { await this[_diffTrees]() await this.#reifyPackages() if (linked) { + await this.#cleanOrphanedStoreEntries() // swap back in the idealTree // so that the lockfile is preserved this.idealTree = oldTree @@ -1247,6 +1248,44 @@ module.exports = cls => class Reifier extends cls { timeEnd() } + // After a linked install, scan node_modules/.store/ and remove any + // directories that are not referenced by the current ideal tree. + // Store entries become orphaned when dependencies are updated or + // removed, because the diff never sees the old store keys. + async #cleanOrphanedStoreEntries () { + const storeDir = resolve(this.path, 'node_modules', '.store') + let entries + try { + entries = await readdir(storeDir) + } catch { + return + } + + // Collect valid store keys from the isolated ideal tree. + // Store entries have location: node_modules/.store/{key}/node_modules/{pkg} + const validKeys = new Set() + for (const child of this.idealTree.children) { + if (child.isInStore) { + const key = child.location.split(sep)[2] + validKeys.add(key) + } + } + + const orphaned = entries.filter(e => !validKeys.has(e)) + if (!orphaned.length) { + return + } + + log.silly('reify', 'cleaning orphaned store entries', orphaned) + await promiseAllRejectLate( + orphaned.map(e => + rm(resolve(storeDir, e), { recursive: true, force: true }) + .catch(/* istanbul ignore next - rm with force rarely fails */ + er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er)) + ) + ) + } + // last but not least, we save the ideal tree metadata to the package-lock // or shrinkwrap file, and any additions or removals to package.json async [_saveIdealTree] (options) { diff --git a/workspaces/arborist/test/isolated-mode.js b/workspaces/arborist/test/isolated-mode.js index cd8434e638966..2bced68a471b9 100644 --- a/workspaces/arborist/test/isolated-mode.js +++ b/workspaces/arborist/test/isolated-mode.js @@ -1637,6 +1637,85 @@ tap.test('bins are installed', async t => { t.ok(binFromBarToWhich) }) +tap.test('orphaned store entries are cleaned up on dependency update', async t => { + const graph = { + registry: [ + { name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } }, + { name: 'which', version: '2.0.0', dependencies: { isexe: '^1.0.0' } }, + { name: 'isexe', version: '1.0.0' }, + ], + root: { + name: 'myproject', + version: '1.0.0', + dependencies: { which: '1.0.0' }, + }, + } + const { dir, registry } = await getRepo(graph) + const cache = fs.mkdtempSync(`${getTempDir()}/test-`) + const storeDir = path.join(dir, 'node_modules', '.store') + + // First install — which@1.0.0 + const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache }) + await arb1.reify({ installStrategy: 'linked' }) + + const entriesAfterV1 = fs.readdirSync(storeDir) + t.ok(entriesAfterV1.some(e => e.startsWith('which@1.0.0-')), + 'store has which@1.0.0 entry after first install') + + // Update package.json to depend on which@2.0.0 + const pkgPath = path.join(dir, 'package.json') + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + pkg.dependencies.which = '2.0.0' + fs.writeFileSync(pkgPath, JSON.stringify(pkg)) + + // Second install — which@2.0.0 + const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache }) + await arb2.reify({ installStrategy: 'linked' }) + + const entriesAfterV2 = fs.readdirSync(storeDir) + t.ok(entriesAfterV2.some(e => e.startsWith('which@2.0.0-')), + 'store has which@2.0.0 entry after update') + t.notOk(entriesAfterV2.some(e => e.startsWith('which@1.0.0-')), + 'old which@1.0.0 store entry is removed after update') +}) + +tap.test('orphaned store entries are cleaned up on dependency removal', async t => { + const graph = { + registry: [ + { name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } }, + { name: 'isexe', version: '1.0.0' }, + ], + root: { + name: 'myproject', + version: '1.0.0', + dependencies: { which: '1.0.0' }, + }, + } + const { dir, registry } = await getRepo(graph) + const cache = fs.mkdtempSync(`${getTempDir()}/test-`) + const storeDir = path.join(dir, 'node_modules', '.store') + + // First install + const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache }) + await arb1.reify({ installStrategy: 'linked' }) + + t.ok(fs.readdirSync(storeDir).length > 0, 'store has entries after install') + + // Remove the dependency + const pkgPath = path.join(dir, 'package.json') + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + delete pkg.dependencies + fs.writeFileSync(pkgPath, JSON.stringify(pkg)) + + // Reinstall + const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache }) + await arb2.reify({ installStrategy: 'linked' }) + + const entriesAfterRemoval = fs.readdirSync(storeDir) + t.equal(entriesAfterRemoval.length, 0, + 'all store entries are removed when dependencies are removed') +}) + function setupRequire (cwd) { return function requireChain (...chain) { return chain.reduce((path, name) => {