Skip to content

Commit 8614b2a

Browse files
fix(arborist): avoid full reinstall on subsequent linked strategy runs (#9031)
1 parent 16fbe13 commit 8614b2a

File tree

3 files changed

+306
-8
lines changed

3 files changed

+306
-8
lines changed

workspaces/arborist/lib/arborist/isolated-reifier.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ module.exports = cls => class IsolatedReifier extends cls {
461461

462462
if (dep.package.bin) {
463463
for (const bn in dep.package.bin) {
464-
target.binPaths.push(join(from.realpath, 'node_modules', '.bin', bn))
464+
target.binPaths.push(join(dep.root.localPath, nmFolder, '.bin', bn))
465465
}
466466
}
467467

@@ -486,9 +486,12 @@ module.exports = cls => class IsolatedReifier extends cls {
486486
parent: root,
487487
path: join(dep.root.localPath, nmFolder, dep.name),
488488
realpath: target.path,
489-
resolved: dep.resolved,
489+
resolved: external
490+
? `file:.store/${toKey}/node_modules/${dep.packageName}`
491+
: dep.resolved,
490492
root,
491493
target,
494+
version: dep.version,
492495
top: { path: dep.root.localPath },
493496
}
494497
const newEdge1 = { optional, from, to: link }

workspaces/arborist/lib/arborist/reify.js

Lines changed: 148 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ const promiseAllRejectLate = require('promise-all-reject-late')
88
const runScript = require('@npmcli/run-script')
99
const { callLimit: promiseCallLimit } = require('promise-call-limit')
1010
const { depth: dfwalk } = require('treeverse')
11-
const { dirname, resolve, relative, join } = require('node:path')
11+
const { dirname, resolve, relative, join, sep } = require('node:path')
1212
const { log, time } = require('proc-log')
13-
const { lstat, mkdir, rm, symlink } = require('node:fs/promises')
13+
const { existsSync } = require('node:fs')
14+
const { lstat, mkdir, readdir, rm, symlink } = require('node:fs/promises')
1415
const { moveFile } = require('@npmcli/fs')
1516
const { subset, intersects } = require('semver')
1617
const { walkUp } = require('walk-up-path')
@@ -73,6 +74,7 @@ module.exports = cls => class Reifier extends cls {
7374
#shrinkwrapInflated = new Set()
7475
#sparseTreeDirs = new Set()
7576
#sparseTreeRoots = new Set()
77+
#linkedActualForDiff = null
7678

7779
constructor (options) {
7880
super(options)
@@ -114,15 +116,20 @@ module.exports = cls => class Reifier extends cls {
114116
// of Node/Link trees
115117
log.warn('reify', 'The "linked" install strategy is EXPERIMENTAL and may contain bugs.')
116118
this.idealTree = await this.createIsolatedTree()
119+
this.#linkedActualForDiff = this.#buildLinkedActualForDiff(
120+
this.idealTree, this.actualTree
121+
)
117122
}
118123
await this[_diffTrees]()
119124
await this.#reifyPackages()
120125
if (linked) {
126+
await this.#cleanOrphanedStoreEntries()
121127
// swap back in the idealTree
122128
// so that the lockfile is preserved
123129
this.idealTree = oldTree
124130
}
125131
await this[_saveIdealTree](options)
132+
this.#linkedActualForDiff = null
126133
// clean inert
127134
for (const node of this.idealTree.inventory.values()) {
128135
if (node.inert) {
@@ -424,9 +431,14 @@ module.exports = cls => class Reifier extends cls {
424431
if (ideal) {
425432
filterNodes.push(ideal)
426433
}
427-
const actual = this.actualTree.children.get(ws)
428-
if (actual) {
429-
filterNodes.push(actual)
434+
// Skip actual-side filterNodes when using the linked diff wrapper.
435+
// Those nodes have root===actualTree, not root===linkedActualForDiff, and Diff.calculate requires filterNode.root to match actual.
436+
// The ideal filterNode alone is sufficient to scope the workspace diff.
437+
if (!this.#linkedActualForDiff) {
438+
const actual = this.actualTree.children.get(ws)
439+
if (actual) {
440+
filterNodes.push(actual)
441+
}
430442
}
431443
}
432444
}
@@ -448,7 +460,7 @@ module.exports = cls => class Reifier extends cls {
448460
omit: this.#omit,
449461
shrinkwrapInflated: this.#shrinkwrapInflated,
450462
filterNodes,
451-
actual: this.actualTree,
463+
actual: this.#linkedActualForDiff || this.actualTree,
452464
ideal: this.idealTree,
453465
})
454466

@@ -571,6 +583,7 @@ module.exports = cls => class Reifier extends cls {
571583
// if the directory already exists, made will be undefined. if that's the case
572584
// we don't want to remove it because we aren't the ones who created it so we
573585
// omit it from the #sparseTreeRoots
586+
/* istanbul ignore next -- pre-existing: mkdir returns undefined when dir exists, covered in reify tests but lost in aggregate coverage merge */
574587
if (made) {
575588
this.#sparseTreeRoots.add(made)
576589
}
@@ -787,6 +800,100 @@ module.exports = cls => class Reifier extends cls {
787800
return join(filePath)
788801
}
789802

803+
// Build a flat actual tree wrapper for linked installs so the diff can correctly match store entries that already exist on disk.
804+
// The proxy tree from createIsolatedTree() is flat (all children on root), but loadActual() produces a nested tree where store entries are deep link targets.
805+
// This wrapper surfaces them at the root level for comparison.
806+
#buildLinkedActualForDiff (idealTree, actualTree) {
807+
// Combined Map keyed by path (how allChildren() in diff.js keys)
808+
const combined = new Map()
809+
810+
// Add actual tree's children (the top-level symlinks)
811+
for (const child of actualTree.children.values()) {
812+
combined.set(child.path, child)
813+
}
814+
815+
// Add synthetic entries for store nodes and store links that exist on disk.
816+
// The proxy tree is flat: all store entries (isInStore) and store links (isStoreLink) are direct children of root.
817+
// The actual tree only has top-level links as root children, so store entries need synthetic actual entries for the diff to match them.
818+
for (const child of idealTree.children.values()) {
819+
if (!combined.has(child.path) && (child.isInStore || child.isStoreLink) &&
820+
existsSync(child.path)) {
821+
const entry = {
822+
global: false,
823+
globalTop: false,
824+
isProjectRoot: false,
825+
isTop: false,
826+
location: child.location,
827+
name: child.name,
828+
optional: child.optional,
829+
top: child.top,
830+
children: [],
831+
edgesIn: new Set(),
832+
edgesOut: new Map(),
833+
binPaths: [],
834+
fsChildren: [],
835+
/* istanbul ignore next -- emulate Node */
836+
getBundler () {
837+
return null
838+
},
839+
hasShrinkwrap: false,
840+
inDepBundle: false,
841+
integrity: null,
842+
isLink: Boolean(child.isLink),
843+
isRoot: false,
844+
isInStore: Boolean(child.isInStore),
845+
path: child.path,
846+
realpath: child.realpath,
847+
resolved: child.resolved,
848+
version: child.version,
849+
package: child.package,
850+
}
851+
entry.target = entry
852+
if (child.isLink && combined.has(child.realpath)) {
853+
entry.target = combined.get(child.realpath)
854+
}
855+
combined.set(child.path, entry)
856+
}
857+
}
858+
859+
// Proxy .get(name) to original actual tree for filterNodes compatibility
860+
// (scoped workspace installs use .get(name), allChildren uses .values())
861+
const origGet = actualTree.children.get.bind(actualTree.children)
862+
const combinedGet = combined.get.bind(combined)
863+
/* istanbul ignore next -- only reached during scoped workspace installs */
864+
combined.get = (key) => combinedGet(key) || origGet(key)
865+
866+
const wrapper = {
867+
isRoot: true,
868+
isLink: actualTree.isLink,
869+
target: actualTree.target,
870+
fsChildren: actualTree.fsChildren,
871+
path: actualTree.path,
872+
realpath: actualTree.realpath,
873+
edgesOut: actualTree.edgesOut,
874+
inventory: actualTree.inventory,
875+
package: actualTree.package,
876+
resolved: actualTree.resolved,
877+
version: actualTree.version,
878+
integrity: actualTree.integrity,
879+
binPaths: actualTree.binPaths,
880+
hasShrinkwrap: false,
881+
inDepBundle: false,
882+
parent: null,
883+
children: combined,
884+
}
885+
886+
// Set parent/root on synthetic entries for consistency
887+
for (const child of combined.values()) {
888+
if (!child.parent) {
889+
child.parent = wrapper
890+
child.root = wrapper
891+
}
892+
}
893+
894+
return wrapper
895+
}
896+
790897
#registryResolved (resolved) {
791898
// the default registry url is a magic value meaning "the currently
792899
// configured registry".
@@ -1245,6 +1352,41 @@ module.exports = cls => class Reifier extends cls {
12451352
timeEnd()
12461353
}
12471354

1355+
// After a linked install, scan node_modules/.store/ and remove any directories that are not referenced by the current ideal tree.
1356+
// Store entries become orphaned when dependencies are updated or removed, because the diff never sees the old store keys.
1357+
async #cleanOrphanedStoreEntries () {
1358+
const storeDir = resolve(this.path, 'node_modules', '.store')
1359+
let entries
1360+
try {
1361+
entries = await readdir(storeDir)
1362+
} catch {
1363+
return
1364+
}
1365+
1366+
// Collect valid store keys from the isolated ideal tree (location: node_modules/.store/{key}/node_modules/{pkg})
1367+
const validKeys = new Set()
1368+
for (const child of this.idealTree.children.values()) {
1369+
if (child.isInStore) {
1370+
const key = child.location.split(sep)[2]
1371+
validKeys.add(key)
1372+
}
1373+
}
1374+
1375+
const orphaned = entries.filter(e => !validKeys.has(e))
1376+
if (!orphaned.length) {
1377+
return
1378+
}
1379+
1380+
log.silly('reify', 'cleaning orphaned store entries', orphaned)
1381+
await promiseAllRejectLate(
1382+
orphaned.map(e =>
1383+
rm(resolve(storeDir, e), { recursive: true, force: true })
1384+
.catch(/* istanbul ignore next -- rm with force rarely fails */
1385+
er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
1386+
)
1387+
)
1388+
}
1389+
12481390
// last but not least, we save the ideal tree metadata to the package-lock
12491391
// or shrinkwrap file, and any additions or removals to package.json
12501392
async [_saveIdealTree] (options) {

0 commit comments

Comments
 (0)