Skip to content

Commit c7702d0

Browse files
fix(arborist): fix non-idempotent linked install with workspace projects (#9094)
1 parent 075ae23 commit c7702d0

2 files changed

Lines changed: 55 additions & 21 deletions

File tree

workspaces/arborist/lib/arborist/reify.js

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -808,28 +808,23 @@ module.exports = cls => class Reifier extends cls {
808808
// Combined Map keyed by path (how allChildren() in diff.js keys)
809809
const combined = new Map()
810810

811-
// Add actual tree's children (the top-level symlinks)
812-
for (const child of actualTree.children.values()) {
813-
combined.set(child.path, child)
814-
}
815-
816-
// Add synthetic entries for store nodes and store links that exist on disk.
817-
// The proxy tree is flat: all store entries (isInStore) and store links (isStoreLink) are direct children of root.
818-
// The actual tree only has top-level links as root children, so store entries need synthetic actual entries for the diff to match them.
811+
// Create synthetic actual entries for ALL ideal children that exist on disk.
812+
// The isolated ideal tree is flat (all entries as root children), but loadActual() produces a nested tree where workspace deps are under fsChildren and store entries are deep link targets.
813+
// Synthetic entries ensure the diff compares matching resolved/integrity values (e.g. workspace links have resolved=undefined in the ideal tree but resolved="file:../packages/..." in the actual tree).
819814
for (const child of idealTree.children.values()) {
820-
if (!combined.has(child.path) && (child.isInStore || child.isStoreLink) &&
821-
existsSync(child.path)) {
822-
let entry
823-
if (child.isLink) {
824-
entry = new IsolatedLink(child)
825-
} else {
826-
entry = new IsolatedNode(child)
827-
}
828-
if (child.isLink && combined.has(child.realpath)) {
829-
entry.target = combined.get(child.realpath)
830-
}
831-
combined.set(child.path, entry)
815+
if (combined.has(child.path) || !existsSync(child.path)) {
816+
continue
817+
}
818+
let entry
819+
if (child.isLink) {
820+
entry = new IsolatedLink(child)
821+
} else {
822+
entry = new IsolatedNode(child)
823+
}
824+
if (child.isLink && combined.has(child.realpath)) {
825+
entry.target = combined.get(child.realpath)
832826
}
827+
combined.set(child.path, entry)
833828
}
834829

835830
// Proxy .get(name) to original actual tree for filterNodes compatibility
@@ -850,7 +845,9 @@ module.exports = cls => class Reifier extends cls {
850845
wrapper.binPaths = actualTree.binPaths
851846
wrapper.children = combined
852847
wrapper.edgesOut = actualTree.edgesOut
853-
wrapper.fsChildren = actualTree.fsChildren
848+
// Use empty fsChildren so that allChildren() only picks up entries from the combined map.
849+
// The actual fsChildren have real children with different resolved values (e.g. file:../../../node_modules/.store/... vs file:.store/...) that would overwrite our synthetic entries in allChildren().
850+
wrapper.fsChildren = new Set()
854851
wrapper.integrity = actualTree.integrity
855852
wrapper.inventory = actualTree.inventory
856853

workspaces/arborist/test/isolated-mode.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,6 +1863,43 @@ tap.test('workspace links are not affected by store resolved fix', async t => {
18631863
t.ok(arb2.diff.unchanged.length > 0, 'second install should have unchanged nodes')
18641864
})
18651865

1866+
tap.test('idempotent install with cross-workspace deps (diamond pattern)', async t => {
1867+
// Regression: when workspace-x depends on workspace-y (and both share a registry dep), the second install would report "changed N packages" because (1) workspace link resolved values didn't match between ideal and actual trees, and (2) actual fsChildren overwrote synthetic store entries in the diff proxy.
1868+
const graph = {
1869+
registry: [
1870+
{ name: 'abbrev', version: '2.0.0' },
1871+
],
1872+
root: {
1873+
name: 'myproject',
1874+
version: '1.0.0',
1875+
dependencies: { 'workspace-x': '*', 'workspace-y': '*' },
1876+
},
1877+
workspaces: [
1878+
{ name: 'workspace-x', version: '1.0.0', dependencies: { abbrev: '2.0.0', 'workspace-y': '*' } },
1879+
{ name: 'workspace-y', version: '1.0.0', dependencies: { abbrev: '2.0.0' } },
1880+
],
1881+
}
1882+
const { dir, registry } = await getRepo(graph)
1883+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
1884+
1885+
// First install
1886+
const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1887+
await arb1.reify({ installStrategy: 'linked' })
1888+
1889+
// Second install — should detect everything is up-to-date
1890+
const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1891+
await arb2.reify({ installStrategy: 'linked' })
1892+
1893+
const leaves = arb2.diff?.leaves || []
1894+
const actions = leaves.filter(l => l.action)
1895+
t.equal(actions.length, 0, 'second install should have no diff actions')
1896+
t.ok(arb2.diff.unchanged.length > 0, 'second install should have unchanged nodes')
1897+
1898+
// Verify packages are still correctly installed (abbrev is a workspace dep, not root)
1899+
t.ok(setupRequire(path.join(dir, 'packages', 'workspace-y'))('abbrev'),
1900+
'abbrev is requireable from workspace after second install')
1901+
})
1902+
18661903
tap.test('orphaned store entries are cleaned up on dependency update', async t => {
18671904
const graph = {
18681905
registry: [

0 commit comments

Comments
 (0)