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 () => { /**