From 95f3d765fcf5da04bd7fb9d2b330395040cd60e4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:16:30 +0000 Subject: [PATCH 1/5] install: keep workspace member entries as written on bun update The named update path wrote "workspace:*" over any entry that resolved to a workspace member ("workspace:^", "workspace:~", "workspace:1.2.3", a plain range linked to the member), and added such an entry to a root that did not declare the member, because the root's dependency rows include one row per workspace member and the never-add check was keyed on whether the request had been bound to a row. Under update, skip the write-back for workspace resolutions and never create a slot; bun add still writes "workspace:*". --- .../PackageManager/PackageJSONEditor.rs | 18 +++----- .../install/bun-update-lockfile-sync.test.ts | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/install/PackageManager/PackageJSONEditor.rs b/src/install/PackageManager/PackageJSONEditor.rs index 88773115f6a9..b5b37e5ab8bf 100644 --- a/src/install/PackageManager/PackageJSONEditor.rs +++ b/src/install/PackageManager/PackageJSONEditor.rs @@ -1131,14 +1131,9 @@ pub(crate) fn edit( } // `bun update ` never adds ``: a name this file does not declare only moves in the lockfile. - let update_in_place = manager.subcommand == Subcommand::Update; - if update_in_place { - remaining -= updates - .iter() - .filter(|request| { - request.e_string.is_none() && request.package_id == INVALID_PACKAGE_ID - }) - .count(); + // (`package_id` is no substitute for this check: the root's rows include every workspace member, declared or not.) + if manager.subcommand == Subcommand::Update { + remaining = 0; } if remaining != 0 { @@ -1160,9 +1155,7 @@ pub(crate) fn edit( }; for request in updates.iter_mut() { - if request.e_string.is_some() - || (update_in_place && request.package_id == INVALID_PACKAGE_ID) - { + if request.e_string.is_some() { continue; } @@ -1451,6 +1444,9 @@ pub(crate) fn edit( arena_dup(arena, installed) } + // `bun update ` leaves an entry that links a workspace member as written (`workspace:^`, + // `workspace:1.2.3`, a plain range), like the bare update does; `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/test/cli/install/bun-update-lockfile-sync.test.ts b/test/cli/install/bun-update-lockfile-sync.test.ts index d83f6eb72cf7..e2e768cbec0f 100644 --- a/test/cli/install/bun-update-lockfile-sync.test.ts +++ b/test/cli/install/bun-update-lockfile-sync.test.ts @@ -280,6 +280,51 @@ 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:*`, and to add one to a root that did not declare it. + test.each(["workspace:^", "workspace:~", "workspace:1.0.0", "^1.0.0"])( + "bun update keeps a %s entry as written", + async literal => { + const dir = await setup(MONOREPO({}, { dependencies: { pkg1: literal } })); + 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); + await expectInSync(dir, ["", PKG1]); + }, + ); + + 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); + await expectInSync(dir, ["", PKG1, PKG2]); + }); + + 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); + await expectInSync(dir, ["", PKG1, PKG2]); + }); + + 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", From 1b4c23dde7dc5c95b90b2c6be3cd6b8d1f19777a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:25:08 +0000 Subject: [PATCH 2/5] install: keep workspace: entries out of both named-update passes; bind requests to the declared row The pass that runs before the install rewrote a workspace: entry to "latest" (--latest) or to an explicit @, so skipping it only after the install was not enough. Extend the literal check that already keeps catalog: references to workspace: ranges, which covers both passes. bind_update_requests now skips the root's implicit workspaces rows, so a request is bound to the package.json entry that names it: an undeclared member stays unbound and is not added, and the write-back is keyed on the entry's own resolution rather than the member's. The earlier remaining rewrite is no longer needed and is reverted. --- .../PackageManager/PackageJSONEditor.rs | 27 ++++++++++++----- src/install/lockfile.rs | 5 ++++ .../install/bun-update-lockfile-sync.test.ts | 30 +++++++++++-------- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/install/PackageManager/PackageJSONEditor.rs b/src/install/PackageManager/PackageJSONEditor.rs index b5b37e5ab8bf..7d647d8af1a0 100644 --- a/src/install/PackageManager/PackageJSONEditor.rs +++ b/src/install/PackageManager/PackageJSONEditor.rs @@ -1131,9 +1131,14 @@ pub(crate) fn edit( } // `bun update ` never adds ``: a name this file does not declare only moves in the lockfile. - // (`package_id` is no substitute for this check: the root's rows include every workspace member, declared or not.) - if manager.subcommand == Subcommand::Update { - remaining = 0; + let update_in_place = manager.subcommand == Subcommand::Update; + if update_in_place { + remaining -= updates + .iter() + .filter(|request| { + request.e_string.is_none() && request.package_id == INVALID_PACKAGE_ID + }) + .count(); } if remaining != 0 { @@ -1155,7 +1160,9 @@ pub(crate) fn edit( }; for request in updates.iter_mut() { - if request.e_string.is_some() { + if request.e_string.is_some() + || (update_in_place && request.package_id == INVALID_PACKAGE_ID) + { continue; } @@ -1329,9 +1336,13 @@ 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 ` keeps a `catalog:` reference or a `workspace:` range as written, in both passes: + // before the install so neither --latest nor `@` replaces it; `bun add` still replaces it. if manager.subcommand == Subcommand::Update - && dependency::Tag::infer(e_string.data.slice()) == dependency::Tag::Catalog + && matches!( + dependency::Tag::infer(e_string.data.slice()), + dependency::Tag::Catalog | dependency::Tag::Workspace + ) { continue; } @@ -1444,8 +1455,8 @@ pub(crate) fn edit( arena_dup(arena, installed) } - // `bun update ` leaves an entry that links a workspace member as written (`workspace:^`, - // `workspace:1.2.3`, a plain range), like the bare update does; `workspace:*` is what `bun add` writes. + // A range or dist-tag that linked a workspace member has nothing to move to: `bun update ` + // leaves it as written, like the bare update does. `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..0e63d592c1db 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -880,6 +880,11 @@ 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 `workspaces` rows precede its package.json entries and are not editable; + // a request binds to the entry that spells its name. + 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 e2e768cbec0f..39342bc502ab 100644 --- a/test/cli/install/bun-update-lockfile-sync.test.ts +++ b/test/cli/install/bun-update-lockfile-sync.test.ts @@ -280,18 +280,24 @@ 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:*`, and to add one to a root that did not declare it. - test.each(["workspace:^", "workspace:~", "workspace:1.0.0", "^1.0.0"])( - "bun update keeps a %s entry as written", - async literal => { - const dir = await setup(MONOREPO({}, { dependencies: { pkg1: literal } })); - 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); - await expectInSync(dir, ["", PKG1]); - }, - ); + // 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); + await expectInSync(dir, ["", PKG1]); + }); test("bun update from a member declaring it keeps the entry as written", async () => { const dir = await setup(WORKSPACES({}, { pkg1: {}, pkg2: { dependencies: { pkg1: "workspace:~" } } })); From 7066cc384b370419aa290dd97e9fc38009ad7f0c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:32:05 +0000 Subject: [PATCH 3/5] install: shorten the comments on the workspace entry checks --- src/install/PackageManager/PackageJSONEditor.rs | 6 ++---- src/install/lockfile.rs | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/install/PackageManager/PackageJSONEditor.rs b/src/install/PackageManager/PackageJSONEditor.rs index 7d647d8af1a0..8086e94c5659 100644 --- a/src/install/PackageManager/PackageJSONEditor.rs +++ b/src/install/PackageManager/PackageJSONEditor.rs @@ -1336,8 +1336,7 @@ 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 or a `workspace:` range as written, in both passes: - // before the install so neither --latest nor `@` replaces it; `bun add` still replaces it. + // `bun update ` keeps a `catalog:` reference or a `workspace:` range as written; `bun add` still replaces them. if manager.subcommand == Subcommand::Update && matches!( dependency::Tag::infer(e_string.data.slice()), @@ -1455,8 +1454,7 @@ pub(crate) fn edit( arena_dup(arena, installed) } - // A range or dist-tag that linked a workspace member has nothing to move to: `bun update ` - // leaves it as written, like the bare update does. `workspace:*` is what `bun add` writes. + // 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 0e63d592c1db..9170295be5f3 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -880,8 +880,7 @@ 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 `workspaces` rows precede its package.json entries and are not editable; - // a request binds to the entry that spells its name. + // The root's implicit `workspaces` rows are not package.json entries; bind to the entry naming the package. if dep.behavior.is_workspace() { continue; } From c826a982d53a79f45ed910ee1f8d277e4a076a80 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:31:49 +0000 Subject: [PATCH 4/5] install: keep every non-registry entry out of the named update passes Use the predicate the bare update already applies (Npm or DistTag literals move, nothing else) instead of listing catalog: and workspace:, so a folder, tarball or git entry named on the command line is no longer replaced by the registry package under --latest or an explicit spec. --- .../PackageManager/PackageJSONEditor.rs | 7 ++----- .../install/bun-update-lockfile-sync.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/install/PackageManager/PackageJSONEditor.rs b/src/install/PackageManager/PackageJSONEditor.rs index 8086e94c5659..f8ed292acb1b 100644 --- a/src/install/PackageManager/PackageJSONEditor.rs +++ b/src/install/PackageManager/PackageJSONEditor.rs @@ -1336,12 +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 or a `workspace:` range as written; `bun add` still replaces them. + // `bun update ` only moves registry entries, like `edit_update_entries`; `bun add` still replaces any entry. if manager.subcommand == Subcommand::Update - && matches!( - dependency::Tag::infer(e_string.data.slice()), - dependency::Tag::Catalog | dependency::Tag::Workspace - ) + && !dependency::Tag::infer(e_string.data.slice()).is_npm() { continue; } diff --git a/test/cli/install/bun-update-lockfile-sync.test.ts b/test/cli/install/bun-update-lockfile-sync.test.ts index 39342bc502ab..4e0f41ca42df 100644 --- a/test/cli/install/bun-update-lockfile-sync.test.ts +++ b/test/cli/install/bun-update-lockfile-sync.test.ts @@ -299,6 +299,24 @@ describe.concurrent("bun update rewrites bun.lock together with package.json", ( await expectInSync(dir, ["", PKG1]); }); + // 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" }); + await expectInSync(dir); + }, + ); + 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)]); From 2776a0999d67996e77a740e3ca28ef1f5dd3fdbb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:00:46 +0000 Subject: [PATCH 5/5] test: drop the frozen reinstall from the byte-identical update cases Both files are asserted identical to what bun install just wrote, so the reinstall cannot add anything; it only adds a process per case. --- test/cli/install/bun-update-lockfile-sync.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/cli/install/bun-update-lockfile-sync.test.ts b/test/cli/install/bun-update-lockfile-sync.test.ts index 4e0f41ca42df..1064a9671139 100644 --- a/test/cli/install/bun-update-lockfile-sync.test.ts +++ b/test/cli/install/bun-update-lockfile-sync.test.ts @@ -296,7 +296,6 @@ describe.concurrent("bun update rewrites bun.lock together with package.json", ( await run(dir, "update", ...args); expect(await pkgText(dir)).toBe(pkgBefore); expect(await lockText(dir)).toBe(lockBefore); - await expectInSync(dir, ["", PKG1]); }); // Same rule for the other non-registry kinds: the registry has a no-deps, so naming this entry with --latest or an @@ -313,7 +312,6 @@ describe.concurrent("bun update rewrites bun.lock together with package.json", ( expect(await pkgText(dir)).toBe(pkgBefore); expect(await lockText(dir)).toBe(lockBefore); expect(await installed(dir, "no-deps")).toMatchObject({ version: "1.0.0" }); - await expectInSync(dir); }, ); @@ -323,7 +321,6 @@ describe.concurrent("bun update rewrites bun.lock together with package.json", ( await runIn(dir, PKG2, "update", "pkg1"); expect(await pkgText(dir, PKG2)).toBe(pkgBefore); expect(await lockText(dir)).toBe(lockBefore); - await expectInSync(dir, ["", PKG1, PKG2]); }); test("bun update -r keeps every workspace's entry as written", async () => { @@ -338,7 +335,6 @@ describe.concurrent("bun update rewrites bun.lock together with package.json", ( expect(await pkgText(dir)).toBe(rootBefore); expect(await pkgText(dir, PKG2)).toBe(pkg2Before); expect(await lockText(dir)).toBe(lockBefore); - await expectInSync(dir, ["", PKG1, PKG2]); }); test("bun update does not add it to a root that does not declare it", async () => {