pm ls: list a workspace the root also depends on once
Problem
bun pm lswithout--allprints a root dependency once per entry the root has for it. A workspaceathat is also in the root'sdevDependenciesprints twice under anode_modules (1)header. So does a name declared in two groups (typescriptin #8283's example).- Cause:
src/runtime/cli/package_manager_command.rs:644prints the root's dependency list as is: one entry per declaration plus one per workspace member.node_moduleshas one folder per name.
Fix
- Stable sort the root's entries by name and drop the later entries of a name (
dedup_by).--trustedfilters the result. - Correct because one name is one
node_modules/<name>folder, the same collapseTree::hoist_dependencyapplies. The list is in the tree builder's order, so the entry kept is the one the tree kept. An alias has its own name and keeps its row. The listing does not read the stored tree, so an olderbun.lockbprints as before (Notes). - Verified:
test/cli/install/bun-pm.test.ts. Three new tests (bun.lock, bun.lockb,--trusted) fail on the released bun. One more pins that a root optional peer bound to a dependency's copy stays listed. On this repo's lockfile the only change is the removed@types/bunrow.
Background
- Each package owns one range of
buffers.dependencies. The root's range holds its declared dependencies plus oneBehavior::WORKSPACEentry per workspace member, sorted by group and name. Entries of one name share one folder. - The hoisted tree (
buffers.trees,buffers.hoisted_dependencies) modelsnode_modules: one tree per folder, one entry per name.--allprints it and the header(N)counts its entries. The default listing prints only the root's range.
Notes
Repro without a registry:
mkdir -p d/packages/a && cd d
echo '{"name":"root","workspaces":["packages/*"],"devDependencies":{"a":"workspace:*"}}' > package.json
echo '{"name":"a","version":"1.0.0"}' > packages/a/package.json
bun install && bun pm ls
Released bun prints a@workspace:packages/a on two lines, three when a is also in dependencies. bun pm ls --all prints it once. bun pm ls --trusted with trustedDependencies: ["a"] prints it twice. In this repo bun pm ls prints @types/bun@workspace:packages/@types/bun twice.
- The first version of this PR printed the tree's root folder filtered to the root's id range. A self-review found a case it drops: a root optional peer that a dependency provides. When the tree builder binds that peer late, it stores the folder entry under the provider's dependency id (
Tree.rs,ResolveReplace). bun 1.4.0 re-hoists on load and on a second pass stores the root's own id, but abun.lockbwritten by bun 1.3.x keeps the provider's id, and bun does not rewrite a lockfile that needs no changes. Checked with abun.lockbwritten by bun 1.3.14 from theoptional-peer-hoist-providerand-targetregistry fixtures: the tree version listed only the provider, the current version lists both, as 1.3.14 and the released 1.4.0 do. The new optional peer test covers the fresh lockfile shape of this case. - Dedupe by package id was rejected:
barandbar-alias: npm:barshare one package id but are two folders. The test keeps both rows. - The
--trustedtest uses bun.lock only. bun.lockb stores only hashes oftrustedDependencies, andhas_trusted_dependencydoes not trust a hash alone since #31339, so--trustedlists nothing from a bun.lockb. Released bun does the same. Not related to this change. - A real conflict (workspace
aplus"a": "file:..."at the root) does not reachpm lstoday:bun installwrites a duplicate package path and the lockfile fails to load (the area of #33156). - #8283 asks for grouped output and
--json. This PR removes the duplicate row in its example and nothing else, so it does not close that issue. - Open PRs that touch this branch and do not fix this: #38923 (filter by what is on disk), #38952 (header wording). The fold branch of #39403 has the same loop.
- Suites run with the debug build:
test/cli/install/bun-pm.test.ts(22 pass),bun-install-lifecycle-scripts.test.ts -t trusted(55 pass),bun-install-registry.test.ts -t "migration is out of sync",test/regression/issue/24502.cargo fmt --checkis clean.bun pm ls --allon this repo is unchanged byte for byte.
[review] gate passed · iteration 0 · 2 files touched
fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-pm.test.ts
bun test v1.4.0 (6e906e468)
test/cli/install/bun-pm.test.ts:
(pass) should list top-level dependency [386.82ms]
(pass) should list all dependencies [333.90ms]
(pass) should list top-level aliased dependency [329.02ms]
(pass) should list aliased dependencies [346.24ms]
(pass) should list only trusted dependencies with --trusted [464.07ms]
(pass) should list only trusted dependencies with --all --trusted [339.68ms]
(pass) should list trusted transitive dependencies under untrusted parents with --all --trusted (isolated) [356.01ms]
(pass) should list nothing with --trusted when no dependencies are trusted [346.58ms]
597 | expect(await exists(join(package_dir, lockfile))).toBeTrue();
598 | urls.length = 0;
599 |
600 | const [stdout, stderr, exitCode] = await spawnAndCollect("pm", "ls");
601 | expect(stderr).toBe("");
602 | expect(normalizeBunSnapshot(stdout, package_dir)).toMatchInlineSnapshot(`
^
error: expect(received).toMatchInlineSnaps
... (truncated)
release without fix: 3 FAILED
bun test v1.4.0-canary.1 (6e906e468)
test/cli/install/bun-pm.test.ts:
(pass) should list top-level dependency [17.59ms]
(pass) should list all dependencies [15.04ms]
(pass) should list top-level aliased dependency [18.13ms]
(pass) should list aliased dependencies [26.89ms]
(pass) should list only trusted dependencies with --trusted [29.53ms]
(pass) should list only trusted dependencies with --all --trusted [30.36ms]
(pass) should list trusted transitive dependencies under untrusted parents with --all --trusted (isolated) [19.13ms]
(pass) should list nothing with --trusted when no dependencies are trusted [20.57ms]
597 | expect(await exists(join(package_dir, lockfile))).toBeTrue();
598 | urls.length = 0;
599 |
600 | const [stdout, stderr, exitCode] = await spawnAndCollect("pm", "ls");
601 | expect(stderr).toBe("");
602 | expect(normalizeBunSnapshot(stdout, package_dir)).toMatchInlineSnapshot(`
^
error: expect(received).toMatchInlineSnapshot(expected)
"<dir> node_modules (5)
├── bar@0.0.2
+ ├── bar@0.0.2
├── bar-alias@0.0.2
├── ws-once@workspace:packages/ws-once
+
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-pm.test.ts
bun test v1.4.0 (6e906e468)
test/cli/install/bun-pm.test.ts:
(pass) should list top-level dependency [381.86ms]
(pass) should list all dependencies [333.18ms]
(pass) should list top-level aliased dependency [351.49ms]
(pass) should list aliased dependencies [347.67ms]
(pass) should list only trusted dependencies with --trusted [463.21ms]
(pass) should list only trusted dependencies with --all --trusted [348.99ms]
(pass) should list trusted transitive dependencies under untrusted parents with --all --trusted (isolated) [321.16ms]
(pass) should list nothing with --trusted when no dependencies are trusted [309.86ms]
(pass) should list a workspace the root also depends on once (bun.lock) [541.88ms]
(pass) should list a workspace the root also depends on once (bun.lockb) [314.14ms]
(pass) should list a trusted workspace the root also depends on once with --trusted [315.39ms]
(pass) should list a root optional peer that a dependency provides [328.74ms]
(pass) should remove all cache [465.82ms]
(pass) bun pm m
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 683ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)
[1m[92m Compiling[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
[1m[92m Compiling[0m bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
[1m[92m Finished[0m `release` profile [optimized + debuginfo] target(s) in 4m 36s
[2/5] link bun-profile
[4/5] strip bun
[4/5] bun-profile --revision
1.4.0-canary.1+b263001b0
[build] done
bun test v1.4.0-canary.1 (b263001b0)
test/cli/install/bun-pm.test.ts:
(pass) should list top-level dependency [15.40ms]
(pass) should list all dependencies [11.19ms]
(pass) should list top-level aliased dependency [9.40ms]
(pass) should list aliased dependencies [9.71ms]
(pass) should list only trusted dependencies with --trusted [10.18ms]
(pass) should list on
... (truncated)
diff hotspot
src/runtime/cli/package_manager_command.rs | 8 +-
test/cli/install/bun-pm.test.ts | 150 ++++++++++++++++++++++++++++-
2 files changed, 152 insertions(+), 6 deletions(-)
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
file reads edits tests
src/runtime/cli/package_manager_command.rs 5 4 0
test/cli/install/bun-pm.test.ts 10 9 0
root cause · written by the author bot
The root package's dependency list receives one entry per workspace member plus one per declared dependency, so a workspace that the root also declares as a dependency has two entries resolving to the same package, and the default bun pm ls loop printed each of them. The fix sorts the root's entries by name with a stable sort and then collapses adjacent entries with the same name, so each package is printed once, while the --trusted filtering in the same branch is unaffected.