Skip to main content

aube_linker/
link.rs

1use tracing::trace;
2
3use crate::patches::{
4    applied_patches_sidecar_name, current_patch_hashes, read_applied_patches,
5    wipe_changed_patched_entries, write_applied_patches,
6};
7use crate::pool::with_link_pool;
8use crate::sweep::{
9    EntryState, classify_entry_state, classify_local_entry_state, is_physical_importer, mkdirp,
10    reconcile_dir_link, remove_hidden_hoist_tree, sweep_dead_hidden_hoist_entries,
11    sweep_stale_tmp_dirs, sweep_stale_top_level_entries, try_remove_entry,
12};
13use crate::{Error, HoistedPlacements, LinkStats, Linker, NodeLinker, hoisted, sys};
14use aube_lockfile::{LocalSource, LockedPackage, LockfileGraph};
15use aube_store::PackageIndex;
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19impl Linker {
20    fn checked_modules_dir(&self, project_dir: &Path) -> Result<PathBuf, Error> {
21        let project_dir = aube_util::path::normalize_lexical(project_dir);
22        let modules_dir =
23            aube_util::path::normalize_lexical(&project_dir.join(&self.modules_dir_name));
24        let lexical_is_safe = modules_dir != project_dir && modules_dir.starts_with(&project_dir);
25        let canonical_is_safe = project_dir.canonicalize().map_or(true, |project| {
26            modules_dir
27                .ancestors()
28                .find_map(|ancestor| {
29                    ancestor.canonicalize().ok().map(|canonical_ancestor| {
30                        if ancestor == modules_dir {
31                            canonical_ancestor != project
32                                && canonical_ancestor.starts_with(&project)
33                        } else {
34                            canonical_ancestor == project
35                                || canonical_ancestor.starts_with(&project)
36                        }
37                    })
38                })
39                .unwrap_or(false)
40        });
41        if !lexical_is_safe || !canonical_is_safe {
42            return Err(Error::UnsafeModulesDir(modules_dir));
43        }
44        Ok(modules_dir)
45    }
46
47    /// Link all packages into node_modules for the given project.
48    pub fn link_all(
49        &self,
50        project_dir: &Path,
51        graph: &LockfileGraph,
52        package_indices: &BTreeMap<String, PackageIndex>,
53    ) -> Result<LinkStats, Error> {
54        let project_dir = aube_util::path::normalize_lexical(project_dir);
55        let modules_dir = self.checked_modules_dir(&project_dir)?;
56        if matches!(self.node_linker, NodeLinker::Hoisted) {
57            let mut stats = LinkStats::default();
58            let mut placements = HoistedPlacements::default();
59            hoisted::link_hoisted_importer(
60                self,
61                hoisted::HoistedImporterDirs {
62                    root: &project_dir,
63                    importer: &project_dir,
64                },
65                graph.root_deps(),
66                graph,
67                package_indices,
68                &mut stats,
69                &mut placements,
70            )?;
71            // Hoisted mode doesn't use the isolated `.aube/` virtual
72            // store, so a hidden hoist tree under `.aube/node_modules/`
73            // has no consumer. If a previous isolated install left one
74            // behind, sweep it — hoisted's top-level cleanup preserves
75            // dotfiles, so it wouldn't be removed otherwise, and a
76            // stale tree would keep satisfying phantom deps for any
77            // leftover `.aube/<dep_path>/` directories until their
78            // eventual cleanup. Honors `virtualStoreDir`.
79            let _ = crate::remove_dir_all_with_retry(
80                &self.aube_dir_for(&project_dir).join("node_modules"),
81            );
82            stats.hoisted_placements = Some(placements);
83            return Ok(stats);
84        }
85
86        let nm = modules_dir;
87        let aube_dir = self.aube_dir_for(&project_dir);
88
89        mkdirp(&aube_dir)?;
90
91        // Reclaim space from prior aborted installs. A crash or
92        // Ctrl+C between materialize_into and the atomic rename
93        // leaves `.tmp-<pid>-*` dirs in the virtual store. Sweep
94        // them now so the current install starts clean.
95        sweep_stale_tmp_dirs(&aube_dir);
96
97        // Clean up stale top-level entries not in the current graph.
98        // With shamefully_hoist, every package name in the graph is
99        // also a legitimate top-level entry, so fold those into the
100        // preserve set before sweeping. Scoped packages live under
101        // `node_modules/@scope/<pkg>`, but `read_dir` on `node_modules`
102        // yields the bare `@scope` directory — so we build a second
103        // set of scope prefixes and preserve any entry that matches.
104        let mut root_dep_names: std::collections::HashSet<&str> =
105            graph.root_deps().iter().map(|d| d.name.as_str()).collect();
106        if self.shamefully_hoist {
107            for pkg in graph.packages.values() {
108                root_dep_names.insert(pkg.name.as_str());
109            }
110        } else if !self.public_hoist_patterns.is_empty() {
111            for pkg in graph.packages.values() {
112                if pkg.local_source.is_none() && self.public_hoist_matches(&pkg.name) {
113                    root_dep_names.insert(pkg.name.as_str());
114                }
115            }
116        }
117        // Preserve the virtual-store leaf name when `aube_dir` sits
118        // directly under `nm`. With the default `.aube` the dotfile
119        // check inside the sweep covers it, but a user who sets
120        // `virtualStoreDir=node_modules/vstore` would otherwise see
121        // the sweep delete the freshly-`mkdirp`d virtual store on
122        // every install because `vstore` isn't a dotfile and isn't
123        // in `root_dep_names`.
124        let aube_dir_leaf: Option<std::ffi::OsString> = if aube_dir.parent() == Some(nm.as_path()) {
125            aube_dir.file_name().map(|s| s.to_owned())
126        } else {
127            None
128        };
129        sweep_stale_top_level_entries(&nm, &root_dep_names, aube_dir_leaf.as_deref());
130
131        let mut stats = LinkStats::default();
132
133        // Reconcile previously-applied patches against the current
134        // `self.patches` set. Without graph hashes (CI / no-global-store
135        // mode) the `.aube/<dep_path>` directory name doesn't change
136        // when a patch is added or removed, so the simple "exists?
137        // skip!" check would otherwise leave stale patched bytes in
138        // place after `aube patch-remove` or fail to apply a brand new
139        // patch after `aube patch-commit`. We track the per-`(name,
140        // version)` patch fingerprint in a sidecar file under
141        // `node_modules/` and wipe the matching `.aube/<dep_path>`
142        // entries whenever the fingerprint changes.
143        let prev_applied = read_applied_patches(&nm);
144        let curr_applied = current_patch_hashes(&self.patches);
145        if !self.use_global_virtual_store {
146            wipe_changed_patched_entries(
147                &aube_dir,
148                graph,
149                &prev_applied,
150                &curr_applied,
151                self.virtual_store_dir_max_length,
152            );
153        }
154
155        let nested_link_targets = build_nested_link_targets(&project_dir, graph);
156
157        // Step 1: Populate .aube virtual store
158        //
159        // `file:` / `link:` / `portal:` / `exec:` sources resolve
160        // against a path inside the project, so they never go into the
161        // shared global virtual store — we materialize them straight
162        // into per-project `.aube/` below. `link:` entries don't need
163        // any `.aube/` entry at all; their top-level symlink points
164        // directly at the target.
165        //
166        // Git and remote-tarball sources are the exception: they're
167        // pinned to immutable content and shared across projects like
168        // registry packages. Under the global virtual store they must
169        // be materialized into the shared store (and have their
170        // `.aube/<dep_path>` entry symlinked at it) so a registry
171        // dependent that also lives in the shared store doesn't get a
172        // dangling sibling symlink to them.
173        for (dep_path, pkg) in &graph.packages {
174            let Some(ref local) = pkg.local_source else {
175                continue;
176            };
177            if matches!(local, LocalSource::Link(_)) {
178                continue;
179            }
180            if self.use_global_virtual_store && local.is_globally_shareable() {
181                // A git / remote-tarball dep shared globally must be
182                // materialized into its content-addressed GVS path so a
183                // registry dependent's sibling symlink resolves. The
184                // package index *defines* that path — the content
185                // fingerprint is folded into the graph hash — so a
186                // missing index is not a skippable cache hit the way a
187                // per-project `file:` dep can be; silently continuing
188                // would leave the GVS entry absent and dangle every
189                // dependent (`require()` throwing `Cannot find module`).
190                // Fail loudly, exactly like the registry pass below.
191                //
192                // There is deliberately no `store.load_index` fallback
193                // here (unlike the registry pass): git/tarball indices
194                // are never persisted by `(name, version, integrity)`,
195                // because a prepared tree and its `--ignore-scripts` raw
196                // checkout share that coordinate and the content
197                // fingerprint is precisely what keeps them on separate
198                // GVS paths. The fetch driver guarantees the index is
199                // present by always re-importing source deps.
200                let index = package_indices
201                    .get(dep_path)
202                    .ok_or_else(|| Error::MissingPackageIndex(dep_path.to_string()))?;
203                self.ensure_shared_local_in_global_store(
204                    &aube_dir,
205                    dep_path,
206                    pkg,
207                    index,
208                    &mut stats,
209                    nested_link_targets.as_ref(),
210                )?;
211                continue;
212            }
213            let Some(index) = package_indices.get(dep_path) else {
214                continue;
215            };
216            let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path));
217            // `file:` directories and portals are mutable while their
218            // dep_path is intentionally stable (it identifies the source
219            // path, not one generation of its contents). A full install has
220            // just re-imported the current tree, so replace the existing
221            // materialization instead of treating the path-keyed entry as a
222            // cache hit. The install-state warm path prevents this work when
223            // the source fingerprint is unchanged.
224            if matches!(local, LocalSource::Directory(_) | LocalSource::Portal(_)) {
225                try_remove_entry(&aube_entry);
226                if aube_entry.exists() {
227                    return Err(Error::Io(
228                        aube_entry,
229                        std::io::Error::other(
230                            "failed to remove stale local dependency materialization",
231                        ),
232                    ));
233                }
234            }
235            if !aube_entry.exists() {
236                self.materialize_into(
237                    &aube_dir,
238                    &aube_dir,
239                    dep_path,
240                    pkg,
241                    index,
242                    &mut stats,
243                    false,
244                    nested_link_targets.as_ref(),
245                )?;
246            } else {
247                stats.packages_cached += 1;
248            }
249        }
250
251        if self.use_global_virtual_store {
252            use rayon::prelude::*;
253            use rustc_hash::FxHashSet;
254
255            // Serial pre-pass over every registry package. It already
256            // walks the whole graph to pre-create parent directories, so
257            // fold the two filename encodes each package needs into the
258            // same pass and carry them into the par_iter:
259            //   - `aube_dir_entry_name(dep_path)` for the local
260            //     `.aube/<entry>` symlink, and
261            //   - `virtual_store_subdir(dep_path)` for the shared-store
262            //     target.
263            // Both are pure functions of `dep_path` + builder state
264            // (`hashes`/`virtual_store_dir_max_length` are fixed before
265            // linking), so computing them once here and threading them
266            // through removes the par_iter's recompute of the entry name
267            // and the subdir, plus the subdir's third encode downstream
268            // in `ensure_in_virtual_store`. Each `dep_path_to_filename`
269            // is a `String` alloc + escape/uppercase scans, and a second
270            // alloc + BLAKE3 short-hash on long/scoped/peer-context
271            // names — a per-package CPU/alloc trim on every install.
272            //
273            // Pre-creating the parents (`aube_dir` itself plus one entry
274            // per unique `@scope/`) once also keeps the par_iter off the
275            // 1.4k `create_dir_all` stat syscalls. The parent set is tiny
276            // (1-5 entries on a typical graph), so the serial pre-pass is
277            // dwarfed by the per-package wins inside the par_iter.
278            let mut step1_parents: FxHashSet<PathBuf> = FxHashSet::default();
279            let mut step1_prep: Vec<(&String, &LockedPackage, String, String)> =
280                Vec::with_capacity(graph.packages.len());
281            for (dep_path, pkg) in &graph.packages {
282                if pkg.local_source.is_some() {
283                    continue;
284                }
285                let entry_name = self.aube_dir_entry_name(dep_path);
286                let subdir = self.virtual_store_subdir(dep_path);
287                let entry = aube_dir.join(&entry_name);
288                if let Some(parent) = entry.parent() {
289                    step1_parents.insert(parent.to_path_buf());
290                }
291                step1_prep.push((dep_path, pkg, entry_name, subdir));
292            }
293            for parent in &step1_parents {
294                mkdirp(parent)?;
295            }
296
297            let link_parallelism = self.link_parallelism();
298            let step1_timer = std::time::Instant::now();
299            let step1_results: Vec<Result<LinkStats, Error>> =
300                with_link_pool(link_parallelism, || {
301                    step1_prep
302                        .par_iter()
303                        .map(|&(dep_path, pkg, ref entry_name, ref subdir)| {
304                            let dep_path = dep_path.as_str();
305                            let mut local_stats = LinkStats::default();
306                            let local_aube_entry = aube_dir.join(entry_name);
307                            let global_entry = self.virtual_store.join(subdir);
308                            let project_local = self.project_local_dep_paths.contains(dep_path);
309
310                            // Single readlink classifies the entry into one of
311                            // three states and drives the whole per-package
312                            // decision tree below. Avoids the double-check
313                            // (`read_link` then `exists`) the previous version
314                            // did and eliminates the unconditional
315                            // `remove_dir`/`remove_file` pair on cold installs,
316                            // which strace showed as ~1.4k ENOENT syscalls per
317                            // install on the medium fixture.
318                            let state = if project_local {
319                                classify_local_entry_state(&local_aube_entry)
320                            } else {
321                                classify_entry_state(&local_aube_entry, &global_entry)
322                            };
323
324                            if matches!(state, EntryState::Fresh) {
325                                if !project_local {
326                                    self.reconcile_virtual_store_entry(
327                                        dep_path,
328                                        pkg,
329                                        nested_link_targets.as_ref(),
330                                    )?;
331                                }
332                                local_stats.packages_cached += 1;
333                                return Ok(local_stats);
334                            }
335
336                            // Symlink is stale or missing — need the package
337                            // index to (re)materialize. The install driver
338                            // omits `package_indices` entries for packages on
339                            // the fast path; load from the store on demand if
340                            // this one slipped through. This keeps the
341                            // fast-path safe against graph-hash changes that
342                            // invalidate the symlink target (patches, engine
343                            // bumps, `allowBuilds` flips).
344                            let owned_index;
345                            let index = match package_indices.get(dep_path) {
346                                Some(idx) => idx,
347                                None => {
348                                    owned_index = self
349                                        .store
350                                        .load_index(
351                                            pkg.registry_name(),
352                                            &pkg.version,
353                                            pkg.integrity.as_deref(),
354                                        )
355                                        .ok_or_else(|| {
356                                            Error::MissingPackageIndex(dep_path.to_string())
357                                        })?;
358                                    &owned_index
359                                }
360                            };
361                            if project_local {
362                                if !matches!(state, EntryState::Missing) {
363                                    try_remove_entry(&local_aube_entry);
364                                }
365                                self.materialize_into(
366                                    &aube_dir,
367                                    &aube_dir,
368                                    dep_path,
369                                    pkg,
370                                    index,
371                                    &mut local_stats,
372                                    false,
373                                    nested_link_targets.as_ref(),
374                                )?;
375                                return Ok(local_stats);
376                            }
377
378                            self.ensure_in_virtual_store_with_subdir(
379                                dep_path,
380                                subdir,
381                                pkg,
382                                index,
383                                &mut local_stats,
384                                nested_link_targets.as_ref(),
385                            )?;
386
387                            // Only pay the `remove_dir`/`remove_file` syscalls
388                            // when we actually have something to remove.
389                            // On Windows, `.aube/<dep_path>` is an NTFS
390                            // junction (created via `sys::create_dir_link`);
391                            // `remove_file` can't unlink those, so try
392                            // `remove_dir` first and fall back to
393                            // `remove_file` for the unix case (where
394                            // `symlink` produces a file-style link).
395                            if matches!(state, EntryState::Stale) {
396                                let _ = std::fs::remove_dir(&local_aube_entry)
397                                    .or_else(|_| std::fs::remove_file(&local_aube_entry));
398                            }
399                            // Parent dirs were pre-created above the
400                            // par_iter; no per-package `mkdirp` here.
401                            sys::create_dir_link(&global_entry, &local_aube_entry)
402                                .map_err(|e| Error::Io(local_aube_entry.clone(), e))?;
403                            Ok(local_stats)
404                        })
405                        .collect()
406                });
407
408            for result in step1_results {
409                let local_stats = result?;
410                stats.packages_linked += local_stats.packages_linked;
411                stats.packages_cached += local_stats.packages_cached;
412                stats.files_linked += local_stats.files_linked;
413            }
414            tracing::debug!("link:step1 (gvs populate) {:.1?}", step1_timer.elapsed());
415        } else {
416            use rayon::prelude::*;
417
418            // `wipe_changed_patched_entries` above already removed any
419            // `.aube/<dep_path>` whose patch fingerprint changed since
420            // the last install, so the existence check below will fall
421            // through to `materialize_into` for those packages and
422            // pick up the current patch state. In per-project mode the
423            // dep paths are already isolated, so we can materialize
424            // them independently on the same rayon pool the gvs path
425            // uses instead of rebuilding the whole tree serially.
426            let link_parallelism = self.link_parallelism();
427            let step1_results: Vec<Result<LinkStats, Error>> =
428                with_link_pool(link_parallelism, || {
429                    graph
430                        .packages
431                        .par_iter()
432                        .filter_map(|(dep_path, pkg)| {
433                            if pkg.local_source.is_some() {
434                                return None;
435                            }
436                            Some((dep_path, pkg))
437                        })
438                        .map(|(dep_path, pkg)| {
439                            let mut local_stats = LinkStats::default();
440                            let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path));
441                            if aube_entry.exists() {
442                                // Already in place from a previous run —
443                                // count as cached. `install.rs`
444                                // deliberately omits this dep_path from
445                                // `package_indices` on the fast path, so
446                                // do the existence check first.
447                                local_stats.packages_cached += 1;
448                                return Ok(local_stats);
449                            }
450                            // Entry missing — load the index. Fast path in
451                            // `install.rs` skips `load_index` when
452                            // `aube_entry` already exists; lazy-load here
453                            // for the case where a patch / allowBuilds
454                            // change invalidated the entry since.
455                            let owned_index;
456                            let index = match package_indices.get(dep_path) {
457                                Some(idx) => idx,
458                                None => {
459                                    owned_index = self
460                                        .store
461                                        .load_index(
462                                            pkg.registry_name(),
463                                            &pkg.version,
464                                            pkg.integrity.as_deref(),
465                                        )
466                                        .ok_or_else(|| {
467                                            Error::MissingPackageIndex(dep_path.to_string())
468                                        })?;
469                                    &owned_index
470                                }
471                            };
472                            self.materialize_into(
473                                &aube_dir,
474                                &aube_dir,
475                                dep_path,
476                                pkg,
477                                index,
478                                &mut local_stats,
479                                false,
480                                nested_link_targets.as_ref(),
481                            )?;
482                            Ok(local_stats)
483                        })
484                        .collect()
485                });
486
487            for result in step1_results {
488                let local_stats = result?;
489                stats.packages_linked += local_stats.packages_linked;
490                stats.packages_cached += local_stats.packages_cached;
491                stats.files_linked += local_stats.files_linked;
492            }
493        }
494
495        // `virtualStoreOnly=true` skips Steps 2 + 3 — the
496        // user-visible top-level `node_modules/<name>` symlinks and
497        // the hoisting passes that target the same directory — but
498        // Step 4 (the hidden `.aube/node_modules/` hoist) still runs
499        // because that tree lives *inside* the virtual store and
500        // packages walking up for undeclared deps need it. Anything
501        // that walks the user-visible root tree (bin linking,
502        // lifecycle scripts, the state sidecar) is the install
503        // driver's responsibility to skip in this mode.
504        if self.virtual_store_only {
505            self.link_hidden_hoist(&aube_dir, graph)?;
506            if let Err(e) = write_applied_patches(&nm, &curr_applied) {
507                let sidecar = applied_patches_sidecar_name();
508                tracing::error!(
509                    code = aube_codes::errors::ERR_AUBE_PATCHES_TRACKING_WRITE,
510                    "failed to write {sidecar}: {e}. next install may miss stale patched entries"
511                );
512            }
513            return Ok(stats);
514        }
515
516        // Step 2: Create top-level entries as symlinks into .aube.
517        // The .aube/<dep_path>/node_modules/ directory already contains the
518        // package and sibling symlinks to its direct deps (set up by
519        // materialize_into / ensure_in_virtual_store), so a single symlink at
520        // node_modules/<name> gives Node everything it needs to resolve
521        // transitive deps via its normal directory walk.
522        use rayon::prelude::*;
523
524        let root_deps: Vec<_> = graph.root_deps().to_vec();
525        let link_parallelism = self.link_parallelism();
526        let step2_timer = std::time::Instant::now();
527        let results: Vec<Result<bool, Error>> = with_link_pool(link_parallelism, || {
528            root_deps
529                .par_iter()
530                .map(|dep| {
531                    crate::validate_package_link_name(&dep.name)?;
532                    let target_dir = nm.join(&dep.name);
533
534                    // `link:` direct deps point at the on-disk target with
535                    // a plain symlink, bypassing `.aube/` entirely.
536                    if let Some(pkg) = graph.packages.get(&dep.dep_path)
537                        && let Some(LocalSource::Link(rel)) = pkg.local_source.as_ref()
538                    {
539                        let abs_target = project_dir.join(rel);
540                        let link_parent = target_dir.parent().unwrap_or(&nm);
541                        let rel_target =
542                            pathdiff::diff_paths(&abs_target, link_parent).unwrap_or(abs_target);
543                        if reconcile_dir_link(&target_dir, &rel_target)? {
544                            return Ok(false);
545                        }
546                        if let Some(parent) = target_dir.parent() {
547                            mkdirp(parent)?;
548                        }
549                        sys::create_dir_link(&rel_target, &target_dir)
550                            .map_err(|e| Error::Io(target_dir.clone(), e))?;
551                        return Ok(true);
552                    }
553
554                    // Verify the source actually exists in .aube before symlinking
555                    let source_dir = aube_dir
556                        .join(self.aube_dir_entry_name(&dep.dep_path))
557                        .join("node_modules")
558                        .join(&dep.name);
559                    if !source_dir.exists() {
560                        return Ok(false);
561                    }
562
563                    // Symlink target is relative to node_modules/<name>'s parent.
564                    // For non-scoped packages the parent is node_modules/, but for
565                    // scoped packages (e.g. @scope/name) it is node_modules/@scope/,
566                    // so we must compute the relative path dynamically.
567                    let link_parent = target_dir.parent().unwrap_or(&nm);
568                    let rel_target = pathdiff::diff_paths(&source_dir, link_parent)
569                        .unwrap_or_else(|| source_dir.clone());
570                    // Target-aware reconcile: a version upgrade keeps the
571                    // old `node_modules/<name>` symlink but it now points
572                    // at a stale `.aube/<old-dep-path>`; we need to
573                    // rewrite it to the new `.aube/<new-dep-path>`.
574                    if reconcile_dir_link(&target_dir, &rel_target)? {
575                        return Ok(false);
576                    }
577                    if let Some(parent) = target_dir.parent() {
578                        mkdirp(parent)?;
579                    }
580
581                    sys::create_dir_link(&rel_target, &target_dir)
582                        .map_err(|e| Error::Io(target_dir.clone(), e))?;
583
584                    trace!("top-level: {}", dep.name);
585                    Ok(true)
586                })
587                .collect()
588        });
589
590        for result in results {
591            if result? {
592                stats.top_level_linked += 1;
593            }
594        }
595        tracing::debug!(
596            "link:step2 (top-level symlinks) {:.1?}",
597            step2_timer.elapsed()
598        );
599
600        // Step 3: public-hoist-pattern matches get surfaced to the
601        // root first, then shamefully_hoist (if enabled) sweeps up
602        // everything else. Both use first-write-wins so direct deps
603        // keep their symlinks and the pattern-matched names take
604        // precedence over the bulk hoist.
605        if !self.public_hoist_patterns.is_empty() {
606            self.hoist_remaining_into(
607                &nm,
608                &aube_dir,
609                graph,
610                &mut stats,
611                "public-hoist",
612                &|name| self.public_hoist_matches(name),
613            )?;
614        }
615        if self.shamefully_hoist {
616            self.hoist_remaining_into(&nm, &aube_dir, graph, &mut stats, "hoist", &|_| true)?;
617        }
618
619        // Step 4: populate (or sweep) the hidden modules tree under
620        // `.aube/node_modules/`. This runs regardless of the root
621        // hoist passes above — it targets a different consumer
622        // (packages inside the virtual store walking up for
623        // undeclared deps) and wouldn't interact with the
624        // root-level symlinks even on name clashes.
625        self.link_hidden_hoist(&aube_dir, graph)?;
626
627        if let Err(e) = write_applied_patches(&nm, &curr_applied) {
628            let sidecar = applied_patches_sidecar_name();
629            tracing::error!(
630                code = aube_codes::errors::ERR_AUBE_PATCHES_TRACKING_WRITE,
631                "failed to write {sidecar}: {e}. next install may miss stale patched entries"
632            );
633        }
634        Ok(stats)
635    }
636
637    /// Hoisted-mode workspace linker. Plans every physical importer as
638    /// one tree rooted at the workspace's `node_modules/`, allowing the
639    /// default unlimited mode to share compatible placements across
640    /// importers while stricter hoisting limits retain local trees.
641    fn link_workspace_hoisted(
642        &self,
643        root_dir: &Path,
644        graph: &LockfileGraph,
645        package_indices: &BTreeMap<String, PackageIndex>,
646        workspace_dirs: &BTreeMap<String, PathBuf>,
647    ) -> Result<LinkStats, Error> {
648        let mut stats = LinkStats::default();
649        let mut placements = HoistedPlacements::default();
650        let mut importers = Vec::with_capacity(graph.importers.len());
651        for (importer_path, deps) in &graph.importers {
652            if !is_physical_importer(importer_path) {
653                continue;
654            }
655            let importer_dir = if importer_path == "." {
656                root_dir.to_path_buf()
657            } else {
658                // Collapse `..` segments lexically — a parent-relative
659                // importer key (`../sibling`, possible when
660                // `pnpm-workspace.yaml#packages` uses `../**`) needs
661                // to land at the actual sibling dir before
662                // `pathdiff`/`strip_prefix` see it.
663                aube_util::path::normalize_lexical(&root_dir.join(importer_path))
664            };
665            // Workspace deps resolve through `workspace_dirs` rather
666            // than going through the placement tree, so the hoisted
667            // planner shouldn't try to copy their contents. Filter
668            // them out of the seed set — we'll symlink them in a
669            // post-pass below.
670            //
671            // Same gating as the isolated mode below: the resolver
672            // omits a `LockedPackage` for workspace-resolved siblings,
673            // so a name match plus a missing package entry is the
674            // signal that the resolver picked the sibling. When the
675            // resolved package IS in `graph.packages`, the resolver
676            // pinned a registry version and the dep should follow the
677            // normal hoisted-placement path (otherwise the post-pass
678            // would silently substitute the local copy).
679            let planner_deps: Vec<aube_lockfile::DirectDep> = deps
680                .iter()
681                .filter(|d| {
682                    !workspace_dirs.contains_key(&d.name)
683                        || graph.packages.contains_key(&d.dep_path)
684                })
685                .cloned()
686                .collect();
687            importers.push(hoisted::HoistedWorkspaceImporter {
688                modules_dir: self.checked_modules_dir(&importer_dir)?,
689                dependencies: planner_deps,
690            });
691        }
692        hoisted::link_hoisted_workspace(
693            self,
694            root_dir,
695            &importers,
696            graph,
697            package_indices,
698            &mut stats,
699            &mut placements,
700        )?;
701
702        // Drop workspace deps in as symlinks, same as isolated mode.
703        for (importer_path, deps) in &graph.importers {
704            if !is_physical_importer(importer_path) {
705                continue;
706            }
707            let importer_dir = if importer_path == "." {
708                root_dir.to_path_buf()
709            } else {
710                aube_util::path::normalize_lexical(&root_dir.join(importer_path))
711            };
712            let nm = self.checked_modules_dir(&importer_dir)?;
713            if !self.hoist_workspace_packages {
714                continue;
715            }
716            for dep in deps {
717                crate::validate_package_link_name(&dep.name)?;
718                let Some(ws_dir) = workspace_dirs.get(&dep.name) else {
719                    continue;
720                };
721                // See planner_deps gating above: skip deps the
722                // resolver actually pinned to a registry version.
723                if graph.packages.contains_key(&dep.dep_path) {
724                    continue;
725                }
726                let link_path = nm.join(&dep.name);
727                if let Some(parent) = link_path.parent() {
728                    mkdirp(parent)?;
729                }
730                try_remove_entry(&link_path);
731                let link_parent = link_path.parent().unwrap_or(&nm);
732                let target = pathdiff::diff_paths(ws_dir, link_parent).unwrap_or(ws_dir.clone());
733                sys::create_dir_link(&target, &link_path)
734                    .map_err(|e| Error::Io(link_path.clone(), e))?;
735                stats.top_level_linked += 1;
736            }
737        }
738        // Same rationale as the non-workspace hoisted path: sweep any
739        // `.aube/node_modules/` left behind by a prior isolated
740        // install so hoisted's dotfile-preserving cleanup doesn't
741        // leak a stale hidden tree. Honors `virtualStoreDir`.
742        let _ = crate::remove_dir_all_with_retry(&self.aube_dir_for(root_dir).join("node_modules"));
743        stats.hoisted_placements = Some(placements);
744        Ok(stats)
745    }
746
747    /// Link all packages for a workspace (multiple importers).
748    ///
749    /// Creates the shared `.aube/` virtual store at root, then for each workspace
750    /// package creates `node_modules/` with its direct deps linked from the root `.aube/`.
751    /// Workspace packages that depend on each other get symlinks to the package directory.
752    pub fn link_workspace(
753        &self,
754        root_dir: &Path,
755        graph: &LockfileGraph,
756        package_indices: &BTreeMap<String, PackageIndex>,
757        workspace_dirs: &BTreeMap<String, PathBuf>,
758    ) -> Result<LinkStats, Error> {
759        let root_dir = aube_util::path::normalize_lexical(root_dir);
760        self.checked_modules_dir(&root_dir)?;
761        for importer_path in graph.importers.keys() {
762            if !is_physical_importer(importer_path) || importer_path == "." {
763                continue;
764            }
765            let importer_dir = aube_util::path::normalize_lexical(&root_dir.join(importer_path));
766            self.checked_modules_dir(&importer_dir)?;
767        }
768
769        if matches!(self.node_linker, NodeLinker::Hoisted) {
770            return self.link_workspace_hoisted(&root_dir, graph, package_indices, workspace_dirs);
771        }
772
773        let root_nm = self.checked_modules_dir(&root_dir)?;
774        let aube_dir = self.aube_dir_for(&root_dir);
775
776        mkdirp(&aube_dir)?;
777        mkdirp(&root_nm)?;
778
779        let mut stats = LinkStats::default();
780
781        // Patch reconciliation. Mirrors `link_all`'s logic: wipe
782        // `.aube/<dep_path>` for any package whose patch fingerprint
783        // changed between the previous and current install. Only
784        // applies to per-project (non-gvs) mode because the gvs path
785        // already folds patches into the hashed `.aube/<dep_path>`
786        // name via `with_graph_hashes`.
787        let prev_applied = read_applied_patches(&root_nm);
788        let curr_applied = current_patch_hashes(&self.patches);
789        if !self.use_global_virtual_store {
790            wipe_changed_patched_entries(
791                &aube_dir,
792                graph,
793                &prev_applied,
794                &curr_applied,
795                self.virtual_store_dir_max_length,
796            );
797        }
798
799        let nested_link_targets = build_nested_link_targets(&root_dir, graph);
800
801        // Step 1a: Materialize local (`file:` dir/tarball, `portal:`,
802        // `exec:`) packages straight into the shared per-project
803        // `.aube/`. They never participate in the global virtual store
804        // since their source resolves against a path inside the
805        // project. `link:` deps get no `.aube/` entry at all — step 2
806        // symlinks directly to the target.
807        //
808        // Git and remote-tarball sources are content-pinned and shared
809        // across projects like registry packages, so under the global
810        // virtual store they're materialized into the shared store with
811        // a `.aube/<dep_path>` symlink pointing at it — otherwise a
812        // registry dependent in the shared store would get a dangling
813        // sibling symlink to them.
814        for (dep_path, pkg) in &graph.packages {
815            let Some(ref local) = pkg.local_source else {
816                continue;
817            };
818            if matches!(local, LocalSource::Link(_)) {
819                continue;
820            }
821            if self.use_global_virtual_store && local.is_globally_shareable() {
822                // See the matching block in `link_isolated`: a globally
823                // shared git/tarball dep's index defines its
824                // content-addressed GVS path, so a missing index must
825                // fail loudly rather than dangle every dependent. No
826                // `load_index` fallback — those indices aren't persisted
827                // by coordinate (prepared vs raw checkout would collide).
828                let index = package_indices
829                    .get(dep_path)
830                    .ok_or_else(|| Error::MissingPackageIndex(dep_path.to_string()))?;
831                self.ensure_shared_local_in_global_store(
832                    &aube_dir,
833                    dep_path,
834                    pkg,
835                    index,
836                    &mut stats,
837                    nested_link_targets.as_ref(),
838                )?;
839                continue;
840            }
841            let Some(index) = package_indices.get(dep_path) else {
842                continue;
843            };
844            let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path));
845            if matches!(local, LocalSource::Directory(_) | LocalSource::Portal(_)) {
846                try_remove_entry(&aube_entry);
847                if aube_entry.exists() {
848                    return Err(Error::Io(
849                        aube_entry,
850                        std::io::Error::other(
851                            "failed to remove stale local dependency materialization",
852                        ),
853                    ));
854                }
855            }
856            if aube_entry.exists() {
857                stats.packages_cached += 1;
858                continue;
859            }
860            self.materialize_into(
861                &aube_dir,
862                &aube_dir,
863                dep_path,
864                pkg,
865                index,
866                &mut stats,
867                false,
868                nested_link_targets.as_ref(),
869            )?;
870        }
871
872        // Step 1b: Populate shared .aube virtual store at root for
873        // registry packages. Mirrors `link_all`'s parallel +
874        // Fresh/Missing/Stale state machine so warm re-runs are a
875        // `readlink` per package instead of a recreate per package.
876        if self.use_global_virtual_store {
877            use rayon::prelude::*;
878            use rustc_hash::FxHashSet;
879
880            // Same precompute-once hoist as `link_all`'s step 1: the
881            // serial parent-creation pre-pass already walks every
882            // registry package, so derive each package's entry name and
883            // virtual-store subdir here and thread them into the
884            // par_iter, removing the par_iter's recompute of both and the
885            // subdir's third encode in `ensure_in_virtual_store`. See the
886            // matching block in `link_all` for the full rationale.
887            let mut step1_parents: FxHashSet<PathBuf> = FxHashSet::default();
888            let mut step1_prep: Vec<(&String, &LockedPackage, String, String)> =
889                Vec::with_capacity(graph.packages.len());
890            for (dep_path, pkg) in &graph.packages {
891                if pkg.local_source.is_some() {
892                    continue;
893                }
894                let entry_name = self.aube_dir_entry_name(dep_path);
895                let subdir = self.virtual_store_subdir(dep_path);
896                let entry = aube_dir.join(&entry_name);
897                if let Some(parent) = entry.parent() {
898                    step1_parents.insert(parent.to_path_buf());
899                }
900                step1_prep.push((dep_path, pkg, entry_name, subdir));
901            }
902            for parent in &step1_parents {
903                mkdirp(parent)?;
904            }
905
906            let link_parallelism = self.link_parallelism();
907            let step1_timer = std::time::Instant::now();
908            let step1_results: Vec<Result<LinkStats, Error>> =
909                with_link_pool(link_parallelism, || {
910                    step1_prep
911                        .par_iter()
912                        .map(|&(dep_path, pkg, ref entry_name, ref subdir)| {
913                            let dep_path = dep_path.as_str();
914                            let mut local_stats = LinkStats::default();
915                            let local_aube_entry = aube_dir.join(entry_name);
916                            let global_entry = self.virtual_store.join(subdir);
917                            let project_local = self.project_local_dep_paths.contains(dep_path);
918
919                            let state = if project_local {
920                                classify_local_entry_state(&local_aube_entry)
921                            } else {
922                                classify_entry_state(&local_aube_entry, &global_entry)
923                            };
924
925                            if matches!(state, EntryState::Fresh) {
926                                if !project_local {
927                                    self.reconcile_virtual_store_entry(
928                                        dep_path,
929                                        pkg,
930                                        nested_link_targets.as_ref(),
931                                    )?;
932                                }
933                                local_stats.packages_cached += 1;
934                                return Ok(local_stats);
935                            }
936
937                            let owned_index;
938                            let index = match package_indices.get(dep_path) {
939                                Some(idx) => idx,
940                                None => {
941                                    owned_index = self
942                                        .store
943                                        .load_index(
944                                            pkg.registry_name(),
945                                            &pkg.version,
946                                            pkg.integrity.as_deref(),
947                                        )
948                                        .ok_or_else(|| {
949                                            Error::MissingPackageIndex(dep_path.to_string())
950                                        })?;
951                                    &owned_index
952                                }
953                            };
954                            if project_local {
955                                if !matches!(state, EntryState::Missing) {
956                                    try_remove_entry(&local_aube_entry);
957                                }
958                                self.materialize_into(
959                                    &aube_dir,
960                                    &aube_dir,
961                                    dep_path,
962                                    pkg,
963                                    index,
964                                    &mut local_stats,
965                                    false,
966                                    nested_link_targets.as_ref(),
967                                )?;
968                                return Ok(local_stats);
969                            }
970
971                            self.ensure_in_virtual_store_with_subdir(
972                                dep_path,
973                                subdir,
974                                pkg,
975                                index,
976                                &mut local_stats,
977                                nested_link_targets.as_ref(),
978                            )?;
979
980                            if matches!(state, EntryState::Stale) {
981                                let _ = std::fs::remove_dir(&local_aube_entry)
982                                    .or_else(|_| std::fs::remove_file(&local_aube_entry));
983                            }
984                            // Parent dirs were pre-created above the
985                            // par_iter; no per-package `mkdirp` here.
986                            sys::create_dir_link(&global_entry, &local_aube_entry)
987                                .map_err(|e| Error::Io(local_aube_entry.clone(), e))?;
988                            Ok(local_stats)
989                        })
990                        .collect()
991                });
992
993            for result in step1_results {
994                let local_stats = result?;
995                stats.packages_linked += local_stats.packages_linked;
996                stats.packages_cached += local_stats.packages_cached;
997                stats.files_linked += local_stats.files_linked;
998            }
999            tracing::debug!(
1000                "link_workspace:step1 (gvs populate) {:.1?}",
1001                step1_timer.elapsed()
1002            );
1003        } else {
1004            use rayon::prelude::*;
1005
1006            let link_parallelism = self.link_parallelism();
1007            let step1_results: Vec<Result<LinkStats, Error>> =
1008                with_link_pool(link_parallelism, || {
1009                    graph
1010                        .packages
1011                        .par_iter()
1012                        .filter_map(|(dep_path, pkg)| {
1013                            if pkg.local_source.is_some() {
1014                                return None;
1015                            }
1016                            Some((dep_path, pkg))
1017                        })
1018                        .map(|(dep_path, pkg)| {
1019                            let mut local_stats = LinkStats::default();
1020                            let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path));
1021                            if aube_entry.exists() {
1022                                local_stats.packages_cached += 1;
1023                                return Ok(local_stats);
1024                            }
1025                            let owned_index;
1026                            let index = match package_indices.get(dep_path) {
1027                                Some(idx) => idx,
1028                                None => {
1029                                    owned_index = self
1030                                        .store
1031                                        .load_index(
1032                                            pkg.registry_name(),
1033                                            &pkg.version,
1034                                            pkg.integrity.as_deref(),
1035                                        )
1036                                        .ok_or_else(|| {
1037                                            Error::MissingPackageIndex(dep_path.to_string())
1038                                        })?;
1039                                    &owned_index
1040                                }
1041                            };
1042                            self.materialize_into(
1043                                &aube_dir,
1044                                &aube_dir,
1045                                dep_path,
1046                                pkg,
1047                                index,
1048                                &mut local_stats,
1049                                false,
1050                                nested_link_targets.as_ref(),
1051                            )?;
1052                            Ok(local_stats)
1053                        })
1054                        .collect()
1055                });
1056
1057            for result in step1_results {
1058                let local_stats = result?;
1059                stats.packages_linked += local_stats.packages_linked;
1060                stats.packages_cached += local_stats.packages_cached;
1061                stats.files_linked += local_stats.files_linked;
1062            }
1063        }
1064
1065        // `virtualStoreOnly=true` skips per-importer node_modules
1066        // population and the root-level hoisting passes, but the
1067        // hidden `.aube/node_modules/` hoist (Step 4 below) still
1068        // runs because it lives *inside* the virtual store. Bin
1069        // linking and lifecycle scripts for the top-level importers
1070        // are the install driver's responsibility to skip in this
1071        // mode.
1072        if self.virtual_store_only {
1073            // Sweep root_nm of any user-visible entries a prior
1074            // (non-virtualStoreOnly) install left behind. With the
1075            // default `virtualStoreDir`, `.aube/` lives directly
1076            // under `root_nm` and must be preserved. Custom
1077            // `virtualStoreDir` overrides put `.aube/` outside the
1078            // sweep zone already.
1079            let aube_dir_leaf: Option<std::ffi::OsString> =
1080                if aube_dir.parent() == Some(root_nm.as_path()) {
1081                    aube_dir.file_name().map(|s| s.to_owned())
1082                } else {
1083                    None
1084                };
1085            if let Ok(entries) = std::fs::read_dir(&root_nm) {
1086                for entry in entries.flatten() {
1087                    let name = entry.file_name();
1088                    let name_str = name.to_string_lossy();
1089                    if name_str.starts_with('.') {
1090                        continue;
1091                    }
1092                    if aube_dir_leaf.as_deref() == Some(name.as_os_str()) {
1093                        continue;
1094                    }
1095                    try_remove_entry(&entry.path());
1096                }
1097            }
1098            self.link_hidden_hoist(&aube_dir, graph)?;
1099            if let Err(e) = write_applied_patches(&root_nm, &curr_applied) {
1100                let sidecar = applied_patches_sidecar_name();
1101                tracing::error!(
1102                    code = aube_codes::errors::ERR_AUBE_PATCHES_TRACKING_WRITE,
1103                    "failed to write {sidecar}: {e}. next install may miss stale patched entries"
1104                );
1105            }
1106            return Ok(stats);
1107        }
1108
1109        // Precompute root importer's direct deps keyed by name so the
1110        // per-importer loop below can short-circuit on `dedupeDirectDeps`
1111        // without walking the root's dep list for every child entry.
1112        // Empty when the root has no direct deps (lockfile-only workspaces)
1113        // or when `dedupeDirectDeps=false` — skipping the build on the
1114        // common path avoids an allocation the per-dep check would
1115        // never consult.
1116        let root_deps_by_name: std::collections::HashMap<&str, &aube_lockfile::DirectDep> =
1117            if self.dedupe_direct_deps {
1118                graph
1119                    .importers
1120                    .get(".")
1121                    .map(|deps| deps.iter().map(|d| (d.name.as_str(), d)).collect())
1122                    .unwrap_or_default()
1123            } else {
1124                std::collections::HashMap::new()
1125            };
1126
1127        // Step 2a: Per-importer setup — ensure each importer's
1128        // `node_modules/` exists and sweep entries no longer in that
1129        // importer's direct deps. Cheap serial work (workspace
1130        // importers count is small; the expensive symlink syscalls
1131        // run in parallel below). For the root importer we also
1132        // expand the preserve set with `shamefullyHoist` /
1133        // `publicHoistPattern` matches so the hoist passes that run
1134        // after Step 2 don't redo work they'd have preserved.
1135        let aube_dir_leaf_root: Option<std::ffi::OsString> =
1136            if aube_dir.parent() == Some(root_nm.as_path()) {
1137                aube_dir.file_name().map(|s| s.to_owned())
1138            } else {
1139                None
1140            };
1141
1142        for (importer_path, deps) in &graph.importers {
1143            if !is_physical_importer(importer_path) {
1144                continue;
1145            }
1146            let nm = if importer_path == "." {
1147                root_nm.clone()
1148            } else {
1149                // Same lexical-normalization rationale as the hoisted
1150                // path above: a `../sibling` importer key has to land
1151                // at the actual sibling's `node_modules` rather than
1152                // `<root>/../sibling/node_modules`, otherwise
1153                // `pathdiff` produces a symlink target with the wrong
1154                // depth (one extra `..` per uncollapsed segment).
1155                aube_util::path::normalize_lexical(
1156                    &root_dir.join(importer_path).join(&self.modules_dir_name),
1157                )
1158            };
1159            if importer_path != "." {
1160                mkdirp(&nm)?;
1161            }
1162
1163            let mut preserve: std::collections::HashSet<&str> =
1164                deps.iter().map(|d| d.name.as_str()).collect();
1165            if importer_path == "." {
1166                if self.shamefully_hoist {
1167                    for pkg in graph.packages.values() {
1168                        preserve.insert(pkg.name.as_str());
1169                    }
1170                } else if !self.public_hoist_patterns.is_empty() {
1171                    for pkg in graph.packages.values() {
1172                        if pkg.local_source.is_none() && self.public_hoist_matches(&pkg.name) {
1173                            preserve.insert(pkg.name.as_str());
1174                        }
1175                    }
1176                }
1177            }
1178            let aube_leaf_here = if importer_path == "." {
1179                aube_dir_leaf_root.as_deref()
1180            } else {
1181                None
1182            };
1183            sweep_stale_top_level_entries(&nm, &preserve, aube_leaf_here);
1184        }
1185
1186        // Step 2b: Create top-level symlinks in parallel.
1187        // Flatten (importer, dep) pairs so every symlink syscall
1188        // runs through the rayon pool — 3k+ serial
1189        // `create_dir_link` calls was the second-biggest slice of
1190        // the workspace install phase before this change.
1191        use rayon::prelude::*;
1192
1193        #[derive(Clone)]
1194        struct Step2Task<'a> {
1195            importer_path: &'a str,
1196            nm: PathBuf,
1197            dep: &'a aube_lockfile::DirectDep,
1198        }
1199        let tasks: Vec<Step2Task<'_>> = graph
1200            .importers
1201            .iter()
1202            .filter(|(importer_path, _)| is_physical_importer(importer_path))
1203            .flat_map(|(importer_path, deps)| {
1204                let nm = if importer_path == "." {
1205                    root_nm.clone()
1206                } else {
1207                    // Same lexical-normalization rationale as
1208                    // `link_workspace_hoisted` above: parent-relative
1209                    // importer keys must collapse before `pathdiff`
1210                    // computes the top-level symlink target.
1211                    aube_util::path::normalize_lexical(
1212                        &root_dir.join(importer_path).join(&self.modules_dir_name),
1213                    )
1214                };
1215                deps.iter().map(move |dep| Step2Task {
1216                    importer_path: importer_path.as_str(),
1217                    nm: nm.clone(),
1218                    dep,
1219                })
1220            })
1221            .collect();
1222
1223        let link_parallelism = self.link_parallelism();
1224        let step2_timer = std::time::Instant::now();
1225        let step2_results: Vec<Result<bool, Error>> = with_link_pool(link_parallelism, || {
1226            tasks
1227                .par_iter()
1228                .map(|task| {
1229                    let Step2Task {
1230                        importer_path,
1231                        nm,
1232                        dep,
1233                    } = task;
1234
1235                    // `dedupeDirectDeps`: non-root importer dep
1236                    // already covered by the root symlink +
1237                    // parent-directory walk.
1238                    if self.dedupe_direct_deps
1239                        && *importer_path != "."
1240                        && let Some(root_dep) = root_deps_by_name.get(dep.name.as_str())
1241                        && root_dep.dep_path == dep.dep_path
1242                    {
1243                        return Ok(false);
1244                    }
1245
1246                    crate::validate_package_link_name(&dep.name)?;
1247                    let link_path = nm.join(&dep.name);
1248
1249                    // Workspace dep (`workspace:` protocol or bare
1250                    // semver that satisfies the sibling's version):
1251                    // link straight into the sibling package dir.
1252                    //
1253                    // Gate on the resolver's decision, not just the
1254                    // name match. The resolver omits a `LockedPackage`
1255                    // entry for workspace-resolved siblings (the
1256                    // `workspace_packages` branch in resolve.rs only
1257                    // pushes a `DirectDep`, never inserts into
1258                    // `resolved`), so a `dep_path` with no package
1259                    // entry means "resolver picked the sibling". When
1260                    // the package IS in `graph.packages`, the resolver
1261                    // pinned a registry version — even if a sibling
1262                    // shares the name, the user's spec didn't
1263                    // satisfy it (e.g. `is-positive: "2.0.0"` with a
1264                    // workspace sibling at `3.0.0`). Falling through
1265                    // to the registry branch in that case prevents the
1266                    // linker from silently substituting an
1267                    // incompatible local copy for the resolved
1268                    // version recorded in the lockfile.
1269                    if workspace_dirs.contains_key(&dep.name)
1270                        && !graph.packages.contains_key(&dep.dep_path)
1271                    {
1272                        let ws_dir = &workspace_dirs[&dep.name];
1273                        if !self.hoist_workspace_packages {
1274                            return Ok(false);
1275                        }
1276                        let link_parent = link_path.parent().unwrap_or(nm);
1277                        let rel_target =
1278                            pathdiff::diff_paths(ws_dir, link_parent).unwrap_or(ws_dir.clone());
1279                        if reconcile_dir_link(&link_path, &rel_target)? {
1280                            return Ok(false);
1281                        }
1282                        if let Some(parent) = link_path.parent() {
1283                            mkdirp(parent)?;
1284                        }
1285                        sys::create_dir_link(&rel_target, &link_path)
1286                            .map_err(|e| Error::Io(link_path.clone(), e))?;
1287                        return Ok(true);
1288                    }
1289
1290                    // `link:` dep — absolute path relative to `root_dir`.
1291                    if let Some(locked) = graph.packages.get(&dep.dep_path)
1292                        && let Some(LocalSource::Link(rel)) = locked.local_source.as_ref()
1293                    {
1294                        let abs_target = root_dir.join(rel);
1295                        let link_parent = link_path.parent().unwrap_or(nm);
1296                        let rel_target =
1297                            pathdiff::diff_paths(&abs_target, link_parent).unwrap_or(abs_target);
1298                        if reconcile_dir_link(&link_path, &rel_target)? {
1299                            return Ok(false);
1300                        }
1301                        if let Some(parent) = link_path.parent() {
1302                            mkdirp(parent)?;
1303                        }
1304                        sys::create_dir_link(&rel_target, &link_path)
1305                            .map_err(|e| Error::Io(link_path.clone(), e))?;
1306                        return Ok(true);
1307                    }
1308
1309                    // Regular registry dep — symlink to the root
1310                    // `.aube/<dep_path>/node_modules/<name>`.
1311                    let source_dir = aube_dir
1312                        .join(self.aube_dir_entry_name(&dep.dep_path))
1313                        .join("node_modules")
1314                        .join(&dep.name);
1315                    if !source_dir.exists() {
1316                        return Ok(false);
1317                    }
1318                    let link_parent = link_path.parent().unwrap_or(nm);
1319                    let rel_target = pathdiff::diff_paths(&source_dir, link_parent)
1320                        .unwrap_or_else(|| source_dir.clone());
1321                    if reconcile_dir_link(&link_path, &rel_target)? {
1322                        return Ok(false);
1323                    }
1324                    if let Some(parent) = link_path.parent() {
1325                        mkdirp(parent)?;
1326                    }
1327                    sys::create_dir_link(&rel_target, &link_path)
1328                        .map_err(|e| Error::Io(link_path.clone(), e))?;
1329                    trace!("workspace top-level: {} -> {}", dep.name, importer_path);
1330                    Ok(true)
1331                })
1332                .collect()
1333        });
1334        for result in step2_results {
1335            if result? {
1336                stats.top_level_linked += 1;
1337            }
1338        }
1339        tracing::debug!(
1340            "link_workspace:step2 (top-level symlinks) {:.1?}",
1341            step2_timer.elapsed()
1342        );
1343
1344        // Hoisting passes run against the *root* importer only —
1345        // pnpm never hoists into nested workspace packages. Run the
1346        // selective public-hoist-pattern first so matched names take
1347        // precedence, then `shamefully_hoist` sweeps up everything
1348        // else.
1349        if !self.public_hoist_patterns.is_empty() {
1350            self.hoist_remaining_into(
1351                &root_nm,
1352                &aube_dir,
1353                graph,
1354                &mut stats,
1355                "workspace public-hoist",
1356                &|name| self.public_hoist_matches(name),
1357            )?;
1358        }
1359        if self.shamefully_hoist {
1360            self.hoist_remaining_into(
1361                &root_nm,
1362                &aube_dir,
1363                graph,
1364                &mut stats,
1365                "workspace hoist",
1366                &|_| true,
1367            )?;
1368        }
1369
1370        // Hidden hoist is shared across importers, so a single sweep
1371        // here is sufficient for the whole workspace.
1372        self.link_hidden_hoist(&aube_dir, graph)?;
1373
1374        if let Err(e) = write_applied_patches(&root_nm, &curr_applied) {
1375            let sidecar = applied_patches_sidecar_name();
1376            tracing::error!(
1377                code = aube_codes::errors::ERR_AUBE_PATCHES_TRACKING_WRITE,
1378                "failed to write {sidecar}: {e}. next install may miss stale patched entries"
1379            );
1380        }
1381        Ok(stats)
1382    }
1383
1384    /// Populate (or sweep) the hidden modules directories at
1385    /// `aube_dir/node_modules/<name>` and, in global-virtual-store mode,
1386    /// `virtual_store/node_modules/<name>`. When `self.hoist` is
1387    /// enabled, walks every non-local package in the graph and creates
1388    /// a symlink for names that match `hoist_patterns` into each
1389    /// corresponding virtual-store package entry.
1390    /// When disabled, wipes the directory so previously-hoisted
1391    /// symlinks don't keep resolving through Node's parent walk.
1392    ///
1393    /// Unlike `hoist_remaining_into`, this writes into a private
1394    /// sibling of `.aube/<dep_path>/` rather than the visible root
1395    /// `node_modules/`. Packages inside the virtual store (e.g.
1396    /// `.aube/react@18/node_modules/react/`) walk up through
1397    /// `.aube/node_modules/` during require resolution, which is the
1398    /// only consumer of these links — nothing inside the user's own
1399    /// `node_modules/<name>` view is affected. In GVS mode, many
1400    /// toolchains canonicalize the package path into
1401    /// `~/.cache/aube/virtual-store/<hash>/node_modules/<name>`, so we
1402    /// mirror the hidden hoist under the shared virtual-store root too.
1403    fn link_hidden_hoist(&self, aube_dir: &Path, graph: &LockfileGraph) -> Result<(), Error> {
1404        self.link_hidden_hoist_at(aube_dir, aube_dir, graph, false, true)?;
1405        if self.use_global_virtual_store {
1406            self.link_hidden_hoist_at(
1407                &self.virtual_store,
1408                &self.virtual_store,
1409                graph,
1410                true,
1411                false,
1412            )?;
1413        }
1414        Ok(())
1415    }
1416
1417    fn link_hidden_hoist_at(
1418        &self,
1419        hidden_root: &Path,
1420        source_root: &Path,
1421        graph: &LockfileGraph,
1422        use_hashed_subdirs: bool,
1423        sweep_stale_entries: bool,
1424    ) -> Result<(), Error> {
1425        let hidden = hidden_root.join("node_modules");
1426        // FxHashSet over the borrowed name (lives for the lockfile graph
1427        // lifetime) drops the SipHash overhead and the per-insert
1428        // `String` clone the `HashSet<String>` version forced.
1429        let mut claimed: rustc_hash::FxHashSet<&str> = rustc_hash::FxHashSet::default();
1430        let packages: Vec<_> = if self.hoist {
1431            graph
1432                .packages
1433                .iter()
1434                .filter_map(|(dep_path, pkg)| {
1435                    if pkg.local_source.is_some() || !self.hoist_matches(&pkg.name) {
1436                        return None;
1437                    }
1438                    // First-writer-wins on name clashes across versions.
1439                    // BTree iteration over `graph.packages` gives a
1440                    // deterministic tiebreaker across runs.
1441                    claimed.insert(pkg.name.as_str()).then_some((dep_path, pkg))
1442                })
1443                .collect()
1444        } else {
1445            Vec::new()
1446        };
1447
1448        if !self.hoist {
1449            // Previous install may have populated this tree with
1450            // hoist=true. Drop entries so Node doesn't keep resolving
1451            // phantom deps through the stale symlinks. Project-local
1452            // hidden hoist owns the whole tree and can remove it in
1453            // one shot; the shared GVS mirror only reclaims broken
1454            // entries because live links may belong to another project.
1455            if sweep_stale_entries {
1456                remove_hidden_hoist_tree(&hidden);
1457            } else {
1458                sweep_dead_hidden_hoist_entries(&hidden);
1459            }
1460            return Ok(());
1461        }
1462        // Wipe before repopulating so a dependency removed from the
1463        // graph (or a pattern that no longer matches) doesn't linger.
1464        // The shared GVS hidden hoist only prunes broken entries:
1465        // removing live cross-project links would make the directory
1466        // last-writer-wins for sequential installs.
1467        if sweep_stale_entries {
1468            remove_hidden_hoist_tree(&hidden);
1469        } else {
1470            sweep_dead_hidden_hoist_entries(&hidden);
1471        }
1472        for (dep_path, pkg) in packages {
1473            let source_subdir = if use_hashed_subdirs {
1474                self.virtual_store_subdir(dep_path)
1475            } else {
1476                self.aube_dir_entry_name(dep_path)
1477            };
1478            let source_dir = source_root
1479                .join(source_subdir)
1480                .join("node_modules")
1481                .join(&pkg.name);
1482            if !source_dir.exists() {
1483                continue;
1484            }
1485            let target_dir = hidden.join(&pkg.name);
1486            if let Some(parent) = target_dir.parent() {
1487                mkdirp(parent)?;
1488            }
1489            let link_parent = target_dir.parent().unwrap_or(&hidden);
1490            let rel_target = pathdiff::diff_paths(&source_dir, link_parent)
1491                .unwrap_or_else(|| source_dir.clone());
1492            if reconcile_dir_link(&target_dir, &rel_target)? {
1493                continue;
1494            }
1495            sys::create_dir_link(&rel_target, &target_dir)
1496                .map_err(|e| Error::Io(target_dir.clone(), e))?;
1497            trace!("hidden-hoist: {}", pkg.name);
1498            // Intentionally not counted in `stats.top_level_linked`.
1499            // That counter reflects the user-visible root
1500            // `node_modules/<name>` entries; hidden-hoist symlinks
1501            // live under `.aube/node_modules/` and are only reached
1502            // via Node's parent-directory walk from inside the
1503            // virtual store, not from the user's own code.
1504        }
1505        Ok(())
1506    }
1507
1508    /// Shared `shamefully_hoist` implementation. For every non-local
1509    /// package in the graph, create a symlink at `nm/<pkg.name>`
1510    /// pointing at the matching `.aube/<dep_path>/node_modules/<pkg.name>`
1511    /// entry.
1512    ///
1513    /// Two separate "first-write-wins" protections apply:
1514    ///
1515    /// - **Direct deps always win over hoisted transitives.** Names
1516    ///   that appear in `graph.root_deps()` were placed (or
1517    ///   deliberately skipped) by Step 2 and must never be overwritten
1518    ///   by a hoist pass — that would silently swap `node_modules/foo`
1519    ///   from the version the user pinned to whatever transitive
1520    ///   happened to sort first.
1521    /// - **Within the hoist pass, BTree iteration order is the
1522    ///   tiebreaker across versions.** The `claimed` set records
1523    ///   names we already hoisted this call so a later iteration with
1524    ///   the same name (different `dep_path`) doesn't clobber the
1525    ///   first winner.
1526    ///
1527    /// For everything else the caller gets a *target-aware* reconcile:
1528    /// an existing symlink at `nm/<name>` that points at the version
1529    /// this iteration wants is kept; one pointing at a stale
1530    /// `.aube/<old-dep-path>/` (leftover from a prior install whose
1531    /// hoisted version has since changed) is replaced. The old
1532    /// plain-`exists?` check here kept stale entries because the
1533    /// surrounding linker used to wipe `nm` unconditionally — now that
1534    /// we sweep surgically, hoist has to cope with partial priors.
1535    ///
1536    /// `trace_label` distinguishes the `link_all` vs `link_workspace`
1537    /// callers in `-v` output.
1538    fn hoist_remaining_into(
1539        &self,
1540        nm: &Path,
1541        aube_dir: &Path,
1542        graph: &LockfileGraph,
1543        stats: &mut LinkStats,
1544        trace_label: &str,
1545        select: &dyn Fn(&str) -> bool,
1546    ) -> Result<(), Error> {
1547        // Root direct-dep names. Populated from the importer map
1548        // rather than an opaque "touched by Step 2" signal so a direct
1549        // dep that *failed* to place (missing `source_dir.exists()`,
1550        // workspace toggle, etc.) still reserves its slot — pnpm
1551        // doesn't hoist over a direct dep even when the direct dep
1552        // couldn't be installed.
1553        let direct_dep_names: std::collections::HashSet<&str> =
1554            graph.root_deps().iter().map(|d| d.name.as_str()).collect();
1555
1556        // FxHashSet over the borrowed name (lives for the lockfile graph
1557        // lifetime) drops the SipHash overhead and the per-insert
1558        // `String` clone the `HashSet<String>` version forced.
1559        let mut claimed: rustc_hash::FxHashSet<&str> = rustc_hash::FxHashSet::default();
1560
1561        for (dep_path, pkg) in &graph.packages {
1562            if pkg.local_source.is_some() {
1563                continue;
1564            }
1565            if !select(&pkg.name) {
1566                continue;
1567            }
1568            // Direct deps always win over hoisting.
1569            if direct_dep_names.contains(pkg.name.as_str()) {
1570                continue;
1571            }
1572            // First-writer-wins within the hoist pass: if an earlier
1573            // iteration already hoisted this name, later iterations
1574            // with the same name don't overwrite it.
1575            if !claimed.insert(pkg.name.as_str()) {
1576                continue;
1577            }
1578            let source_dir = aube_dir
1579                .join(self.aube_dir_entry_name(dep_path))
1580                .join("node_modules")
1581                .join(&pkg.name);
1582            if !source_dir.exists() {
1583                // Don't remove `name` from `claimed` — another
1584                // iteration for the same name would also find its
1585                // `source_dir` missing (the `.aube` populate phase
1586                // runs before hoist for every package), and leaving
1587                // the name claimed preserves the existing symlink
1588                // (whatever it points at) instead of repeatedly
1589                // probing for a materialization that isn't coming.
1590                continue;
1591            }
1592            let target_dir = nm.join(&pkg.name);
1593            let link_parent = target_dir.parent().unwrap_or(nm);
1594            let rel_target = pathdiff::diff_paths(&source_dir, link_parent)
1595                .unwrap_or_else(|| source_dir.clone());
1596            if reconcile_dir_link(&target_dir, &rel_target)? {
1597                continue;
1598            }
1599            if let Some(parent) = target_dir.parent() {
1600                mkdirp(parent)?;
1601            }
1602            sys::create_dir_link(&rel_target, &target_dir)
1603                .map_err(|e| Error::Io(target_dir.clone(), e))?;
1604            trace!("{trace_label}: {}", pkg.name);
1605            stats.top_level_linked += 1;
1606        }
1607        Ok(())
1608    }
1609}
1610
1611/// Build a `dep_path → absolute on-disk target` map for every
1612/// `LocalSource::Link` in the graph. Returned `None` when the graph
1613/// has no link entries (vast majority of installs), so the materialize
1614/// hot path can short-circuit without a per-dep lookup.
1615pub fn build_nested_link_targets(
1616    project_dir: &Path,
1617    graph: &LockfileGraph,
1618) -> Option<BTreeMap<String, PathBuf>> {
1619    let map: BTreeMap<String, PathBuf> = graph
1620        .packages
1621        .iter()
1622        .filter_map(|(dp, pkg)| match pkg.local_source.as_ref() {
1623            Some(LocalSource::Link(rel)) => Some((dp.clone(), project_dir.join(rel))),
1624            _ => None,
1625        })
1626        .collect();
1627    if map.is_empty() { None } else { Some(map) }
1628}