Skip to content

Commit ca92323

Browse files
fix(arborist): clean up stale .store and hoisted dirs on strategy switch (#9647)
In continuation of our exploration of using `install-strategy=linked` in the [Gutenberg monorepo](WordPress/gutenberg#75814), which powers the WordPress Block Editor. Switching `install-strategy` in the same project directory left behind the previous strategy's layout. Going hoisted → linked kept the stale real top-level transitive directories alongside the new `.store/` and symlinks; going linked → hoisted kept the entire `node_modules/.store/` directory. A fresh install of either strategy was already clean — only the switch was affected. ## Why Under the linked strategy the actual tree the diff compares against is synthesized from the ideal tree (`#buildLinkedActualForDiff`), so real directories left over from a prior hoisted layout are never seen, and `#cleanOrphanedTopLevelLinks` only removed symlinks. In the other direction `load-actual` ignores dot-directories, so the hoisted diff never sees `node_modules/.store` and never removes it. ## How `reify.js` now removes the leftover `.store` on a non-linked reify via `#removeStaleStoreDir`. The store lives only at the project root and is exclusively a linked artifact, so a single removal covers the project. It runs only for a full-project install — a workspace-filtered or `--workspaces=false` install is skipped, because out-of-scope workspaces may still link into the store. `#cleanOrphanedTopLevelLinks` (run only under linked) additionally removes stale real package directories — a directory containing a `package.json` that is not in the ideal tree's valid top-level set — and prunes an emptied `@scope` directory afterward. Non-package real directories and symlinks pointing outside the project are still preserved. The valid-top-level collection in `#cleanOrphanedStoreEntries` no longer skips non-link nodes, so the root's bundled dependencies — materialized as real top-level directories under linked — are recorded as valid and never swept as stale. ## References Fixes #9615 Part of #9608
1 parent d6fbb55 commit ca92323

2 files changed

Lines changed: 147 additions & 11 deletions

File tree

workspaces/arborist/lib/arborist/reify.js

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ module.exports = cls => class Reifier extends cls {
144144
if (!this.options.dryRun && !this.options.packageLockOnly) {
145145
await this.#cleanOrphanedStoreEntries()
146146
}
147+
} else if (!this.options.dryRun && !this.options.packageLockOnly) {
148+
// The .store directory is exclusively a linked-strategy artifact, and load-actual ignores dot-directories, so the diff never sees it.
149+
// Remove it here when switching away from linked so it does not linger under hoisted/nested.
150+
// Only do this for a full-project install: a workspace-filtered or --workspaces=false install may leave out-of-scope workspaces with links still pointing into the store.
151+
const filtered = this.options.workspaces.length > 0 || !this.options.workspacesEnabled
152+
if (!filtered) {
153+
await this.#removeStaleStoreDir()
154+
}
147155
}
148156
} finally {
149157
// Restore the non-isolated tree so the lockfile is preserved and a reused Arborist never sees the isolated tree, even if reify throws.
@@ -1452,6 +1460,18 @@ module.exports = cls => class Reifier extends cls {
14521460
timeEnd()
14531461
}
14541462

1463+
// Remove the root .store left behind by a previous linked install when reifying under a non-linked strategy.
1464+
async #removeStaleStoreDir () {
1465+
const storeDir = resolve(this.path, 'node_modules', '.store')
1466+
if (!existsSync(storeDir)) {
1467+
return
1468+
}
1469+
log.silly('reify', 'removing stale .store from a previous linked install')
1470+
await rm(storeDir, { recursive: true, force: true })
1471+
.catch(/* istanbul ignore next -- rm with force rarely fails */
1472+
er => log.warn('cleanup', 'Failed to remove stale .store directory', er))
1473+
}
1474+
14551475
// After a linked install, scan node_modules/.store/ and remove any directories that are not referenced by the current ideal tree.
14561476
// Store entries become orphaned when dependencies are updated or removed, because the diff never sees the old store keys.
14571477
// Then sweep the top-level node_modules/ for orphaned symlinks (e.g. an uninstalled dep whose store entry was just removed) so we don't leave dangling links.
@@ -1490,7 +1510,7 @@ module.exports = cls => class Reifier extends cls {
14901510
// Locations are normalized to forward slashes here because IsolatedNode/IsolatedLink locations are built with path.join, which uses backslashes on Windows.
14911511
const validKeys = new Set()
14921512
const nmDirs = new Map()
1493-
// Valid bin shim names per node_modules dir, collected from each top-level link's package.bin so the .bin sweep keeps only shims a still-linked package provides.
1513+
// Valid bin shim names per node_modules dir, collected from each top-level entry's package.bin so the .bin sweep keeps only shims a still-installed package provides.
14941514
const binsByDir = new Map()
14951515
const NM_PREFIX = 'node_modules/'
14961516
const STORE_MARKER = '/.store/'
@@ -1504,13 +1524,11 @@ module.exports = cls => class Reifier extends cls {
15041524
validKeys.add(key)
15051525
continue
15061526
}
1507-
if (!child.isLink) {
1508-
continue
1509-
}
15101527
// Tree-only Links never exist on disk; skipping them lets the sweep remove any stale self-link left by an older npm version.
1511-
if (child.isUndeclaredWorkspaceLink) {
1528+
if (child.isLink && child.isUndeclaredWorkspaceLink) {
15121529
continue
15131530
}
1531+
// Real top-level Nodes (e.g. the root's bundled deps) fall through here too, so they are recorded as valid and never swept as stale.
15141532
const nmIdx = loc.lastIndexOf(NM_PREFIX)
15151533
if (nmIdx === -1 || loc.includes(STORE_MARKER)) {
15161534
continue
@@ -1654,8 +1672,10 @@ module.exports = cls => class Reifier extends cls {
16541672
// Remove node_modules/ entries that aren't represented in the ideal tree.
16551673
// Run for the project root and each workspace's node_modules.
16561674
// The linked diff path can't see these because #buildLinkedActualForDiff derives the actual tree from the ideal, so removed deps are never compared.
1657-
// Only symlinks whose target resolves inside the project root are removed — that covers store links (node_modules/.store/...) and workspace self-links (e.g. node_modules/<ws> -> ../packages/<ws>) that npm itself created.
1658-
// Symlinks pointing outside the project (e.g. `npm link foo` without --save targeting the global prefix, or hand-made `ln -s` to an external path) and real directories are preserved.
1675+
// Two kinds of stale entry are removed:
1676+
// - symlinks whose target resolves inside the project root — store links (node_modules/.store/...) and workspace self-links (e.g. node_modules/<ws> -> ../packages/<ws>) that npm itself created.
1677+
// - real package directories — hoisted-layout deps left behind when switching from the hoisted strategy to linked, where every valid top-level entry is a symlink.
1678+
// Symlinks pointing outside the project (e.g. `npm link foo` without --save targeting the global prefix, or hand-made `ln -s` to an external path) and non-package real directories are preserved.
16591679
async #cleanOrphanedTopLevelLinks (nmDir, validTopLevel) {
16601680
const projectPrefix = resolve(this.path) + sep
16611681
let dirents
@@ -1676,7 +1696,15 @@ module.exports = cls => class Reifier extends cls {
16761696
return resolve(dirname(linkPath), target).startsWith(projectPrefix)
16771697
}
16781698

1699+
// A real directory is stale only when it is an actual package (has a package.json), so unrelated user directories are never touched.
1700+
const isStaleRealPkg = (dirent, entPath) =>
1701+
dirent.isDirectory() && existsSync(resolve(entPath, 'package.json'))
1702+
1703+
const isOrphan = async (dirent, entPath) =>
1704+
(dirent.isSymbolicLink() && await isOurOrphan(entPath)) || isStaleRealPkg(dirent, entPath)
1705+
16791706
const orphaned = []
1707+
const scopes = new Set()
16801708
for (const ent of dirents) {
16811709
// skip npm-managed entries (.bin, .store, .package-lock.json, etc)
16821710
if (ent.name.startsWith('.')) {
@@ -1692,11 +1720,12 @@ module.exports = cls => class Reifier extends cls {
16921720
}
16931721
for (const pkgEnt of scoped) {
16941722
const key = `${ent.name}${sep}${pkgEnt.name}`
1695-
if (!validTopLevel.has(key) && pkgEnt.isSymbolicLink() && await isOurOrphan(resolve(nmDir, key))) {
1723+
if (!validTopLevel.has(key) && await isOrphan(pkgEnt, resolve(nmDir, key))) {
16961724
orphaned.push(key)
1725+
scopes.add(ent.name)
16971726
}
16981727
}
1699-
} else if (!validTopLevel.has(ent.name) && ent.isSymbolicLink() && await isOurOrphan(resolve(nmDir, ent.name))) {
1728+
} else if (!validTopLevel.has(ent.name) && await isOrphan(ent, resolve(nmDir, ent.name))) {
17001729
orphaned.push(ent.name)
17011730
}
17021731
}
@@ -1705,14 +1734,29 @@ module.exports = cls => class Reifier extends cls {
17051734
return
17061735
}
17071736

1708-
log.silly('reify', 'cleaning orphaned top-level links', orphaned)
1737+
log.silly('reify', 'cleaning orphaned top-level entries', orphaned)
17091738
await promiseAllRejectLate(
17101739
orphaned.map(name =>
17111740
rm(resolve(nmDir, name), { recursive: true, force: true })
17121741
.catch(/* istanbul ignore next -- rm with force rarely fails */
1713-
er => log.warn('cleanup', `Failed to remove orphaned link ${name}`, er))
1742+
er => log.warn('cleanup', `Failed to remove orphaned entry ${name}`, er))
17141743
)
17151744
)
1745+
1746+
// Removing the last package under a scope leaves an empty @scope directory behind, so prune any scope directory that is now empty.
1747+
await promiseAllRejectLate(
1748+
[...scopes].map(async scope => {
1749+
const scopeDir = resolve(nmDir, scope)
1750+
try {
1751+
const remaining = await readdir(scopeDir)
1752+
if (!remaining.length) {
1753+
await rm(scopeDir, { recursive: true, force: true })
1754+
}
1755+
} catch {
1756+
/* istanbul ignore next -- readdir of a scope dir we just listed should not fail */
1757+
}
1758+
})
1759+
)
17161760
}
17171761

17181762
// last but not least, we save the ideal tree metadata to the package-lock

workspaces/arborist/test/arborist/reify.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4399,6 +4399,98 @@ t.test('install strategy linked', async (t) => {
43994399
t.ok(fs.lstatSync(scmd), 'surviving semver.cmd kept')
44004400
t.ok(fs.lstatSync(sps1), 'surviving semver.ps1 kept')
44014401
})
4402+
4403+
t.test('switching hoisted -> linked removes stale real top-level dirs', async t => {
4404+
// Regression test for https://github.com/npm/cli/issues/9615
4405+
const path = t.testdir({
4406+
'package.json': JSON.stringify({
4407+
name: 'sw', version: '1.0.0', dependencies: { minimatch: '3.0.4' },
4408+
}),
4409+
})
4410+
createRegistry(t, true)
4411+
4412+
// A hoisted install lays the transitive deps out as real top-level dirs.
4413+
await reify(path, { installStrategy: 'hoisted' })
4414+
const nm = resolve(path, 'node_modules')
4415+
for (const dep of ['balanced-match', 'brace-expansion', 'concat-map']) {
4416+
t.ok(fs.statSync(resolve(nm, dep)).isDirectory(), `${dep} is a real dir under hoisted`)
4417+
}
4418+
4419+
// Plant a stale scoped real package to cover the scoped removal and empty-scope pruning path.
4420+
const scopedPkg = resolve(nm, '@scope/stale')
4421+
fs.mkdirSync(scopedPkg, { recursive: true })
4422+
fs.writeFileSync(resolve(scopedPkg, 'package.json'),
4423+
JSON.stringify({ name: '@scope/stale', version: '1.0.0' }))
4424+
// A non-package real dir must be preserved.
4425+
fs.mkdirSync(resolve(nm, 'not-a-package'), { recursive: true })
4426+
4427+
// Switching to linked must remove those stale real dirs, leaving only the symlink + .store.
4428+
await reify(path, { installStrategy: 'linked' })
4429+
for (const dep of ['balanced-match', 'brace-expansion', 'concat-map']) {
4430+
t.notOk(fs.existsSync(resolve(nm, dep)), `${dep} stale real dir removed after switch to linked`)
4431+
}
4432+
t.notOk(fs.existsSync(scopedPkg), 'stale scoped real package removed')
4433+
t.notOk(fs.existsSync(resolve(nm, '@scope')), 'emptied scope dir pruned')
4434+
t.ok(fs.existsSync(resolve(nm, 'not-a-package')), 'non-package real dir preserved')
4435+
t.ok(fs.lstatSync(resolve(nm, 'minimatch')).isSymbolicLink(), 'minimatch is a store symlink')
4436+
t.ok(fs.statSync(resolve(nm, '.store')).isDirectory(), '.store created')
4437+
})
4438+
4439+
t.test('switching linked -> hoisted removes the stale .store dir', async t => {
4440+
// Regression test for https://github.com/npm/cli/issues/9615
4441+
const path = t.testdir({
4442+
'package.json': JSON.stringify({
4443+
name: 'sw', version: '1.0.0', dependencies: { minimatch: '3.0.4' },
4444+
}),
4445+
})
4446+
createRegistry(t, true)
4447+
4448+
await reify(path, { installStrategy: 'linked' })
4449+
const nm = resolve(path, 'node_modules')
4450+
t.ok(fs.statSync(resolve(nm, '.store')).isDirectory(), '.store created under linked')
4451+
4452+
// A hoisted install must not leave the linked store behind.
4453+
await reify(path, { installStrategy: 'hoisted' })
4454+
t.notOk(fs.existsSync(resolve(nm, '.store')), '.store removed after switch to hoisted')
4455+
t.ok(fs.statSync(resolve(nm, 'balanced-match')).isDirectory(), 'transitive dep hoisted to a real dir')
4456+
})
4457+
4458+
t.test('a partial hoisted install does not wipe a still-referenced linked .store', async t => {
4459+
// Regression test for https://github.com/npm/cli/issues/9615
4460+
// A workspace-filtered or --workspaces=false hoisted install must not remove the root .store, since out-of-scope workspaces still link into it.
4461+
const path = t.testdir({
4462+
'package.json': JSON.stringify({
4463+
name: 'root', version: '1.0.0', workspaces: ['packages/*'],
4464+
}),
4465+
packages: {
4466+
// a is the out-of-scope workspace that keeps a live link into the store.
4467+
a: { 'package.json': JSON.stringify({ name: 'a', version: '1.0.0', dependencies: { minimatch: '3.0.4' } }) },
4468+
b: { 'package.json': JSON.stringify({ name: 'b', version: '1.0.0' }) },
4469+
},
4470+
})
4471+
createRegistry(t, true)
4472+
const nm = resolve(path, 'node_modules')
4473+
const aLink = resolve(path, 'packages/a/node_modules/minimatch')
4474+
// a's dep resolves through the root .store, so deleting the store would break it.
4475+
const stillLinked = msg => t.ok(
4476+
fs.lstatSync(aLink).isSymbolicLink() && fs.existsSync(fs.realpathSync(aLink)), msg)
4477+
4478+
await reify(path, { installStrategy: 'linked' })
4479+
t.ok(fs.statSync(resolve(nm, '.store')).isDirectory(), '.store created under linked')
4480+
t.match(fs.realpathSync(aLink), /node_modules[\\/]\.store[\\/]/, 'workspace a links into the store')
4481+
4482+
// Filter to workspace b: a is out of scope and must keep its live store link.
4483+
await reify(path, { installStrategy: 'hoisted', workspaces: ['b'] })
4484+
t.ok(fs.existsSync(resolve(nm, '.store')), '.store kept during a workspace-filtered install')
4485+
stillLinked('workspace a still resolves through the store after the filtered install')
4486+
4487+
await reify(path, { installStrategy: 'hoisted', workspacesEnabled: false })
4488+
t.ok(fs.existsSync(resolve(nm, '.store')), '.store kept during a --workspaces=false install')
4489+
stillLinked('workspace a still resolves through the store after the --workspaces=false install')
4490+
4491+
await reify(path, { installStrategy: 'hoisted' })
4492+
t.notOk(fs.existsSync(resolve(nm, '.store')), '.store removed by a full hoisted install')
4493+
})
44024494
})
44034495

44044496
t.test('linked strategy --workspaces=false and --include-workspace-root do not crash', async t => {

0 commit comments

Comments
 (0)