bunch-package
v1.19.0
Published
Patch management tool for Bun - alternative to patch-package
Maintainers
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_modulesedits the cache too — and every other project on the machine with it.editgives 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-packageUsage
1. Take the package off bun's shared cache
bunx bunch-package edit some-packageThen make your changes in node_modules/some-package.
2. Create a patch
bunx bunch-package create some-packageThis 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-pickerRun 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-packageThe 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/123The 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.jsEverything 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 hydrationMultiple 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.patchThe 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 applyApplies 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 backThe 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 treeEvery 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.patchThe 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.patchstatus 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 reverseUn-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 mshttps://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 --openOn 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/andPods/at any depth — these are never sourcebuild/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 --binaryWithout 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/innerContext 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 100755Only 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 installThe 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 offA 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
- 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.
- Apply applies every
.patchfile inpatches/itself, without shelling out topatch(1): one run at a time, each file replaced rather than rewritten in place, and a record of what landed left innode_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 importThe 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 exportexport 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.
createalso needsdiffon 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!
