Skip to content

Commit 00ae7b0

Browse files
committed
fix(arborist): clean-up orphaned .store/ entries on linked install
1 parent 6cb34ca commit 00ae7b0

2 files changed

Lines changed: 119 additions & 1 deletion

File tree

workspaces/arborist/lib/arborist/reify.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const { callLimit: promiseCallLimit } = require('promise-call-limit')
1010
const { depth: dfwalk } = require('treeverse')
1111
const { dirname, resolve, relative, join } = require('node:path')
1212
const { log, time } = require('proc-log')
13-
const { lstat, mkdir, rm, symlink } = require('node:fs/promises')
13+
const { lstat, mkdir, readdir, rm, symlink } = require('node:fs/promises')
1414
const { moveFile } = require('@npmcli/fs')
1515
const { subset, intersects } = require('semver')
1616
const { walkUp } = require('walk-up-path')
@@ -120,6 +120,7 @@ module.exports = cls => class Reifier extends cls {
120120
await this[_diffTrees]()
121121
await this.#reifyPackages()
122122
if (linked) {
123+
await this.#cleanOrphanedStoreEntries()
123124
// swap back in the idealTree
124125
// so that the lockfile is preserved
125126
this.idealTree = oldTree
@@ -1247,6 +1248,44 @@ module.exports = cls => class Reifier extends cls {
12471248
timeEnd()
12481249
}
12491250

1251+
// After a linked install, scan node_modules/.store/ and remove any
1252+
// directories that are not referenced by the current ideal tree.
1253+
// Store entries become orphaned when dependencies are updated or
1254+
// removed, because the diff never sees the old store keys.
1255+
async #cleanOrphanedStoreEntries () {
1256+
const storeDir = resolve(this.path, 'node_modules', '.store')
1257+
let entries
1258+
try {
1259+
entries = await readdir(storeDir)
1260+
} catch {
1261+
return
1262+
}
1263+
1264+
// Collect valid store keys from the isolated ideal tree.
1265+
// Store entries have location: node_modules/.store/{key}/node_modules/{pkg}
1266+
const validKeys = new Set()
1267+
for (const child of this.idealTree.children) {
1268+
if (child.isInStore) {
1269+
const key = child.location.split('/')[2]
1270+
validKeys.add(key)
1271+
}
1272+
}
1273+
1274+
const orphaned = entries.filter(e => !validKeys.has(e))
1275+
if (!orphaned.length) {
1276+
return
1277+
}
1278+
1279+
log.silly('reify', 'cleaning orphaned store entries', orphaned)
1280+
await promiseAllRejectLate(
1281+
orphaned.map(e =>
1282+
rm(resolve(storeDir, e), { recursive: true, force: true })
1283+
.catch(/* istanbul ignore next - rm with force rarely fails */
1284+
er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
1285+
)
1286+
)
1287+
}
1288+
12501289
// last but not least, we save the ideal tree metadata to the package-lock
12511290
// or shrinkwrap file, and any additions or removals to package.json
12521291
async [_saveIdealTree] (options) {

workspaces/arborist/test/isolated-mode.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,6 +1637,85 @@ tap.test('bins are installed', async t => {
16371637
t.ok(binFromBarToWhich)
16381638
})
16391639

1640+
tap.test('orphaned store entries are cleaned up on dependency update', async t => {
1641+
const graph = {
1642+
registry: [
1643+
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
1644+
{ name: 'which', version: '2.0.0', dependencies: { isexe: '^1.0.0' } },
1645+
{ name: 'isexe', version: '1.0.0' },
1646+
],
1647+
root: {
1648+
name: 'myproject',
1649+
version: '1.0.0',
1650+
dependencies: { which: '1.0.0' },
1651+
},
1652+
}
1653+
const { dir, registry } = await getRepo(graph)
1654+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
1655+
const storeDir = path.join(dir, 'node_modules', '.store')
1656+
1657+
// First install — which@1.0.0
1658+
const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1659+
await arb1.reify({ installStrategy: 'linked' })
1660+
1661+
const entriesAfterV1 = fs.readdirSync(storeDir)
1662+
t.ok(entriesAfterV1.some(e => e.startsWith('which@1.0.0-')),
1663+
'store has which@1.0.0 entry after first install')
1664+
1665+
// Update package.json to depend on which@2.0.0
1666+
const pkgPath = path.join(dir, 'package.json')
1667+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
1668+
pkg.dependencies.which = '2.0.0'
1669+
fs.writeFileSync(pkgPath, JSON.stringify(pkg))
1670+
1671+
// Second install — which@2.0.0
1672+
const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1673+
await arb2.reify({ installStrategy: 'linked' })
1674+
1675+
const entriesAfterV2 = fs.readdirSync(storeDir)
1676+
t.ok(entriesAfterV2.some(e => e.startsWith('which@2.0.0-')),
1677+
'store has which@2.0.0 entry after update')
1678+
t.notOk(entriesAfterV2.some(e => e.startsWith('which@1.0.0-')),
1679+
'old which@1.0.0 store entry is removed after update')
1680+
})
1681+
1682+
tap.test('orphaned store entries are cleaned up on dependency removal', async t => {
1683+
const graph = {
1684+
registry: [
1685+
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
1686+
{ name: 'isexe', version: '1.0.0' },
1687+
],
1688+
root: {
1689+
name: 'myproject',
1690+
version: '1.0.0',
1691+
dependencies: { which: '1.0.0' },
1692+
},
1693+
}
1694+
const { dir, registry } = await getRepo(graph)
1695+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
1696+
const storeDir = path.join(dir, 'node_modules', '.store')
1697+
1698+
// First install
1699+
const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1700+
await arb1.reify({ installStrategy: 'linked' })
1701+
1702+
t.ok(fs.readdirSync(storeDir).length > 0, 'store has entries after install')
1703+
1704+
// Remove the dependency
1705+
const pkgPath = path.join(dir, 'package.json')
1706+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
1707+
delete pkg.dependencies
1708+
fs.writeFileSync(pkgPath, JSON.stringify(pkg))
1709+
1710+
// Reinstall
1711+
const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1712+
await arb2.reify({ installStrategy: 'linked' })
1713+
1714+
const entriesAfterRemoval = fs.readdirSync(storeDir)
1715+
t.equal(entriesAfterRemoval.length, 0,
1716+
'all store entries are removed when dependencies are removed')
1717+
})
1718+
16401719
function setupRequire (cwd) {
16411720
return function requireChain (...chain) {
16421721
return chain.reduce((path, name) => {

0 commit comments

Comments
 (0)