diff --git a/docs/pm/cli/pm.mdx b/docs/pm/cli/pm.mdx index 94b615753b85..d695267e4930 100644 --- a/docs/pm/cli/pm.mdx +++ b/docs/pm/cli/pm.mdx @@ -332,6 +332,8 @@ To clear Bun's global module cache: bun pm cache rm ``` +Pass `--dry-run` to print the directories that would be deleted without deleting them. + ## migrate To migrate another package manager's lockfile without installing anything: @@ -340,6 +342,8 @@ To migrate another package manager's lockfile without installing anything: bun pm migrate ``` +Pass `--dry-run` to run the migration without writing `bun.lock`. + ## untrusted To print current untrusted dependencies with scripts: @@ -366,6 +370,7 @@ bun pm trust Options for the `trust` command: - `--all`: Trust all untrusted dependencies. +- `--dry-run`: Print the scripts that would run and the packages that would be trusted, without running scripts or writing `package.json` and `bun.lock`. ## default-trusted @@ -406,6 +411,7 @@ Options: --message=, -m Custom commit message, use %s for version substitution --preid= Prerelease identifier (i.e beta → 1.0.1-beta.0) --force, -f Bypass dirty git history check + --dry-run Print the new version without writing, running scripts, or tagging Examples: bun pm version patch @@ -423,7 +429,7 @@ bun pm version patch v1.0.1 ``` -Supports `patch`, `minor`, `major`, `premajor`, `preminor`, `prepatch`, `prerelease`, `from-git`, or specific versions like `1.2.3`. By default it creates a git commit and tag; pass `--no-git-tag-version` to skip them. +Supports `patch`, `minor`, `major`, `premajor`, `preminor`, `prepatch`, `prerelease`, `from-git`, or specific versions like `1.2.3`. By default it creates a git commit and tag; pass `--no-git-tag-version` to skip them. Pass `--dry-run` to print the new version without writing `package.json`, running lifecycle scripts, or touching git. ## pkg @@ -459,3 +465,5 @@ bun pm pkg delete scripts.test contributors[0] # multiple/nested # fix bun pm pkg fix # auto-fix common issues ``` + +`set`, `delete`, and `fix` accept `--dry-run`, which prints the resulting `package.json` instead of writing it. diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 3e473b67b8c3..30bdef15522a 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -309,7 +309,7 @@ pub enum LockfileFormat { } impl LockfileFormat { - pub(crate) fn filename(self) -> &'static ZStr { + pub fn filename(self) -> &'static ZStr { match self { LockfileFormat::Text => zstr!("bun.lock"), LockfileFormat::Binary => zstr!("bun.lockb"), @@ -396,7 +396,7 @@ impl<'a> LoadResult<'a> { } } - pub(crate) fn save_format(&self, options: &PackageManagerOptions) -> LockfileFormat { + pub fn save_format(&self, options: &PackageManagerOptions) -> LockfileFormat { match self { LoadResult::NotFound => { // saving a lockfile for a new project. default to text lockfile diff --git a/src/runtime/cli/link_command.rs b/src/runtime/cli/link_command.rs index 37e454a1ce58..f987ce3023ee 100644 --- a/src/runtime/cli/link_command.rs +++ b/src/runtime/cli/link_command.rs @@ -2,7 +2,7 @@ use bstr::BStr; use bun_core::strings; use bun_core::{Global, Output}; -use bun_paths::AbsPath; +use bun_paths::{AbsPath, platform, resolve_path}; use bun_resolver::fs::FileSystem; use bun_sys::{Dir, Fd, FdDirExt}; @@ -112,6 +112,22 @@ fn link(ctx: command::Context) -> crate::Result<()> { // from `package_json_source` (dropped above). let name = lockfile.str(&package.name); + if manager.options.dry_run { + if manager.options.log_level != LogLevel::Silent { + let link_path = resolve_path::join_abs_string_z::( + pm::global_link_dir_path(manager), + &[name], + ); + bun_core::prettyln!( + "dry run: would link \"{}\" at {}", + BStr::new(name), + BStr::new(link_path.as_bytes()), + ); + } + Output::flush(); + Global::exit(0); + } + // Step 2. Setup the global directory let node_modules: Dir = 'brk: { bin::Linker::ensure_umask(); @@ -169,7 +185,6 @@ fn link(ctx: command::Context) -> crate::Result<()> { #[cfg(windows)] { use bun_core::ZStr; - use bun_paths::{platform, resolve_path}; // create the junction let top_level = FileSystem::instance().top_level_dir_without_trailing_slash(); let mut link_path_buf = bun_paths::path_buffer_pool::get(); diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 4dbcc945eea9..cbc065f7a298 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -220,7 +220,8 @@ impl PackageManagerCommand { bun pm migrate migrate another package manager's lockfile without installing anything\n\ bun pm untrusted print current untrusted dependencies with scripts\n\ bun pm trust names ... run scripts for untrusted dependencies and add to `trustedDependencies`\n\ - └ --all trust all untrusted dependencies\n\ + ├ --all trust all untrusted dependencies\n\ + └ --dry-run print the scripts that would run, without running them or saving\n\ bun pm default-trusted print the default trusted dependencies list\n\ \n\ Learn more about these at https://bun.com/docs/cli/pm.\n"; @@ -436,38 +437,47 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; && strings::eql_comptime(pm.options.positionals[1], b"rm") { let mut had_err = false; + let dry_run = pm.options.dry_run; let mut process_env = bun_dotenv::Loader::init(); process_env.load_process()?; let cache_dir = fetch_cache_directory_path(&mut process_env, None); - let mut rm_buf = bun_paths::path_buffer_pool::get(); - let rm_dir = match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { - Ok(d) => d, - Err(err) => { - bun_core::pretty_errorln!( - "{} getting cache directory", - crate::Error::from(err).name(), - ); - Global::crash(); - } - }; - let rm_path = match rm_dir.get_fd_path(&mut rm_buf) { - Ok(p) => &p[..], - Err(err) => { - bun_core::pretty_errorln!( - "{} getting cache directory", - crate::Error::from(err).name(), - ); - Global::crash(); - } - }; - rm_dir.close(); + if dry_run { + bun_core::prettyln!( + "dry run: would delete 'bun install' cache at {}", + bstr::BStr::new(&cache_dir.path), + ); + } else { + let mut rm_buf = bun_paths::path_buffer_pool::get(); + let rm_dir = + match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { + Ok(d) => d, + Err(err) => { + bun_core::pretty_errorln!( + "{} getting cache directory", + crate::Error::from(err).name(), + ); + Global::crash(); + } + }; + let rm_path = match rm_dir.get_fd_path(&mut rm_buf) { + Ok(p) => &p[..], + Err(err) => { + bun_core::pretty_errorln!( + "{} getting cache directory", + crate::Error::from(err).name(), + ); + Global::crash(); + } + }; + rm_dir.close(); - if let Err(err) = bun_sys::delete_tree_absolute(rm_path) { - Output::err(err, "Could not delete {s}", (bstr::BStr::new(rm_path),)); - had_err = true; + if let Err(err) = bun_sys::delete_tree_absolute(rm_path) { + Output::err(err, "Could not delete {s}", (bstr::BStr::new(rm_path),)); + had_err = true; + } + bun_core::prettyln!("Cleared 'bun install' cache"); } - bun_core::prettyln!("Cleared 'bun install' cache"); 'bunx: { let tmp = Fs::RealFS::platform_temp_dir(); @@ -516,7 +526,14 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; }; let name = entry.name.slice_u8(); if name.starts_with(prefix.as_slice()) { - if let Err(err) = tmp_dir.delete_tree(name) { + if dry_run { + bun_core::prettyln!( + "dry run: would delete {}{}{}", + bstr::BStr::new(strings::without_trailing_slash(tmp)), + std::path::MAIN_SEPARATOR, + bstr::BStr::new(name), + ); + } else if let Err(err) = tmp_dir.delete_tree(name) { Output::err(err, "Could not delete {s}", (bstr::BStr::new(name),)); had_err = true; continue; @@ -526,7 +543,11 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; } } - bun_core::prettyln!("Cleared {} cached 'bunx' packages", deleted); + if dry_run { + bun_core::prettyln!("Would clear {} cached 'bunx' packages", deleted); + } else { + bun_core::prettyln!("Cleared {} cached 'bunx' packages", deleted); + } } Global::exit(if had_err { 1 } else { 0 }); @@ -742,6 +763,18 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; Global::exit(1); } Self::handle_load_lockfile_errors(&load_lockfile, log_level); + // SAFETY: `pm_raw` singleton; `options` is CLI config set at init. + let options = unsafe { &(*pm_raw).options }; + if options.dry_run { + if log_level != LogLevel::Silent { + bun_core::prettyln!( + "dry run: would write {}", + bstr::BStr::new(load_lockfile.save_format(options).filename().as_bytes()), + ); + } + Output::flush(); + Global::exit(0); + } // Reshaped for borrowck — `save_to_disk` needs // `&mut Lockfile` (self) and `&LoadResult` simultaneously, but // `LoadResultOk.lockfile` already holds the only `&mut` into the diff --git a/src/runtime/cli/pm_pkg_command.rs b/src/runtime/cli/pm_pkg_command.rs index 9c4700446669..ab8d71a7bed1 100644 --- a/src/runtime/cli/pm_pkg_command.rs +++ b/src/runtime/cli/pm_pkg_command.rs @@ -326,14 +326,14 @@ impl PmPkgCommand { } if modified { - Self::save_package_json(&path, root, &pkg)?; + Self::save_package_json(&path, root, &pkg, pm.options.dry_run)?; } Ok(()) } fn exec_delete( ctx: &Context, - _pm: &mut PackageManager, + pm: &mut PackageManager, args: &[&[u8]], cwd: &[u8], ) -> Result<(), Error> { @@ -369,12 +369,12 @@ impl PmPkgCommand { } if modified { - Self::save_package_json(&path, root, &pkg)?; + Self::save_package_json(&path, root, &pkg, pm.options.dry_run)?; } Ok(()) } - fn exec_fix(ctx: &Context, _pm: &mut PackageManager, cwd: &[u8]) -> Result<(), Error> { + fn exec_fix(ctx: &Context, pm: &mut PackageManager, cwd: &[u8]) -> Result<(), Error> { let path = Self::find_package_json(cwd)?; let pkg = Self::load_package_json(ctx, &path)?; @@ -426,7 +426,7 @@ impl PmPkgCommand { } if modified { - Self::save_package_json(&path, root, &pkg)?; + Self::save_package_json(&path, root, &pkg, pm.options.dry_run)?; } Ok(()) } @@ -825,7 +825,12 @@ impl PmPkgCommand { Ok(true) } - fn save_package_json(path: &[u8], root: Expr, pkg: &PackageJson) -> Result<(), Error> { + fn save_package_json( + path: &[u8], + root: Expr, + pkg: &PackageJson, + dry_run: bool, + ) -> Result<(), Error> { let preserve_newline = !pkg.contents.is_empty() && pkg.contents[pkg.contents.len() - 1] == b'\n'; @@ -853,6 +858,14 @@ impl PmPkgCommand { } let content = writer.ctx.written_without_trailing_zero(); + if dry_run { + let _ = Output::writer().write_all(content); + if !preserve_newline { + let _ = Output::writer().write_all(b"\n"); + } + Output::flush(); + return Ok(()); + } let path_z = bun_core::ZBox::from_bytes(path); if let Err(e) = bun_sys::File::write_file(bun_sys::Fd::cwd(), path_z.as_zstr(), content) { Output::err_generic( diff --git a/src/runtime/cli/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index d03bd50c0d34..368ece9ef74f 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -238,6 +238,47 @@ impl TrustCommand { } } + fn print_dry_run( + lockfile: &Lockfile, + scripts_at_depth: &ArrayHashMap>, + package_names_to_add: &StringArrayHashMap<()>, + ) { + let buf = lockfile.buffers.string_bytes.as_slice(); + let resolutions = lockfile.packages.items_resolution(); + let mut total_scripts: usize = 0; + let mut total_packages: usize = 0; + + Output::print(format_args!("\n")); + for entry in scripts_at_depth.values().iter().rev() { + for info in entry.iter() { + if info.skip { + continue; + } + total_packages += 1; + total_scripts += info.scripts_list.total as usize; + info.scripts_list.print_scripts( + &resolutions[info.package_id as usize], + buf, + PrintFormat::Untrusted, + ); + Output::print(format_args!("\n")); + } + } + + bun_core::prettyln!( + "dry run: would run {} script{} across {} package{}", + total_scripts, + if total_scripts != 1 { "s" } else { "" }, + total_packages, + if total_packages != 1 { "s" } else { "" }, + ); + bun_core::prettyln!("dry run: would add to trustedDependencies:"); + for name in package_names_to_add.keys() { + bun_core::pretty!(" - {}\n", bstr::BStr::new(name)); + } + Output::flush(); + } + pub(crate) fn exec( ctx: Command::Context, pm: &mut PackageManager, @@ -439,6 +480,12 @@ impl TrustCommand { Global::crash(); } + // SAFETY: `pm_raw` singleton; `options` is CLI config set at init. + if unsafe { (*pm_raw).options.dry_run } { + Self::print_dry_run(lockfile, &scripts_at_depth, &package_names_to_add); + return Ok(()); + } + let mut scripts_node: Progress::Node; // SAFETY: `pm_raw` singleton; `progress` is owned inline. let show_progress = unsafe { (*pm_raw).options.log_level.show_progress() }; diff --git a/src/runtime/cli/pm_version_command.rs b/src/runtime/cli/pm_version_command.rs index caa1e9e1ece2..4f0498a8726f 100644 --- a/src/runtime/cli/pm_version_command.rs +++ b/src/runtime/cli/pm_version_command.rs @@ -133,7 +133,8 @@ impl PmVersionCommand { Global::exit(1); } - let scripts = if pm.options.do_.run_scripts() { + let dry_run = pm.options.dry_run; + let scripts = if pm.options.do_.run_scripts() && !dry_run { json.as_property(b"scripts") } else { None @@ -193,6 +194,12 @@ impl PmVersionCommand { } } + if dry_run { + Output::print(format_args!("v{}\n", BStr::new(&new_version_str))); + Output::flush(); + return Ok(()); + } + { json.data .e_object_mut() @@ -469,6 +476,7 @@ impl PmVersionCommand { \x20 --message=\\, -m Custom commit message, use %s for version substitution\n\ \x20 --preid=\\ Prerelease identifier (i.e beta → {})\n\ \x20 --force, -f Bypass dirty git history check\n\ + \x20 --dry-run Print the new version without writing, running scripts, or tagging\n\ \n\ Examples:\n\ \x20 $ bun pm version patch\n\ diff --git a/src/runtime/cli/unlink_command.rs b/src/runtime/cli/unlink_command.rs index bac275fdb361..fa4510777136 100644 --- a/src/runtime/cli/unlink_command.rs +++ b/src/runtime/cli/unlink_command.rs @@ -112,10 +112,13 @@ fn unlink(ctx: &mut ContextData) -> crate::Result<()> { // `lockfile.buffers.string_bytes`. let name = lockfile.str(&package.name); - match sys::lstat(resolve_path::join_abs_string_z::( + let dry_run = manager.options.dry_run; + let log_level = manager.options.log_level; + let link_path = resolve_path::join_abs_string_z::( global_link_dir_path(manager), &[name], - )) { + ); + match sys::lstat(link_path) { Ok(stat) => { if !sys::S::ISLNK(stat.st_mode as _) { bun_core::pretty_errorln!( @@ -134,6 +137,18 @@ fn unlink(ctx: &mut ContextData) -> crate::Result<()> { } } + if dry_run { + if log_level != LogLevel::Silent { + bun_core::prettyln!( + "dry run: would unlink \"{}\" ({})", + BStr::new(name), + BStr::new(link_path.as_bytes()), + ); + } + Output::flush(); + Global::exit(0); + } + // Step 2. Setup the global directory let node_modules: Dir = 'brk: { bin::Linker::ensure_umask(); diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index 3ccbfb3b4c3f..53a9d8a505a7 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -3761,6 +3761,61 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) { assertManifestsPopulated(join(packageDir, ".bun-cache"), verdaccio.registryUrl()); }); + test("bun pm trust --dry-run runs no scripts and writes nothing", async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env; + + await writeFile( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + "uses-what-bin": "1.5.0", + }, + }), + ); + + let { stderr, exited } = spawn({ + cmd: [bunExe(), "i"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env: testEnv, + }); + + let err = await stderr.text(); + expect(err).toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(await exited).toBe(0); + expect(await exists(join(packageDir, "node_modules", "uses-what-bin", "what-bin.txt"))).toBeFalse(); + + const packageJsonBefore = await file(packageJson).text(); + const lockfileBefore = await file(join(packageDir, "bun.lock")).text(); + + const proc = spawn({ + cmd: [bunExe(), "pm", "trust", "uses-what-bin", "--dry-run"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env: testEnv, + }); + + const [out, dryErr, dryExit] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(dryErr).toContain("bun pm trust"); + expect(dryErr).not.toContain("error:"); + expect(out).toContain("[install]: what-bin"); + expect(out).toContain("dry run: would run 1 script across 1 package"); + expect(out).toContain("dry run: would add to trustedDependencies:"); + expect(out).toContain(" - uses-what-bin"); + expect(out).not.toContain("script ran"); + expect(dryExit).toBe(0); + + expect(await exists(join(packageDir, "node_modules", "uses-what-bin", "what-bin.txt"))).toBeFalse(); + expect(await file(packageJson).text()).toBe(packageJsonBefore); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfileBefore); + }); + test("bun pm trust and untrusted on missing package", async () => { using ctx = await setupTest(); const { packageDir, packageJson, env } = ctx; diff --git a/test/cli/install/bun-link.test.ts b/test/cli/install/bun-link.test.ts index 8a937dad63fd..5a7e51526720 100644 --- a/test/cli/install/bun-link.test.ts +++ b/test/cli/install/bun-link.test.ts @@ -1,12 +1,13 @@ import { file, spawn } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "bun:test"; -import { access, mkdir, writeFile } from "fs/promises"; +import { access, exists, mkdir, writeFile } from "fs/promises"; import { bunExe, bunEnv as env, isWindows, readdirSorted, runBunInstall, + tempDir, tmpdirSync, toBeValidBin, toHaveBins, @@ -471,3 +472,48 @@ it("should link dependency without crashing", async () => { // This should fail with a non-zero exit code. expect(await exited4).toBe(1); }); + +it("link and unlink --dry-run do not touch the global link directory", async () => { + using dir = tempDir("link-dry-run", { + "package.json": JSON.stringify({ name: "linkme-dry", version: "1.0.0" }), + }); + const bunInstall = join(String(dir), "bun-install"); + const globalLink = join(bunInstall, "install", "global", "node_modules", "linkme-dry"); + const testEnv = { ...env, BUN_INSTALL: bunInstall }; + + const run = async (...cmd: string[]) => { + await using proc = spawn({ + cmd: [bunExe(), ...cmd], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env: testEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }; + + let res = await run("link", "--dry-run"); + expect(res.stderr).not.toContain("error"); + expect(res.stdout).toContain(`dry run: would link "linkme-dry" at ${globalLink}`); + expect(res.stdout).not.toContain("Registered"); + expect(res.exitCode).toBe(0); + expect(await exists(globalLink)).toBeFalse(); + + res = await run("link"); + expect(res.stdout).toContain(`Success! Registered "linkme-dry"`); + expect(res.exitCode).toBe(0); + expect(await exists(globalLink)).toBeTrue(); + + res = await run("unlink", "--dry-run"); + expect(res.stderr).not.toContain("error"); + expect(res.stdout).toContain(`dry run: would unlink "linkme-dry"`); + expect(res.stdout).not.toContain("unlinked package"); + expect(res.exitCode).toBe(0); + expect(await exists(globalLink)).toBeTrue(); + + res = await run("unlink"); + expect(res.stdout).toContain(`success: unlinked package "linkme-dry"`); + expect(res.exitCode).toBe(0); + expect(await exists(globalLink)).toBeFalse(); +}); diff --git a/test/cli/install/bun-pm-pkg.test.ts b/test/cli/install/bun-pm-pkg.test.ts index 42f573f655d4..8ff5507a50f0 100644 --- a/test/cli/install/bun-pm-pkg.test.ts +++ b/test/cli/install/bun-pm-pkg.test.ts @@ -372,6 +372,40 @@ describe.concurrent("bun pm pkg", () => { }); }); + describe("--dry-run", () => { + it("set prints the result without writing package.json", async () => { + using dir = makeTestDir(); + const before = await readPkg(dir); + const { output, error, code } = await runPmPkg(["set", "description=New description", "--dry-run"], dir); + expect(error).toBe(""); + expect(JSON.parse(output)).toMatchObject({ name: "test-package", description: "New description" }); + expect(code).toBe(0); + expect(await readPkg(dir)).toEqual(before); + }); + + it("delete prints the result without writing package.json", async () => { + using dir = makeTestDir(); + const before = await readPkg(dir); + const { output, error, code } = await runPmPkg(["delete", "description", "--dry-run"], dir); + expect(error).toBe(""); + expect(JSON.parse(output).description).toBeUndefined(); + expect(code).toBe(0); + expect(await readPkg(dir)).toEqual(before); + }); + + it("fix prints the result without writing package.json", async () => { + using dir = tempDir("pm-pkg-fix-dry-run", { + "package.json": JSON.stringify({ name: "TEST-PACKAGE", version: "1.0.0" }, null, 2), + }); + const before = await readPkg(String(dir)); + const { output, error, code } = await runPmPkg(["fix", "--dry-run"], String(dir)); + expect(error).toBe(""); + expect(JSON.parse(output)).toEqual({ name: "test-package", version: "1.0.0" }); + expect(code).toBe(0); + expect(await readPkg(String(dir))).toEqual(before); + }); + }); + describe("delete command", () => { it("should delete a property", async () => { using dir = makeTestDir(); diff --git a/test/cli/install/bun-pm-version.test.ts b/test/cli/install/bun-pm-version.test.ts index a89ea0f84ed9..03d36ab48a37 100644 --- a/test/cli/install/bun-pm-version.test.ts +++ b/test/cli/install/bun-pm-version.test.ts @@ -487,6 +487,58 @@ describe.concurrent("bun pm version", () => { }); }); + describe("--dry-run", () => { + it("prints the new version without writing package.json or running scripts", async () => { + await using testDir = tempDir(`version-${i++}`, { + "package.json": JSON.stringify( + { + name: "test", + version: "1.0.0", + scripts: { + preversion: "echo 'step1' >> lifecycle.log", + version: "echo 'step2' >> lifecycle.log", + postversion: "echo 'step3' >> lifecycle.log", + }, + }, + null, + 2, + ), + }); + + const { output, error, code } = await runCommand( + [bunExe(), "pm", "version", "minor", "--dry-run", "--no-git-tag-version"], + testDir, + ); + + expect(error.trim()).toBe(""); + expect(output.trim()).toBe("v1.1.0"); + expect(code).toBe(0); + + const pkg = await Bun.file(join(testDir, "package.json")).json(); + expect(pkg.version).toBe("1.0.0"); + expect(await Bun.file(join(testDir, "lifecycle.log")).exists()).toBe(false); + }); + + it("does not commit or tag in a git repository", async () => { + const testDir = await setupGitTest(); + + const { output, error, code } = await runCommand([bunExe(), "pm", "version", "patch", "--dry-run"], testDir); + + expect(error.trim()).toBe(""); + expect(output.trim()).toBe("v1.0.1"); + expect(code).toBe(0); + + const pkg = await Bun.file(join(testDir, "package.json")).json(); + expect(pkg.version).toBe("1.0.0"); + + const { output: tagOutput } = await runCommand(["git", "tag", "-l"], testDir); + expect(tagOutput.trim()).toBe(""); + + const { output: logOutput } = await runCommand(["git", "log", "--oneline"], testDir); + expect(logOutput).not.toContain("v1.0.1"); + }); + }); + describe("JSON formatting preservation", () => { it("preserves JSON formatting correctly", async () => { const originalJson1 = `{ diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 8ab93b18ba94..345d117e6106 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1084,3 +1084,36 @@ test("bun pm cache rm does not create the directory named by a project-local .en expect(stderr).not.toContain("error"); expect(exitCode).toBe(0); }); + +test("bun pm cache rm --dry-run prints the cache directory and deletes nothing", async () => { + using dir = tempDir("pm-cache-rm-dry-run", { + "package.json": JSON.stringify({ name: "cache-rm-dry-run", version: "1.0.0" }), + "bun-install/install/cache/cached-package.txt": "cached artifact", + }); + const dirStr = String(dir); + const bunInstallDir = join(dirStr, "bun-install"); + const realCacheDir = join(bunInstallDir, "install", "cache"); + + const spawnEnv: NodeJS.Dict = { + ...env, + BUN_INSTALL: bunInstallDir, + XDG_CACHE_HOME: join(dirStr, "xdg-cache"), + HOME: dirStr, + }; + delete spawnEnv.BUN_INSTALL_CACHE_DIR; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "cache", "rm", "--dry-run"], + cwd: dirStr, + stdout: "pipe", + stderr: "pipe", + env: spawnEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("error"); + expect(stdout).toInclude(`dry run: would delete 'bun install' cache at ${realCacheDir}`); + expect(stdout).not.toInclude("Cleared"); + expect(exitCode).toBe(0); + expect(await exists(join(realCacheDir, "cached-package.txt"))).toBeTrue(); +}); diff --git a/test/cli/install/migration/migrate.test.ts b/test/cli/install/migration/migrate.test.ts index 096021f8ecf3..b24250b4c02a 100644 --- a/test/cli/install/migration/migrate.test.ts +++ b/test/cli/install/migration/migrate.test.ts @@ -823,6 +823,31 @@ describe("package-lock.json migration fixes", () => { const sha = (n: number) => Buffer.alloc(39, "0").toString() + n; + test.concurrent("pm migrate --dry-run does not write bun.lock", async () => { + const dependencies = { a: "github:user/a" }; + using dir = synthetic("npm-migrate-dry-run", { + "package.json": JSON.stringify({ name: "dry-run", dependencies }), + "package-lock.json": JSON.stringify({ + name: "dry-run", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "dry-run", dependencies }, + "node_modules/a": { version: "1.0.0", resolved: `git+ssh://git@github.com/user/a.git#${sha(1)}` }, + }, + }), + }); + + const { stdout, stderr, exitCode } = await run(dir, "pm", "migrate", "--dry-run"); + expect(stderr).toContain("migrated lockfile from package-lock.json"); + expect(stdout).toContain("dry run: would write bun.lock"); + expect(exitCode).toBe(0); + expect(await Bun.file(join(String(dir), "bun.lock")).exists()).toBe(false); + + const { text } = await migrate(dir); + expect(text).toContain(`a@github:user/a#${sha(1)}`); + }); + test.concurrent("git hosts round-trip (B1, github: parity)", async () => { const dependencies = { a: "github:user/a",