fix(fs,package-manager): match upstream symlink-dir semantics in pacquet#11684
Conversation
`pacquet_fs::symlink_dir` was a thin wrapper over the platform syscall and diverged from pnpm's [`symlink-dir`](https://github.com/pnpm/symlink-dir) npm package (v10.0.1) in three user-visible ways: 1. Unix symlinks stored the absolute target. Upstream writes the target as a path relative to the link's parent dir (`path.relative(dirname(link), src)`), so installs survive a project move and on-disk symlink contents match pnpm's byte-for-byte. 2. Windows always created junctions. Upstream tries a true directory symlink first and only falls back to a junction on `ERROR_PRIVILEGE_NOT_HELD`. Dev-Mode / Administrator users get a true symlink with pacquet now too. The choice is cached process-wide via `AtomicU8` so subsequent calls skip the EPERM probe, mirroring upstream's binding-rebind. 3. The library default `{ overwrite: true }` retargets stale links and moves non-symlink occupants to `.ignored_<basename>`. Pacquet only exposed the bare primitive, so `symlink_package` (the direct-dep linker, equivalent to pnpm's `symlinkDependency`) silently swallowed `AlreadyExists` — re-installs that retargeted a dependency left the stale link in place. Adds `force_symlink_dir` and `ForceSymlinkOutcome` to `pacquet_fs` porting upstream's `forceSymlink` flow: mkdir-on-`NotFound`, reuse on matching target, unlink-and-retry on stale link, rename-to-`.ignored_*` on non-symlink occupant (with the macOS unlink fallback on the second attempt, per #5909). `symlink_package` calls it. Existing call sites that intentionally use `{ overwrite: false }` semantics (`hoist::symlink_hoisted_dependencies`, the `register_project` heal path) stay on the bare `symlink_dir` primitive. Tests: - New tests in `pacquet/crates/fs/src/symlink_dir/tests.rs` cover the relative-target encoding, idempotent re-symlink, stale-link retarget, occupant rename, and missing-parent mkdir. - Updated `create_symlink_layout/tests.rs`, `install_package_from_registry/tests.rs`, and `install/tests.rs` to compare against the relative encoding (via `pathdiff` / `canonicalize`) instead of the prior absolute string. --- Written by an agent (Claude Code, claude-opus-4-7).
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR makes directory symlinks store parent-relative targets on Unix, caches Windows junction vs symlink probing, adds an idempotent overwrite API ChangesRelative symlinks and idempotent creation
🎯 3 (Moderate) | ⏱️ ~25 minutes
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Micro-Benchmark ResultsLinux |
…ncoding CI surfaced two more test sites that compared `fs::read_link` / `dunce::canonicalize(read_link_output)` against the absolute target path. Those resolved fine when pacquet wrote absolute symlinks but fail now that the symlink contents are stored as paths relative to the link's parent (matching upstream `symlink-dir`). Canonicalize via the symlink path itself so the kernel resolves the relative target, the same fix already applied to the `package-manager` tests in the previous commit. - `pacquet/crates/cli/tests/add.rs::should_symlink_correctly` - `pacquet/crates/store-dir/src/project_registry.rs::register_creates_symlink_to_project_dir` --- Written by an agent (Claude Code, claude-opus-4-7).
CI's `cargo dylint --all -- --all-targets --workspace` flagged two multi-line macro invocations missing a trailing comma: - `format!` in the mkdir-error wrapping branch (line 163). - `matches!` over the rename-overwrite occupied-kind set (line 271). Both surfaced under `-D warnings` via `perfectionist::macro-trailing-comma`. Added the trailing commas; local `cargo check -p pacquet-fs` is clean. --- Written by an agent (Claude Code, claude-opus-4-7).
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #11684 +/- ##
==========================================
- Coverage 89.12% 88.92% -0.21%
==========================================
Files 127 129 +2
Lines 14470 14666 +196
==========================================
+ Hits 12897 13042 +145
- Misses 1573 1624 +51 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Integrated-Benchmark Report (Linux)Scenario: Frozen Lockfile
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.48463919444,
"stddev": 0.061776005573105315,
"median": 2.4939262837400005,
"user": 2.6901946199999998,
"system": 3.81208892,
"min": 2.35142610224,
"max": 2.55406450724,
"times": [
2.54176735924,
2.55406450724,
2.52463842724,
2.35142610224,
2.5217251432400003,
2.4967426242400004,
2.41668376424,
2.49110994324,
2.4580826572400003,
2.4901514162400002
]
},
{
"command": "pacquet@main",
"mean": 2.54103206154,
"stddev": 0.06897087277471421,
"median": 2.5193222302400002,
"user": 2.64352842,
"system": 3.8905692199999997,
"min": 2.47696948024,
"max": 2.6803493522400004,
"times": [
2.4958347612400003,
2.6803493522400004,
2.56878205424,
2.62857830724,
2.47696948024,
2.50768984324,
2.5596072102400003,
2.47959714924,
2.53095461724,
2.48195784024
]
},
{
"command": "pnpm",
"mean": 4.96267035274,
"stddev": 0.05906958402094379,
"median": 4.96377433174,
"user": 8.494304320000001,
"system": 4.29851152,
"min": 4.84093811524,
"max": 5.0527600472400005,
"times": [
4.90864213724,
5.0527600472400005,
4.99516100324,
4.9678596522400005,
4.95968901124,
4.94406685424,
4.99945578024,
5.01238727524,
4.84093811524,
4.94574365124
]
}
]
}Scenario: Frozen Lockfile (Hot Cache)
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.69906476008,
"stddev": 0.05004767381943957,
"median": 0.6856874772800001,
"user": 0.38689724,
"system": 1.57697986,
"min": 0.6661487757800001,
"max": 0.83901607578,
"times": [
0.83901607578,
0.67910905478,
0.6757591357800001,
0.6985881907800001,
0.6927374697800001,
0.6833536667800001,
0.6661487757800001,
0.68802128778,
0.6782053277800001,
0.6897086157800001
]
},
{
"command": "pacquet@main",
"mean": 0.79473980198,
"stddev": 0.08819724674546814,
"median": 0.7532501362800001,
"user": 0.39307654000000003,
"system": 1.65113746,
"min": 0.70927445178,
"max": 0.9779977697800001,
"times": [
0.8893432507800001,
0.70927445178,
0.73717594478,
0.7409030177800001,
0.9779977697800001,
0.7274115937800001,
0.7655972547800001,
0.7377112087800001,
0.8677077347800001,
0.79427579278
]
},
{
"command": "pnpm",
"mean": 2.7419538903800005,
"stddev": 0.08795118575743076,
"median": 2.71847687678,
"user": 3.33364464,
"system": 2.288340559999999,
"min": 2.65839993778,
"max": 2.93579695978,
"times": [
2.70548382178,
2.67121579778,
2.69426393078,
2.75423133578,
2.6633526037800004,
2.8390229427800002,
2.73146993178,
2.76630164178,
2.93579695978,
2.65839993778
]
}
]
} |
`cargo fmt` collapsed five multi-line `assert!` calls in `symlink_dir/tests.rs` to single lines but left the trailing comma intact. Dylint's `perfectionist::macro-trailing-comma` rejects the single-line-with-trailing-comma shape under `RUSTFLAGS=-D warnings`. Removed the commas. `cargo test -p pacquet-fs` still passes. --- Written by an agent (Claude Code, claude-opus-4-7).
The helper has always claimed to detect "symlink or junction" but only called `junction::exists` on Windows, which checks the `IO_REPARSE_TAG_MOUNT_POINT` reparse tag. With pacquet's symlink writer now trying a true directory symlink first on Windows (and only falling back to a junction on `PermissionDenied`), Windows runners that have `SeCreateSymbolicLinkPrivilege` enabled — including GitHub Actions `windows-latest`, which runs as Administrator with Developer Mode on — get a true symlink at `IO_REPARSE_TAG_SYMLINK`. The old helper returned `false` for those, which fooled `should_install_dependencies` and `install_resolves_env_var_in_npmrc_registry` into thinking the install hadn't produced any symlink at all. Combine `junction::exists` with `Path::is_symlink` so the helper matches its name. No change on Unix. --- Written by an agent (Claude Code, claude-opus-4-7).
Clippy's `needless_return` (denied under `-D warnings` on the Windows `Clippy` step) flagged the trailing `return Ok(path.is_symlink())` inside the `#[cfg(windows)]` block I added in the previous commit. Fold the junction-or-symlink check into a single boolean expression so each cfg branch ends with the result directly. --- Written by an agent (Claude Code, claude-opus-4-7).
Upstream `symlink-dir`'s `resolveSrcOnTrueSymlink` (which feeds the
`fs.symlink(target, path, 'dir')` call) runs on both Unix and Windows
and always passes the path-relative-to-dirname target. The Windows
junction path takes the absolute target with a trailing backslash
instead, but the `junction` crate handles that wrapping internally.
Pacquet's Windows branch was calling
`std::os::windows::fs::symlink_dir(original, link)` with the absolute
target, which diverged from pnpm on hosts that have
`SeCreateSymbolicLinkPrivilege` (GitHub Actions Windows runners do —
they run as Administrator with Developer Mode on) and tripped the
two `create_symlink_layout` parity tests on Windows CI:
Diff < left / right > :
<"C:\\...\\.tmpdluV0r\\string-width@4.2.3\\node_modules\\string-width"
>"..\\..\\string-width@4.2.3\\node_modules\\string-width"
Lift the existing Unix helper `relative_target_for` out of its
`cfg(any(unix, test))` gate so the Windows symlink branch can call
it too, and route both the cached `USE_SYMLINK` path and the
probe-and-cache path through a new `create_true_symlink` helper
that computes the relative form before the `CreateSymbolicLinkW`
syscall. The junction branch keeps the absolute `original`.
---
Written by an agent (Claude Code, claude-opus-4-7).
…uet (pnpm#11684) * fix(fs,package-manager): match upstream symlink-dir semantics `pacquet_fs::symlink_dir` was a thin wrapper over the platform syscall and diverged from pnpm's [`symlink-dir`](https://github.com/pnpm/symlink-dir) npm package (v10.0.1) in three user-visible ways: 1. Unix symlinks stored the absolute target. Upstream writes the target as a path relative to the link's parent dir (`path.relative(dirname(link), src)`), so installs survive a project move and on-disk symlink contents match pnpm's byte-for-byte. 2. Windows always created junctions. Upstream tries a true directory symlink first and only falls back to a junction on `ERROR_PRIVILEGE_NOT_HELD`. Dev-Mode / Administrator users get a true symlink with pacquet now too. The choice is cached process-wide via `AtomicU8` so subsequent calls skip the EPERM probe, mirroring upstream's binding-rebind. 3. The library default `{ overwrite: true }` retargets stale links and moves non-symlink occupants to `.ignored_<basename>`. Pacquet only exposed the bare primitive, so `symlink_package` (the direct-dep linker, equivalent to pnpm's `symlinkDependency`) silently swallowed `AlreadyExists` — re-installs that retargeted a dependency left the stale link in place. Adds `force_symlink_dir` and `ForceSymlinkOutcome` to `pacquet_fs` porting upstream's `forceSymlink` flow: mkdir-on-`NotFound`, reuse on matching target, unlink-and-retry on stale link, rename-to-`.ignored_*` on non-symlink occupant (with the macOS unlink fallback on the second attempt, per pnpm#5909). `symlink_package` calls it. Existing call sites that intentionally use `{ overwrite: false }` semantics (`hoist::symlink_hoisted_dependencies`, the `register_project` heal path) stay on the bare `symlink_dir` primitive. Tests: - New tests in `pacquet/crates/fs/src/symlink_dir/tests.rs` cover the relative-target encoding, idempotent re-symlink, stale-link retarget, occupant rename, and missing-parent mkdir. - Updated `create_symlink_layout/tests.rs`, `install_package_from_registry/tests.rs`, and `install/tests.rs` to compare against the relative encoding (via `pathdiff` / `canonicalize`) instead of the prior absolute string. --- Written by an agent (Claude Code, claude-opus-4-7). * test(cli,store-dir): adapt remaining symlink assertions to relative encoding CI surfaced two more test sites that compared `fs::read_link` / `dunce::canonicalize(read_link_output)` against the absolute target path. Those resolved fine when pacquet wrote absolute symlinks but fail now that the symlink contents are stored as paths relative to the link's parent (matching upstream `symlink-dir`). Canonicalize via the symlink path itself so the kernel resolves the relative target, the same fix already applied to the `package-manager` tests in the previous commit. - `pacquet/crates/cli/tests/add.rs::should_symlink_correctly` - `pacquet/crates/store-dir/src/project_registry.rs::register_creates_symlink_to_project_dir` --- Written by an agent (Claude Code, claude-opus-4-7). * fix(fs): satisfy dylint perfectionist lints in symlink_dir CI's `cargo dylint --all -- --all-targets --workspace` flagged two multi-line macro invocations missing a trailing comma: - `format!` in the mkdir-error wrapping branch (line 163). - `matches!` over the rename-overwrite occupied-kind set (line 271). Both surfaced under `-D warnings` via `perfectionist::macro-trailing-comma`. Added the trailing commas; local `cargo check -p pacquet-fs` is clean. --- Written by an agent (Claude Code, claude-opus-4-7). * fix(fs): drop trailing commas on collapsed single-line asserts `cargo fmt` collapsed five multi-line `assert!` calls in `symlink_dir/tests.rs` to single lines but left the trailing comma intact. Dylint's `perfectionist::macro-trailing-comma` rejects the single-line-with-trailing-comma shape under `RUSTFLAGS=-D warnings`. Removed the commas. `cargo test -p pacquet-fs` still passes. --- Written by an agent (Claude Code, claude-opus-4-7). * test(testing-utils): recognize true symlinks in is_symlink_or_junction The helper has always claimed to detect "symlink or junction" but only called `junction::exists` on Windows, which checks the `IO_REPARSE_TAG_MOUNT_POINT` reparse tag. With pacquet's symlink writer now trying a true directory symlink first on Windows (and only falling back to a junction on `PermissionDenied`), Windows runners that have `SeCreateSymbolicLinkPrivilege` enabled — including GitHub Actions `windows-latest`, which runs as Administrator with Developer Mode on — get a true symlink at `IO_REPARSE_TAG_SYMLINK`. The old helper returned `false` for those, which fooled `should_install_dependencies` and `install_resolves_env_var_in_npmrc_registry` into thinking the install hadn't produced any symlink at all. Combine `junction::exists` with `Path::is_symlink` so the helper matches its name. No change on Unix. --- Written by an agent (Claude Code, claude-opus-4-7). * fix(testing-utils): drop needless return in is_symlink_or_junction Clippy's `needless_return` (denied under `-D warnings` on the Windows `Clippy` step) flagged the trailing `return Ok(path.is_symlink())` inside the `#[cfg(windows)]` block I added in the previous commit. Fold the junction-or-symlink check into a single boolean expression so each cfg branch ends with the result directly. --- Written by an agent (Claude Code, claude-opus-4-7). * fix(fs): write relative target for Windows true symlinks too Upstream `symlink-dir`'s `resolveSrcOnTrueSymlink` (which feeds the `fs.symlink(target, path, 'dir')` call) runs on both Unix and Windows and always passes the path-relative-to-dirname target. The Windows junction path takes the absolute target with a trailing backslash instead, but the `junction` crate handles that wrapping internally. Pacquet's Windows branch was calling `std::os::windows::fs::symlink_dir(original, link)` with the absolute target, which diverged from pnpm on hosts that have `SeCreateSymbolicLinkPrivilege` (GitHub Actions Windows runners do — they run as Administrator with Developer Mode on) and tripped the two `create_symlink_layout` parity tests on Windows CI: Diff < left / right > : <"C:\\...\\.tmpdluV0r\\string-width@4.2.3\\node_modules\\string-width" >"..\\..\\string-width@4.2.3\\node_modules\\string-width" Lift the existing Unix helper `relative_target_for` out of its `cfg(any(unix, test))` gate so the Windows symlink branch can call it too, and route both the cached `USE_SYMLINK` path and the probe-and-cache path through a new `create_true_symlink` helper that computes the relative form before the `CreateSymbolicLinkW` syscall. The junction branch keeps the absolute `original`. --- Written by an agent (Claude Code, claude-opus-4-7).
Summary
Pacquet's
pacquet_fs::symlink_dirwas a thin wrapper over theplatform syscall and diverged from pnpm's
symlink-dirnpm package (thelibrary
@pnpm/fs.symlink-dependencyuses) in three user-visibleways. This PR closes those gaps and aligns pacquet's on-disk symlink
shape with what pnpm writes.
Per the cardinal rule in
pacquet/AGENTS.md— "any change in pacquetmust match how the same feature is implemented in the TypeScript pnpm
CLI" — pacquet is the side moving toward pnpm here.
Divergences fixed
Unix symlinks were absolute. Upstream writes the target as a
path relative to the link's parent dir
(
path.relative(dirname(link), src)). Pacquet now does the samevia
pathdiff::diff_paths. Thesymlink_package.rsTODO callingthis out is removed.
Windows always created a junction. Upstream tries a true
directory symlink first and only falls back to a junction on
ERROR_PRIVILEGE_NOT_HELD(PermissionDenied). Pacquet nowmirrors that probe and caches the winning branch process-wide in
an
AtomicU8, matching how the JS module rebindscreateSymlinkAsync/createSymlinkSyncafter the first call.Re-installs left stale links in place. Upstream
symlinkDependencyusessymlinkDir's default{ overwrite: true }— retarget stale symlinks, rename non-symlink occupants to.ignored_<basename>,mkdir -pmissing parents. The pacquetprimitive only exposed the bare syscall, and
symlink_package.rssilently swallowedAlreadyExists. A newforce_symlink_dirinpacquet_fsports upstream'sforceSymlinkflow (including the macOS unlink fallback on thesecond-attempt path, per
pnpm iget stuck forever onpostinstallscript #5909), andsymlink_packagenow calls it.
Call sites that intentionally use
{ overwrite: false }semantics (
hoist::symlink_hoisted_dependencies,register_project's heal path) stay on the baresymlink_dirprimitive.
Test plan
cargo test -p pacquet-fs— 21 passed (6 new tests coveringrelative encoding, idempotent re-symlink, stale retarget,
occupant rename, missing-parent mkdir, and read-back).
cargo test -p pacquet-package-manager --lib— 268 passed.Three existing tests (
install/tests.rs,create_symlink_layout/tests.rs,install_package_from_registry/tests.rs) were updated tocompare against the new relative encoding (via
pathdiff/canonicalize) instead of the prior absolute string.cargo build --workspace— clean.cargo clippy -p pacquet-fs -p pacquet-package-manager— nonew warnings from this PR (the 6 pre-existing
clippy::unnecessary_get_then_check/clippy::type_complexitywarnings in unrelated test files areout of scope).
Linux-host-uncoverable; relying on CI to validate.
Written by an agent (Claude Code, claude-opus-4-7).
Summary by CodeRabbit
New Features
Bug Fixes
Tests
Chores