Skip to content

install: remove the node_modules links of a renamed or removed workspace - #38511

Open
robobun wants to merge 5 commits into
mainfrom
farm/dd93f2b8/remove-dropped-workspace-links
Open

install: remove the node_modules links of a renamed or removed workspace#38511
robobun wants to merge 5 commits into
mainfrom
farm/dd93f2b8/remove-dropped-workspace-links

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Rename a workspace package (its package.json name and the dependencies on it) and run bun install: the summary prints 2 packages removed, but node_modules/<old name> is still there, pointing at the same folder as node_modules/<new name>. Imports of the old name keep working on the machine the rename was made on, so the leftovers are found by CI or the next fresh clone instead of by the person doing the rename.
  • With the isolated linker the same stale link is also left in packages/<dependent>/node_modules/<old name> for every workspace that depended on it. Removing a workspace from the project leaves its (now dangling) link behind the same way. Scoped names too. Reproduces on 1.4.0 and main with both linkers (script and output below). Found by a bot while tightening bun-install.test.ts; there is no user report for this specific case, only the general stale-entry issues (Running bun install does not delete extraneous dependencies #16176 and friends).
  • Cause: neither linker deletes anything. Both walk the new lockfile and create or repoint the links it asks for (isolated: Step::SymlinkDependencies in src/install/isolated_install/Installer.rs; hoisted: verify + install_from_link in src/install/PackageInstaller.rs), so an entry whose name is no longer in the lockfile is never visited. The removed count comes from the lockfile diff (src/install/lockfile/Package.rs:1336, "It will be cleaned up later"), and nothing cleans it up.
  • The 2 is the two root dependency edges on the old name: the implicit edge every workspace gets on the root package plus the explicit devDependencies entry (a workspace the root does not list prints 1). The count is left as is.

Scope

Fix

  • New src/install/PackageManager/remove_stale_workspace_links.rs, called from install_with_manager after Lockfile::clean_with_logger and before the linker runs: every package with a workspace resolution in the previous lockfile whose name is not in the new lockfile's workspace_paths has node_modules/<name> unlinked in the project root and in every workspace of the new lockfile.
  • It only runs when the lockfile diff found changes (had_any_diffs). A renamed or removed workspace always changes the root package's dependency list, and without a diff the two workspace sets are identical, so a no-op bun install does no extra work. --dry-run, --lockfile-only and --frozen-lockfile never reach it.
  • It only removes what readlink succeeds on: symlinks, and on Windows junctions, removed with rmdir the way the isolated linker's Symlinker replaces a wrong link. A real directory or file under the old name was not put there as a workspace link and is left alone. Names pass the same alias_is_safe_install_target check the linkers apply before they are used as a path component.
  • Why this is correct: the previous lockfile is the record of which names were linked as workspaces, and a name that left it can no longer be placed as a workspace link by either linker. Running before the linker means nothing the new lockfile still wants can be lost: if the old name is still a dependency (an alias such as "a": "workspace:a-renamed@*", or an npm package that now has that name), the linker sees it missing and links it again, exactly as on a fresh install. The hoisted linker verifies links through node_modules/<alias>/package.json, the isolated linker through readlink, so both recreate a missing one.
  • Verified with test/cli/install/bun-workspaces.test.ts, new links of a renamed or removed workspace block: rename (root link, and with isolated the dependent workspace's link; a third install reports no changes), scoped rename and removal of a workspace, each for both linkers; the old name linked again when an alias still uses it, for both linkers; a directory under the old name kept; --dry-run leaving the links alone. 7 of the 10 fail on the current release, all 10 pass with the fix.
  • Also ran bun-workspaces, isolated-install, bun-add, bun-update, bun-remove and the workspace tests of bun-install.test.ts against the debug build (all pass), and cargo check of the crate for the Windows and macOS targets.

Background

  • Workspace links: a workspace package is never copied into node_modules. Both linkers create a symlink (on Windows a symlink or junction) named after the dependency alias that points at the workspace folder. The hoisted linker links every workspace into the root node_modules under its own name, because Package::parse gives the root package an implicit dependency on each workspace; the isolated linker only links the workspaces a package explicitly depends on, into that package's own node_modules.
  • Diff and clean: bun install diffs the loaded lockfile against package.json (Diff::generate, the source of the add/remove counts in the summary), then Lockfile::clean_with_logger builds a fresh lockfile holding only the packages still reachable from the root. install_with_manager keeps the old one as lockfile_before_clean; its packages still contain the old workspace entries, which is what this change reads. workspace_paths on the new lockfile maps the name hash of each current workspace to its path.
  • Related open PRs, neither of which covers this case: install: remove node_modules entries that left the lockfile #32974 prunes the root node_modules only and keeps symlinks under the hoisted linker; install: prune stale workspace node_modules in hoisted installs #29794 walks workspace directories under the hoisted linker only.
Repro (release 1.4.0, same result with --linker hoisted)
mkdir -p ws/packages/{a,b} && cd ws
echo '{"name":"root","workspaces":["packages/*"],"devDependencies":{"a":"*"}}' > package.json
echo '{"name":"a","version":"0.0.0"}' > packages/a/package.json
echo '{"name":"b","version":"0.0.0","dependencies":{"a":"*"}}' > packages/b/package.json
bun install
echo '{"name":"root","workspaces":["packages/*"],"devDependencies":{"a-renamed":"*"}}' > package.json
echo '{"name":"a-renamed","version":"0.0.0"}' > packages/a/package.json
echo '{"name":"b","version":"0.0.0","dependencies":{"a-renamed":"*"}}' > packages/b/package.json
bun install            # 2 packages removed
ls node_modules packages/b/node_modules

Before:

node_modules:
a  a-renamed

packages/b/node_modules:
a  a-renamed

After (BUN_DEBUG_PackageManager=1 on a debug build also logs the two removals):

node_modules:
a-renamed

packages/b/node_modules:
a-renamed

bun.lock is identical in both cases and only contains a-renamed.

Both linkers only create or repoint the node_modules/<name> links the
current lockfile asks for. When a workspace package is renamed (or
removed from the project), its old name leaves the lockfile and the
install summary counts it as removed, but the link under the old name
stayed behind in the root node_modules and, with the isolated linker, in
the node_modules of every workspace that depended on it.

After the lockfile is rebuilt and before the linker runs, unlink
node_modules/<name> at the root and in every current workspace for each
workspace name that was in the previous lockfile and is not in the new
one. Only symlinks (junctions on Windows) are removed, and because this
happens before linking, a name the new lockfile still places there is
linked again.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e1fcc98b-5cd3-4fd6-99c4-1bc0fc6f491b

📥 Commits

Reviewing files that changed from the base of the PR and between 44acc3d and 2d81856.

📒 Files selected for processing (4)
  • src/install/PackageManager.rs
  • src/install/PackageManager/install_with_manager.rs
  • src/install/PackageManager/remove_stale_workspace_links.rs
  • test/cli/install/bun-workspaces.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the 1.4.0 release and on a debug build of main with both linkers: after renaming a workspace package and running bun install, node_modules/<old name> (and, with the isolated linker, packages/<dependent>/node_modules/<old name>) is still present next to the new link while the summary reports the package as removed. Repro script and before/after output are in the PR description.

Fix in this PR: #38511. Scope: the install that performs the rename or removal (the description's Scope section covers what this deliberately does not do and how it relates to #38333). The new tests are the links of a renamed or removed workspace block in test/cli/install/bun-workspaces.test.ts; 7 of the 10 fail on the current release and all pass with the change.

CI on the final commit (1d58928, build 96349): 177 of 179 jobs passed, including every Linux, ASAN and Windows test shard; the build is marked failed only because the two macOS shards expired in the queue without ever running. The annotations are flaky tests unrelated to install that passed alone or on retry. Ready for a maintainer decision.

@claude claude Bot 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.

I reviewed this PR and didn't find any bugs. Because it changes install_with_manager to delete entries from node_modules — a behavior change in the core install path — a human look would still be worthwhile.

What was reviewed:

  • The had_any_diffs gate and placement after clean_with_logger / before the linker; confirmed --dry-run, --lockfile-only, and --frozen-lockfile never reach the new call.
  • Path construction: alias_is_safe_install_target guards the name component, and the readlink gate keeps real directories/files untouched (the Windows rmdirunlink fallback matches the pattern in isolated_install/Symlinker.rs).
  • lockfile_before_clean still holds the old workspace packages after the diff (only workspace_paths was overwritten in place, which this code does not read from previous).
Extended reasoning...

Overview

Adds remove_stale_workspace_links (~90 lines of Rust) that runs during install_with_manager after Lockfile::clean_with_logger and before either linker. It walks the pre-clean lockfile's packages, and for every ResolutionTag::Workspace whose name hash is absent from the new lockfile's workspace_paths, unlinks node_modules/<name> in the project root and in every current workspace's directory. The call is gated on had_any_diffs, and each removal is gated on readlink succeeding (so only symlinks/junctions are touched). Ten new tests in bun-workspaces.test.ts cover rename/remove for both linkers, scoped names, an alias that still wants the old name, a real directory being left alone, and --dry-run.

Security risks

None identified. The only untrusted-ish component of the removed path is the workspace name, which is filtered through the same alias_is_safe_install_target check the linkers already apply (rejects empty, ./.., absolute, backslash, NUL, and multi-segment non-scoped names). Workspace paths come from the user's own package.json. The readlink gate means a non-symlink at that path is never deleted.

Level of scrutiny

High. install_with_manager is the core of bun install, and this introduces a new place where bun deletes things from node_modules. The gating looks sound (had_any_diffs is only set in the LoadResult::Ok diffing branch, so a fresh install or unchanged lockfile skips it entirely; --dry-run clears INSTALL_PACKAGES so the block is never entered; --lockfile-only returns earlier; --frozen-lockfile with a diff crashes before this point). But the decision of what to clean (workspace links) vs. what to leave (.bin entries, entries stale from a previous install on another machine) is a scoping call a maintainer should confirm.

Other factors

The PR description is unusually thorough and the test matrix is good (both linkers × rename/scoped-rename/remove/alias-reuse, plus the negative cases). I verified the lockfile_before_clean snapshot still contains the old workspace packages entries after the in-place diff (the diff only overwrites workspace_paths/workspace_versions/etc. on the old lockfile, and the new code reads previous.packages, not previous.workspace_paths). The Windows removal (rmdir then unlink on failure) is a slightly broader fallback than Symlinker.rs (which only falls back on EPERM), but since this is best-effort cleanup with errors discarded either way, that difference is harmless.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the one difference noted above: the Windows fallback is deliberately broader than the one in Symlinker.rs. The link under a dropped workspace name is normally a directory link, so rmdir is the call that removes it; unlink is only reached when rmdir refused, which is the file-symlink case, and whatever errno that refusal carries the outcome is the same best-effort attempt with the result discarded. Nothing else in the review needs a change, so the diff stays as pushed.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. install: remove node_modules entries that left the lockfile #32974 - Adds the same automatic post-install cleanup at the same insertion point (a prune called from both linkers right after Lockfile::clean_with_logger) that deletes node_modules entries no longer reachable from the lockfile, covering the isolated-linker case of a renamed/removed workspace's stale root symlink.
  2. install: prune stale workspace node_modules in hoisted installs #29794 - Walks every workspace's node_modules on each hoisted install and deletes entries the current lockfile tree does not place there, removing exactly the stale sibling-workspace links in dependent workspaces that this PR targets.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Checked both against their diffs; neither covers the case this PR fixes, and together they still do not.

  • install: remove node_modules entries that left the lockfile #32974 prunes the root node_modules only (two call sites, one per linker), and its hoisted call passes keep_symlinks = true, so under the hoisted linker a workspace link under the old name is exactly what it leaves in place. It never looks at a workspace's own node_modules. Of the four places a stale link can be, it covers one: the root entry under the isolated linker.
  • install: prune stale workspace node_modules in hoisted installs #29794 is in hoisted_install.rs only and walks the directories in workspace_paths, never the root node_modules, and does nothing under the isolated linker. It covers the hoisted case of a dependent workspace's own node_modules, which with hoisting is the rare layout.

Neither removes the root link after a rename under the hoisted linker (the repro in the description), and neither removes packages/<dependent>/node_modules/<old name> under the isolated linker. Both are general extraneous-entry prunes driven by scanning node_modules against the lockfile; this PR is driven by the lockfile diff and only touches the links whose workspace name left the lockfile, which is why it is small and works the same for both linkers and both locations. If either of them lands first this still applies on top of it.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:44 AM PT - Aug 14th, 2026

@robobun, your commit 1d58928 is building: #96349

Comment thread src/install/PackageManager/install_with_manager.rs Outdated
Comment thread src/install/PackageManager/remove_stale_workspace_links.rs Outdated
Comment thread src/install/PackageManager/remove_stale_workspace_links.rs Outdated
Comment thread src/install/PackageManager/remove_stale_workspace_links.rs Outdated
Comment thread src/install/PackageManager/install_with_manager.rs Outdated
Comment thread src/install/PackageManager/remove_stale_workspace_links.rs Outdated
Comment thread src/install/PackageManager/remove_stale_workspace_links.rs Outdated

@claude claude Bot 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.

I reviewed this PR and didn't find any bugs. Since it changes bun install's filesystem behavior (unlinking entries in every workspace's node_modules based on the lockfile diff) and overlaps in scope with #32974 / #29794, a human look at the approach would still be worthwhile.

Checked: readlink gate keeps real directories/files intact (tested); alias_is_safe_install_target blocks path escape; --dry-run/--lockfile-only/--frozen-lockfile never reach the new call; append(b"") for the root case is a no-op after trim; running before the linker lets a still-wanted alias be recreated (tested for both linkers).

Extended reasoning...

Overview

Adds src/install/PackageManager/remove_stale_workspace_links.rs (~86 lines) and wires it into install_with_manager immediately before the linker runs. When the lockfile diff shows changes, it walks the previous lockfile's packages, and for every ResolutionTag::Workspace package whose name hash is no longer in the new lockfile's workspace_paths, it unlinks node_modules/<name> in the project root and in every current workspace directory. Ten new tests in test/cli/install/bun-workspaces.test.ts cover both linkers, scoped names, removal, alias re-linking, real-directory preservation, and --dry-run.

Security risks

The name that becomes a path component is guarded by the same alias_is_safe_install_target the linkers already use (rejects empty, ./.., absolute, backslash, NUL, and anything with more than one /), so it cannot escape node_modules. Only entries where readlink succeeds are removed, so a real directory or file the user placed there is left alone. The workspace paths iterated come from the current lockfile Bun just built. I don't see a path-traversal or unintended-deletion vector.

Level of scrutiny

High. This is the main bun install code path and it now deletes filesystem entries it previously left in place. The change is defensive (had_any_diffs gate, symlink-only removal, safe-name check, runs before the linker so anything the new lockfile still wants is recreated) and thoroughly tested, but it is a behavioral/design change rather than a mechanical fix. Two open PRs (#32974, #29794) take a different, scan-driven approach to overlapping cleanup; the author explains why neither covers this case, but a maintainer should weigh in on which direction to take.

Other factors

  • The comment-cop bot re-fired at 17:13 on the current commit; the comments in the diff are now 1–5 lines each and look fine to me, so I read those as resolved by a02fc5d.
  • The one CI failure so far (test-cluster-shared-leak.js timeout on Windows aarch64) is unrelated to this change.
  • AutoAbsPathChecked::append returns Ok(()) on empty input after trim, so passing b"" for the root's package_dir is correct; scoped names keep their single / inside the appended segment.
  • The Windows rmdir-then-unlink fallback matches how the isolated linker's Symlinker removes wrong links, per the author's note.

@claude claude Bot 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.

I reviewed this PR and didn't find any bugs. Because it changes what bun install deletes from node_modules and overlaps with two other pending prune PRs (#32974, #29794), a human look would still be worthwhile.

What was reviewed:

  • Path safety: names go through alias_is_safe_install_target (rejects .., empty, NUL, etc.) before use as a path component, and only entries where readlink succeeds are removed — real directories and files are left alone.
  • Ordering: runs after clean_with_logger and before the linker, so anything the new lockfile still places under the old name is re-linked (covered by the alias test); gated on had_any_diffs inside do_.install_packages(), so no-op installs, --dry-run, --lockfile-only and --frozen-lockfile never reach it.
  • The Windows rmdirunlink fallback matches the existing pattern in isolated_install/Installer.rs; needs_new_lockfile paths leave had_any_diffs false so lockfile_before_clean is never read in a state where it lacks the old workspace entries.
Extended reasoning...

Overview

Adds src/install/PackageManager/remove_stale_workspace_links.rs (~80 lines) and a call site in install_with_manager gated on had_any_diffs && do_.install_packages(). When a workspace's name leaves the lockfile (rename or removal), unlinks node_modules/<name> in the project root and in every current workspace, for both linkers. Ships 10 new tests in bun-workspaces.test.ts covering rename, scoped rename, removal, alias re-link, directory-left-alone, and --dry-run, each across both linkers where applicable.

Security risks

The only untrusted-ish input is a package name from the previous lockfile. It is filtered through the existing alias_is_safe_install_target (rejects empty, ., .., \\, :, NUL; allows only name or @scope/name) before being appended to <top_level_dir>/[<workspace_path>/]node_modules/, and only paths where readlink succeeds are ever removed. No traversal or over-deletion vector found.

Level of scrutiny

High — this is bun install, and the change deletes filesystem entries. That said, the logic is narrow, best-effort (all syscall errors discarded), only touches symlinks/junctions, and runs immediately before the linker that would re-create anything the new lockfile still wants. The Windows branch mirrors isolated_install/Installer.rs:1171. I traced the had_any_diffs / needs_new_lockfile interaction: when the differ short-circuits to a new lockfile, had_any_diffs stays false and this code never runs, so lockfile_before_clean is always the loaded-from-disk lockfile with the old workspace entries.

Other factors

The PR description is unusually thorough about mechanism and scope. The author already addressed the comment-cop feedback (comments are now one-liners). Two related PRs (#32974, #29794) propose broader node_modules pruning; the author's analysis of why neither covers this case looks correct, but a maintainer should decide whether to land this targeted fix now or fold it into a general prune. Given it deletes from node_modules in a critical path, deferring rather than auto-approving.

@claude claude Bot 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.

I reviewed this PR and didn't find any bugs. Because it adds a new deletion step to the bun install hot path and overlaps with the design space of #32974 / #29794 / #38333, a maintainer should confirm the approach before it lands.

What was reviewed:

  • Traced the call site: gated on had_any_diffs (only set inside the LoadResult::Ok differ arm) and behind do_.install_packages(), so --dry-run / --lockfile-only / --frozen-lockfile / no-lockfile installs never reach it.
  • Path safety: names go through alias_is_safe_install_target (rejects ., .., empty, \/:/NUL, and >2 components), and AutoAbsPathChecked::append(b"") is a no-op so the root path is built correctly.
  • readlink gate leaves real directories/files alone; verified against the "a directory under the old name" test.
Extended reasoning...

Overview

The PR adds a new 80-line module remove_stale_workspace_links.rs and a 5-line call site in install_with_manager.rs, plus a 176-line test block in bun-workspaces.test.ts (10 tests × 2 linkers). It compares the pre-clean lockfile's workspace-resolved packages against the post-clean lockfile's workspace_paths, and for any name that dropped out, unlinks node_modules/<name> in the project root and in every remaining workspace directory — but only if the entry is a symlink/junction (checked via readlink).

Security risks

The only filesystem write is a targeted unlink/rmdir of a path built from <top_level_dir>/[<workspace_path>/]node_modules/<name>. <name> comes from the previous lockfile and is validated by the same alias_is_safe_install_target the linkers use (no .., no absolute components, at most @scope/name). <workspace_path> comes from the current lockfile's workspace_paths, which is the same source the linkers already use to place links. AutoAbsPathChecked::append returns Err(MaxPathExceeded) on overflow, which is handled by early return. I don't see a path-traversal or accidental-deletion vector; the readlink gate additionally means a real directory can never be removed.

Level of scrutiny

High. install_with_manager is the core of bun install, and this adds a new syscall path that runs on every install where the diff is non-empty. A regression here (e.g., unlinking something the linker won't recreate) would corrupt users' node_modules. The change itself is small and defensive, but the placement and interaction with the two linkers is subtle enough that I'm not comfortable approving it without a maintainer look.

Other factors

  • The PR description is unusually thorough and the author has already responded to the comment-cop bot and the duplicate-PR bot with detailed rebuttals.
  • Test coverage is good: both linkers × {rename, scoped rename, remove, alias-still-uses-old-name}, plus the negative cases (real directory left alone, --dry-run skipped). 7 of 10 tests fail on release per the description, satisfying USE_SYSTEM_BUN=1 validation.
  • CI on the head commit passed 177/179 (2 macOS shards expired unrun).
  • The author explicitly notes this sits in the same design space as #38333's install-time prune and two other open PRs; the description says the module "becomes deletable" if a general prune lands. Whether to merge this narrow fix now or wait for/fold into the general prune is a maintainer call.
  • I checked one candidate concern the bug-hunt didn't flag: when needs_new_lockfile is true, lockfile_before_clean is not the loaded-from-disk lockfile but the freshly-built one — however had_any_diffs is only ever set inside the LoadResult::Ok differ block, so that path never reaches remove_stale_workspace_links.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

No changes needed from the review above. The merge question it raises (narrow fix now vs waiting for a general install-time prune) is the one already laid out in the PR description: this removes dangling links left behind by a workspace rename or removal today, and the module can be deleted if a general prune like #38333 lands later. That call is a maintainer decision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants