@@ -10,6 +10,7 @@ const { callLimit: promiseCallLimit } = require('promise-call-limit')
1010const { depth : dfwalk } = require ( 'treeverse' )
1111const { dirname, resolve, relative, join } = require ( 'node:path' )
1212const { log, time } = require ( 'proc-log' )
13+ const { existsSync } = require ( 'node:fs' )
1314const { lstat, mkdir, rm, symlink } = require ( 'node:fs/promises' )
1415const { moveFile } = require ( '@npmcli/fs' )
1516const { 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".
0 commit comments