diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs
index d1075ccba9a3..713f8f99d740 100644
--- a/src/install/PackageManager.rs
+++ b/src/install/PackageManager.rs
@@ -461,6 +461,9 @@ pub struct PackageManager {
pub(crate) patched_dependencies_to_remove:
ArrayHashMap,
+ // bun patch --commit: the folder that was diffed; the isolated linker puts its link back.
+ pub(crate) committed_patch: Option,
+
pub(crate) active_lifecycle_scripts: crate::lifecycle_script_runner::List<'static>,
pub(crate) last_reported_slow_lifecycle_script_at: u64,
pub(crate) cached_tick_for_slow_lifecycle_script_logging: u64,
@@ -2147,6 +2150,7 @@ pub fn init(
wr!(edited_package_jsons, Vec::new());
wr!(catalog_add, add_catalog::State::default());
wr!(patched_dependencies_to_remove, ArrayHashMap::default());
+ wr!(committed_patch, None);
wr!(last_reported_slow_lifecycle_script_at, 0);
wr!(cached_tick_for_slow_lifecycle_script_logging, 0);
}
@@ -2615,6 +2619,7 @@ fn init_with_runtime_once(
wr!(edited_package_jsons, Vec::new());
wr!(catalog_add, add_catalog::State::default());
wr!(patched_dependencies_to_remove, ArrayHashMap::default());
+ wr!(committed_patch, None);
wr!(last_reported_slow_lifecycle_script_at, 0);
wr!(cached_tick_for_slow_lifecycle_script_logging, 0);
}
diff --git a/src/install/PackageManager/patchPackage.rs b/src/install/PackageManager/patchPackage.rs
index 22f89dfd3146..1b61888054eb 100644
--- a/src/install/PackageManager/patchPackage.rs
+++ b/src/install/PackageManager/patchPackage.rs
@@ -53,6 +53,37 @@ pub struct PatchCommitResult {
pub(crate) not_in_workspace_root: bool,
}
+/// The folder that `bun patch --commit` diffed. The isolated linker puts its link back.
+pub struct CommittedPatch {
+ real_path: Box<[u8]>,
+ /// False when the diff was empty: the folder equals the package, and no patch is recorded.
+ pub(crate) has_changes: bool,
+}
+
+impl CommittedPatch {
+ fn new(folder: &[u8]) -> sys::Result {
+ let mut buf = bun_paths::path_buffer_pool::get();
+ Ok(CommittedPatch {
+ real_path: Box::from(real_path_of_folder(folder, &mut buf)?),
+ has_changes: true,
+ })
+ }
+
+ pub(crate) fn is_folder(&self, path: &[u8]) -> bool {
+ // The name rules out most paths without a syscall.
+ if bun_paths::basename(path) != bun_paths::basename(&self.real_path) {
+ return false;
+ }
+ let mut buf = bun_paths::path_buffer_pool::get();
+ real_path_of_folder(path, &mut buf).is_ok_and(|real_path| real_path == &*self.real_path)
+ }
+}
+
+fn real_path_of_folder<'a>(folder: &[u8], buf: &'a mut PathBuffer) -> sys::Result<&'a [u8]> {
+ let dir = Dir::cwd().open_dir(folder, sys::OpenDirOptions::default())?;
+ dir.get_fd_path(buf).map(|real_path| &*real_path)
+}
+
/// - Arg is the dir containing the package with changes OR name and version
/// - Get the patch file contents by running git diff on the temp dir and the original package dir
/// - Write the patch file to $PATCHES_DIR/$PKG_NAME_AND_VERSION.patch
@@ -267,6 +298,30 @@ pub fn do_patch_commit(
}
};
+ // `git diff` records a link as `new file mode 120000`, and no install can apply that.
+ if !is_real_dir_not_symlink(&changes_dir) {
+ bun_core::pretty_errorln!(
+ "error: {} is not a folder that bun patch prepared",
+ bstr::BStr::new(&changes_dir),
+ );
+ bun_core::note!(
+ "Run `bun patch {}` first",
+ bstr::BStr::new(manager.options.positionals[1]),
+ );
+ Global::crash();
+ }
+ manager.committed_patch = match CommittedPatch::new(&changes_dir) {
+ Ok(committed) => Some(committed),
+ Err(e) => {
+ Output::err(
+ e,
+ "failed to open directory {s}",
+ (bstr::BStr::new(&changes_dir),),
+ );
+ Global::crash();
+ }
+ };
+
// `compute_cache_dir_and_subpath` resolves `pkg.resolution`'s strings against `manager.lockfile`.
manager.lockfile = lockfile;
let name = manager.lockfile.str(&pkg.name).to_vec();
@@ -353,6 +408,10 @@ pub fn do_patch_commit(
break 'has_nested_node_modules true;
};
+ // The diff leaves that folder out. It can hold the `bun patch` copy of a nested package.
+ if has_nested_node_modules {
+ manager.committed_patch = None;
+ }
let patch_tag_tmpname = match bun_paths::fs::FileSystem::tmpname(
b"patch_tmp",
@@ -537,9 +596,23 @@ pub fn do_patch_commit(
);
Output::flush();
drop(contents);
+ if let Some(committed) = &mut manager.committed_patch {
+ committed.has_changes = false;
+ }
return Ok(None);
}
+ // The patch parser drops such a file, so the folder has edits that the patch lacks.
+ if strings::split(&contents, b"\n")
+ .any(|line| line.starts_with(b"Binary files ") && line.ends_with(b" differ"))
+ {
+ bun_core::warn!(
+ "git cannot diff binary files as text. The patch does not include the changes to them in {}",
+ bstr::BStr::new(new_folder),
+ );
+ manager.committed_patch = None;
+ }
+
break 'brk contents;
};
diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs
index 65128085d4be..c229674153f7 100644
--- a/src/install/isolated_install.rs
+++ b/src/install/isolated_install.rs
@@ -2619,6 +2619,8 @@ pub(crate) fn install_isolated_packages(
}
}
+ installer.relink_committed_patch_after_tasks();
+
if installer.manager().options.log_level.show_progress() {
progress.root.end();
*progress = Progress::default();
diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs
index 1ee1715dbf53..5ed07141cbd2 100644
--- a/src/install/isolated_install/Installer.rs
+++ b/src/install/isolated_install/Installer.rs
@@ -37,6 +37,7 @@ use crate::bun_fs;
use crate::lockfile_real::package::PackageColumns as _;
use crate::package_manager_real::directories;
use crate::package_manager_real::package_manager_options::Do;
+use crate::package_manager_real::patch_package::CommittedPatch;
/// The enum lives at module level in `crate::resolution`.
type ResolutionTag = resolution::Tag;
@@ -1488,12 +1489,13 @@ impl Task {
symlinker::Strategy::ExpectMissing
};
- let changed = match installer.symlink_dependencies(self.entry_id, strategy) {
- sys::Result::Ok(changed) => changed,
- sys::Result::Err(err) => {
- return Ok(Yield::failure(TaskError::SymlinkDependencies(err)));
- }
- };
+ let changed =
+ match installer.symlink_dependencies(self.entry_id, strategy, None) {
+ sys::Result::Ok(changed) => changed,
+ sys::Result::Err(err) => {
+ return Ok(Yield::failure(TaskError::SymlinkDependencies(err)));
+ }
+ };
if relinking {
if !changed {
@@ -1536,6 +1538,13 @@ impl Task {
Step::SymlinkDependencyBinaries => {
let current_step = Step::SymlinkDependencyBinaries;
+ if matches!(pkg_res.tag, ResolutionTag::Root | ResolutionTag::Workspace) {
+ if let sys::Result::Err(err) =
+ installer.relink_committed_patch(self.entry_id)
+ {
+ return Ok(Yield::failure(TaskError::SymlinkDependencies(err)));
+ }
+ }
if let Err(err) = installer.link_dependency_bins(self.entry_id) {
return Ok(Yield::failure(TaskError::Binaries(err)));
}
@@ -2235,10 +2244,12 @@ impl<'a> Installer<'a> {
}
/// Ok(true) when at least one dependency link of the entry was written.
+ /// `only_committed` limits the links to the folder of that commit.
fn symlink_dependencies(
&self,
entry_id: StoreEntryId,
strategy: symlinker::Strategy,
+ only_committed: Option<&CommittedPatch>,
) -> sys::Result {
let lockfile = self.lockfile();
let string_buf = lockfile.buffers.string_bytes.as_slice();
@@ -2279,6 +2290,14 @@ impl<'a> Installer<'a> {
self.append_store_path(&mut dep_store_path, dep.entry_id);
}
+ if let Some(committed) = only_committed {
+ if !committed.is_folder(dest.slice())
+ || !self.store_holds_commit(committed, dep.entry_id, &mut dep_store_path)
+ {
+ continue;
+ }
+ }
+
let dest_len = dest.len();
dest.undo(1);
let target = dest.relative(&dep_store_path);
@@ -2298,6 +2317,72 @@ impl<'a> Installer<'a> {
Ok(changed)
}
+ /// `store_path` has the tag file of the committed patch, or exists when the diff was empty.
+ fn store_holds_commit(
+ &self,
+ committed: &CommittedPatch,
+ entry_id: StoreEntryId,
+ store_path: &mut AutoAbsPath,
+ ) -> bool {
+ if !committed.has_changes {
+ return sys::directory_exists_at(Fd::cwd(), store_path.slice_z()).unwrap_or(false);
+ }
+
+ let node_id = self.store.entries.items_node_id()[entry_id.get() as usize];
+ let pkg_id = self.store.nodes.items_pkg_id()[node_id.get() as usize];
+ let pkgs = self.lockfile().packages.slice();
+ let Ok(PatchInfo::Patch(patch)) = self.package_patch_info(
+ pkgs.items_name()[pkg_id as usize],
+ pkgs.items_name_hash()[pkg_id as usize],
+ &pkgs.items_resolution()[pkg_id as usize],
+ ) else {
+ return false;
+ };
+
+ let mut tag_buf: install::BuntagHashBuf = Default::default();
+ let tag = install::buntaghashbuf_make(&mut tag_buf, patch.contents_hash);
+ let store_path_len = store_path.len();
+ store_path.append(&*tag).assume_ok();
+ let has_tag = sys::exists_z(store_path.slice_z());
+ store_path.set_length(store_path_len);
+ has_tag
+ }
+
+ /// For a root or workspace entry whose dependencies are installed, before its scripts run.
+ fn relink_committed_patch(&self, entry_id: StoreEntryId) -> sys::Result<()> {
+ let Some(committed) = self.manager().committed_patch.as_ref() else {
+ return Ok(());
+ };
+ self.symlink_dependencies(
+ entry_id,
+ symlinker::Strategy::ReplaceDirectory,
+ Some(committed),
+ )?;
+ Ok(())
+ }
+
+ /// An entry in a dependency cycle does not wait for its dependencies, so its task can be early.
+ pub(crate) fn relink_committed_patch_after_tasks(&mut self) {
+ if self.manager().committed_patch.is_none() {
+ return;
+ }
+ let pkg_resolutions = self.lockfile().packages.items_resolution();
+ let node_pkg_ids = self.store.nodes.items_pkg_id();
+
+ for (entry_id, node_id) in self.store.entries.items_node_id().iter().enumerate() {
+ let pkg_res = &pkg_resolutions[node_pkg_ids[node_id.get() as usize] as usize];
+ if !matches!(pkg_res.tag, ResolutionTag::Root | ResolutionTag::Workspace) {
+ continue;
+ }
+ let entry_id = StoreEntryId::from(u32::try_from(entry_id).expect("int cast"));
+ if let sys::Result::Err(err) = self.relink_committed_patch(entry_id) {
+ Output::err(err, "failed to link the patched package again", ());
+ Output::flush();
+ self.summary.fail += 1;
+ }
+ }
+ }
+
pub(crate) fn link_dependency_bins(&self, parent_entry_id: StoreEntryId) -> crate::Result<()> {
let lockfile = self.lockfile();
let store = self.store;
diff --git a/src/install/isolated_install/Symlinker.rs b/src/install/isolated_install/Symlinker.rs
index 52c90735abba..6fc39d626fff 100644
--- a/src/install/isolated_install/Symlinker.rs
+++ b/src/install/isolated_install/Symlinker.rs
@@ -1,5 +1,6 @@
use bun_core::strings;
use bun_paths;
+use bun_paths::path_options::AssumeOk as _;
use bun_sys::{self, Errno, Fd, FdDirExt, FdExt};
pub(crate) struct Symlinker {
@@ -31,6 +32,28 @@ impl Symlinker {
}
}
+ /// The directory moves aside first, so a failure never leaves a partly deleted copy at `dest`.
+ fn replace_directory(&mut self) -> bun_sys::Result<()> {
+ let mut aside =
+ bun_paths::Path::::from(self.dest.dirname().unwrap_or(b".")).assume_ok();
+ aside
+ .append_fmt(format_args!(
+ ".{}.old-{:x}",
+ bstr::BStr::new(self.dest.basename()),
+ bun_core::fast_random(),
+ ))
+ .assume_ok();
+
+ bun_sys::renameat(Fd::cwd(), self.dest.slice_z(), Fd::cwd(), aside.slice_z())?;
+ if let Err(err) = self.symlink() {
+ // When the copy cannot move back, this error names where it is.
+ bun_sys::renameat(Fd::cwd(), aside.slice_z(), Fd::cwd(), self.dest.slice_z())?;
+ return Err(err);
+ }
+ let _ = Fd::cwd().delete_tree(aside.slice_z());
+ Ok(())
+ }
+
// Ok(true) when a link was written.
pub(crate) fn ensure_symlink(&mut self, strategy: Strategy) -> bun_sys::Result {
match strategy {
@@ -54,7 +77,7 @@ impl Symlinker {
},
};
}
- Strategy::ExpectExisting => {
+ Strategy::ExpectExisting | Strategy::ReplaceDirectory => {
let mut current_link_buf = bun_paths::path_buffer_pool::get();
let current_link_len =
match bun_sys::readlink(self.dest.slice_z(), &mut current_link_buf) {
@@ -100,7 +123,10 @@ impl Symlinker {
false
};
if is_dir {
- return Ok(false);
+ if !matches!(strategy, Strategy::ReplaceDirectory) {
+ return Ok(false);
+ }
+ return self.replace_directory().map(|()| true);
}
let _ = bun_sys::unlink(self.dest.slice_z());
return self.symlink().map(|()| true);
@@ -153,4 +179,6 @@ impl Symlinker {
pub enum Strategy {
ExpectExisting,
ExpectMissing,
+ /// `ExpectExisting`, but the link replaces a real directory that `bun patch --commit` diffed.
+ ReplaceDirectory,
}
diff --git a/test/cli/install/bun-patch.test.ts b/test/cli/install/bun-patch.test.ts
index 0e61d4526398..e09ac39d4e13 100644
--- a/test/cli/install/bun-patch.test.ts
+++ b/test/cli/install/bun-patch.test.ts
@@ -1,7 +1,7 @@
import { $, ShellOutput } from "bun";
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
-import { lstatSync, readFileSync } from "fs";
-import { bunEnv, bunExe, isASAN, tempDir, VerdaccioRegistry } from "harness";
+import { existsSync, lstatSync, readdirSync, readFileSync, symlinkSync } from "fs";
+import { bunEnv, bunExe, isASAN, isWindows, tempDir, VerdaccioRegistry } from "harness";
import { isAbsolute, join, sep } from "path";
const expectNoError = (o: ShellOutput) => expect(o.stderr.toString()).not.toContain("error");
@@ -66,21 +66,35 @@ describe("error messages", () => {
});
});
+// CI exports BUN_INSTALL_CACHE_DIR, which overrides the harness bunfig's per-test `cache`. Concurrent tests that
+// install the same tarball spec into one shared cache replace each other's `@T@` folder on Windows.
+async function runBun(cwd: string, ...args: string[]) {
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), ...args],
+ cwd,
+ env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(cwd, ".bun-cache") },
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ return { stdout, stderr, exitCode };
+}
+
+const registry = new VerdaccioRegistry();
+
+beforeAll(async () => {
+ await registry.start();
+});
+
+afterAll(() => {
+ registry.stop();
+});
+
// `bun patch` identifies packages by `name@label`, where a tarball package's label is
// the spec it was installed from. These labels used to be formatted into 1024 byte
// stack buffers (512 bytes in the installer itself), so a long enough spec crashed
// every command that formatted it.
describe("packages whose label is longer than 1024 bytes", () => {
- const registry = new VerdaccioRegistry();
-
- beforeAll(async () => {
- await registry.start();
- });
-
- afterAll(() => {
- registry.stop();
- });
-
// `x/../` normalizes away, so the tarball still lives at a short path that is valid
// on every platform while the recorded spec stays long.
const longSpec = (tarball: string) => `./${Buffer.alloc(1050, "x/../").toString()}${tarball}`;
@@ -96,20 +110,6 @@ describe("packages whose label is longer than 1024 bytes", () => {
return packageDir;
}
- // CI exports BUN_INSTALL_CACHE_DIR, which overrides the harness bunfig's per-test `cache`. Two of these concurrent
- // tests install the same tarball spec; sharing one cache, they replace each other's `@T@` folder on Windows.
- async function runBun(cwd: string, ...args: string[]) {
- await using proc = Bun.spawn({
- cmd: [bunExe(), ...args],
- cwd,
- env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(cwd, ".bun-cache") },
- stdout: "pipe",
- stderr: "pipe",
- });
- const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
- return { stdout, stderr, exitCode };
- }
-
async function install(cwd: string) {
const { stderr, exitCode } = await runBun(cwd, "install");
expect(stderr).not.toContain("error:");
@@ -177,6 +177,271 @@ describe("packages whose label is longer than 1024 bytes", () => {
});
});
+// With the isolated linker `node_modules/` is a link into `node_modules/.bun`, and the
+// dependencies of are siblings of its store entry. `bun patch ` swaps the link for a
+// detached copy of the package, and a copy in a real directory cannot reach those siblings.
+describe("bun patch --commit with the isolated linker", () => {
+ const isLink = (path: string) => lstatSync(path).isSymbolicLink();
+ const NOT_PREPARED = "is not a folder that bun patch prepared\n";
+
+ async function install(globalStore: boolean, files: Record) {
+ const { packageDir } = await registry.createTestDir({
+ bunfigOpts: { linker: "isolated", globalStore },
+ files: files as any,
+ });
+ const { stderr, exitCode } = await runBun(packageDir, "install");
+ expect(stderr).not.toContain("error:");
+ expect(exitCode).toBe(0);
+ return packageDir;
+ }
+
+ // Returns the detached copy that `bun patch ` puts in place of the link at `folder`.
+ async function patch(packageDir: string, arg: string, folder: string) {
+ const copy = join(packageDir, folder);
+ expect(isLink(copy)).toBe(true);
+ const { stdout, stderr, exitCode } = await runBun(packageDir, "patch", arg);
+ expect(stderr).not.toContain("error:");
+ expect(stdout).toContain(`edit the following folder:\n\n ${folder}\n`);
+ expect(exitCode).toBe(0);
+ expect(isLink(copy)).toBe(false);
+ return copy;
+ }
+
+ // index.js of has-bin-entries requires each dependency of the package, and "no-deps" is one.
+ const addPatchedExport = async (copy: string) =>
+ Bun.write(
+ join(copy, "index.js"),
+ (await Bun.file(join(copy, "index.js")).text()) + "\nmodule.exports.patched = true;\n",
+ );
+ const load = (cwd: string) =>
+ runBun(
+ cwd,
+ "-e",
+ `const pkg = require("has-bin-entries"); console.log(pkg.version, pkg.patched, pkg.dependencies["no-deps"].version);`,
+ );
+
+ const rootOnly = {
+ "package.json": JSON.stringify({
+ name: "foo",
+ dependencies: { "has-bin-entries": "1.0.0", "a-dep": "1.0.1" },
+ }),
+ };
+
+ describe.each([
+ { globalStore: false, commitArg: "has-bin-entries" },
+ { globalStore: true, commitArg: "node_modules/has-bin-entries" },
+ ])("globalStore: $globalStore, --commit $commitArg", ({ globalStore, commitArg }) => {
+ test.concurrent("links node_modules/ into node_modules/.bun again", async () => {
+ const packageDir = await install(globalStore, rootOnly);
+ const committed = await patch(packageDir, "has-bin-entries", "node_modules/has-bin-entries");
+ const uncommitted = await patch(packageDir, "a-dep", "node_modules/a-dep");
+ await addPatchedExport(committed);
+ await Bun.write(join(uncommitted, "edit.js"), "module.exports = 'in progress';\n");
+
+ const commit = await runBun(packageDir, "patch", "--commit", commitArg);
+ expect(commit.stderr).not.toContain("error:");
+ expect(commit.exitCode).toBe(0);
+
+ // The edits to a-dep are not committed, so its copy stays.
+ expect({
+ committed: isLink(committed),
+ uncommitted: isLink(uncommitted),
+ edit: await Bun.file(join(uncommitted, "edit.js")).text(),
+ nodeModules: readdirSync(join(packageDir, "node_modules")).sort(),
+ }).toEqual({
+ committed: true,
+ uncommitted: false,
+ edit: "module.exports = 'in progress';\n",
+ nodeModules: [".bin", ".bun", "a-dep", "has-bin-entries"],
+ });
+
+ const loaded = await load(packageDir);
+ expect(loaded.stderr).toBe("");
+ expect(loaded.stdout).toBe("1.0.0 true 1.0.0\n");
+ expect(loaded.exitCode).toBe(0);
+
+ // bin-with-require.js requires "no-deps" as well.
+ const bin = await runBun(packageDir, "run", "has-bin-entries-with-require");
+ expect(bin.stdout).toBe("no-deps\n1.0.0\n");
+ expect(bin.exitCode).toBe(0);
+
+ // The copy is gone. A second --commit must not write a patch of the link.
+ const patchFile = join(packageDir, "patches", "has-bin-entries@1.0.0.patch");
+ const patchText = await Bun.file(patchFile).text();
+ const again = await runBun(packageDir, "patch", "--commit", commitArg);
+ expect(again.stderr).toContain(NOT_PREPARED);
+ expect(again.exitCode).toBe(1);
+ expect(await Bun.file(patchFile).text()).toBe(patchText);
+ });
+ });
+
+ test.concurrent("refuses to commit a link that bun patch did not replace", async () => {
+ const packageDir = await install(false, rootOnly);
+ const packageJson = await Bun.file(join(packageDir, "package.json")).text();
+
+ for (const arg of ["has-bin-entries", "node_modules/has-bin-entries"]) {
+ const commit = await runBun(packageDir, "patch", "--commit", arg);
+ expect(commit.stderr).toContain(`${NOT_PREPARED}note: Run \`bun patch ${arg}\` first\n`);
+ expect(commit.exitCode).toBe(1);
+ }
+
+ expect({
+ patches: existsSync(join(packageDir, "patches")),
+ packageJson: await Bun.file(join(packageDir, "package.json")).text(),
+ }).toEqual({ patches: false, packageJson });
+ });
+
+ test.concurrent("links the folder again when --commit finds no changes", async () => {
+ const packageDir = await install(false, rootOnly);
+ const packageJson = await Bun.file(join(packageDir, "package.json")).json();
+ const copy = await patch(packageDir, "has-bin-entries", "node_modules/has-bin-entries");
+
+ const commit = await runBun(packageDir, "patch", "--commit", "has-bin-entries");
+ expect(commit.stderr).not.toContain("error:");
+ expect(commit.stdout).toContain("No changes detected");
+ expect(commit.exitCode).toBe(0);
+
+ expect({
+ copy: isLink(copy),
+ patches: existsSync(join(packageDir, "patches")),
+ packageJson: await Bun.file(join(packageDir, "package.json")).json(),
+ }).toEqual({ copy: true, patches: false, packageJson });
+ const loaded = await load(packageDir);
+ expect(loaded.stderr).toBe("");
+ expect(loaded.stdout).toBe("1.0.0 undefined 1.0.0\n");
+ expect(loaded.exitCode).toBe(0);
+ });
+
+ // One patch exists per name@version, but the edits in the second copy were never diffed.
+ test.concurrent("keeps a second copy of the same package that was not committed", async () => {
+ const packageDir = await install(false, {
+ "package.json": JSON.stringify({
+ name: "foo",
+ workspaces: ["packages/*"],
+ dependencies: { "has-bin-entries": "1.0.0" },
+ }),
+ packages: {
+ w: {
+ "package.json": JSON.stringify({ name: "w", version: "1.0.0", dependencies: { "has-bin-entries": "1.0.0" } }),
+ },
+ },
+ });
+ const committed = await patch(packageDir, "has-bin-entries", "node_modules/has-bin-entries");
+ const other = "packages/w/node_modules/has-bin-entries";
+ const uncommitted = await patch(packageDir, other, other);
+ await addPatchedExport(committed);
+ await Bun.write(join(uncommitted, "edit.js"), "module.exports = 'in progress';\n");
+
+ const commit = await runBun(packageDir, "patch", "--commit", "has-bin-entries");
+ expect(commit.stderr).not.toContain("error:");
+ expect(commit.exitCode).toBe(0);
+
+ expect({
+ committed: isLink(committed),
+ uncommitted: isLink(uncommitted),
+ edit: await Bun.file(join(uncommitted, "edit.js")).text(),
+ }).toEqual({
+ committed: true,
+ uncommitted: false,
+ edit: "module.exports = 'in progress';\n",
+ });
+ });
+
+ // The postinstall script of a workspace package runs in every install, this one included.
+ test.concurrent("links the dependency of a workspace package again, before its scripts run", async () => {
+ const packageDir = await install(false, {
+ "package.json": JSON.stringify({
+ name: "foo",
+ workspaces: ["packages/*"],
+ dependencies: { "has-bin-entries": "2.0.0", w: "workspace:*" },
+ }),
+ packages: {
+ w: {
+ "package.json": JSON.stringify({
+ name: "w",
+ version: "1.0.0",
+ dependencies: { "has-bin-entries": "1.0.0" },
+ scripts: { postinstall: `${bunExe()} postinstall.js` },
+ }),
+ "postinstall.js": `require("fs").writeFileSync("postinstall.txt", String(require("has-bin-entries").patched));`,
+ },
+ },
+ });
+ const postinstall = join(packageDir, "packages", "w", "postinstall.txt");
+ expect(await Bun.file(postinstall).text()).toBe("undefined");
+ // `node_modules/w` is a link to packages/w, so this is packages/w/node_modules/has-bin-entries.
+ const folder = "node_modules/w/node_modules/has-bin-entries";
+ await addPatchedExport(await patch(packageDir, "has-bin-entries@1.0.0", folder));
+
+ const commit = await runBun(packageDir, "patch", "--commit", folder);
+ expect(commit.stderr).not.toContain("error:");
+ expect(commit.exitCode).toBe(0);
+
+ expect(isLink(join(packageDir, "packages", "w", "node_modules", "has-bin-entries"))).toBe(true);
+ expect(await Bun.file(postinstall).text()).toBe("true");
+ const loaded = await load(join(packageDir, "packages", "w"));
+ expect(loaded.stderr).toBe("");
+ expect(loaded.stdout).toBe("1.0.0 true 1.0.0\n");
+ expect(loaded.exitCode).toBe(0);
+ });
+
+ // The hoisted layout nests no-deps@1.0.0 under has-bin-entries, so its copy lands inside the first copy.
+ test.concurrent("keeps the edited copy when it holds the copy of a nested package", async () => {
+ const packageDir = await install(false, {
+ "package.json": JSON.stringify({
+ name: "foo",
+ dependencies: { "has-bin-entries": "1.0.0", "no-deps": "2.0.0" },
+ }),
+ });
+ const copy = await patch(packageDir, "has-bin-entries", "node_modules/has-bin-entries");
+ await addPatchedExport(copy);
+
+ const nested = "node_modules/has-bin-entries/node_modules/no-deps";
+ const prepare = await runBun(packageDir, "patch", "no-deps@1.0.0");
+ expect(prepare.stderr).not.toContain("error:");
+ expect(prepare.stdout).toContain(`edit the following folder:\n\n ${nested}\n`);
+ expect(prepare.exitCode).toBe(0);
+ await Bun.write(join(packageDir, nested, "edit.js"), "module.exports = 'in progress';\n");
+
+ const commit = await runBun(packageDir, "patch", "--commit", "has-bin-entries");
+ expect(commit.stderr).not.toContain("error:");
+ expect(commit.exitCode).toBe(0);
+
+ expect({
+ copy: isLink(copy),
+ edit: await Bun.file(join(packageDir, nested, "edit.js")).text(),
+ }).toEqual({ copy: false, edit: "module.exports = 'in progress';\n" });
+ });
+
+ // git prints `Binary files ... differ` for it, and the patch parser drops that file.
+ test.concurrent("keeps the edited copy when the diff has a binary file", async () => {
+ const packageDir = await install(false, rootOnly);
+ const copy = await patch(packageDir, "has-bin-entries", "node_modules/has-bin-entries");
+ await addPatchedExport(copy);
+ await Bun.write(join(copy, "data.bin"), new Uint8Array([0, 1, 2, 0, 255, 254, 0, 10]));
+
+ const commit = await runBun(packageDir, "patch", "--commit", "has-bin-entries");
+ expect(commit.stderr).toContain("The patch does not include the changes to them in");
+ expect(commit.exitCode).toBe(0);
+
+ expect({ copy: isLink(copy), data: existsSync(join(copy, "data.bin")) }).toEqual({ copy: false, data: true });
+ });
+
+ // A patch that adds a symlink does not apply (`bad_file_mode`). Windows is skipped: git records
+ // a symlink as mode 120000 only with `core.symlinks`, which is off there by default.
+ test.skipIf(isWindows).concurrent("keeps the edited copy when the patch does not apply", async () => {
+ const packageDir = await install(false, rootOnly);
+ const copy = await patch(packageDir, "has-bin-entries", "node_modules/has-bin-entries");
+ symlinkSync("index.js", join(copy, "link.js"));
+
+ const commit = await runBun(packageDir, "patch", "--commit", "has-bin-entries");
+ expect(commit.stderr).toContain("failed to patch package: has-bin-entries@1.0.0");
+ expect(commit.exitCode).toBe(1);
+
+ expect({ copy: isLink(copy), edit: isLink(join(copy, "link.js")) }).toEqual({ copy: false, edit: true });
+ });
+});
+
describe("bun patch ", async () => {
describe("workspace interactions", async () => {
/**