Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.

feat(package-manager): force + keep_modules_dir on package import (#438 slice 1)#441

Merged
zkochan merged 4 commits into
mainfrom
feat/438
May 13, 2026
Merged

feat(package-manager): force + keep_modules_dir on package import (#438 slice 1)#441
zkochan merged 4 commits into
mainfrom
feat/438

Conversation

@zkochan

@zkochan zkochan commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

Extends pacquet's package-import primitive to cover pnpm's
importIndexedDir(..., { force, keepModulesDir }) call shape so the
hoisted node-linker (the umbrella of #438) has a primitive to build on.

  • Renames create_cas_filesimport_indexed_dir to match
    pnpm's name (its docstring already said it mirrored
    tryImportIndexedDir), and adds an ImportIndexedDirOpts struct
    with force and keep_modules_dir. Both linkers now go through
    the same function — pnpm uses one function for both, so does
    pacquet.
  • Default opts (force: false, keep_modules_dir: false): the
    isolated linker's behavior. Existing target short-circuits; fresh
    target gets mkdir + parallel link_file. Unchanged hot path
    for the two existing call sites
    (create_virtual_dir_by_snapshot, install_package_from_registry).
  • force: true: stage the new contents in a sibling directory,
    remove the old tree, rename stage into place. A regular file or
    symlink occupying the target is unlinked first.
  • force: true + keep_modules_dir: true: before the swap,
    dir_path/node_modules/ is moved into the staging directory so
    nested deps survive the rebuild. On any failure after the move,
    the staged copy is restored to dir_path/node_modules/ before
    staging is cleaned up — staging never holds the user's only copy
    of nested deps. This is the call shape the hoisted linker will
    use.
  • No install-pipeline wiring for hoisted yet. Subsequent slices
    (linkHoistedModules analog, hoist algorithm, Install::run
    branch on node_linker) will pass force + keep_modules_dir
    from the new hoisted path. The default-opts behavior is exercised
    in production today via the existing isolated callers.

Upstream reference

Ported from pnpm/pnpm@94240bc0:

Scope omissions (intentional)

  • moveOrMergeModulesDirs semantics for the case where the indexed
    file map itself contains node_modules/ entries. Upstream merges;
    pacquet errors with NodeModulesCollision. The hoisted-linker call
    site never produces that state in practice (npm and pnpm strip
    node_modules/ at pack time), so erroring loudly until a real
    caller demands the merge is the right call.
  • No safeToSkip short-circuit. Upstream's pre-existence check
    beyond the default short-circuit lives in index.ts around the
    importer; that decision belongs at the Slice 5 call site, not in
    this primitive.

Performance

The isolated-linker hot path's syscall count is unchanged. The old
if dir_path.exists() { return Ok(()); } (one stat) becomes
fs::symlink_metadata(dir_path) (one lstat) plus a match
dispatch; populate_dir is the old create_cas_files body
verbatim and gets inlined under release LTO. The new force
branches only run for hoisted, which has no callers yet.

One behavior diff: a dangling symlink at the target under default
opts used to fall through to mkdir and surface an IO error;
under the new code it short-circuits as "already populated". The
virtual store doesn't produce dangling symlinks under normal
operation, so this is theoretical.

Test plan

  • fresh_target_links_files — default opts, no existing target.
  • existing_target_short_circuits_under_default_opts — pins
    the isolated-linker invariant.
  • force_keep_replaces_files_and_preserves_node_modules — the
    hoisted call shape.
  • force_without_keep_clobbers_node_modulesforce without
    keep_modules_dir does not preserve nested deps.
  • force_keep_without_node_modules_replaces_cleanly — preserve
    branch with nothing to preserve.
  • force_replaces_regular_file_target — unlinks before fresh
    install.
  • force_replaces_symlink_target_without_following — unlinks
    the link, leaves the pointee.
  • fresh_target_creates_nested_directories — mkdir pre-pass
    reaches deep entries.
  • node_modules_collision_in_file_map_errorsforce_keep +
    tarball-shipped node_modules/ surfaces NodeModulesCollision
    without clobbering the existing tree.
  • hardlink_method_survives_staging_swapforce + Hardlink
    shares inode with the store source.
  • remove_dir_all_failure_restores_preserved_node_modules
    data-loss rescue path under force_keep.
  • node_modules_inspect_permission_denied_surfaces
    non-NotFound inspect errors are not swallowed.
  • concurrent_force_imports_into_different_targets_do_not_collide
    — staging-path uniqueness across rayon workers.
  • just ready, cargo doc --document-private-items,
    taplo format --check, just dylint all clean.
  • Reviewer threads (Copilot ×2, CodeRabbit critical) addressed in
    f084645.

Written by an agent (Claude Code, claude-opus-4-7).

Summary by CodeRabbit

Release Notes

  • Refactor

    • Improved internal package import mechanism with enhanced handling for re-importing into existing directories while optionally preserving node_modules.
  • Tests

    • Expanded test coverage for package installation scenarios including concurrent imports, symlink handling, and directory preservation strategies.

…438 slice 1)

Port pnpm's `importIndexedDir(..., { force: true, keepModulesDir: true })`
from `fs/indexed-pkg-importer/src/importIndexedDir.ts` as the foundation
for the hoisted node-linker. Slice 1 of #438.

`import_indexed_dir` lives next to `create_cas_files` in
`crates/package-manager/`. Fresh targets delegate straight to
`create_cas_files`; existing targets are rebuilt by staging the new
contents in a sibling directory, moving any pre-existing
`node_modules/` into the stage to preserve nested deps, removing the
old directory, then renaming the stage into place. Symlinks and
regular files at the target path are unlinked before the fresh-target
branch runs.

Slice 1 is stand-alone — no install-pipeline wiring yet. Subsequent
slices (`linkHoistedModules` analog, hoist algorithm port,
`Install::run` branch on `node_linker`) will consume this primitive.

Upstream reference: `pnpm/pnpm@94240bc0` —
https://github.com/pnpm/pnpm/blob/94240bc0/fs/indexed-pkg-importer/src/importIndexedDir.ts
Copilot AI review requested due to automatic review settings May 13, 2026 10:21
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 87afb60a-372a-4309-86b2-f6ebf86df300

📥 Commits

Reviewing files that changed from the base of the PR and between d5efa88 and 5d02f55.

📒 Files selected for processing (1)
  • crates/package-manager/src/import_indexed_dir.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/package-manager/src/import_indexed_dir.rs
📜 Recent review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Code Coverage
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Run benchmark on ubuntu-latest

📝 Walkthrough

Walkthrough

Adds import_indexed_dir: materializes CAS-indexed package files into a target directory, branching on path kind and using a staged sibling directory to atomically swap in new contents while optionally preserving an existing node_modules/. Includes rescue logic and extensive tests for edge cases and concurrency.

Changes

Indexed Directory Import with Staging/Swap

Layer / File(s) Summary
Import contract, opts, and errors
crates/package-manager/src/import_indexed_dir.rs, crates/package-manager/src/lib.rs
Adds ImportIndexedDirOpts { force, keep_modules_dir }, ImportIndexedDirError enum, declares mod import_indexed_dir; and re-exports its public API from lib.rs.
Import entry point and branching
crates/package-manager/src/import_indexed_dir.rs
Implements import_indexed_dir which inspects dir_path via metadata and dispatches: create missing target, unlink non-directory targets, or invoke staging-and-swap when replacing an existing directory (respects force flag).
Populate destination from CAS
crates/package-manager/src/import_indexed_dir.rs
Adds populate_dir to create required parent dirs and materialize indexed entries by invoking link_file (parallelized), mapping link failures into ImportIndexedDirError::LinkFile.
Staging-and-swap replacement
crates/package-manager/src/import_indexed_dir.rs
Adds stage_and_swap that picks a unique sibling stage path, populates it, optionally renames dir_path/node_modules into the stage (collision-detected), removes the old target, and renames the stage into place. Includes pick_stage_path, finalize_stage_cleanup_after_failure, restore_preserved_node_modules, and leak_stage rescue/cleanup logic.
Edge handling of non-directory targets
crates/package-manager/src/import_indexed_dir.rs
Adds remove_non_dir_dirent to clear regular files or platform-specific symlink behaviors before populating when force=true.
Tests exercising behavior and edge cases
crates/package-manager/src/import_indexed_dir/tests.rs
Adds comprehensive tests and helpers: fresh installs, default no-op, force replace with/without preserving node_modules, replacing files/symlinks, nested directories, node_modules collision error, hardlink inode preservation (Unix), restore-on-remove_dir_all failure (Unix), permission-inspect failure (Unix), and concurrent forced imports.
Call-site updates
crates/package-manager/src/create_virtual_dir_by_snapshot.rs, crates/package-manager/src/install_package_from_registry.rs, crates/package-manager/src/link_file.rs
Replaces previous create_cas_files usage with import_indexed_dir, updates error variants to wrap ImportIndexedDirError, and updates link_file docstring to reference import_indexed_dir as the production pre-pass.
Removed helper
crates/package-manager/src/create_cas_files.rs
Removes the old create_cas_files module and its exported API (function and error enum) now superseded by import_indexed_dir.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

Possibly related PRs

  • pnpm/pacquet#356: Modifies the package-manager install/CAS materialization pipeline; both PRs touch the CAS import flow and reporting/link_file plumbing.

Suggested reviewers

  • anthonyshew
  • anonrig

Poem

🐰 A stage takes the floor, swaps in the files,
node_modules/ tucks safe within cozy aisles.
Rename and restore when the swap meets a snag,
Tests hop in to chase every tricky flag.
The rabbit applauds—imports pass without drag.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately and concisely describes the main change: adding force and keep_modules_dir options to the package import functionality, which aligns with the core objectives of renaming create_cas_files to import_indexed_dir with new configuration options.
Description check ✅ Passed The PR description comprehensively covers the summary, upstream reference, scope omissions, performance considerations, and test plan. All required template sections are completed with substantial detail and rationale.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/438

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new package-manager primitive for hoisted-linker work: importing an “indexed” package into a real directory while overwriting existing contents but preserving an existing node_modules/ subtree. This is intended as a foundational building block for later hoisted-linker slices.

Changes:

  • Introduces import_indexed_dir with a “fresh target” path (delegates to create_cas_files) and an “existing target” stage-and-swap path that preserves node_modules/.
  • Exposes the new primitive via pacquet-package-manager’s public API surface.
  • Adds stand-alone unit tests covering fresh installs, overwrite semantics, node_modules preservation, collisions, hardlink behavior, and basic concurrency.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
crates/package-manager/src/lib.rs Registers and re-exports the new import_indexed_dir module.
crates/package-manager/src/import_indexed_dir.rs Implements the stage-and-swap import primitive with node_modules preservation and new error types.
crates/package-manager/src/import_indexed_dir/tests.rs Adds unit tests validating overwrite + preserve semantics and several edge cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/package-manager/src/import_indexed_dir.rs Outdated
Comment thread crates/package-manager/src/import_indexed_dir.rs Outdated
@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.00     17.1±0.56ms   253.1 KB/sec    1.00     17.1±0.73ms   254.1 KB/sec

@codecov

codecov Bot commented May 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.81%. Comparing base (b514929) to head (5d02f55).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
crates/package-manager/src/import_indexed_dir.rs 78.20% 34 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #441      +/-   ##
==========================================
- Coverage   86.98%   86.81%   -0.18%     
==========================================
  Files          93       93              
  Lines        6647     6803     +156     
==========================================
+ Hits         5782     5906     +124     
- Misses        865      897      +32     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/package-manager/src/import_indexed_dir/tests.rs (1)

274-321: ⚡ Quick win

Tighten the stage-path collision test.

Using pkg-a and pkg-b means this still passes if pick_stage_path() becomes deterministic per target name, so it doesn't actually protect the "every call gets a unique sibling path" invariant described in the comment. Reusing the same target basename or asserting on pick_stage_path() directly would make the regression signal much stronger.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/package-manager/src/import_indexed_dir/tests.rs` around lines 274 -
321, The test uses distinct basenames ("pkg-a"/"pkg-b") so a deterministic
per-target basename in pick_stage_path() could still pass; change the test to
force the same target basename (e.g., create two different parent dirs but use
the same final basename such as "pkg") or directly call pick_stage_path() twice
and assert the returned sibling paths are different to ensure every call yields
a unique sibling path; update the
concurrent_imports_into_different_targets_do_not_collide test to either (a)
create targets like tmp/.../t1/pkg and tmp/.../t2/pkg and invoke
import_indexed_dir on both, or (b) call pick_stage_path() twice (via its
public/internal API) and assert the two paths differ.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/package-manager/src/import_indexed_dir.rs`:
- Around line 137-142: The current stage_and_swap() flow unconditionally removes
the staging tree on any error, which can delete the only remaining copy after a
failed rename; instead implement a safe swap: first rename the existing dir_path
to a backup name (e.g., dir_path.with_extension(".backup")), then rename stage
to dir_path, and only after the final rename succeeds remove the backup
(fs::remove_dir_all on the backup). On any failure during the swap, attempt to
restore by renaming the backup back to dir_path and do not remove stage until
success; update all code paths that call fs::remove_dir_all(&stage) (and the
analogous 172-200 region) to follow this backup-then-rename-then-cleanup pattern
so one valid tree always survives.
- Around line 166-185: The code treats all errors from
fs::symlink_metadata(&target_modules) as "no node_modules", which can silently
drop unreadable node_modules; change the match so that Err(e) is only ignored
when e.kind() == std::io::ErrorKind::NotFound and all other Err(e) values are
propagated as an ImportIndexedDirError::PreserveModulesDir (use the same from:
target_modules, to: stage_modules, error: e shape) before continuing; keep the
existing Ok(meta) if meta.file_type().is_dir() branch and its rename/error
mapping unchanged and ensure stage_modules is constructed once
(stage.join("node_modules")) so you can reference it in the error case.

---

Nitpick comments:
In `@crates/package-manager/src/import_indexed_dir/tests.rs`:
- Around line 274-321: The test uses distinct basenames ("pkg-a"/"pkg-b") so a
deterministic per-target basename in pick_stage_path() could still pass; change
the test to force the same target basename (e.g., create two different parent
dirs but use the same final basename such as "pkg") or directly call
pick_stage_path() twice and assert the returned sibling paths are different to
ensure every call yields a unique sibling path; update the
concurrent_imports_into_different_targets_do_not_collide test to either (a)
create targets like tmp/.../t1/pkg and tmp/.../t2/pkg and invoke
import_indexed_dir on both, or (b) call pick_stage_path() twice (via its
public/internal API) and assert the two paths differ.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e8f87350-39f2-42a0-a31b-d932deb241f8

📥 Commits

Reviewing files that changed from the base of the PR and between b514929 and 88e20fd.

📒 Files selected for processing (3)
  • crates/package-manager/src/import_indexed_dir.rs
  • crates/package-manager/src/import_indexed_dir/tests.rs
  • crates/package-manager/src/lib.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: Agent
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Code Coverage
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Dylint
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (ubuntu-latest)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Preserve existing method chains and pipe-trait chains; do not break them into intermediate let bindings unless there is a concrete justification such as a compilation failure, borrow checker rejection, meaningful performance improvement, or other technical necessity. Refactoring for style alone is not sufficient justification.
Choose owned vs. borrowed parameters to minimize copies; prefer borrowed types (&Path over &PathBuf, &str over &String) when it does not force extra copies.
Prefer Arc::clone(&x) and Rc::clone(&x) over x.clone() for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Write use super::{Foo, bar} instead of use super::*; for any glob whose target is a module you control. External-crate preludes (e.g., use rayon::prelude::*;) and root-of-module re-exports (e.g., pub use submodule::*; in lib.rs) are exceptions.
Follow Rust API Guidelines for naming, as documented in https://rust-lang.github.io/api-guidelines/naming.html.
Declare a newtype wrapper for any branded string type being ported from TypeScript pnpm. Do not collapse the brand into a plain String or &str; give the type its own struct so misuse is a type error.
When porting branded string types where upstream TypeScript always validates before construction, validate in the Rust port too. Construct the wrapper only via TryFrom<String> and/or FromStr; do not provide an infallible public constructor that takes an arbitrary string.
For branded string types where upstream TypeScript never validates (used purely for type-safety to prevent confusion between string slots), expose an infallible From<String> and From<&str> constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bare as assertion), add a from_str_unchecked (or similarly named) constructor on the Rust side. Keep the validating constructor as well; `from_str_u...

Files:

  • crates/package-manager/src/lib.rs
  • crates/package-manager/src/import_indexed_dir.rs
  • crates/package-manager/src/import_indexed_dir/tests.rs
🧠 Learnings (2)
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.

Applied to files:

  • crates/package-manager/src/lib.rs
  • crates/package-manager/src/import_indexed_dir.rs
  • crates/package-manager/src/import_indexed_dir/tests.rs
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., `eprintln!`/`eprintln!(...)`) so that useful diagnostic values are printed when a test fails, unless the assertion is `assert_eq!` (where the differing values are already included). Concretely, if you use assertions like `assert!`, `assert_ne!`, etc., ensure the test logs the relevant actual/expected values (or context) before/around the assertion so failures can be diagnosed without rerunning.

Applied to files:

  • crates/package-manager/src/import_indexed_dir/tests.rs

Comment thread crates/package-manager/src/import_indexed_dir.rs Outdated
Comment thread crates/package-manager/src/import_indexed_dir.rs Outdated
…438 slice 1 review)

Address two review findings on the new `import_indexed_dir` primitive:

1. Data loss on cleanup. The blanket `remove_dir_all(stage)` after
   any post-step-3 failure would delete a `node_modules/` that had
   already been moved into the staging directory. Replace it with a
   step-by-step error path that, when the preserved `node_modules/`
   is in stage, renames it back onto `dir_path/node_modules/` before
   the staging directory is cleaned up. The user's nested deps are
   never the staging directory's exclusive copy past the rescue
   point.

2. Swallowed inspect errors. `Ok(_) | Err(_) => {}` for the
   pre-existing `node_modules/` lookup hid `PermissionDenied` and
   transient I/O errors, which would let the subsequent
   `remove_dir_all` silently clobber nested deps. Only `NotFound` is
   now treated as benign; everything else surfaces as
   `InspectTarget`.

Also fix the `cargo doc --document-private-items` workspace job by
disambiguating the intra-doc links to `create_cas_files()` and
`link_file()` — both names resolve to a function *and* a module, and
rustdoc rejects the ambiguity under `-D warnings`.

Tests:
- `remove_dir_all_failure_restores_preserved_node_modules` — pins the
  rescue path by chmod'ing a subdir of `dir_path` to 0o500 so
  `remove_dir_all` fails after step 3.
- `node_modules_inspect_permission_denied_surfaces` — proves
  `PermissionDenied` on the `node_modules/` inspect returns
  `InspectTarget` and cleans up staging.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
crates/package-manager/src/import_indexed_dir.rs (1)

194-212: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Keep the staged node_modules/ when rescue cannot put it back.

These failure paths still call remove_dir_all(&stage) even when rescue_node_modules cannot restore stage/node_modules to dir_path/node_modules, or when create_dir_all(dir_path) fails before the rescue attempt. In both cases that cleanup deletes the only remaining copy of the preserved nested deps.

💡 Localized fix
-        rescue_node_modules(nm_moved, &stage_modules, &target_modules);
-        let _ = fs::remove_dir_all(&stage);
+        let restored = rescue_node_modules(nm_moved, &stage_modules, &target_modules);
+        if restored {
+            let _ = fs::remove_dir_all(&stage);
+        }
         return Err(ImportIndexedDirError::RemoveExisting { path: dir_path.to_path_buf(), error });
@@
-        if nm_moved && fs::create_dir_all(dir_path).is_ok() {
-            rescue_node_modules(nm_moved, &stage_modules, &target_modules);
-        }
-        let _ = fs::remove_dir_all(&stage);
+        let restored = if nm_moved && fs::create_dir_all(dir_path).is_ok() {
+            rescue_node_modules(nm_moved, &stage_modules, &target_modules)
+        } else {
+            !nm_moved
+        };
+        if restored {
+            let _ = fs::remove_dir_all(&stage);
+        }
         return Err(ImportIndexedDirError::Swap { from: stage, to: dir_path.to_path_buf(), error });
@@
-fn rescue_node_modules(nm_moved: bool, stage_modules: &Path, target_modules: &Path) {
+fn rescue_node_modules(nm_moved: bool, stage_modules: &Path, target_modules: &Path) -> bool {
     if !nm_moved {
-        return;
+        return true;
     }
     if let Err(error) = fs::rename(stage_modules, target_modules) {
         tracing::warn!(
             target: "pacquet::import_indexed_dir",
             ?stage_modules,
             ?target_modules,
             %error,
             "failed to restore preserved node_modules/ after a partial stage-and-swap; \
-             the staged copy is at the source path until cleanup runs",
+             leaving the staged copy in place for manual recovery",
         );
+        return false;
     }
+    true
 }

Also applies to: 222-235

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/package-manager/src/import_indexed_dir.rs` around lines 194 - 212, The
staged node_modules can be lost because the code always calls
fs::remove_dir_all(&stage) even when rescue_node_modules fails or when
fs::create_dir_all(dir_path) fails; update the failure paths around fs::rename
and fs::remove_dir_all so we only delete the staged tree when the rescue
actually succeeded (or when nm_moved is false). Concretely, change
rescue_node_modules usage (in the ImportIndexedDir handling around the
fs::rename error and the earlier RemoveExisting path) so rescue_node_modules
returns a success indicator (bool or Result) and branch: if nm_moved &&
create_dir_all(dir_path).is_ok() then call rescue_node_modules and only call
fs::remove_dir_all(&stage) when that rescue reports success; if create_dir_all
fails or rescue fails, keep the stage directory intact and return the
appropriate ImportIndexedDirError::Swap/RemoveExisting without removing &stage.
Ensure the same change is applied to the other identical block (lines 222–235).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@crates/package-manager/src/import_indexed_dir.rs`:
- Around line 194-212: The staged node_modules can be lost because the code
always calls fs::remove_dir_all(&stage) even when rescue_node_modules fails or
when fs::create_dir_all(dir_path) fails; update the failure paths around
fs::rename and fs::remove_dir_all so we only delete the staged tree when the
rescue actually succeeded (or when nm_moved is false). Concretely, change
rescue_node_modules usage (in the ImportIndexedDir handling around the
fs::rename error and the earlier RemoveExisting path) so rescue_node_modules
returns a success indicator (bool or Result) and branch: if nm_moved &&
create_dir_all(dir_path).is_ok() then call rescue_node_modules and only call
fs::remove_dir_all(&stage) when that rescue reports success; if create_dir_all
fails or rescue fails, keep the stage directory intact and return the
appropriate ImportIndexedDirError::Swap/RemoveExisting without removing &stage.
Ensure the same change is applied to the other identical block (lines 222–235).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3def5535-9f9b-42a0-89ad-74c1f500dffc

📥 Commits

Reviewing files that changed from the base of the PR and between 88e20fd and f084645.

📒 Files selected for processing (2)
  • crates/package-manager/src/import_indexed_dir.rs
  • crates/package-manager/src/import_indexed_dir/tests.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Code Coverage
  • GitHub Check: Run benchmark on ubuntu-latest
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Preserve existing method chains and pipe-trait chains; do not break them into intermediate let bindings unless there is a concrete justification such as a compilation failure, borrow checker rejection, meaningful performance improvement, or other technical necessity. Refactoring for style alone is not sufficient justification.
Choose owned vs. borrowed parameters to minimize copies; prefer borrowed types (&Path over &PathBuf, &str over &String) when it does not force extra copies.
Prefer Arc::clone(&x) and Rc::clone(&x) over x.clone() for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Write use super::{Foo, bar} instead of use super::*; for any glob whose target is a module you control. External-crate preludes (e.g., use rayon::prelude::*;) and root-of-module re-exports (e.g., pub use submodule::*; in lib.rs) are exceptions.
Follow Rust API Guidelines for naming, as documented in https://rust-lang.github.io/api-guidelines/naming.html.
Declare a newtype wrapper for any branded string type being ported from TypeScript pnpm. Do not collapse the brand into a plain String or &str; give the type its own struct so misuse is a type error.
When porting branded string types where upstream TypeScript always validates before construction, validate in the Rust port too. Construct the wrapper only via TryFrom<String> and/or FromStr; do not provide an infallible public constructor that takes an arbitrary string.
For branded string types where upstream TypeScript never validates (used purely for type-safety to prevent confusion between string slots), expose an infallible From<String> and From<&str> constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bare as assertion), add a from_str_unchecked (or similarly named) constructor on the Rust side. Keep the validating constructor as well; `from_str_u...

Files:

  • crates/package-manager/src/import_indexed_dir/tests.rs
  • crates/package-manager/src/import_indexed_dir.rs
🧠 Learnings (2)
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., `eprintln!`/`eprintln!(...)`) so that useful diagnostic values are printed when a test fails, unless the assertion is `assert_eq!` (where the differing values are already included). Concretely, if you use assertions like `assert!`, `assert_ne!`, etc., ensure the test logs the relevant actual/expected values (or context) before/around the assertion so failures can be diagnosed without rerunning.

Applied to files:

  • crates/package-manager/src/import_indexed_dir/tests.rs
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.

Applied to files:

  • crates/package-manager/src/import_indexed_dir/tests.rs
  • crates/package-manager/src/import_indexed_dir.rs

@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown

Integrated-Benchmark Report (Linux)

Scenario: Frozen Lockfile

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.543 ± 0.181 2.360 3.019 1.05 ± 0.08
pacquet@main 2.428 ± 0.073 2.337 2.525 1.00
pnpm 5.767 ± 0.060 5.662 5.847 2.38 ± 0.08
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.542566411,
      "stddev": 0.18120085827116897,
      "median": 2.5104989657,
      "user": 2.59279526,
      "system": 3.4377771000000004,
      "min": 2.3595975386999997,
      "max": 3.0190684577,
      "times": [
        2.6041347227,
        2.4221024747,
        3.0190684577,
        2.5449075286999996,
        2.4953513196999997,
        2.5372520336999997,
        2.4521905126999997,
        2.5256466117,
        2.3595975386999997,
        2.4654129097
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.4278898943000002,
      "stddev": 0.07336233112681019,
      "median": 2.4275294942,
      "user": 2.57392406,
      "system": 3.3619006,
      "min": 2.3366916936999997,
      "max": 2.5245525616999998,
      "times": [
        2.4811947527,
        2.3547917827,
        2.3999603247,
        2.3366916936999997,
        2.3773671196999997,
        2.5245525616999998,
        2.5189407447,
        2.4855246397,
        2.3447766597,
        2.4550986637
      ]
    },
    {
      "command": "pnpm",
      "mean": 5.7673685189,
      "stddev": 0.060386920291060624,
      "median": 5.7694976857,
      "user": 8.414824460000002,
      "system": 4.3127346,
      "min": 5.6615186617,
      "max": 5.8471316307,
      "times": [
        5.7558004437,
        5.8119701397,
        5.8453684527,
        5.6615186617,
        5.7526916207,
        5.7297811917,
        5.6984457877,
        5.8471316307,
        5.7877823327,
        5.7831949277
      ]
    }
  ]
}

Scenario: Frozen Lockfile (Hot Cache)

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 689.5 ± 36.7 660.7 787.6 1.00
pacquet@main 760.0 ± 48.6 692.5 842.4 1.10 ± 0.09
pnpm 2372.5 ± 65.0 2268.6 2470.7 3.44 ± 0.21
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6894869912199999,
      "stddev": 0.03671669988806593,
      "median": 0.6785652153200001,
      "user": 0.35553241999999996,
      "system": 1.4944203000000003,
      "min": 0.6606604583200001,
      "max": 0.7876375693200001,
      "times": [
        0.7876375693200001,
        0.68546889332,
        0.69239461032,
        0.6703537363200001,
        0.66635466732,
        0.6805108833200001,
        0.6606604583200001,
        0.70305830432,
        0.67181124232,
        0.6766195473200001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.7600168821200001,
      "stddev": 0.04860632457110471,
      "median": 0.75712953982,
      "user": 0.36844252,
      "system": 1.5265053,
      "min": 0.69254372032,
      "max": 0.8424329073200001,
      "times": [
        0.81362090032,
        0.7133948303200001,
        0.79896983732,
        0.75548616932,
        0.70326965732,
        0.75877291032,
        0.8424329073200001,
        0.7682595643200001,
        0.75341832432,
        0.69254372032
      ]
    },
    {
      "command": "pnpm",
      "mean": 2.3725066152200003,
      "stddev": 0.0649832620662973,
      "median": 2.38116730432,
      "user": 2.83153612,
      "system": 2.1741208999999992,
      "min": 2.26856430232,
      "max": 2.47068268232,
      "times": [
        2.47068268232,
        2.37907712232,
        2.31553233332,
        2.41809692432,
        2.32459121532,
        2.31241654332,
        2.26856430232,
        2.43981695932,
        2.3832574863200002,
        2.41303058332
      ]
    }
  ]
}

…_dir (#438 slice 1)

Per maintainer feedback: pnpm uses one function for both node-linkers
parameterized by opts. Collapse pacquet's two-function split into the
same shape.

`create_cas_files` and the previously-separate `import_indexed_dir`
fold into a single `import_indexed_dir(.., opts: ImportIndexedDirOpts)`
that services both linkers:

- Default opts (isolated linker): existing-target short-circuit,
  fresh-target mkdir + parallel link. Matches what `create_cas_files`
  did and what the virtual-store callers need.
- `force: true` (hoisted linker): stage-and-swap re-import.
- `force: true` + `keep_modules_dir: true` (hoisted linker): also
  preserve `dir_path/node_modules/` with the same rescue logic as
  before.

The two existing isolated-linker callers
(`create_virtual_dir_by_snapshot`, `install_package_from_registry`)
now call `import_indexed_dir(.., ImportIndexedDirOpts::default())`.
The error type renames from `CreateCasFilesError` to
`ImportIndexedDirError`. `link_file`'s docstring updates to point at
the new entry point.

Tests stay equivalent: same behaviors covered, but `FORCE_KEEP` /
`FORCE_ONLY` constants make the call shapes explicit. A new
`existing_target_short_circuits_under_default_opts` pins the
isolated-linker invariant that an existing dir is never re-imported
when opts are defaulted, and `force_without_keep_clobbers_node_modules`
covers the `force` + `keep_modules_dir: false` parameter combination.

Upstream reference: `pnpm/pnpm@94240bc0` —
- https://github.com/pnpm/pnpm/blob/94240bc0/fs/indexed-pkg-importer/src/importIndexedDir.ts
- https://github.com/pnpm/pnpm/blob/94240bc0/store/controller-types/src/index.ts
Copilot AI review requested due to automatic review settings May 13, 2026 10:50

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
crates/package-manager/src/import_indexed_dir.rs (1)

285-303: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid deleting the staging tree when node_modules restoration may have failed.

After nm_moved == true, stage can contain the only recoverable node_modules copy. In both failure branches, stage is removed unconditionally even if restoration cannot run (e.g., create_dir_all(dir_path) fails) or restoration itself fails.

💡 Minimal hardening patch
-    if let Err(error) = fs::remove_dir_all(dir_path) {
-        rescue_node_modules(nm_moved, &stage_modules, &target_modules);
-        let _ = fs::remove_dir_all(&stage);
+    if let Err(error) = fs::remove_dir_all(dir_path) {
+        let restored = rescue_node_modules(nm_moved, &stage_modules, &target_modules);
+        if !nm_moved || restored {
+            let _ = fs::remove_dir_all(&stage);
+        }
         return Err(ImportIndexedDirError::RemoveExisting { path: dir_path.to_path_buf(), error });
     }
@@
-    if let Err(error) = fs::rename(&stage, dir_path) {
-        if nm_moved && fs::create_dir_all(dir_path).is_ok() {
-            rescue_node_modules(nm_moved, &stage_modules, &target_modules);
-        }
-        let _ = fs::remove_dir_all(&stage);
+    if let Err(error) = fs::rename(&stage, dir_path) {
+        let restored = if nm_moved && fs::create_dir_all(dir_path).is_ok() {
+            rescue_node_modules(nm_moved, &stage_modules, &target_modules)
+        } else {
+            !nm_moved
+        };
+        if restored {
+            let _ = fs::remove_dir_all(&stage);
+        }
         return Err(ImportIndexedDirError::Swap { from: stage, to: dir_path.to_path_buf(), error });
     }
-fn rescue_node_modules(nm_moved: bool, stage_modules: &Path, target_modules: &Path) {
+fn rescue_node_modules(nm_moved: bool, stage_modules: &Path, target_modules: &Path) -> bool {
     if !nm_moved {
-        return;
+        return true;
     }
     if let Err(error) = fs::rename(stage_modules, target_modules) {
         tracing::warn!(
@@
             "failed to restore preserved node_modules/ after a partial stage-and-swap; \
              the staged copy is at the source path until cleanup runs",
         );
+        return false;
     }
+    true
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/package-manager/src/import_indexed_dir.rs` around lines 285 - 303, The
staging tree (`stage`) is being removed unconditionally even when `nm_moved ==
true` and restoration of `node_modules` may have failed; update the error
branches around fs::remove_dir_all(dir_path) and fs::rename(&stage, dir_path) so
that you only call fs::remove_dir_all(&stage) after successful rescue;
specifically, when nm_moved is true: attempt to recreate the target dir with
fs::create_dir_all(dir_path) and run rescue_node_modules(nm_moved,
&stage_modules, &target_modules), and only if that sequence succeeds remove the
staging tree (`stage`); if create_dir_all or the rescue step fails, do not
delete `stage` and return the appropriate
ImportIndexedDirError::{RemoveExisting, Swap} so the recoverable `node_modules`
remains intact. Ensure these changes touch the error handling around
ImportIndexedDirError::RemoveExisting and ImportIndexedDirError::Swap and keep
logic for nm_moved == false unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@crates/package-manager/src/import_indexed_dir.rs`:
- Around line 285-303: The staging tree (`stage`) is being removed
unconditionally even when `nm_moved == true` and restoration of `node_modules`
may have failed; update the error branches around fs::remove_dir_all(dir_path)
and fs::rename(&stage, dir_path) so that you only call
fs::remove_dir_all(&stage) after successful rescue; specifically, when nm_moved
is true: attempt to recreate the target dir with fs::create_dir_all(dir_path)
and run rescue_node_modules(nm_moved, &stage_modules, &target_modules), and only
if that sequence succeeds remove the staging tree (`stage`); if create_dir_all
or the rescue step fails, do not delete `stage` and return the appropriate
ImportIndexedDirError::{RemoveExisting, Swap} so the recoverable `node_modules`
remains intact. Ensure these changes touch the error handling around
ImportIndexedDirError::RemoveExisting and ImportIndexedDirError::Swap and keep
logic for nm_moved == false unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 98b84d39-af74-480c-8ee7-018d1f2a7e46

📥 Commits

Reviewing files that changed from the base of the PR and between f084645 and d5efa88.

📒 Files selected for processing (7)
  • crates/package-manager/src/create_cas_files.rs
  • crates/package-manager/src/create_virtual_dir_by_snapshot.rs
  • crates/package-manager/src/import_indexed_dir.rs
  • crates/package-manager/src/import_indexed_dir/tests.rs
  • crates/package-manager/src/install_package_from_registry.rs
  • crates/package-manager/src/lib.rs
  • crates/package-manager/src/link_file.rs
💤 Files with no reviewable changes (1)
  • crates/package-manager/src/create_cas_files.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/package-manager/src/link_file.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/package-manager/src/import_indexed_dir/tests.rs
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Preserve existing method chains and pipe-trait chains; do not break them into intermediate let bindings unless there is a concrete justification such as a compilation failure, borrow checker rejection, meaningful performance improvement, or other technical necessity. Refactoring for style alone is not sufficient justification.
Choose owned vs. borrowed parameters to minimize copies; prefer borrowed types (&Path over &PathBuf, &str over &String) when it does not force extra copies.
Prefer Arc::clone(&x) and Rc::clone(&x) over x.clone() for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Write use super::{Foo, bar} instead of use super::*; for any glob whose target is a module you control. External-crate preludes (e.g., use rayon::prelude::*;) and root-of-module re-exports (e.g., pub use submodule::*; in lib.rs) are exceptions.
Follow Rust API Guidelines for naming, as documented in https://rust-lang.github.io/api-guidelines/naming.html.
Declare a newtype wrapper for any branded string type being ported from TypeScript pnpm. Do not collapse the brand into a plain String or &str; give the type its own struct so misuse is a type error.
When porting branded string types where upstream TypeScript always validates before construction, validate in the Rust port too. Construct the wrapper only via TryFrom<String> and/or FromStr; do not provide an infallible public constructor that takes an arbitrary string.
For branded string types where upstream TypeScript never validates (used purely for type-safety to prevent confusion between string slots), expose an infallible From<String> and From<&str> constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bare as assertion), add a from_str_unchecked (or similarly named) constructor on the Rust side. Keep the validating constructor as well; `from_str_u...

Files:

  • crates/package-manager/src/install_package_from_registry.rs
  • crates/package-manager/src/create_virtual_dir_by_snapshot.rs
  • crates/package-manager/src/lib.rs
  • crates/package-manager/src/import_indexed_dir.rs
🧠 Learnings (1)
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.

Applied to files:

  • crates/package-manager/src/install_package_from_registry.rs
  • crates/package-manager/src/create_virtual_dir_by_snapshot.rs
  • crates/package-manager/src/lib.rs
  • crates/package-manager/src/import_indexed_dir.rs
🔇 Additional comments (4)
crates/package-manager/src/import_indexed_dir.rs (1)

15-281: LGTM!

Also applies to: 308-349

crates/package-manager/src/lib.rs (1)

10-10: LGTM!

Also applies to: 33-33

crates/package-manager/src/create_virtual_dir_by_snapshot.rs (1)

1-4: LGTM!

Also applies to: 62-62, 94-110

crates/package-manager/src/install_package_from_registry.rs (1)

2-2: LGTM!

Also applies to: 61-61, 179-186

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

crates/package-manager/src/import_indexed_dir.rs:304

  • On fs::rename(stage, dir_path) failure, the code only attempts a node_modules/ rescue if create_dir_all(dir_path) succeeds, and then it unconditionally removes the staging dir. If create_dir_all or the rescue rename fails, the subsequent remove_dir_all(stage) can delete the preserved stage/node_modules. To uphold the “no data loss” invariant, consider keeping the stage directory (or at least not deleting stage/node_modules) when rescue is not confirmed to have succeeded, and surface the stage path in the error/logs for manual recovery.
    if let Err(error) = fs::rename(&stage, dir_path) {
        if nm_moved && fs::create_dir_all(dir_path).is_ok() {
            rescue_node_modules(nm_moved, &stage_modules, &target_modules);
        }
        let _ = fs::remove_dir_all(&stage);
        return Err(ImportIndexedDirError::Swap { from: stage, to: dir_path.to_path_buf(), error });
    }

crates/package-manager/src/import_indexed_dir.rs:326

  • The warning in rescue_node_modules says “the staged copy is at the source path until cleanup runs”, but all current callers immediately run a staging cleanup (remove_dir_all(stage)), which would remove that source path if the rescue rename fails. Either avoid cleaning up the stage when rescue fails (preferred, to prevent data loss) or adjust the log message to accurately describe what remains on disk and where recovery is possible.
    if let Err(error) = fs::rename(stage_modules, target_modules) {
        tracing::warn!(
            target: "pacquet::import_indexed_dir",
            ?stage_modules,
            ?target_modules,
            %error,
            "failed to restore preserved node_modules/ after a partial stage-and-swap; \
             the staged copy is at the source path until cleanup runs",
        );
    }

Comment thread crates/package-manager/src/import_indexed_dir.rs
Comment thread crates/package-manager/src/import_indexed_dir.rs
@zkochan zkochan changed the title feat(package-manager): real-directory linking primitive (#438 slice 1) feat(package-manager): force + keep_modules_dir on package import (#438 slice 1) May 13, 2026
…ks correctly on Windows (#438 slice 1 review)

Address two new review findings on the unified import primitive:

1. Rescue failure used to be silently followed by an unconditional
   `remove_dir_all(stage)`, which would destroy the user's only
   remaining copy of the preserved `node_modules/` when the
   restoration rename couldn't run (permissions, transient I/O).
   `restore_preserved_node_modules` now returns whether the restore
   actually completed, and the combined cleanup helper
   `finalize_stage_cleanup_after_failure` only rimrafs the staging
   directory when the restore succeeded or wasn't needed. On
   restore failure the staging directory is left on disk and a
   warning emits the path so an operator can recover manually.

2. The non-directory-with-force unlink path called `fs::remove_file`
   directly. On Windows that rejects directory symlinks and
   junctions (the OS treats them as directory-shaped), which would
   wedge the overwrite path the moment a stale dir symlink occupied
   the target. `remove_non_dir_dirent` now resolves the link on
   Windows and routes through `fs::remove_dir` when the target is a
   directory; Unix continues to use `remove_file` unchanged.

The Unix path has no behavior change. Existing tests still pass;
the rescue-failure leak path is hard to drive deterministically in
a unit test (it requires both `remove_dir_all` and the subsequent
`fs::rename` to fail in the same configuration) so the new path is
covered by docstring + tracing emit rather than a dedicated test.
The Windows path is exercised on CI's windows-latest job.
@zkochan zkochan merged commit 71ad815 into main May 13, 2026
14 of 16 checks passed
@zkochan zkochan deleted the feat/438 branch May 13, 2026 11:43
zkochan added a commit that referenced this pull request May 13, 2026
Threads `VirtualStoreLayout` through the frozen-lockfile install
pipeline so packages installed under
`enableGlobalVirtualStore: true` (pacquet's default since #444) land
at the upstream-shaped
`<store_dir>/links/<scope>/<name>/<version>/<hash>/node_modules/<pkg>`
path instead of the legacy per-project flat layout. Closes the rest
of #432 (Section B + C activation + D entry tests).

Pipeline changes (frozen-lockfile path):

- `InstallFrozenLockfile::run` constructs the install-scoped
  `VirtualStoreLayout` from the lockfile + engine_name + config,
  then threads `&VirtualStoreLayout` through `CreateVirtualStore`,
  `CreateVirtualDirBySnapshot`, `create_symlink_layout`,
  `SymlinkDirectDependencies`, `InstallPackageBySnapshot`,
  `BuildModules`, and `LinkVirtualStoreBins`. Every site that used
  to compute `virtual_store_dir.join(key.to_virtual_store_name())`
  now goes through one `layout.slot_dir(key)` lookup.
- `Install::run` calls `pacquet_store_dir::register_project` when
  `enable_global_virtual_store` is on, writing
  `<store_dir>/projects/<short-hash>` so a future
  `pacquet store prune` can find the projects that still reference
  `<store_dir>/links/...` slots. Best-effort: registry write
  failures degrade to `tracing::warn!` and the install continues.
- `Config::current` now applies
  `apply_global_virtual_store_derivation` after yaml — pacquet's
  variant of upstream's
  [`extendInstallOptions.ts:343-355`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/extendInstallOptions.ts#L343-L355).

Field-split deviation from upstream:

Upstream pnpm *mutates* `virtualStoreDir` in place under GVS, so
every consumer that reads `virtualStoreDir` sees `<storeDir>/links`.
Pacquet keeps `virtual_store_dir` at its project-local value and
writes the GVS path into the separate `global_virtual_store_dir`
field. The frozen-lockfile path consumes `global_virtual_store_dir`
through `VirtualStoreLayout`; the legacy non-frozen
`InstallWithoutLockfile` keeps reading `virtual_store_dir` (now via
`VirtualStoreLayout::legacy`). The split is a pacquet-only concern:
upstream has no without-lockfile install path and so doesn't need
the second field. See the doc on
`Config::apply_global_virtual_store_derivation` for the reasoning.

Tests:

- `install::tests::frozen_lockfile_under_gvs_registers_project_and_runs_clean`
  pins the registry write under default GVS-on.
- `install::tests::frozen_lockfile_with_gvs_off_skips_project_registry`
  pins that GVS-off opts out of the registry write.
- All 653 workspace tests pass under the new layout-threaded path.
- Existing per-snapshot legacy tests construct
  `VirtualStoreLayout::legacy(...)` explicitly so they keep
  asserting the flat-name layout regardless of any global default.

End-to-end port of upstream's
[`installing/deps-installer/test/install/globalVirtualStore.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/test/install/globalVirtualStore.ts)
is tracked as a follow-up — those tests need a small frozen-
lockfile fixture against the registry-mock, which is out of scope
here.

Based on #441 (`feat/438`); the `import_indexed_dir` rename from
that PR is already merged in. The two PRs only co-touch
`create_virtual_dir_by_snapshot.rs` — both pieces land cleanly on
top of each other.
zkochan added a commit that referenced this pull request May 13, 2026
Six items from the Copilot review on #449:

1. **Gate `register_project` on frozen-lockfile** — the call previously
   fired for any GVS-on install, including the legacy
   `InstallWithoutLockfile` path that uses
   `VirtualStoreLayout::legacy` and never references
   `<store_dir>/links/...`. Tightened to
   `frozen_lockfile && config.enable_global_virtual_store` so a
   registry entry only appears for installs that actually consume the
   shared store.

2. **`VirtualStoreLayout::new` engine: `Option<&str>`** — the param
   was `&str` and the call site passed `engine_name.as_deref()
   .unwrap_or("")`, which turned a missing `node` into `Some("")` and
   hashed to a different value than `None` (the latter omits the
   `engine` contribution from `calc_graph_node_hash`). Switched the
   signature to `Option<&str>` and the call site to
   `engine_name.as_deref()` so the missing-engine case round-trips
   correctly. Test call sites updated.

3. **`run_emits_imported_event_after_create_cas_files`** — renamed to
   `run_emits_imported_event_after_import_indexed_dir` (and updated
   the doc comment) since `create_cas_files` was renamed to
   `import_indexed_dir` in #441.

4. **`VirtualStoreLayout::package_store_dir` doc** — the field doc
   still claimed the root was `Config::virtual_store_dir` in both
   modes. Updated to reflect the post-split design: it's
   `Config::global_virtual_store_dir` under GVS, `virtual_store_dir`
   otherwise.

5. **Test manifest placement** — the new
   `frozen_lockfile_under_gvs_registers_project_and_runs_clean` and
   `frozen_lockfile_with_gvs_off_skips_project_registry` tests
   created the manifest at `<tmp>/package.json` while
   `project_root` was `<tmp>/project/`. `Install::run` derives the
   registry target from `manifest.path().parent()`, so the registry
   entry actually pointed at `<tmp>`, not the intended project root.
   Moved the manifest into `project_root` and replaced the
   entry-count-only check with a `read_link` + canonicalize
   assertion that the symlink resolves back to `project_root`.

6. **Preserve yaml-set `globalVirtualStoreDir`** —
   `apply_global_virtual_store_derivation` unconditionally
   overwrote the field, so a user-pinned `globalVirtualStoreDir:` in
   `pnpm-workspace.yaml` was silently lost. Added a
   `global_virtual_store_dir_explicit` parameter that short-circuits
   the derivation when yaml provided the value, mirroring the
   pre-existing `virtual_store_dir_explicit` plumbing. New
   `yaml_global_virtual_store_dir_wins_over_derivation` test pins
   the behaviour.

All 704 workspace tests pass; `taplo --check`, `just dylint`,
`cargo doc -D warnings` clean.
zkochan added a commit that referenced this pull request May 13, 2026
Threads `VirtualStoreLayout` through the frozen-lockfile install
pipeline so packages installed under
`enableGlobalVirtualStore: true` (pacquet's default since #444) land
at the upstream-shaped
`<store_dir>/links/<scope>/<name>/<version>/<hash>/node_modules/<pkg>`
path instead of the legacy per-project flat layout. Closes the rest
of #432 (Section B + C activation + D entry tests).

Pipeline changes (frozen-lockfile path):

- `InstallFrozenLockfile::run` constructs the install-scoped
  `VirtualStoreLayout` from the lockfile + engine_name + config,
  then threads `&VirtualStoreLayout` through `CreateVirtualStore`,
  `CreateVirtualDirBySnapshot`, `create_symlink_layout`,
  `SymlinkDirectDependencies`, `InstallPackageBySnapshot`,
  `BuildModules`, and `LinkVirtualStoreBins`. Every site that used
  to compute `virtual_store_dir.join(key.to_virtual_store_name())`
  now goes through one `layout.slot_dir(key)` lookup.
- `Install::run` calls `pacquet_store_dir::register_project` when
  `enable_global_virtual_store` is on, writing
  `<store_dir>/projects/<short-hash>` so a future
  `pacquet store prune` can find the projects that still reference
  `<store_dir>/links/...` slots. Best-effort: registry write
  failures degrade to `tracing::warn!` and the install continues.
- `Config::current` now applies
  `apply_global_virtual_store_derivation` after yaml — pacquet's
  variant of upstream's
  [`extendInstallOptions.ts:343-355`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/extendInstallOptions.ts#L343-L355).

Field-split deviation from upstream:

Upstream pnpm *mutates* `virtualStoreDir` in place under GVS, so
every consumer that reads `virtualStoreDir` sees `<storeDir>/links`.
Pacquet keeps `virtual_store_dir` at its project-local value and
writes the GVS path into the separate `global_virtual_store_dir`
field. The frozen-lockfile path consumes `global_virtual_store_dir`
through `VirtualStoreLayout`; the legacy non-frozen
`InstallWithoutLockfile` keeps reading `virtual_store_dir` (now via
`VirtualStoreLayout::legacy`). The split is a pacquet-only concern:
upstream has no without-lockfile install path and so doesn't need
the second field. See the doc on
`Config::apply_global_virtual_store_derivation` for the reasoning.

Tests:

- `install::tests::frozen_lockfile_under_gvs_registers_project_and_runs_clean`
  pins the registry write under default GVS-on.
- `install::tests::frozen_lockfile_with_gvs_off_skips_project_registry`
  pins that GVS-off opts out of the registry write.
- All 653 workspace tests pass under the new layout-threaded path.
- Existing per-snapshot legacy tests construct
  `VirtualStoreLayout::legacy(...)` explicitly so they keep
  asserting the flat-name layout regardless of any global default.

End-to-end port of upstream's
[`installing/deps-installer/test/install/globalVirtualStore.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/test/install/globalVirtualStore.ts)
is tracked as a follow-up — those tests need a small frozen-
lockfile fixture against the registry-mock, which is out of scope
here.

Based on #441 (`feat/438`); the `import_indexed_dir` rename from
that PR is already merged in. The two PRs only co-touch
`create_virtual_dir_by_snapshot.rs` — both pieces land cleanly on
top of each other.
zkochan added a commit that referenced this pull request May 13, 2026
Six items from the Copilot review on #449:

1. **Gate `register_project` on frozen-lockfile** — the call previously
   fired for any GVS-on install, including the legacy
   `InstallWithoutLockfile` path that uses
   `VirtualStoreLayout::legacy` and never references
   `<store_dir>/links/...`. Tightened to
   `frozen_lockfile && config.enable_global_virtual_store` so a
   registry entry only appears for installs that actually consume the
   shared store.

2. **`VirtualStoreLayout::new` engine: `Option<&str>`** — the param
   was `&str` and the call site passed `engine_name.as_deref()
   .unwrap_or("")`, which turned a missing `node` into `Some("")` and
   hashed to a different value than `None` (the latter omits the
   `engine` contribution from `calc_graph_node_hash`). Switched the
   signature to `Option<&str>` and the call site to
   `engine_name.as_deref()` so the missing-engine case round-trips
   correctly. Test call sites updated.

3. **`run_emits_imported_event_after_create_cas_files`** — renamed to
   `run_emits_imported_event_after_import_indexed_dir` (and updated
   the doc comment) since `create_cas_files` was renamed to
   `import_indexed_dir` in #441.

4. **`VirtualStoreLayout::package_store_dir` doc** — the field doc
   still claimed the root was `Config::virtual_store_dir` in both
   modes. Updated to reflect the post-split design: it's
   `Config::global_virtual_store_dir` under GVS, `virtual_store_dir`
   otherwise.

5. **Test manifest placement** — the new
   `frozen_lockfile_under_gvs_registers_project_and_runs_clean` and
   `frozen_lockfile_with_gvs_off_skips_project_registry` tests
   created the manifest at `<tmp>/package.json` while
   `project_root` was `<tmp>/project/`. `Install::run` derives the
   registry target from `manifest.path().parent()`, so the registry
   entry actually pointed at `<tmp>`, not the intended project root.
   Moved the manifest into `project_root` and replaced the
   entry-count-only check with a `read_link` + canonicalize
   assertion that the symlink resolves back to `project_root`.

6. **Preserve yaml-set `globalVirtualStoreDir`** —
   `apply_global_virtual_store_derivation` unconditionally
   overwrote the field, so a user-pinned `globalVirtualStoreDir:` in
   `pnpm-workspace.yaml` was silently lost. Added a
   `global_virtual_store_dir_explicit` parameter that short-circuits
   the derivation when yaml provided the value, mirroring the
   pre-existing `virtual_store_dir_explicit` plumbing. New
   `yaml_global_virtual_store_dir_wins_over_derivation` test pins
   the behaviour.

All 704 workspace tests pass; `taplo --check`, `just dylint`,
`cargo doc -D warnings` clean.
zkochan added a commit that referenced this pull request May 13, 2026
Threads `VirtualStoreLayout` through the frozen-lockfile install
pipeline so packages installed under
`enableGlobalVirtualStore: true` (pacquet's default since #444) land
at the upstream-shaped
`<store_dir>/links/<scope>/<name>/<version>/<hash>/node_modules/<pkg>`
path instead of the legacy per-project flat layout. Closes the rest
of #432 (Section B + C activation + D entry tests).

Pipeline changes (frozen-lockfile path):

- `InstallFrozenLockfile::run` constructs the install-scoped
  `VirtualStoreLayout` from the lockfile + engine_name + config,
  then threads `&VirtualStoreLayout` through `CreateVirtualStore`,
  `CreateVirtualDirBySnapshot`, `create_symlink_layout`,
  `SymlinkDirectDependencies`, `InstallPackageBySnapshot`,
  `BuildModules`, and `LinkVirtualStoreBins`. Every site that used
  to compute `virtual_store_dir.join(key.to_virtual_store_name())`
  now goes through one `layout.slot_dir(key)` lookup.
- `Install::run` calls `pacquet_store_dir::register_project` when
  `enable_global_virtual_store` is on, writing
  `<store_dir>/projects/<short-hash>` so a future
  `pacquet store prune` can find the projects that still reference
  `<store_dir>/links/...` slots. Best-effort: registry write
  failures degrade to `tracing::warn!` and the install continues.
- `Config::current` now applies
  `apply_global_virtual_store_derivation` after yaml — pacquet's
  variant of upstream's
  [`extendInstallOptions.ts:343-355`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/extendInstallOptions.ts#L343-L355).

Field-split deviation from upstream:

Upstream pnpm *mutates* `virtualStoreDir` in place under GVS, so
every consumer that reads `virtualStoreDir` sees `<storeDir>/links`.
Pacquet keeps `virtual_store_dir` at its project-local value and
writes the GVS path into the separate `global_virtual_store_dir`
field. The frozen-lockfile path consumes `global_virtual_store_dir`
through `VirtualStoreLayout`; the legacy non-frozen
`InstallWithoutLockfile` keeps reading `virtual_store_dir` (now via
`VirtualStoreLayout::legacy`). The split is a pacquet-only concern:
upstream has no without-lockfile install path and so doesn't need
the second field. See the doc on
`Config::apply_global_virtual_store_derivation` for the reasoning.

Tests:

- `install::tests::frozen_lockfile_under_gvs_registers_project_and_runs_clean`
  pins the registry write under default GVS-on.
- `install::tests::frozen_lockfile_with_gvs_off_skips_project_registry`
  pins that GVS-off opts out of the registry write.
- All 653 workspace tests pass under the new layout-threaded path.
- Existing per-snapshot legacy tests construct
  `VirtualStoreLayout::legacy(...)` explicitly so they keep
  asserting the flat-name layout regardless of any global default.

End-to-end port of upstream's
[`installing/deps-installer/test/install/globalVirtualStore.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/test/install/globalVirtualStore.ts)
is tracked as a follow-up — those tests need a small frozen-
lockfile fixture against the registry-mock, which is out of scope
here.

Based on #441 (`feat/438`); the `import_indexed_dir` rename from
that PR is already merged in. The two PRs only co-touch
`create_virtual_dir_by_snapshot.rs` — both pieces land cleanly on
top of each other.
zkochan added a commit that referenced this pull request May 13, 2026
Six items from the Copilot review on #449:

1. **Gate `register_project` on frozen-lockfile** — the call previously
   fired for any GVS-on install, including the legacy
   `InstallWithoutLockfile` path that uses
   `VirtualStoreLayout::legacy` and never references
   `<store_dir>/links/...`. Tightened to
   `frozen_lockfile && config.enable_global_virtual_store` so a
   registry entry only appears for installs that actually consume the
   shared store.

2. **`VirtualStoreLayout::new` engine: `Option<&str>`** — the param
   was `&str` and the call site passed `engine_name.as_deref()
   .unwrap_or("")`, which turned a missing `node` into `Some("")` and
   hashed to a different value than `None` (the latter omits the
   `engine` contribution from `calc_graph_node_hash`). Switched the
   signature to `Option<&str>` and the call site to
   `engine_name.as_deref()` so the missing-engine case round-trips
   correctly. Test call sites updated.

3. **`run_emits_imported_event_after_create_cas_files`** — renamed to
   `run_emits_imported_event_after_import_indexed_dir` (and updated
   the doc comment) since `create_cas_files` was renamed to
   `import_indexed_dir` in #441.

4. **`VirtualStoreLayout::package_store_dir` doc** — the field doc
   still claimed the root was `Config::virtual_store_dir` in both
   modes. Updated to reflect the post-split design: it's
   `Config::global_virtual_store_dir` under GVS, `virtual_store_dir`
   otherwise.

5. **Test manifest placement** — the new
   `frozen_lockfile_under_gvs_registers_project_and_runs_clean` and
   `frozen_lockfile_with_gvs_off_skips_project_registry` tests
   created the manifest at `<tmp>/package.json` while
   `project_root` was `<tmp>/project/`. `Install::run` derives the
   registry target from `manifest.path().parent()`, so the registry
   entry actually pointed at `<tmp>`, not the intended project root.
   Moved the manifest into `project_root` and replaced the
   entry-count-only check with a `read_link` + canonicalize
   assertion that the symlink resolves back to `project_root`.

6. **Preserve yaml-set `globalVirtualStoreDir`** —
   `apply_global_virtual_store_derivation` unconditionally
   overwrote the field, so a user-pinned `globalVirtualStoreDir:` in
   `pnpm-workspace.yaml` was silently lost. Added a
   `global_virtual_store_dir_explicit` parameter that short-circuits
   the derivation when yaml provided the value, mirroring the
   pre-existing `virtual_store_dir_explicit` plumbing. New
   `yaml_global_virtual_store_dir_wins_over_derivation` test pins
   the behaviour.

All 704 workspace tests pass; `taplo --check`, `just dylint`,
`cargo doc -D warnings` clean.
zkochan added a commit that referenced this pull request May 13, 2026
Threads `VirtualStoreLayout` through the frozen-lockfile install
pipeline so packages installed under
`enableGlobalVirtualStore: true` (pacquet's default since #444) land
at the upstream-shaped
`<store_dir>/links/<scope>/<name>/<version>/<hash>/node_modules/<pkg>`
path instead of the legacy per-project flat layout. Closes the rest
of #432 (Section B + C activation + D entry tests).

Pipeline changes (frozen-lockfile path):

- `InstallFrozenLockfile::run` constructs the install-scoped
  `VirtualStoreLayout` from the lockfile + engine_name + config,
  then threads `&VirtualStoreLayout` through `CreateVirtualStore`,
  `CreateVirtualDirBySnapshot`, `create_symlink_layout`,
  `SymlinkDirectDependencies`, `InstallPackageBySnapshot`,
  `BuildModules`, and `LinkVirtualStoreBins`. Every site that used
  to compute `virtual_store_dir.join(key.to_virtual_store_name())`
  now goes through one `layout.slot_dir(key)` lookup.
- `Install::run` calls `pacquet_store_dir::register_project` when
  `enable_global_virtual_store` is on, writing
  `<store_dir>/projects/<short-hash>` so a future
  `pacquet store prune` can find the projects that still reference
  `<store_dir>/links/...` slots. Best-effort: registry write
  failures degrade to `tracing::warn!` and the install continues.
- `Config::current` now applies
  `apply_global_virtual_store_derivation` after yaml — pacquet's
  variant of upstream's
  [`extendInstallOptions.ts:343-355`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/extendInstallOptions.ts#L343-L355).

Field-split deviation from upstream:

Upstream pnpm *mutates* `virtualStoreDir` in place under GVS, so
every consumer that reads `virtualStoreDir` sees `<storeDir>/links`.
Pacquet keeps `virtual_store_dir` at its project-local value and
writes the GVS path into the separate `global_virtual_store_dir`
field. The frozen-lockfile path consumes `global_virtual_store_dir`
through `VirtualStoreLayout`; the legacy non-frozen
`InstallWithoutLockfile` keeps reading `virtual_store_dir` (now via
`VirtualStoreLayout::legacy`). The split is a pacquet-only concern:
upstream has no without-lockfile install path and so doesn't need
the second field. See the doc on
`Config::apply_global_virtual_store_derivation` for the reasoning.

Tests:

- `install::tests::frozen_lockfile_under_gvs_registers_project_and_runs_clean`
  pins the registry write under default GVS-on.
- `install::tests::frozen_lockfile_with_gvs_off_skips_project_registry`
  pins that GVS-off opts out of the registry write.
- All 653 workspace tests pass under the new layout-threaded path.
- Existing per-snapshot legacy tests construct
  `VirtualStoreLayout::legacy(...)` explicitly so they keep
  asserting the flat-name layout regardless of any global default.

End-to-end port of upstream's
[`installing/deps-installer/test/install/globalVirtualStore.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/test/install/globalVirtualStore.ts)
is tracked as a follow-up — those tests need a small frozen-
lockfile fixture against the registry-mock, which is out of scope
here.

Based on #441 (`feat/438`); the `import_indexed_dir` rename from
that PR is already merged in. The two PRs only co-touch
`create_virtual_dir_by_snapshot.rs` — both pieces land cleanly on
top of each other.
zkochan added a commit that referenced this pull request May 13, 2026
Six items from the Copilot review on #449:

1. **Gate `register_project` on frozen-lockfile** — the call previously
   fired for any GVS-on install, including the legacy
   `InstallWithoutLockfile` path that uses
   `VirtualStoreLayout::legacy` and never references
   `<store_dir>/links/...`. Tightened to
   `frozen_lockfile && config.enable_global_virtual_store` so a
   registry entry only appears for installs that actually consume the
   shared store.

2. **`VirtualStoreLayout::new` engine: `Option<&str>`** — the param
   was `&str` and the call site passed `engine_name.as_deref()
   .unwrap_or("")`, which turned a missing `node` into `Some("")` and
   hashed to a different value than `None` (the latter omits the
   `engine` contribution from `calc_graph_node_hash`). Switched the
   signature to `Option<&str>` and the call site to
   `engine_name.as_deref()` so the missing-engine case round-trips
   correctly. Test call sites updated.

3. **`run_emits_imported_event_after_create_cas_files`** — renamed to
   `run_emits_imported_event_after_import_indexed_dir` (and updated
   the doc comment) since `create_cas_files` was renamed to
   `import_indexed_dir` in #441.

4. **`VirtualStoreLayout::package_store_dir` doc** — the field doc
   still claimed the root was `Config::virtual_store_dir` in both
   modes. Updated to reflect the post-split design: it's
   `Config::global_virtual_store_dir` under GVS, `virtual_store_dir`
   otherwise.

5. **Test manifest placement** — the new
   `frozen_lockfile_under_gvs_registers_project_and_runs_clean` and
   `frozen_lockfile_with_gvs_off_skips_project_registry` tests
   created the manifest at `<tmp>/package.json` while
   `project_root` was `<tmp>/project/`. `Install::run` derives the
   registry target from `manifest.path().parent()`, so the registry
   entry actually pointed at `<tmp>`, not the intended project root.
   Moved the manifest into `project_root` and replaced the
   entry-count-only check with a `read_link` + canonicalize
   assertion that the symlink resolves back to `project_root`.

6. **Preserve yaml-set `globalVirtualStoreDir`** —
   `apply_global_virtual_store_derivation` unconditionally
   overwrote the field, so a user-pinned `globalVirtualStoreDir:` in
   `pnpm-workspace.yaml` was silently lost. Added a
   `global_virtual_store_dir_explicit` parameter that short-circuits
   the derivation when yaml provided the value, mirroring the
   pre-existing `virtual_store_dir_explicit` plumbing. New
   `yaml_global_virtual_store_dir_wins_over_derivation` test pins
   the behaviour.

All 704 workspace tests pass; `taplo --check`, `just dylint`,
`cargo doc -D warnings` clean.
@zkochan zkochan mentioned this pull request May 14, 2026
59 tasks
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants