@@ -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
@@ -573,6 +579,7 @@ module.exports = cls => class Reifier extends cls {
573579 // if the directory already exists, made will be undefined. if that's the case
574580 // we don't want to remove it because we aren't the ones who created it so we
575581 // omit it from the #sparseTreeRoots
582+ /* istanbul ignore next -- mkdir returns path only when dir is new */
576583 if ( made ) {
577584 this . #sparseTreeRoots. add ( made )
578585 }
@@ -789,6 +796,104 @@ module.exports = cls => class Reifier extends cls {
789796 return join ( filePath )
790797 }
791798
799+ // Build a flat actual tree wrapper for linked installs so the diff can
800+ // correctly match store entries that already exist on disk. The proxy
801+ // tree from _createIsolatedTree() is flat (all children on root), but
802+ // loadActual() produces a nested tree where store entries are deep link
803+ // targets. This wrapper surfaces them at the root level for comparison.
804+ #buildLinkedActualForDiff ( idealTree , actualTree ) {
805+ // Combined Map keyed by path (how allChildren() in diff.js keys)
806+ const combined = new Map ( )
807+
808+ // Add actual tree's children (the top-level symlinks)
809+ for ( const child of actualTree . children . values ( ) ) {
810+ combined . set ( child . path , child )
811+ }
812+
813+ // Add synthetic entries for store nodes and store links that exist on disk.
814+ // The proxy tree is flat — all store entries (isInStore) and store links
815+ // (isStoreLink) are direct children of root. The actual tree only has
816+ // top-level links as root children, so store entries and store links
817+ // need synthetic actual entries for the diff to match them.
818+ for ( const child of idealTree . children ) {
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+
792897 #registryResolved ( resolved ) {
793898 // the default registry url is a magic value meaning "the currently
794899 // configured registry".
0 commit comments