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}