npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bunch-package

v1.19.0

Published

Patch management tool for Bun - alternative to patch-package

Readme

bunch-package

Patch management tool for Bun - alternative to patch-package

bunch-package lets you fix broken node_modules instantly and persist the changes through postinstall scripts. It's like patch-package but optimized for Bun.

Why bunch-package?

  • Built for bun's install cache. Bun links installed packages to a shared cache, so editing a file in node_modules edits the cache too — and every other project on the machine with it. edit gives the package a private copy first; every other command knows about the cache as well. A tool written for npm does not.
  • Twelve commands: edit, create, apply, status, rebase, fold, annotate, retarget, reverse, import, export and upstream.
  • It tells you when it cannot do something. Binary files, symbolic links, a hunk that no longer fits — all of them are named out loud rather than dropped, because a patch that silently carries less than you think is worse than no patch.
  • Applying twice changes nothing, even for a patch that once landed a few lines away from where it said it would.

Installation

bun add -d bunch-package

Usage

1. Take the package off bun's shared cache

bunx bunch-package edit some-package

Then make your changes in node_modules/some-package.

2. Create a patch

bunx bunch-package create some-package

This creates patches/some-package+1.2.3.patch

3. Add postinstall script

In your package.json:

{
  "scripts": {
    "postinstall": "bunx bunch-package apply"
  }
}

4. Commit the patch

git add patches/
git commit -m "fix: patch some-package"

Now whenever someone runs bun install, patches are automatically applied!

Commands

| Command | What it does | |---|---| | edit <package> | Give it a private copy in node_modules, so editing it stays yours | | create <package> --why <text> | Record in the patch file what the patch is for | | create <package> | Create or update a patch for a package | | apply | Apply every patch in patches/ | | status | Show which patches are in the tree right now | | rebase <package> <patch> | Un-apply the patches sitting on top of one, to edit it | | retarget <package> | Move its patches to the version now installed | | fold <package> | Collapse its patch sequence into a single patch | | annotate <package> <file> | Show which patch brought each line of a file | | import | Convert patches written by bun patch to this format | | export [package] | Convert patches back to bun patch format, for use with bun install | | upstream <package> | Print a GitHub draft-issue URL with the patch diff |

Before you edit

bunx bunch-package edit <package-name>

bun links installed packages out of its shared cache, so a file in node_modules and the entry in ~/.bun/install/cache are often the same inode. Editing that file in place edits the cache with it, and the next clean install in any other project on the machine arrives already carrying your change. Measured on bun 1.4.0: with --backend=hardlink, which is the default on Linux, a line appended to node_modules/ms/index.js was in the cache immediately and travelled into a third project that knew nothing about it. On macOS the default is clonefile and the copy is already private, so there the command has nothing to do and says so.

edit replaces every shared file of the package with a copy of itself — same bytes, same permissions, its own inode — leaving symbolic links alone. After that the package is yours to edit, and create diffs it as usual.

Patches are unaffected either way: apply has always written past a hardlink rather than through it. What edit protects is the hand-editing step before the patch exists.

Create a patch

bunx bunch-package create <package-name>

Example:

bunx bunch-package create react-native-date-picker

Run create from the directory that owns the package — the one where node_modules/<package> lives. A patch records paths relative to that root, so running it from a subdirectory without its own node_modules would produce a patch with wrong paths. When the package is found in a parent directory, create names that directory and says where to run the command from.

create downloads a pristine copy of the package to compare against, and gives that download 60 seconds. On a slow link, or for a very large package, that is not always enough — raise it with BUNCH_FETCH_TIMEOUT, in seconds:

BUNCH_FETCH_TIMEOUT=300 bunx bunch-package create some-enormous-package

The pristine copy comes from the registry the project configured. bun reads registry settings from the directory it installs into and looks no higher, and that directory is a temporary one inside the project — so create copies .npmrc and bunfig.toml there first, and prints which file it used. In a monorepo the nearest config wins: the workspace's own, otherwise the one at the workspace root. Without that copy a package from a private registry could not be fetched at all, and a package whose name also exists in the public registry would quietly arrive from there — the diff would be taken against the wrong pristine copy, and the patch would be wrong without saying so.

When bun add fails, create falls back to npm pack. npm does not read bunfig.toml, so a project that names its registry only there gets a refusal instead of that fallback: going to the default registry would be the same silently wrong patch.

Packages installed from a local path rather than the registry — file:, link:, workspace:, a git URL, or a .tgz file — cannot be patched this way: there is no pristine copy to fetch from the registry. create detects the specifier in package.json and says so before attempting any network request.

The pristine copy is kept in a cache of its own — ~/.cache/bunch-package/pristine (%LOCALAPPDATA%\bunch-package\pristine on Windows), moved elsewhere with BUNCH_PRISTINE_CACHE. It is separate from bun's own cache on purpose: bun links packages out of that cache, so a file edited in place in node_modules would change the cached copy too, and the "pristine" package would arrive already patched. Deleting the cache is always safe; the next create refills it.

Keeping it costs disk and saves the download. Measured on [email protected] (31 MB): create takes 3.8 s the first time and 0.6 s after that, against 8–10 s every single run when the copy was fetched from scratch.

If the resulting diff would be larger than 50 MB, create refuses instead of writing a truncated patch. That size nearly always means generated or build output is being compared rather than source.

Why a patch exists

bunx bunch-package create some-package --why "breaks SSR hydration" \
  --upstream https://github.com/owner/repo/issues/123

The file name says which package and which version. It does not say what the patch is for, and six months later nobody can tell whether it is still needed. --why and --upstream put that in the patch file itself, above the diff:

Why: breaks SSR hydration
Upstream: https://github.com/owner/repo/issues/123

diff --git a/node_modules/some-package/index.js b/node_modules/some-package/index.js

Everything above the first line of the diff is the header, and both tools skip it: measured on 73 real patches from public repositories, a patch with a header and the same patch without one produce byte-identical trees, and patch-package 8.0.1 reads it exactly as this tool does.

The header travels on its own. create rewrites the whole patch file, and retarget writes a new one for the new version — both carry it over, including lines you wrote by hand. --why given again replaces the reason and leaves the rest alone. A reason that would be read as part of a diff, or one spanning two lines, is refused when you type it rather than breaking the patch later.

status prints the reason under each patch, and knows the difference between the header changing and the patch changing:

  ✅ some-package+1.2.3.patch — in the tree, only its header changed since it was applied on 2026-08-21T17:46:38.613Z
     breaks SSR hydration

Multiple patches for one package

bunx bunch-package create <package-name> --append <name>

Adds another patch instead of overwriting the existing one:

patches/react-native+0.81.4+001+initial.patch
patches/react-native+0.81.4+002+fix-touchable.patch

The patches form a sequence and build on each other, like commits. Each one is diffed against the state left by the ones before it, so a later patch contains only its own change. create without --append updates the last patch in the sequence, leaving the earlier ones alone. The naming matches patch-package, so patches travel between the two.

A sequence is recognised as applied by its last patch, since that is the state the tree ends up in — an intermediate patch cannot be checked on its own once a later one sits on top of it.

Apply all patches

bunx bunch-package apply

Applies all patches from the patches/ directory.

A patch counts as applied only when its changes are actually in the tree. The whole patch is computed in memory first, and nothing is written unless every file in it fits — so a patch can never leave your tree half-changed, and a failed apply leaves no .rej or .orig files behind. Applying the same patch twice is a no-op.

If the patch file was rewritten after it landed — you switched branches, or pulled a patch someone else changed — apply refuses it instead of laying the new version on top of the old one, and names the file in node_modules the previous version left there. It refuses only when the tree proves it: every file that patch touched is still exactly what it left, so nothing has been reinstalled since. Delete the package directory, install again, and the patch applies as usual. If the tree has moved on and there is nothing left to prove either way, you get a warning instead of a refusal, and the patch is applied.

Each file is written beside its target and moved over it, so a file is never visible half-written or briefly missing. An apply killed outright — Ctrl-C, a CI runner that died, a disk that filled up — leaves every file either as it was or fully patched, and running apply again finishes the job. Writing in place instead would leave a truncated file that no later apply could ever patch, because a hunk does not fit against emptiness.

Only one apply runs at a time. It holds node_modules/.bunch-package.lock for the length of the run — in a monorepo, the one at the workspace root, since that is the tree the workspaces share; a second apply waits up to 30 seconds for the first to finish, then reports who is holding the lock. Two runs a moment apart are ordinary — postinstall firing twice, workspaces installing in parallel — and without the lock they can interleave into a tree that is neither the patched nor the unpatched one. A lock left behind by a killed run is recognised by the process id inside it and taken over, so a killed apply does not lock the project up.

Exit codes:

| Code | Meaning | |------|---------| | 0 | Every patch is in the tree (applied now or already applied) | | 1 | At least one patch failed — the reason is printed under it, and nothing was written |

A non-zero exit makes postinstall fail, so a broken patch stops CI instead of silently shipping an unpatched build.

Patches created by bunch-package before 1.1.0 contain absolute paths and cannot be applied; apply reports them as failed and asks you to recreate them with create.

Editing a patch that is not the last one

create without --append updates the last patch of a sequence. To change an earlier one, first take off the patches sitting on top of it:

bunx bunch-package rebase react-native 001+initial
🔧 Rebasing react-native onto react-native+0.81.4+001+initial.patch...
  ↩️  react-native+0.81.4+002+fix-touchable.patch

Now edit node_modules/react-native, then run:
  bunch-package create react-native                   to update react-native+0.81.4+001+initial.patch
  bunch-package create react-native --append <name>   to insert a patch after it
  bunch-package apply                                 to put the rest back

The target can be named however is convenient — 001+initial, initial, 1, or the file name — and 0 un-applies the whole sequence, which is how you insert a new patch before all the others. This is what --rebase does in patch-package, so the habit travels along with the patches.

Un-applying is applying the patch backwards, through the same code that applies it forwards: one implementation, one set of rules for creations, deletions, renames, modes and missing trailing newlines. A patch the tree no longer matches is refused rather than half-removed, exactly like a patch that does not apply.

The patch is the only record of the lines it removed, and it does not necessarily record their line endings: git and the GitHub web editor normalise those in both directions, so a patch made from a CRLF file often stores LF, and a patch written entirely in CRLF carries \r into files that never had any. Restored lines are therefore given the line ending the file itself uses, taken from the hunk's context lines — those came from the file, not from the patch — or from the rest of the file when a hunk has no context at all. Applying is deliberately left alone: it puts the patch's line through as written, which is what patch-package does and what makes the two produce identical trees.

Measured on 290 real patches from public repositories: 289 restore the package byte for byte. The one that does not is irreducible — its hunk replaces the only line of a one-line file, so neither the file nor the patch holds any evidence of what the line ending used to be.

rebase also re-applies the patch to what it is about to write and refuses when that does not reproduce the current tree.

After the rebase, create updates the patch you rebased onto rather than the last one in the sequence. It knows which that is from the record apply and rebase keep — checked against the tree, since a record can go stale: the patches above the target must really be absent, or their changes would be swallowed into the patch being rewritten.

Folding a sequence back into one patch

bunx bunch-package fold react-native
🗜  Folding 3 patches for [email protected]...
   These files will be replaced by react-native+0.81.4.patch:
     react-native+0.81.4+001+initial.patch
     react-native+0.81.4+002+fix-touchable.patch
     react-native+0.81.4+003+another.patch

✅ react-native+0.81.4.patch
   3 patches folded into one; node_modules is unchanged.

A sequence is useful while the changes are still being made — each patch carries only its own change, and rebase lets you edit one in the middle. Once they have settled, the intermediate patches have no separate meaning left, and every retarget has to move all of them. fold collapses the sequence into a single patch describing the same tree.

Nothing is invented for the collapse: the diff is taken between a pristine copy and node_modules, the same way create takes it. So the folded patch produces byte for byte the tree the sequence produced — un-apply it and apply it again to see that.

The whole sequence has to be in the tree. If any of it is missing, fold refuses and names the files: the missing patch's change is not in node_modules either, so it would silently vanish from the result.

Why: lines of all the patches are carried into the folded one, joined in order. The dev mark is inherited. The files that are about to be deleted are printed before they are deleted — folding cannot be undone, and only git can bring them back, and only if they were committed.

Which patch brought this line

bunx bunch-package annotate ms index.js
📖 node_modules/ms/index.js

  001  ms+2.1.2+001+initial.patch
  002  ms+2.1.2+002+two.patch
  003  ms+2.1.2+003+three.patch

    1  001  // FROM THE FIRST PATCH
    2  002  // FROM THE SECOND
    3       /**
    4        * Helpers.
    5        */

📊 3 line(s) from 3 patch(es).

With a sequence of three or four patches, nothing tells you where a given line came from except reading every patch and adding them up in your head. annotate does the adding: it replays the patches one by one onto a pristine copy and carries line authorship from each version to the next. Lines that came with the package are left unmarked.

It never touches node_modules. The replay happens on the pristine copy in a temporary directory, so a command you run to read something cannot damage the tree — and the tree is compared against the result afterwards. If the file is not what the patches produce, nothing is annotated:

❌ node_modules/ms/index.js is not what the patches produce.
   Either a patch is missing from the tree (run `bunch-package apply`), or the file was edited by hand.
   Nothing is annotated, because the lines could not be attributed honestly.

That refusal is the point of the command: an annotation that quietly attributes hand-written lines to a patch is worse than no annotation.

After upgrading the package

A patch is written against one exact version. Upgrade the package and the patch file still says the old one — apply warns about the mismatch and then it is up to you. It usually still fits: a patch written for 2.1.2 normally applies to 2.1.3.

What is worth knowing is what happens if you leave it and run create again. The new patch is named after the new version, so patches/ now holds two files for one package — and both are applied, one after the other, leaving the tree with both sets of changes. create says so as soon as the second file appears, and tells you whether the old one's changes are already inside the new patch; apply repeats it, naming the file written for the version that is not installed. Neither refuses: patch-package applies both too, and the tree is byte for byte the same.

retarget moves the patches over instead:

bunx bunch-package retarget ms
📦 Moving 1 patch(es) for ms from 2.1.2 to 2.1.3...
📥 Fetching pristine [email protected]...
  ✅ ms+2.1.2.patch → ms+2.1.3.patch

📊 1 patch(es) now target 2.1.3
   Run `bunch-package apply` to put them into node_modules.

Each patch is replayed onto a pristine copy of the installed version and diffed back out, so the new file carries context lines and line numbers from that version — the patch stops being approximately right and becomes exact. A sequence moves as a whole, keeping its numbers and labels, and each patch keeps carrying only its own change.

Three things it will tell you rather than paper over:

  • A patch that no longer fits. The package changed around it. Nothing is written, the old patch files stay where they are, and the message names the file and hunk, so you can make the change by hand and run create.
  • A patch that is no longer needed, because the fix went upstream between the versions. It is dropped, and said aloud.
  • Patches for more than one version in patches/, which means the sequence is not in a state anyone can move safely: moving them all onto the installed version would give two files the same name. The files written for a version you no longer have are listed, so you can decide which changes stay.

How often this works is measured, not promised. Of 289 patches taken from public repositories, applied to the next published version of their package: 64% still fit and were moved, 5% had already been fixed upstream, and 30% no longer fit. Jumping straight to the latest version instead — often many releases later — only 22% fit. A patch is written against one version, and the further the package moves, the less of it survives.

What is in the tree right now

bunx bunch-package status
📋 2 patch(es) in patches/

  ✅ is-number+7.0.0.patch — in the tree, applied 2026-08-21T17:46:38.613Z
  ⬜ left-pad+1.3.0.patch — not in the tree, applied 2026-08-20T09:12:04.201Z

📊 1 of 2 in the tree

Every answer is worked out from node_modules itself, by checking whether each patch is already in the files. apply also keeps a record at node_modules/.bunch-package-state.json — which patch, which version, the hash of the patch file, the hashes of the files it left in the tree, and when it first landed — and status uses it only for the parts the tree cannot tell you: when a patch was applied, whether the patch file has been edited since, and whether a patch file that used to be applied has been deleted while its changes are still in node_modules. apply reads it for one thing: to notice that the patch describing what is in the tree is no longer the patch on disk. The record is never taken as proof that a patch is applied; the files are.

An entry whose patch file has been deleted stays in the record, and survives every later install: it is the only thing that remembers those changes are probably still in node_modules. Only fold drops such entries, because it is the one command that removes patch files itself, and the changes live on in the folded patch.

status exits 1 when anything is missing from the tree, so it can stand in CI as a cheap check that node_modules is what the patches say it should be.

Patches for packages that production does not install

A package in devDependencies is not there on a production install, and a patch for it should not fail the deploy. Name it .dev.patch — the same convention patch-package uses — and apply skips it when the package is absent:

bunx bunch-package create eslint-plugin-something --dev
# → patches/eslint-plugin-something+1.2.3.dev.patch

The skip happens only when NODE_ENV=production. Anywhere else a missing package means a broken install, and you get told about it. In production a patch for a package listed in the root devDependencies counts as dev-only even without the suffix, and a missing package that is not marked names the way out:

❌ some-package+1.0.0.patch
   node_modules/some-package is not installed
   If it is a dev dependency, rename this patch to some-package+1.0.0.dev.patch

status leaves skipped patches out of its count rather than calling them missing. A whole sequence is dev or it is not: the mark is inherited from the patches already there, so --dev is only needed for the first one.

Taking every patch back out

bunx bunch-package reverse

Un-applies all of them, top down, and empties the record. Patches bun owns are left alone. This is rebase <package> 0 for the whole project, and it is what to reach for when node_modules needs to be what the installer produced without reinstalling it.

Filing a patch upstream

A patch is a temporary fork, and the goal is for it to go away. It does happen: of a corpus of 292 real patches, 220 had a newer version of their package to move to, and 11 of those 220 (5%) were no longer needed — the fix had gone upstream between the two versions. The faster it is reported, the sooner the patch is gone.

upstream builds a GitHub new-issue URL with the patch diff in the body, so you can open it, add context, and submit:

bunx bunch-package upstream ms
https://github.com/vercel/ms/issues/new?title=Patch%20for%20ms%402.1.2&body=…

If the patch header carries Why: or Upstream: (written there by create --why/--upstream), those go into the body too — so the issue arrives with the reason already written.

Open the URL in the browser automatically with --open:

bunx bunch-package upstream ms --open

On macOS this calls open, on Linux xdg-open, on Windows explorer.exe — directly, without going through cmd.exe (the URL contains &, which cmd.exe treats as a command separator).

When the patch is too large to fit in a URL (limit: 8192 characters), upstream says so and prints a shorter URL instead, with a placeholder in the body prompting you to paste the diff manually. It never silently sends a truncated URL.

If the package's repository field points to a non-GitHub host, upstream prints the repository address and tells you to file manually — it understands GitHub only.

This is the equivalent of patch-package --create-issue, but without the open dependency: the project has no runtime dependencies beyond diff on PATH for create.

Options

--patch-dir <dir>                  Where the patches live (default: patches)
--include <regexp>                 Only these paths go into a new patch
--exclude <regexp>                 These paths do not
--case-sensitive-path-filtering    Match those two case-sensitively
--error-on-warn                    Make `apply` exit 1 after a warning as well
--dev                              Mark a new patch as needed only in development
--why <text>                       Why the patch exists, kept in the patch file
--upstream <url>                   Where it was reported upstream, kept with it

--patch-dir applies to every command, and the directory has to be inside the directory you run in. A monorepo does not need it — a workspace runs in its own directory and so already has its own patches/, see Monorepos; it is for keeping two sets of patches side by side in one project.

--include and --exclude are matched against paths relative to the root of the package being patched, and only affect create. Case is ignored unless you say otherwise. What they leave out is listed rather than silently dropped — a typo in a regexp would otherwise look like "nothing changed". The built-in exclusions below apply regardless.

--error-on-warn is for CI. The warnings it counts are the ones about the package's version: the patch was made for another version, or the manifest could not be read to check. By default those do not fail the install, because the patch still applied; with this flag they do.

create takes more than one package at a time (bunch-package create react-native react-native-svg), and a failure on one does not cancel the others: each is reported, and the exit code is 1 if any of them failed.

Anything else starting with -- is refused rather than ignored, so a mistyped --exclud cannot quietly widen a patch.

What gets excluded?

bunch-package automatically excludes, at any depth:

  • Binary files (*.so, *.jar, *.aar, *.class, *.dex, *.apk, *.a, *.framework, *.xcframework, *.dylib)
  • Media files (*.png, *.jpg, *.jpeg, *.gif, *.webp)
  • Fonts (*.ttf, *.otf, *.woff, *.woff2)
  • Leftovers from a failed apply (*.rej, *.orig)
  • patch-package's own state file (.patch-package.json), which it writes inside the patched package — so a project moving over from it does not carry that file into its first patch
  • Version control (.git/, node_modules/)

Build artifacts are excluded by path, not by name:

  • .gradle/, .cxx/, .transforms/, DerivedData/ and Pods/ at any depth — these are never source
  • build/ only under a platform directory (android/build/, ios/build/, …)

A build/ directory at the root of a package is not excluded: for a great many packages that is where their published JavaScript lives, and dropping it would silently discard the change you came to make. Anything skipped is listed in the output — create never drops a path without saying so.

Binary files cannot be represented in a text diff. create lists the ones that differ rather than letting the change disappear, and it refuses outright if a changed file is not valid UTF-8, because writing that patch would corrupt it.

Symbolic links cannot travel in a patch either — the format carries file contents, not links. create lists every link that differs instead of reporting no changes. apply refuses a patch section that describes a symlink (git writes those with mode 120000), and refuses to overwrite a symlink that is already in node_modules: putting a regular file where a link belongs would report success and leave you with the wrong tree.

Binary files

A patch made by git diff --binary carries the file itself, and those are applied — literal blocks (the whole content) and delta blocks (a change against what is already there), both in git's own base85-over-zlib encoding. No new dependency: zlib ships with bun.

Which side of the change a file is on is decided by the git hashes in the index <old>..<new> line, not by guesswork: matching the new hash means the patch is already in, matching the old one means it applies, and anything else is refused before a byte is written. That is what keeps a delta from landing on a file it was not made against.

create writes them too, but only when asked:

bunx bunch-package create some-package --binary

Without the flag, files whose extension marks them as binary — images, fonts, .so, .jar — stay out of the patch and are named in the output, so you know what it does not carry. With it, they go in as literal blocks in git's format, and create says which files went and what that costs.

The flag is not the default on purpose, and the reason is not size. patch-package and bun patch read such a section as "create an empty file" and write a zero-byte file without a word — measured on both. Patches travel between tools, and a patch of ours should not turn into silent damage in somebody else's. Use --binary when the patch is only ever applied by bunch-package or by git.

A patch written this way is a git patch, not an approximation of one: git apply takes it, and git apply -R takes it back out — that is a test in the suite, not a claim.

Binary files … differ is a note, not data. diff prints it without -a to say a difference exists that it cannot show; patches carry those lines next to ordinary hunks, and they are passed over.

For comparison: patch-package writes a zero-byte file in place of the real one and reports success, and so does bun patch — measured on both.

What it understands

Unified diffs as git diff and patch-package write them: content hunks, file creation and deletion, renames, mode changes, and files with no trailing newline. Patch files in CRLF are read correctly against LF sources.

An empty file that was added or deleted travels too, as the section git writes for it — new file mode with no hunks. diff says nothing about such a file, since there is no text on either side to compare, so create builds that section itself. Without it a .keep or a py.typed was left behind in silence, and next to a text change the patch still looked complete.

Nested dependencies use patch-package's naming: outer++inner+1.0.0.patch is a patch for node_modules/outer/node_modules/inner, the copy bun installs when two versions of a package are needed at once. Create one by naming the path:

bunx bunch-package create outer/node_modules/inner

Context lines are compared ignoring trailing whitespace, because trailing whitespace does not survive editors, linters or the GitHub web editor — and a line that is only indentation becomes an empty one. Context is verified but never rewritten: only added and removed lines reach your files.

Hunks are located at the line the patch declares, then by widening search, matching patch's offset handling. Fuzzy context matching is deliberately absent — a patch is made against one exact version, and stretching context to fit is how a failure comes to look like a success.

Whether a patch is already in the tree is decided by reading the file both ways: how well the hunks' old side fits, and how well the new side fits, each located by the same widening search. Whichever sits closer to the lines the patch declares wins, and a tie counts as applied. This matters for a patch that once landed at an offset — checking only the declared position would never recognise it again, and apply in a postinstall hook would lay it down anew on every install, appending its changes over and over.

File permissions

A change to a file's executable bit is captured and restored, including for files the patch creates. It is recorded with the same git headers patch-package uses:

diff --git a/node_modules/some-package/bin/run.sh b/node_modules/some-package/bin/run.sh
old mode 100644
new mode 100755

Only the executable bit is tracked, the way git does it — comparing full permission bits would report differences that are really just a different umask.

On Windows there is no executable bit to track. A patch carrying a mode change still applies there — the mode part is skipped rather than attempted, so the patch counts as applied once and stays that way instead of being reported as work on every run.

How bun lays the package out

bun install has more than one layout, and where a patch actually lands depends on it. All of it below was measured on bun 1.4.0, not read off documentation.

| layout | node_modules/<pkg> really is | this tool | |---|---|---| | default (hoisted) | a real directory in the project | works | | --linker isolated | a link to node_modules/.bun/<pkg>@<ver>/…, still in the project | works — the whole cycle is covered by a test | | --linker isolated and a global store (BUN_INSTALL_GLOBAL_STORE=1, or install.globalStore in bunfig) | a link into <bun cache>/links/…, shared by every project on the machine | refused, out loud — see below | | node_modules symlinked or moved outside the project (any tool, any reason) | a real directory outside the project | refused, out loud — see below |

When node_modules/<pkg> resolves to bun's shared cache (BUN_INSTALL_CACHE_DIR, default ~/.bun/install/cache), the check exists because writing through such a link changes the package for every project on the machine: measured, a second project that knew nothing about the patch came out of a plain bun install already carrying it. bun itself refuses to patch through that link — "refusing to patch through it" — and so do we. The message names the store and tells you how to move it back inside the project:

❌ ms+2.1.2.patch
   node_modules/ms resolves to /Users/you/.bun/install/cache/links/[email protected]/node_modules/ms, outside the project — that is bun's shared store.
   Patching there would change the package for every project on this machine.
   Reinstall with the store inside the project: BUN_INSTALL_GLOBAL_STORE=0 bun install

The way out is in the message, and it works: with the store back inside the project, everything runs as usual.

When node_modules (or a package in it) is simply outside the project — symlinked elsewhere, passed to a different tool via --modules-folder, mounted on a shared volume — the message says so without suggesting bun-specific steps, because BUN_INSTALL_GLOBAL_STORE=0 won't move the directory:

❌ sym+1.0.0.patch
   node_modules/sym resolves to /data/shared-modules/sym, outside the project.
   Patches are only ever written inside the project.

In both cases apply, reverse, rebase and edit refuse; status says the same instead of answering about a tree that is not the project's; create warns, since the package it is about to diff may have been changed by somebody else's project.

In a monorepo the project is the workspace root, not the directory you run in, so the store under <root>/node_modules/.bun/ is not somebody else's store and nothing is refused there. A link that leaves the monorepo still is. See Monorepos.

npm aliases

When you install a package under a different name with bun add mynum@npm:[email protected], the directory in node_modules is named by the alias (mynum). The patch file is named after that directory, not after the package's own manifest:

bunx bunch-package create mynum      # reads node_modules/mynum, writes patches/mynum+7.0.0.patch
bunx bunch-package rebase mynum 0    # takes it back off

A patch is named after the directory it patches — the same way patch-package names it — because the directory is what makes it unique. The same package installed twice, once directly and once under an alias, is the point of aliases: two versions side by side. Both would share one manifest name, so a name taken from the manifest would put two different patches in one file, and whichever was written second would silently replace the first.

Patches created before 1.17.0 carry the manifest name (is-number+7.0.0.patch). They are still read, and the next create for that package moves the file onto the directory name and says so. A patch that belongs to a neighbouring directory is never moved or overwritten. rebase also accepts the manifest name as its argument and works out which directory you mean — unless two of them answer to it, and then it says so instead of guessing.

Monorepos

Measured on bun 1.4.0. bun install at the root runs the postinstall of every workspace and of the root, each with its own directory as the working directory, and the workspaces start in parallel — two starts matched to the microsecond. So one patches/ directory per workspace works on its own: each workspace patches what it depends on, and --patch-dir is not needed for it.

Where the package physically is depends on the linker:

| layout | packages/a/node_modules/ms | |---|---| | default for workspaces | symlink to <root>/node_modules/.bun/[email protected]/node_modules/ms | | --linker hoisted | not there at all — ms sits in <root>/node_modules/ms | | --linker hoisted, two versions wanted | the workspace that lost the hoist keeps its own copy |

Both are handled. A package is looked for in the workspace first and then upwards, as far as the workspace root — the parent node_modules that patch-package #356 asks for. The patch file does not change: the paths inside it stay node_modules/<pkg>/…, so the same patch works in a plain project and still travels to and from patch-package.

The lock lives at the workspace root, <root>/node_modules/.bunch-package.lock. A lock per workspace would exclude nothing: the workspaces run at the same moment and write into the same directory.

The upward search happens only when a parent package.json declares workspaces and names the directory you are in among them. A forgotten manifest higher up the tree does not turn your home directory into the project.

Two workspaces, one directory

Two workspaces that depend on the same version of a package get one directory — measured: packages/a/node_modules/is-number and packages/b/node_modules/is-number came back with the same inode. If they patch it differently there is no tree that satisfies both, and whichever ran last would win. We refuse, in whichever workspace runs, and name the other one:

❌ is-number+7.0.0.patch
   node_modules/is-number is shared with workspace packages/b, which patches it differently.
   Both resolve to /repo/node_modules/.bun/[email protected]/node_modules/is-number — one directory, two different patches
   (is-number+7.0.0.patch here, is-number+7.0.0.patch there), so whichever ran last would win.
   Give the two workspaces different versions of the package, or make the patches identical.

Identical patches are not a conflict: both workspaces want the same thing from the shared directory, and one of them putting it there suits the other.

What patch-package 8.0.1 does in that situation was measured on the same tree: it applies the patch and says nothing. Workspace a's change appears in b's tree, and since it writes files in place, with --backend=hardlink the change also reaches bun's cache entry — that is, the next clean install of any project on the machine. apply here writes through a temporary file and a rename, which gives the file its own inode: checked on the same tree, the cache entry was unchanged afterwards.

Platforms

Checked against 290 patches taken from public repositories, applied with both this tool and patch-package and compared byte for byte: all 290 trees are identical, and the two tools agree on the exit code every time.

Tested on Linux, macOS and Windows. apply is plain JavaScript and needs nothing from the system; create shells out to diff, which is present on all three (on Windows it comes with Git).

The bun >= 1.0.0 in engines is checked rather than assumed: CI applies a patch, re-applies it, reads status and un-applies it on bun 1.0, 1.1 and 1.2, alongside the full suite on the current release. Developing the tool needs bun 1.2 or newer, though — the text lockfile and the test runner it uses arrived there.

If diff is not on PATH, create says so before it does anything else, rather than downloading a pristine copy of the package first and failing afterwards.

Example

# Install a package
bun add react-native-date-picker

# Make changes to fix a bug
code node_modules/react-native-date-picker/ios/RNDatePicker.h

# Create patch
bunx bunch-package create react-native-date-picker
# ✅ Patch created: patches/react-native-date-picker+5.0.13.patch
# 📊 Stats:
#    Lines: 13
#    Size: 1.11 KB

# Add to package.json
{
  "scripts": {
    "postinstall": "bunx bunch-package apply"
  }
}

# Commit
git add patches/
git commit -m "fix: add missing include in react-native-date-picker"

How it works

  1. Create fetches a pristine copy of the package into a temp directory, diffs it against your modified version, and writes the diff as a patch file.
  2. Apply applies every .patch file in patches/ itself, without shelling out to patch(1): one run at a time, each file replaced rather than rewritten in place, and a record of what landed left in node_modules.

Unified diffs are parsed and applied in process. That is a deliberate choice: patch is GNU on Linux and a much older Apple build on macOS, and they disagree on exit codes, on the wording of their diagnostics, and on whether a patch is allowed to write outside the project at all. Doing it in process also makes the whole patch atomic, keeps deletions idempotent, and means paths are checked against the project root by us rather than by whichever patch happens to be installed.

Whether a patch is already in the tree is never taken on faith from that record — it is worked out from the files, by reading them both ways: how well the hunks' old side fits, and how well the new side fits. That is what makes a second apply a no-op even for a patch that landed at an offset.

The pristine copy is installed with a download cache of its own, and copied out of it rather than linked. This matters: bun links installed packages to its shared cache, so editing a file in node_modules edits the cache entry too — and a "clean" install pulled from that cache would come back carrying your change, making the diff come out empty. Our own cache is never written to, which is checked by a test rather than assumed: patches are replayed onto the pristine copy, and the cache has to come out of that byte for byte the same.

Patch headers are rebuilt from the file paths rather than rewritten in place, so an absolute path that happens to appear inside a file is left alone.

Compared with patch-package

Patch files are interchangeable between the two, and on real patches the result is the same. Everything below was measured by running both, not assumed:

| | bunch-package | patch-package 8.0.1 | |---|---|---| | Result on 290 real patches | identical trees, identical exit codes | identical trees, identical exit codes | | apply on a 3-section patch | 15 ms | 71 ms | | apply on a 35-section patch | 22 ms | 76 ms | | Applying a patch that only appends lines, three times | applied once, then recognised | 1, then 2, then 3 copies of the added lines | | Patch section describing a symlink (mode 120000) | refused, and named as a symlink | refused as "could not be parsed" | | Record of what was applied | node_modules/.bunch-package-state.json | .patch-package.json, written inside the patched package | | Bun's shared install cache | handled | not addressed — its README does not mention bun | | Taking the package off that cache before you edit it | edit | — | | Recording why a patch exists | --why / --upstream, carried through rewrites | — | | npm, yarn, pnpm | not attempted | documented | | A package hoisted to the monorepo root | found by searching upwards | Cannot find module …/package.json, a Node stack trace | | Two workspaces patching one shared directory differently | refused in both, and the other workspace named | applied silently, last one wins | | Moving a patch to a newer version of the package | retarget | — | | Converting patches from the other tool | import, for the ones bun writes | — | | Dev-only patches (*.dev.patch) | skipped when the package is absent, --dev to create one | skipped when the package is absent | | --create-issue | — | opens a draft issue on GitHub | | Unknown command-line option | refused | ignored |

Most of the speed difference is process startup, which is what a postinstall hook pays on every install. The rest of the table is a difference in behaviour, not a score: patch-package runs where this tool does not, and that is the honest reason to keep using it.

Alongside bun patch

bun has patched packages itself since 1.2: bun patch --commit writes a patch into the same patches/ directory and records it under patchedDependencies in package.json, and the installer applies it — no postinstall involved. Those patches are written differently: the file is named [email protected], and the paths inside it are relative to the package root (a/index.js), not to the project (a/node_modules/ms/index.js).

This tool leaves them alone. apply skips anything listed in patchedDependencies and says who owns it; status lists those separately rather than counting them; and a patch whose paths are not under node_modules/ is refused out loud, because applying it as if the paths were ours would write into the project's own files. create refuses a package bun already patches, since bun's patch is in node_modules but not in the pristine copy and would end up inside the new patch.

In a monorepo the list is read from the workspace root's package.json as well as your own. Measured on bun 1.4.0: bun honours the key in both, and resolves the patch file's path from the root either way — a workspace patches/[email protected] gave Couldn't find patch file, the same file at the root applied. Reading only the manifest we run in would mean create in a workspace never noticed that bun already patches the package. For the same reason export from a workspace writes the path the way bun reads it, packages/a/patches/[email protected].

If you would rather this tool owned them, bunch-package import converts them: it renames [email protected] to ms+2.1.2.patch (@vercel%[email protected] to @vercel+og+0.4.1.patch), rewrites the paths inside to start from the project root, drops the .bun-tag-… file bun adds while you edit, and removes the entry from patchedDependencies so bun stops looking for a file that is no longer there. Patches written by patch-package need no conversion — that format is this one.

bunx bunch-package import

The conversion is checked against bun itself: a patch made with bun patch --commit and then imported produces, through bunch-package apply, the same bytes bun's own installer produced.

The other direction is also available. If you have patches in this tool's format and would like bun install to apply them instead of a postinstall script:

bunx bunch-package export

export renames ms+2.1.2.patch to [email protected] (@vercel+og+0.4.1.patch to @vercel%[email protected]), rewrites the paths inside to be relative to the package root, and adds the entry to patchedDependencies so bun picks it up at the next bun install. Pass a package name to export only that package's patches.

export refuses patches that bun cannot handle: dev-only patches (*.dev.patch), nested dependencies, and sequences of multiple patches on the same package — bun supports one patch per package, regenerated whole.

What this tool does that bun patch does not (stated as fact, not as a reason to stay): it verifies the lines being removed rather than applying by position, warns when the installed version drifts from the patch target, and supports status, retarget, and patch sequences. Leaving is your choice; this command makes it easy.

What the two tools do is not the same job. Measured on bun 1.4.0: bun install applies a patch by line number without checking the lines it claims to remove — a hunk deleting a line that is not in the file still rewrites whatever sits at that position, silently, exit code 0 — and when the installed version no longer matches the patchedDependencies key, the patch is quietly not applied at all, with nothing in the output to say so. There is one patch per package, regenerated whole. This tool refuses instead of guessing, warns when the version drifts, moves patches to a new version with retarget, keeps a sequence of patches per package, and can tell you what is in the tree right now.

Requirements

  • Using it: bun >= 1.0.0, checked in CI on 1.0, 1.1 and 1.2 as well as the current release. create also needs diff on PATH.
  • Working on it: bun >= 1.2 — the text lockfile and the test runner the suite uses arrived there.

License

MIT

Contributing

Issues and PRs welcome!