Skip to content

bun patch: use the isolated store folder when the hoisted path does not exist - #43383

Open
robobun wants to merge 4 commits into
mainfrom
robobun/ae011b0b/patch-isolated-store-folder
Open

robobun wants to merge 4 commits into
mainfrom
robobun/ae011b0b/patch-isolated-store-folder

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With linker = "isolated", bun patch <name> puts the copy at the hoisted path of the package. The isolated layout can have nothing there. The root depends on b, b depends on a: bun patch a creates a real node_modules/a that stays after --commit and after every install.
  • A copy at node_modules/<workspace>/node_modules/<name> later stops the install from linking that workspace (error: Cannot find package 'w'). A hoisted path through a link puts the copy inside another store entry.
  • Cause: prepare_patch and do_patch_commit (src/install/PackageManager/patchPackage.rs) take the folder from the hoisted tree.

Fix

  • When nothing exists at the hoisted path, both commands use the store folder, node_modules/.bun/<entry>/node_modules/<name>. build_store names the entry, and the entry that the current workspace links wins. The install after --commit rebuilds it in place, and dependents load the edits before the commit.
  • bun patch --commit follows a symlink argument. git diff --no-index reads a symlink as a file and wrote new file mode 120000.
  • An entry that links into the global store is not used. An install puts that link back over a detached copy.
  • Verified: test/cli/install/bun-patch.test.ts (9 new tests, 3 updated, 11 fail without the fix). Also bun-install-patch.test.ts, isolated-install.test.ts.

Background

  • The hoisted tree is the npm layout that every lockfile stores.
  • The isolated linker installs each package once, in its store entry. It links a package only into the packages that declare it.
  • bun patch <pkg> replaces the package folder with a copy that shares no files with the cache. --commit diffs the copy against the cache and installs again.
Notes

Repro output (loopback registry, root depends on b, b depends on a, linker = "isolated").

Before (1.4.3-canary.1+b52d51348):

$ bun patch a
  node_modules/a
$ bun patch --commit a
node_modules/a: REAL dir [index.js, package.json]
node_modules/.bun/a@1.0.0/node_modules/a: [index.js, .bun-tag-41b812740eb4408b, package.json]
$ bun install
node_modules/a: REAL dir [index.js, package.json]

After:

$ bun patch a
  node_modules/.bun/a@1.0.0/node_modules/a
$ bun patch --commit a
node_modules/a: missing
node_modules/.bun/a@1.0.0/node_modules/a: [.bun-tag-..., index.js, package.json]

The three shapes that the new tests cover:

  • A transitive dependency. The hoisted path is node_modules/<name>.
  • A dependency of a workspace that the root does not depend on. The hoisted path is node_modules/<workspace>/node_modules/<name>. After the root adds "w": "workspace:*", node_modules/w must be a link. Symlinker::ensure_symlink keeps a real directory it finds there, so the leftover broke require("w").
  • A dependency that the hoisted tree nests under its dependent, node_modules/<dependent>/node_modules/<name>. node_modules/<dependent> is a link, so the copy landed inside the store entry of the dependent and shadowed the real package for it.

Why the store folder:

  • It is the only folder of the package in the isolated layout. Every dependent links to it, so the edits are live. The copy at the hoisted path was not: b resolves a through its own link.
  • It already works as a target. bun patch node_modules/.bun/a@1.0.0/node_modules/a and --commit with the same path behave correctly on the release build: the install finds no .bun-tag-<hash> of the new patch and rebuilds the folder.
  • The rule reads the file system, not the linker option. With the hoisted linker the hoisted path exists after an install, so nothing changes there. A path that is a link (the root or a linked workspace depends on the package) also keeps the current behavior.

Peer variants: a package with peer dependencies has one store entry for each peer resolution (peer-deps@1.0.0+<hash>). build_store is the function that bun install and bun prune use to name the entries. The search goes breadth first from the entry of the root, or of the workspace of the current directory, so bun patch in a workspace prepares the entry that this workspace loads. Its node_modules/<name> link then leads to the edits, for its own code and for --commit node_modules/<name>. The walk visits every entry of a workspace package, because the entry that a workspace: range links has no dependencies of its own. An entry that the walk does not reach is found in store order. The install after --commit patches every variant.

--commit <name> uses the same rule, so it names the prepared folder when both commands run in the same directory. When bun patch ran in another directory, the named entry has no changes. The output then lists the other store folders of the package with the command that commits each one. The command that bun patch prints works from every directory. I did not make --commit <name> search for the entry that differs from the cache: nothing marks a folder as prepared, an entry that a lifecycle script changed also differs, and for an already patched package every entry differs from the unpatched cache.

Global store (install.globalStore = true, off by default): node_modules/.bun/<entry> is a link into <cache>/links. bun patch on a store folder replaces that link with a real directory. The next install sees a real directory where the link belongs and replaces it (link_project_to_global_store), so edits made before --commit are lost. This happens on the release build with an explicit store path. To not make it reachable by name, the store folder is used only when the entry is a real directory. With the global store the copy is still made at the hoisted path. One test pins that the edits survive an install there.

Symlink argument: on the release build, bun patch --commit node_modules/b from packages/w, where that path is the link to the store folder, writes

diff --git a/packages/w/node_modules/b b/packages/w/node_modules/b
new file mode 120000

and the install fails with failed to parse patchfile: bad_file_mode. The existing test "relative node_modules/" (#12200, #12882) passed only because the copy was at the root node_modules/is-odd. That path no longer exists, so the command resolves the link.

Existing tests that changed: "inside workspace with hoisting > @types/ws@8.5.4" expected node_modules/@types/ws, a folder that only bun patch created, and read the leftover copy after --commit. The two "committing with the path bun suggested" tests wrote to a fixed root path. They now write to the path that bun patch prints, and assert that the root folder does not exist.

Related work:

Not in this PR:

Review follow-up (two rounds of automated review, every thread has an answer):

  • A reviewer found that bun patch in a workspace could prepare the peer variant of another workspace (first entry in store order). 53f2b4e fixes it with the breadth first search above, and two tests cover it. ed14bb8 makes the walk continue through a workspace that another workspace depends on, and adds the list of other folders to the No changes detected output. Two more tests cover these.
  • With the global store, bun patch <name> still writes through node_modules/<dependent> into <cache>/links when the hoisted tree nests the package under a dependent. Main does the same (checked with is-odd@3.0.1 and is-number@7.0.0: a second project that shares the cache loads the edited copy). The fix needs the installer to keep a detached node_modules/.bun/<entry>, so it is a separate change.

Tests run: bun-patch.test.ts 46 pass on Linux (debug, ASAN), and the new block plus "workspace interactions" pass on a Windows debug build. bun-install-patch.test.ts 31 pass, isolated-install.test.ts 85 pass.

… at its hoisted path

With the isolated linker a package has a folder at its hoisted path only
when the root, or a workspace that the root links, depends on it.
`bun patch <name>` still created the editable copy at the hoisted path.
No install removed that directory. It shadowed the package for every
importer above it, and a directory at `node_modules/<workspace>` kept
a later install from linking the workspace there.

When nothing exists at the hoisted path, `bun patch <name>` and
`bun patch --commit <name>` now use the store folder of the package,
`node_modules/.bun/<entry>/node_modules/<name>`. An entry that is a
link into the global store is not used, because an install puts that
link back over a detached copy. `bun patch --commit` also follows a
symlink argument to the folder it points to, because
`git diff --no-index` reads a symlink operand as a file.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 2 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 2 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 14 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: f7de3616-a170-4353-96c3-8caf86f9f8f6

📥 Commits

Reviewing files that changed from the base of the PR and between 367d939 and ed14bb8.

📒 Files selected for processing (2)
  • src/install/PackageManager/patchPackage.rs
  • test/cli/install/bun-patch.test.ts

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

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on a release build of main (1.4.3-canary.1+b52d51348) with linker = "isolated" in bunfig.toml, a root that depends on b, and b that depends on a:

bun install
bun patch a                                  # prints node_modules/a
echo "// patched" >> node_modules/a/index.js
bun patch --commit a
  • Before: node_modules/a is a real directory after the last command, and bun install does not remove it. The same happens with node_modules/w/node_modules/b for a dependency of a workspace w that the root does not depend on. After the root adds "w": "workspace:*", node_modules/w stays a real directory and require("w") fails with Cannot find package 'w'.
  • With this PR: bun patch a prints node_modules/.bun/a@1.0.0/node_modules/a. After --commit, that folder holds the patched package, and nothing exists at node_modules/a.

Test: test/cli/install/bun-patch.test.ts, block "isolated linker: package with no folder at its hoisted path", plus 3 updated tests in "workspace interactions". 11 tests fail on main, all 46 pass with this PR.

PR: #43383

Comment thread src/install/PackageManager/patchPackage.rs Outdated
Comment thread src/install/PackageManager/patchPackage.rs Outdated
Comment thread src/install/PackageManager/patchPackage.rs Outdated
Comment thread src/install/PackageManager/patchPackage.rs
A package with peer dependencies has one store entry for each peer
resolution. `bun patch <name>` took the first entry in store order. In a
workspace that links another entry, the edits were not behind its
`node_modules/<name>`, and `bun patch --commit node_modules/<name>`
from that workspace found no changes.

The search now goes breadth first from the entry of the root or of the
workspace of the current directory, and falls back to store order.
Comment thread src/install/PackageManager/patchPackage.rs Outdated
Comment thread src/install/PackageManager/patchPackage.rs Outdated
Comment thread src/install/PackageManager/patchPackage.rs Outdated
Comment thread src/install/PackageManager/patchPackage.rs Outdated
Comment thread src/install/PackageManager/patchPackage.rs Outdated
@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:00 PM PT - Sep 18th, 2026

✅ @robobun, your commit ed14bb8c2b092f0409ed1a5df43d3b82b2403cc7 passed in Build #118078! 🎉


🧪   To try this PR locally:

bunx bun-pr 43383

That installs a local version of the PR into your bun-43383 executable, so you can run:

bun-43383 --bun

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up to the review, in 53f2b4e and c491b6f:

  • Peer variants in a workspace: bun patch <name> took the first store entry of the package. In a workspace that links another entry, the edits were not behind its node_modules/<name>, and bun patch --commit node_modules/<name> from there found no changes. The search now goes breadth first from the entry of the root, or of the workspace of the current directory, and falls back to store order. Two new tests run the flow from each workspace. One of them failed on the first revision.
  • Every added comment in patchPackage.rs is one line now.
  • Not changed, as on main: with install.globalStore = true the copy still goes to the hoisted path. When the hoisted tree nests the package under a dependent, that path goes through node_modules/<dependent> into <cache>/links. I confirmed it on the release build with is-odd@3.0.1 and is-number@7.0.0: a second project that shares the cache loads the edited copy. The store folder is the right target there too, but an install deletes a detached node_modules/.bun/<entry> (link_project_to_global_store). The installer must keep a bun patch copy first, and that is a separate change.
  • Not changed: a directory that an older build left at a hoisted path still wins over the store folder. bun prune reports it, and after its removal bun patch uses the store folder.

Tests: test/cli/install/bun-patch.test.ts 44 pass (9 fail on main), bun-install-patch.test.ts 31 pass, isolated-install.test.ts 85 pass.

Comment thread src/install/PackageManager/patchPackage.rs
Comment thread src/install/PackageManager/patchPackage.rs
Comment thread src/install/PackageManager/patchPackage.rs
… the other entries when --commit finds no changes

A workspace that another workspace depends on has a second store entry
with no dependencies, so the search for the entry that the current
workspace loads stopped there. Every entry of a workspace is now visited.

`bun patch --commit <name>` names the entry that the current directory
loads. When `bun patch` ran in another directory, that entry has no
changes. The output now lists the other store folders of the package with
the command that commits them.
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up to the second review round, in ed14bb8:

  • A workspace that reaches the package only through another workspace: the entry that a workspace: range links has no dependencies of its own, so the walk stopped there. It now visits every entry of a workspace package. New test with a workspace consumer that depends only on pkg-2.0.0.
  • bun patch --commit <name> in another directory than bun patch <name>, for a package with several peer variants: the named entry has no changes. The output now lists the other store folders with the command that commits each one. I did not add a search for the entry that differs from the cache. Nothing marks a folder as prepared, an entry that a lifecycle script changed also differs, and for an already patched package every entry differs from the unpatched cache. The command that bun patch prints works from every directory. New test for this flow.
  • Not changed: a search over both feature sets for --production and --omit. bun patch runs its own install first, so the names differ only when the two commands get different flags.

Tests: test/cli/install/bun-patch.test.ts 46 pass (11 fail on main). The new block and "workspace interactions" also pass on a Windows debug build.

@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 re-reviewed the latest push (ed14bb8) and found no new bugs; given that bun patch now rebuilds the isolated store and walks its entry graph, a human look at the entry-selection logic is still worthwhile.

What was reviewed in the new commit:

  • The same_package expansion in enqueue (patchPackage.rs:1140-1155): a workspace: edge now reaches the entry that carries the workspace's dependencies, so the BFS crosses workspace-to-workspace links as the new consumer test expects.
  • The other_store_folders hint (patchPackage.rs:551-563) prints root-relative paths; from a workspace cwd the process has already chdir'd to the root (PackageManager.rs:1845) and the Path guard at patchPackage.rs:131 keeps the argument, so the printed command works from either directory.
  • build_store (isolated_install.rs:216) is a pure function of the lockfile, so calling it from bun patch/--commit has no filesystem side effects.
Extended reasoning...

Overview

The PR changes src/install/PackageManager/patchPackage.rs (about 200 lines) and adds a test block to test/cli/install/bun-patch.test.ts. Since my last review, commit ed14bb8 added the workspace-aware expansion in the store-entry walk (treating Root/Workspace resolutions as sharing all entries of the same package) and the "has more than one folder" hint printed when --commit <name> finds no changes. Both are responses to threads I opened at 22:55; the peer-variant cwd mismatch is now mitigated by the hint rather than by making --commit <name> locate the prepared folder, which is a design choice a maintainer should weigh.

Security risks

None specific to this change. The new paths are built from lockfile-derived store entry names (fmt_store_path) and package names that the lockfile already validated; resolve_symlinked_folder only follows a symlink that already sits inside the project's node_modules and falls back to the original path on any failure. build_store does not write to disk. The only new subprocess input is changes_dir, which was already passed to git diff --no-index on the base branch.

Level of scrutiny

Moderate-to-high. This is a package-manager code path with several new invariants: the BFS from the current workspace entry must agree with what the installer linked, the global-store exclusion (RealDir check on node_modules/.bun/<entry>) must not select a link into the shared cache, and the symlink resolution must happen before git diff sees the operand. The #[cfg(windows)] branch of folder_kind and the Windows get_fd_path result are not type-checked or exercised in this Linux run; the author reports a Windows debug build passing the new block. I did not build or run the tests in this run.

Other factors

The new tests cover the transitive, workspace-only, nested-under-dependent, peer-variant (from root, from each workspace, through another workspace), cross-directory commit, and global-store shapes, and the three existing tests that asserted the old hoisted copy were updated with stated reasons. Two of my earlier inline threads remain open (the cwd-dependent entry choice, now mitigated by a hint, and the --production peer-hash naming note); I did not restate them here. No third-party CHANGES_REQUESTED review is visible in the timeline, but the change is too large and layered for me to approve without a human maintainer's look.

This branch has not been deployed

No deployments
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.

1 participant