Skip to content

run: run a package once when two "workspaces" entries match it - #43357

Open
robobun wants to merge 1 commit into
mainfrom
robobun/bec24bad/run-filter-dedup-workspace-entries
Open

robobun wants to merge 1 commit into
mainfrom
robobun/bec24bad/run-filter-dedup-workspace-entries

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun run --filter, --workspaces, --parallel and --sequential run a package's script twice when two entries of the "workspaces" array match it. With "workspaces": ["packages/*", "packages/legacy"], bun run --workspaces build prints legacy build: BUILD-legacy twice. If the script cannot run twice (mkdir out), the command exits 1.
  • The cause is select_packages (src/runtime/cli/filter_arg.rs:181). PackageFilterIterator starts one glob walk per entry. Each walk drops its own repeated matches, but nothing compares two walks. select_packages pushes each path into discovered.

Fix

  • select_packages keeps a set of the package.json paths it has seen and skips a repeated path. The check runs before the parse, so a package.json that fails to parse prints its warning once.
  • Correct because each walk returns a normalized absolute path, so one file has one spelling (packages/legacy and ./packages/legacy/ give the same path). The first match keeps its place, so the discovery order does not change.
  • bun install uses the same key (WorkspaceMap, keyed by the member path). Not in this PR: a symlink alias of a package is a different path and still runs (bun run --filter runs a package twice when a symlink that a "workspaces" glob matches points to it #43358).
  • Verified: test/cli/run/filter-workspace.test.ts (8 new tests, all fail without the fix). Also multi-run.test.ts, workspaces.test.ts and run-quote.test.ts. Self-reviewed: 3 concerns raised, 3 addressed.

Background

  • "workspaces" in the root package.json lists the member packages, as paths or globs.
  • select_packages is the discovery step that bun run --filter/--workspaces (filter_run.rs) and --parallel/--sequential (multi_run.rs) share.
  • PackageFilterIterator walks <entry>/package.json with one GlobWalker per entry, in order.
Notes

I found this by reading the code. No issue reports it. The config shape exists in public bun monorepos: the root package.json of opencode lists packages/* and packages/slack, in the {"packages": [...]} form.

Repro, offline, on 1.4.3-canary (b52d513) and main (367d939):

W=$(mktemp -d); cd $W
mkdir -p packages/app packages/legacy
echo '{"name":"app","scripts":{"build":"echo BUILD-app"}}' > packages/app/package.json
echo '{"name":"legacy","scripts":{"build":"echo BUILD-legacy"}}' > packages/legacy/package.json
echo '{"name":"root","private":true,"workspaces":["packages/*","packages/legacy"]}' > package.json
bun run --workspaces build     # "legacy build: BUILD-legacy" twice
bun run --filter '*' build     # same
bun run --parallel --workspaces build   # "legacy:build | BUILD-legacy" twice
bun install && grep -c '"packages/legacy"' bun.lock   # 1

With "build": "mkdir out" in both packages, the second run of legacy fails with mkdir: cannot create directory 'out': File exists and bun run --filter '*' build exits 1. With the fix it exits 0.

Other "workspaces" values that gave a duplicate before the fix, all checked by hand:

  • ["packages/*", "packages/l*"], ["packages/*", "packages/**"], ["packages/*", "packages/{legacy,app}"]
  • ["packages/legacy", "packages/legacy"]
  • ["packages/*", "./packages/legacy"], ["packages/*", "packages/legacy/"], ["packages/*", "packages//legacy"], ["packages/*", "packages/../packages/legacy"]
  • ["packages/*", "!!packages/legacy"]
  • the object form {"packages": ["packages/*", "packages/legacy"]}

With BUN_DEBUG_Glob=1 on a debug build, each of the spellings above logs the same prepared match: <root>/packages/legacy/package.json from both walks. The walks run in absolute mode, and GlobWalker::join normalizes the path in that mode. This is why the yielded path works as the key with no more normalization.

The key is the byte-exact path, like bun install. Two paths that differ only in letter case on a case-insensitive file system are two keys in both places.

Why the set sits in select_packages and not in PackageFilterIterator::next: the set then covers each path the iterator returns, whatever produces it. Other open work on this code:

Not in this PR:

The self-review raised three points. State how the bug was found and why it matters (the exit 1). List the open PRs that touch this loop, and track what this PR leaves out (#43358, #43359). Cover the {"packages": [...]} form in the tests. This version has all three.

Suites run with the debug build: test/cli/run/filter-workspace.test.ts (95 pass, 2 skip on Linux), test/cli/run/multi-run.test.ts (127 pass), test/cli/run/workspaces.test.ts, test/cli/run/run-quote.test.ts, test/regression/issue/26207.test.ts, test/regression/issue/31636.test.ts.

`bun run --filter`, `--workspaces`, `--parallel` and `--sequential` walk
each "workspaces" entry on its own. Two entries that match the same
package.json, for example "packages/*" and "packages/legacy", put the
package in the list twice, so its script ran twice.

`select_packages` now skips a package.json path it already has. The
first match keeps its place, so the discovery order does not change.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 2 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 2 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 8 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 7b2374db-10f1-4bf7-b00d-2aa16535ba8c

📥 Commits

Reviewing files that changed from the base of the PR and between 367d939 and 2862856.

📒 Files selected for processing (2)
  • src/runtime/cli/filter_arg.rs
  • test/cli/run/filter-workspace.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:38 PM PT - Sep 18th, 2026

✅ @robobun, your commit 28628568abb058159dc51dbeebe0fba0a792b5ab passed in Build #117929! 🎉


🧪   To try this PR locally:

bunx bun-pr 43357

That installs a local version of the PR into your bun-43357 executable, so you can run:

bun-43357 --bun

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How I reproduced it: I ran the script from the Notes section of the description on bun 1.4.3-canary.1 (b52d513) and on a debug build of main (367d939). bun run --workspaces build, bun run --filter '*' build and bun run --parallel --workspaces build each ran legacy twice. With this branch each command runs it once.

The 8 new tests in test/cli/run/filter-workspace.test.ts fail on the two builds without the fix and pass on this branch.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — a small, self-contained dedup in select_packages with tests covering both entry points and both workspaces shapes.

What was reviewed:

  • The seen_paths check in src/runtime/cli/filter_arg.rs: PackageFilterIterator runs each walk with absolute = true, so GlobWalker::join goes through bun_join and yields a normalized path — byte-exact keying on it is sound for the ./ and trailing-slash spellings the tests exercise.
  • get_or_put(...)? routes AllocError through crate::Error::Alloc(#[from]); the StringHashMap<()> dedup idiom matches pack_command.rs.
  • Considered whether a workspace entry written as an absolute path would still run twice — that is base-branch behavior untouched by this diff, not a regression here.
  • The new tests spawn 8 subprocesses under describe.concurrent, drain stdout/stderr/exit concurrently, and assert a combined object; echo runs through bun's shell as in the file's existing fixtures.
Extended reasoning...

Overview

The PR touches two files. In src/runtime/cli/filter_arg.rs::select_packages it adds a StringHashMap<()> keyed on the package.json path yielded by PackageFilterIterator and continues when a path was already seen, placed before the root-skip check, the PackageJSON::parse, and the discovered.push. In test/cli/run/filter-workspace.test.ts it adds a describe.concurrent block with a fixture (root + packages/app + packages/legacy), a run() helper that counts ran-* tokens in stdout, a test.each over four overlapping workspaces shapes with --filter '*', a test.each over --workspaces, --parallel --workspaces, and --sequential --filter '*', and a test asserting the parse-failure warning is emitted once.

Security risks

None identified. The change only narrows which already-discovered paths get parsed and executed; it adds no new parsing of user data, no new I/O, and no new path construction. The map key is the walker's own output, and allocation failure propagates as an error rather than panicking.

Level of scrutiny

Low-to-moderate. The functional change is five lines with an obvious invariant. The one non-obvious dependency — that two differently-spelled workspaces entries resolve to a byte-identical key — I checked against GlobWalker::init_with_cwd (the fourth argument, absolute, is true here) and GlobWalker::join, which uses the normalizing bun_join in absolute mode. The test case ["packages/app", "packages/legacy", "./packages/legacy/"] directly exercises that path, so a normalization gap would surface as a CI failure rather than a silent miss. I could not run the suite locally (no debug build present in this checkout), so correctness of the tests rests on reading plus the author's stated run; the tests are structured so they can only pass when each package runs exactly once and the exit code is 0.

Other factors

The ruled-out candidate (absolute-path workspaces entries still running twice) is pre-existing base-branch behavior and outside what this diff claims to fix, so it does not block. The test additions follow the file's existing conventions (tempDir with using, bunExe()/bunEnv, concurrent pipe draining, combined-object assertions, echo scripts through bun's shell), cover both CLI entry points (filter_run and multi_run) and both workspaces forms, and are cheap (8 short subprocesses, concurrent). No CODEOWNERS entry covers the changed paths, the bug-hunt exit reason was dry_streak with no findings, and no third-party objections appear in the timeline.

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

I checked the absolute path case from the review above on bun 1.4.3-canary.1 (b52d513) and on this branch.

  • An absolute entry with a glob is covered. With "workspaces": ["<root>/packages/*", "packages/legacy"], stock bun runs legacy twice and this branch runs it once. BUN_DEBUG_Glob=1 shows that the two walks return the same path, <root>/packages/legacy/package.json.
  • An absolute entry with no glob ("<root>/packages/legacy") does not run twice. bun run --filter and --workspaces fail with error: Unexpected before any script runs, with and without this change. bun install accepts the same entry. The cause is in DirEntryAccessor::openat, not in this loop, so I filed it as bun run --filter fails with error: Unexpected when a "workspaces" entry is an absolute path #43360.

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

#43372 includes this change and adds a dedupe by real path for a directory symlink that a "workspaces" glob also matches (#43358). The two PRs edit the same loop in select_packages. If this one merges first, #43372 rebases cleanly on it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant