Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions workspaces/arborist/lib/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
79 changes: 79 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading