Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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

Exact symptoms and error messages Exact symptoms and error messages in production JavaScript package workflows. Exact symptoms and error messages Exact symptoms and error messages in production JavaScript package workflows.
Exact symptoms and error messages — the core idea of this section at a glance.
$ 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.

Root cause analysis pnpm -r and npm --workspaces fan a script across the whole set with no notion of what changed. Root cause analysis pnpm -r and npm --workspaces fan a script across the whole set with no notion of what changed.
Root cause analysis — the core idea of this section at a glance.

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.

Running a script across every workspace package is wasteful when only a few changed, and the cause is that pnpm -r and npm --workspaces fan a task across the whole set with no notion of what changed. To run only affected packages 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 affected set, and it is the same model behind fast monorepo CI.

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, and an accurate dependency graph is what makes the dependent traversal complete.

Resolution and configuration patch

Use a change-aware selector so only the diff's packages (and their dependents) run:

Changed-only run Diff selects changed packages plus dependents to run. git diff base changed packages + dependents ...[ref] run subset skip the rest
A change filter runs only the diff's packages and everything that imports them.
# 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.

Scope the run to packages changed since a base ref, plus their dependents:

# pnpm: 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:

{ "scripts": { "test:changed": "pnpm --filter '...[origin/main]' run test" } }

The leading ... adds dependents, so a change to a shared library also runs the packages that import it — the correct set to validate.

CLI validation and debug commands

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# 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.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.
  • Fetch full git 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 so runs are 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.

Include dependents The changed package plus everything that imports it. changed package the edit + dependents ...[ref] correct set no missed break
Testing dependents catches regressions a changed-only run would ship.
# ...[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.

Scope or full run Whether a change warrants a scoped or full run. Did the change touch a global file? lockfile / base config full run shallow clone fix history package files only scoped run
A global-file change must widen to a full run; everyday edits stay scoped.

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.

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. The leading-ellipsis form (...pkg or ...[ref]) is what adds those dependents; without it you validate only the thing you edited and ship its consumers untested.

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 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 mis
Why dependents must be included — the core idea of this section at a glance.

This is why the workspace graph has to be accurate. The filter walks declared dependency edges to find dependents, so an undeclared cross-package import — a deep relative path into another package's source instead of a workspace dependency — is an edge the filter cannot see, and 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 rather than tidiness. When the graph is honest, the affected set is complete; when it is not, the run looks scoped but quietly skips packages a change could break.

Handling global changes that affect everything

Change-aware selection is the fast path, but some changes legitimately affect every package and must trigger a full run. A change to the root lockfile, a shared tsconfig.base.json, or the CI configuration can alter any package's behavior, so scoping to the changed packages would incorrectly skip most of the work. The robust pattern detects those global files and widens to a full run when one is touched, keeping everyday changes fast while ensuring a genuinely global change is never under-tested.

Scope or full run Whether a change warrants scoped or full execution. Did the change touch a global file? lockfile / base config full run shallow clone fix history package files only scoped run
A global-file change widens to a full run; everyday edits stay scoped.
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 is the same distinction that governs monorepo CI generally: a full run triggered by a global-input change is correct and expected, while a full run triggered by an ordinary package edit signals a misconfiguration — usually a shallow clone or an over-broad match. Keeping the list of global files small and explicit is what makes legitimate full runs rare and accidental ones rarer, so change-aware execution stays fast in the common case without ever skipping work a global change requires.

Combining change-aware runs with caching

Change-aware selection removes packages a change cannot reach; caching removes the work for those it can reach whose inputs have not changed. Layering the two is what makes a large workspace's scripts fast rather than merely scoped. A task runner reads the same workspace graph the filter does, so turbo run test --filter='...[origin/main]' runs only the affected packages and, among those, replays any whose inputs already have a cached result — on this machine or another through a remote cache. A one-line change then runs its package and dependents, and even among those, only the ones that genuinely changed are recomputed.

Filter plus cache Filter selects affected; cache replays unchanged. filter affected changed + dependents cache replays unchanged survivors cost tracks change fast run
Filtering narrows the set; caching short-circuits the survivors that did not change.

The two mechanisms are complementary and neither substitutes for the other. Filtering without caching still recomputes every affected package on every run; caching without filtering still checks every package in the repo even if most are irrelevant. Used together, the filter narrows the set and the cache short-circuits the unchanged survivors, so the run's cost tracks the change rather than the repository size. This is the same composition that keeps monorepo CI fast, applied to everyday local scripts: run the changed set, replay what has not changed, and the workspace stays fast to work in no matter how many packages it grows to.

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.

Why include dependents, not just the changed package?

A change to a shared library can break the packages that import it, so testing only the library misses those regressions. The ...[ref] form adds dependents, which is the correct set to validate — and it is only complete if the dependency graph is accurate.

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.

Do I need a task runner for change-aware runs?

No — pnpm's --filter '...[ref]' handles it natively; a task runner adds caching on top. Either way you need full git history so the base resolves and an accurate dependency graph so the dependent traversal is complete.

How do I make a change-aware run fast, not just scoped?

Combine the filter with a task runner's cache. The filter selects the affected packages; the cache replays any of them whose inputs are unchanged, locally or via a remote cache. Together the run's cost tracks the change rather than the repository size.

What base ref should I diff against for change-aware runs?

For a pull request, the merge base with the target branch selects exactly the PR's changes; for a push to main, the previous commit. Pass the base explicitly rather than relying on a guess, and fetch full history so it resolves.

Related

Root-Level vs Package-Level Scripts