diff --git a/src/install/PackageManager/PackageJSONEditor.rs b/src/install/PackageManager/PackageJSONEditor.rs index 88773115f6a9..f8ed292acb1b 100644 --- a/src/install/PackageManager/PackageJSONEditor.rs +++ b/src/install/PackageManager/PackageJSONEditor.rs @@ -1336,9 +1336,9 @@ pub(crate) fn edit( // derived from a `StoreRef` to the same `E::EString` is live inside this loop body, // so this is the sole mutable borrow. let e_string = unsafe { &mut *e_string }; - // `bun update ` keeps a `catalog:` reference; `bun add` still replaces it. + // `bun update ` only moves registry entries, like `edit_update_entries`; `bun add` still replaces any entry. if manager.subcommand == Subcommand::Update - && dependency::Tag::infer(e_string.data.slice()) == dependency::Tag::Catalog + && !dependency::Tag::infer(e_string.data.slice()).is_npm() { continue; } @@ -1451,6 +1451,8 @@ pub(crate) fn edit( arena_dup(arena, installed) } + // A range that linked a workspace member has nothing to move to; `workspace:*` is what `bun add` writes. + resolution::Tag::Workspace if manager.subcommand == Subcommand::Update => continue, resolution::Tag::Workspace => b"workspace:*", _ => arena_dup(arena, request.version.literal.slice(request.version_buf())), }; diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index da5691de7ee9..9170295be5f3 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -880,6 +880,10 @@ impl Lockfile { let resolved_ids: &[PackageID] = res_list.get(self.buffers.resolutions.as_slice()); debug_assert_eq!(resolved_ids.len(), workspace_deps.len()); for (&package_id, dep) in resolved_ids.iter().zip(workspace_deps.iter()) { + // The root's implicit `workspaces` rows are not package.json entries; bind to the entry naming the package. + if dep.behavior.is_workspace() { + continue; + } if update.matches(dep, string_buf) { if package_id as usize > self.packages.len() { continue; diff --git a/test/cli/install/bun-update-lockfile-sync.test.ts b/test/cli/install/bun-update-lockfile-sync.test.ts index d83f6eb72cf7..1064a9671139 100644 --- a/test/cli/install/bun-update-lockfile-sync.test.ts +++ b/test/cli/install/bun-update-lockfile-sync.test.ts @@ -280,6 +280,71 @@ describe.concurrent("bun update rewrites bun.lock together with package.json", ( await expectInSync(dir); }); + // Naming a workspace member used to rewrite the entry linking it to `workspace:*` (or, with --latest / an explicit + // range, to send the name to the registry), and to add an entry to a root that did not declare the member. + test.each([ + ["workspace:^", ["pkg1"]], + ["workspace:~", ["pkg1"]], + ["workspace:1.0.0", ["pkg1"]], + ["^1.0.0", ["pkg1"]], + ["workspace:^", ["pkg1", "--latest"]], + ["workspace:^", ["pkg1@^1.0.0"]], + ["workspace:^", ["pkg1@latest"]], + ])("a %s entry linking a workspace member is kept as written by bun update %j", async (literal, args) => { + const dir = await setup(MONOREPO({}, { dependencies: { pkg1: literal } })); + const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir), lockText(dir)]); + await run(dir, "update", ...args); + expect(await pkgText(dir)).toBe(pkgBefore); + expect(await lockText(dir)).toBe(lockBefore); + }); + + // Same rule for the other non-registry kinds: the registry has a no-deps, so naming this entry with --latest or an + // explicit spec used to replace the folder with the registry package (exit 0). + test.each([[["no-deps"]], [["no-deps", "--latest"]], [["no-deps@^1.0.0"]], [["no-deps@latest"]]])( + "a file: entry is kept as written by bun update %j", + async args => { + const dir = await setup({ + "package.json": root({ dependencies: { "no-deps": "file:./local-no-deps" } }), + "local-no-deps/package.json": { name: "no-deps", version: "1.0.0" }, + }); + const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir), lockText(dir)]); + await run(dir, "update", ...args); + expect(await pkgText(dir)).toBe(pkgBefore); + expect(await lockText(dir)).toBe(lockBefore); + expect(await installed(dir, "no-deps")).toMatchObject({ version: "1.0.0" }); + }, + ); + + test("bun update from a member declaring it keeps the entry as written", async () => { + const dir = await setup(WORKSPACES({}, { pkg1: {}, pkg2: { dependencies: { pkg1: "workspace:~" } } })); + const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir, PKG2), lockText(dir)]); + await runIn(dir, PKG2, "update", "pkg1"); + expect(await pkgText(dir, PKG2)).toBe(pkgBefore); + expect(await lockText(dir)).toBe(lockBefore); + }); + + test("bun update -r keeps every workspace's entry as written", async () => { + const dir = await setup( + WORKSPACES( + { dependencies: { pkg1: "workspace:^" } }, + { pkg1: {}, pkg2: { dependencies: { pkg1: "workspace:1.0.0" } } }, + ), + ); + const [rootBefore, pkg2Before, lockBefore] = await Promise.all([pkgText(dir), pkgText(dir, PKG2), lockText(dir)]); + await run(dir, "update", "pkg1", "-r"); + expect(await pkgText(dir)).toBe(rootBefore); + expect(await pkgText(dir, PKG2)).toBe(pkg2Before); + expect(await lockText(dir)).toBe(lockBefore); + }); + + test("bun update does not add it to a root that does not declare it", async () => { + const dir = await setup(MONOREPO()); + const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir), lockText(dir)]); + await run(dir, "update", "pkg1"); + expect(await pkgText(dir)).toBe(pkgBefore); + expect(await lockText(dir)).toBe(lockBefore); + }); + test.each([[[]], [["--latest"]]])("bun update %j leaves folder, tarball and workspace literals alone", async args => { const dependencies = { "no-deps": "^1.0.0",