Skip to content

Commit 2be33fb

Browse files
committed
fix(arborist): avoid full reinstall on subsequent linked strategy runs
1 parent 6cb34ca commit 2be33fb

3 files changed

Lines changed: 181 additions & 3 deletions

File tree

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

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

414414
binNames.forEach(bn => {
415-
target.binPaths.push(join(from.realpath, 'node_modules', '.bin', bn))
415+
target.binPaths.push(join(dep.root.localPath, nmFolder, '.bin', bn))
416416
})
417417

418418
const link = {
@@ -428,7 +428,10 @@ module.exports = cls => class IsolatedReifier extends cls {
428428
path: join(dep.root.localPath, nmFolder, dep.name),
429429
realpath: target.path,
430430
name: toKey,
431-
resolved: dep.resolved,
431+
version: dep.version,
432+
resolved: external
433+
? `file:.store/${toKey}/node_modules/${dep.packageName}`
434+
: dep.resolved,
432435
top: { path: dep.root.localPath },
433436
children: [],
434437
fsChildren: [],

workspaces/arborist/lib/arborist/reify.js

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +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 { existsSync } = require('node:fs')
1314
const { lstat, mkdir, rm, symlink } = require('node:fs/promises')
1415
const { moveFile } = require('@npmcli/fs')
1516
const { subset, intersects } = require('semver')
@@ -75,6 +76,7 @@ module.exports = cls => class Reifier extends cls {
7576
#shrinkwrapInflated = new Set()
7677
#sparseTreeDirs = new Set()
7778
#sparseTreeRoots = new Set()
79+
#linkedActualForDiff = null
7880

7981
constructor (options) {
8082
super(options)
@@ -116,6 +118,9 @@ module.exports = cls => class Reifier extends cls {
116118
// of Node/Link trees
117119
log.warn('reify', 'The "linked" install strategy is EXPERIMENTAL and may contain bugs.')
118120
this.idealTree = await this[_createIsolatedTree]()
121+
this.#linkedActualForDiff = this.#buildLinkedActualForDiff(
122+
this.idealTree, this.actualTree
123+
)
119124
}
120125
await this[_diffTrees]()
121126
await this.#reifyPackages()
@@ -125,6 +130,7 @@ module.exports = cls => class Reifier extends cls {
125130
this.idealTree = oldTree
126131
}
127132
await this[_saveIdealTree](options)
133+
this.#linkedActualForDiff = null
128134
// clean inert
129135
for (const node of this.idealTree.inventory.values()) {
130136
if (node.inert) {
@@ -450,7 +456,7 @@ module.exports = cls => class Reifier extends cls {
450456
omit: this.#omit,
451457
shrinkwrapInflated: this.#shrinkwrapInflated,
452458
filterNodes,
453-
actual: this.actualTree,
459+
actual: this.#linkedActualForDiff || this.actualTree,
454460
ideal: this.idealTree,
455461
})
456462

@@ -789,6 +795,101 @@ module.exports = cls => class Reifier extends cls {
789795
return join(filePath)
790796
}
791797

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

workspaces/arborist/test/isolated-mode.js

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

1640+
tap.test('subsequent linked install is a no-op', async t => {
1641+
const graph = {
1642+
registry: [
1643+
{ name: 'which', version: '1.0.0', bin: './bin.js', dependencies: { isexe: '^1.0.0' } },
1644+
{ name: 'isexe', version: '1.0.0' },
1645+
],
1646+
root: {
1647+
name: 'myproject',
1648+
version: '1.0.0',
1649+
dependencies: { which: '1.0.0' },
1650+
},
1651+
}
1652+
const { dir, registry } = await getRepo(graph)
1653+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
1654+
1655+
// First install
1656+
const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1657+
await arb1.reify({ installStrategy: 'linked' })
1658+
1659+
// Verify packages are installed
1660+
t.ok(fs.lstatSync(path.join(dir, 'node_modules', 'which')).isSymbolicLink(),
1661+
'which is a symlink after first install')
1662+
1663+
// Second install — should detect everything is up-to-date
1664+
const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1665+
await arb2.reify({ installStrategy: 'linked' })
1666+
1667+
// Verify the diff has zero actionable leaves
1668+
const leaves = arb2.diff?.leaves || []
1669+
const actions = leaves.filter(l => l.action)
1670+
t.equal(actions.length, 0, 'second install should have no diff actions')
1671+
1672+
// Verify unchanged nodes were detected
1673+
t.ok(arb2.diff.unchanged.length > 0, 'second install should have unchanged nodes')
1674+
1675+
// Verify packages are still correctly installed
1676+
t.ok(fs.lstatSync(path.join(dir, 'node_modules', 'which')).isSymbolicLink(),
1677+
'which is still a symlink after second install')
1678+
t.ok(setupRequire(dir)('which'), 'which is requireable after second install')
1679+
})
1680+
1681+
tap.test('workspace links are not affected by store resolved fix', async t => {
1682+
const graph = {
1683+
registry: [
1684+
{ name: 'abbrev', version: '1.0.0' },
1685+
],
1686+
root: {
1687+
name: 'myproject',
1688+
version: '1.0.0',
1689+
dependencies: { abbrev: '1.0.0' },
1690+
},
1691+
workspaces: [
1692+
{ name: 'mypkg', version: '1.0.0', dependencies: { abbrev: '1.0.0' } },
1693+
],
1694+
}
1695+
const { dir, registry } = await getRepo(graph)
1696+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
1697+
1698+
// First install
1699+
const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1700+
await arb1.reify({ installStrategy: 'linked' })
1701+
1702+
// Second install
1703+
const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1704+
await arb2.reify({ installStrategy: 'linked' })
1705+
1706+
// Verify workspace is still correctly linked
1707+
t.ok(setupRequire(dir)('mypkg'), 'workspace is requireable after second install')
1708+
t.ok(setupRequire(dir)('abbrev'), 'registry dep is requireable after second install')
1709+
1710+
// Verify the diff has unchanged nodes (store entries are correctly matched)
1711+
t.ok(arb2.diff.unchanged.length > 0, 'second install should have unchanged nodes')
1712+
})
1713+
16401714
function setupRequire (cwd) {
16411715
return function requireChain (...chain) {
16421716
return chain.reduce((path, name) => {

0 commit comments

Comments
 (0)