Running a Script Only in Changed Packages
Running a script across every workspace package wastes time when only a few changed. This page shows how to scope script execution to the packages affected by your diff.
Exact symptoms and error messages
$ pnpm -r run test
# runs tests in all 40 packages even though one changed
Scope: 40 of 40 workspace projects
Root cause analysis
pnpm -r and npm --workspaces fan a script across the whole set with no notion of what changed. To run only affected packages you need a change filter that consults git plus the dependency graph, exactly the affected-execution model behind pnpm workspace filtering and the CI pipeline optimization cluster, and it builds on script scope from Root-Level vs Package-Level Scripts.
pnpm -r and npm --workspaces fan a script across the entire set with no notion of what changed, so a one-package edit still runs the task in all forty. To scope execution you need a change filter that consults git for the diff and the workspace graph for dependents. That combination — changed packages plus everything that imports them — is the same affected-execution model behind pnpm workspace filtering.
Getting the base ref right is the precondition. The filter diffs your working tree against a base branch, and if CI uses a shallow clone that lacks the base commit, the diff cannot be computed and the filter falls back to selecting everything — silently undoing the speed-up. Full history plus an explicit base is what makes change-aware selection reliable.
Resolution and configuration patch
Use a change-aware selector so only the diff's packages (and their dependents) run:
# pnpm: filter to packages changed since main, plus dependents
pnpm --filter '...[origin/main]' run test
# turborepo equivalent
pnpm turbo run test --filter='...[origin/main]'
Commit a root script that encodes the base ref so contributors do not have to remember it.
CLI validation and debug commands
# Preview which packages the filter selects
pnpm --filter '...[origin/main]' exec pwd
# Confirm the base ref is reachable
git fetch origin main && pnpm --filter '...[origin/main]' run test
Prevention and CI guardrails
- Fetch full history in CI so the change base resolves.
- Include dependents (
...[ref]) so a change to a library also tests its consumers. - Encode the base ref in a committed script to keep runs consistent.
- Fall back to a full run when a root-level file (lockfile, tsconfig) changes.
Why dependents must be included
Scoping to only the changed package is a trap: a change to a shared library can break every package that imports it, so running the task on just the library misses those regressions. The correct set is the changed package plus its dependents — the packages whose behavior could change because of the edit.
# ...[ref] expands to changed packages AND their dependents
pnpm --filter '...[origin/main]' run test
The leading ... is what adds the dependents; without it you test only the thing you edited and ship its consumers untested. This is why the workspace graph has to be accurate — an undeclared cross-package import is an edge the filter cannot see, so a real dependent is silently excluded. Change-aware selection is only as trustworthy as the graph it walks, which makes declaring internal dependencies properly a correctness requirement, not just tidiness.
Falling back to a full run when a global file changes
Change-aware selection is the fast path, but some changes legitimately affect everything and must trigger a full run. A change to the root lockfile, a shared tsconfig.base.json, or the CI config can alter any package's behavior, so scoping to 'changed packages' would incorrectly skip most of the work.
The robust pattern is to detect those global files and widen to a full run when one is touched:
if git diff --name-only origin/main | grep -qE '^(pnpm-lock.yaml|tsconfig.base.json)$'; then
pnpm -r run test # global change: run everything
else
pnpm --filter '...[origin/main]' run test
fi
This keeps everyday changes fast while ensuring a change to a truly global input is never under-tested — the same 'legitimate full run versus accidental one' distinction that governs monorepo CI as a whole.
Frequently Asked Questions
Why include dependents in the filter?
A change to a library can break packages that import it, so testing only the changed library misses regressions. The ...[ref] form adds dependents, which is the correct set to validate.
Does this need a task runner?
pnpm's --filter handles it natively; a task runner adds caching on top. Either way you need accurate workspace metadata so the graph traversal finds the right dependents.
Why include dependents, not just the changed package?
Because a change to a shared library can break packages that import it. Testing only the library misses those regressions; the ...[ref] form adds dependents, which is the correct set to validate.
Why does my change filter sometimes run everything?
Usually a shallow clone with no base commit, so the diff can't be computed and the filter falls back to all packages. Fetch full history and pass an explicit base. A change to a global file (lockfile, base tsconfig) also correctly triggers a full run.
Does change-aware selection need a task runner?
No — pnpm's --filter handles it natively; a task runner just adds caching on top. Either way you need an accurate workspace graph so the dependent traversal finds the right packages.
Related
- Root-Level vs Package-Level Scripts — how script scope and delegation work across a workspace.