Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Filtering Packages Changed Since a Git Ref

pnpm can select workspace packages by what changed in git, without any task runner: --filter "[origin/main]" picks packages with changed files since origin/main, and the ... prefix and suffix extend the selection to dependents or dependencies. Used well, it turns a thirty-minute "test everything" pipeline into a few minutes for typical pull requests. Used carelessly, it skips packages that were affected by a change outside their folder — a lockfile update, a root config file, a shared script. This guide explains the selector syntax, the ways changed-file detection can miss things, and a CI setup that stays fast without being wrong.

The selector syntax

A changed-since selector is a git ref in square brackets, optionally combined with graph operators:

Selector Selects
[origin/main] packages with changed files since origin/main
...[origin/main] changed packages and their dependents
[origin/main]... changed packages and their dependencies
...^[origin/main] only the dependents of changed packages, not the packages themselves
@acme/web...[origin/main] @acme/web and its dependencies, limited to those that changed

For testing, ...[origin/main] is the usual choice: if @acme/ui changed, every package that depends on it might break, so their tests must run too. The general filter language is covered in pnpm Workspace Filtering.

Which packages ...[origin/main] selects The ui package changed; its dependents forms and web are selected through the dependents operator; utils and api are not selected because they neither changed nor depend on ui. @acme/utils not selected @acme/ui changed @acme/api not selected @acme/forms dependent: selected @acme/web dependent: selected
The ... prefix adds every package downstream of a change; unrelated packages are skipped.

Running it locally and in CI

# Test everything affected by your branch
pnpm --filter "...[origin/main]" run test

# Build what changed and everything it needs, then test dependents
pnpm --filter "[origin/main]..." run build
pnpm --filter "...[origin/main]" run test

# Show what would be selected, without running anything
pnpm --filter "...[origin/main]" list --depth -1

In CI, the base ref must exist locally, which shallow clones break:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0                     # full history so origin/main and the merge base exist
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: .nvmrc, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - name: Test affected packages
        run: pnpm --filter "...[origin/${{ github.base_ref }}]" run test

For pushes to main, compare with the previous commit ([HEAD~1]) or, better, with the last commit that passed CI, which you can store as a tag or fetch from your CI system's API.

Affected testing with pnpm in a pull request Check out with full history, install, compute the selection from the diff against the base branch, then run tests in the selected packages in dependency order. checkout fetch-depth: 0 pnpm install frozen lockfile diff vs base git diff origin/main...HEAD run selected ...[origin/main] test
The base branch must be present locally; a shallow checkout makes every selector fail or select nothing.

How changed files map to packages

pnpm runs a git diff between the ref and the working tree, then assigns each changed file to the workspace package whose folder contains it. That rule is simple and fast, and it has predictable blind spots:

  • Files outside any package — the root package.json, pnpm-lock.yaml, tsconfig.base.json, .github/workflows, shared scripts — belong to no package (or only to the root project), so changes to them select nothing downstream.
  • Lockfile-only dependency changes — a Renovate update to zod changes pnpm-lock.yaml and maybe one package.json. Packages that use zod through a catalog or through the lockfile alone are not selected.
  • Generated or ignored files do not appear in git diffs at all.

pnpm offers two controls for the first problem:

# Changes to test files select only their own package, not its dependents
pnpm --filter "...[origin/main]" --test-pattern "**/*.test.ts" run test

# Ignore changes that cannot affect behaviour (docs, markdown)
pnpm --filter "...[origin/main]" --changed-files-ignore-pattern "**/*.md" run test

--test-pattern marks files that, when changed, select only the package itself rather than its dependents (useful to avoid re-running dependents' tests when only a test file changed). --changed-files-ignore-pattern drops files like documentation from the change set. For root-level configuration, the robust answer is a guard step: if any file outside apps/ and packages/ changed, run everything.

if git diff --name-only "origin/main...HEAD" | grep -qvE '^(apps|packages)/'; then
  echo "Root-level change detected: running all packages"
  pnpm -r run test
else
  pnpm --filter "...[origin/main]" run test
fi
What changed-file filtering catches and misses Compares kinds of changes on whether pnpm's changed-since filter selects the right packages and what safeguard to add. selected correctly? safeguard source file in a package yes, plus dependents none needed test file in a package selects dependents too -test-pattern docs / markdown triggers needless runs -changed-files-ignore-pattern root config, lockfile nothing selected run all on root changes catalog version bump nothing selected run all when pnpm-workspace.yaml changes
Changes inside package folders are handled well; root files and lockfile-only updates need a guard.

Choosing the right base ref

The ref inside the brackets decides what "changed" means, and the obvious choice is not always right.

Pull requests should compare with the merge base between the branch and its target, not with the target's current tip. If main has moved on since the branch was created, diffing against origin/main directly attributes every change on main to your branch and selects packages you never touched. Compute the merge base explicitly and pass it in:

BASE=$(git merge-base origin/main HEAD)
pnpm --filter "...[$BASE]" run test

Pushes to main are harder. [HEAD~1] covers only the last commit, so a merge of several commits or a push that bundles multiple merges can miss changes. A more reliable base is the last commit on main for which CI passed — the approach Nx's nx-set-shas action takes. Store it as a lightweight tag (ci/last-green) that the pipeline moves forward on success, and filter against that tag.

Release branches and long-lived feature branches should compare with the branch point for their pull requests and with their own last green commit for pushes, following the same two rules.

Whatever you choose, print the base ref and the resulting package list at the start of the job. When a pipeline skips something it should not have, the first question is always "what did it compare against?", and the log should answer it without anyone rerunning the job.

Combining changed-since filters with task ordering

Selection and ordering are separate concerns. A filter chooses which packages run; pnpm still orders them topologically within the selection. That matters when you build and test in one command: if @acme/ui changed and @acme/web depends on it, pnpm --filter "...[origin/main]" run build builds ui first and web second. But it will not build a dependency that is outside the selection — for example, an unchanged @acme/utils that ui needs — unless you add the dependencies operator on the other side (...[origin/main]...) or build the whole workspace first. For repositories whose packages consume each other's build output, run a broader build (or a cached one) before the filtered test step.

When to use a task runner instead

pnpm's filter is file-based. Turborepo and Nx go further: they include lockfile-resolved dependency versions and declared global inputs in their affected calculations, and they cache task results so unchanged packages are skipped even on main. If your pipeline needs caching or precise handling of dependency updates, see Fixing Slow Monorepo CI with Affected Builds and Choosing a Monorepo Task Runner. For small and medium repositories, pnpm's filter plus a root-change guard is often all you need, as described in Running a Monorepo with Plain pnpm Scripts.

Worked example: a skipped test run after a dependency update

A Renovate pull request bumps date-fns in packages/utils/package.json and the lockfile. CI runs pnpm --filter "...[origin/main]" test, which selects utils and its dependents — correct. A week later another Renovate pull request bumps date-fns again, but this time only in the lockfile (the range already allowed it). No package folder changed, the filter selects nothing, CI passes with zero tests, and a breaking change in a minor release reaches main. The team adds the root-change guard, which treats a changed pnpm-lock.yaml as a reason to run everything, and the next lockfile-only update runs the full suite and catches the break.

Prevention and CI/CD guardrails

  • Always fetch full history (or at least the merge base) for changed-since filters.
  • Run everything when files outside packages change, including the lockfile.
  • Log the selection with pnpm --filter ... list so reviewers can see what ran.
  • Keep a scheduled full run on main as a backstop for anything file-based detection misses.

Frequently Asked Questions

Why does the filter select nothing in CI? Usually a shallow clone: the base ref is missing, so there is nothing to diff against. Use fetch-depth: 0, or fetch the base branch explicitly before running pnpm.

Does [origin/main] compare with the merge base or the tip of main? pnpm diffs against the given ref. Use a three-dot style base — the merge base of your branch and main — if main has moved on, so changes that landed on main after you branched are not attributed to your branch.

Can I combine a changed-since filter with a name filter? Yes. --filter "./apps/**...[origin/main]" or multiple --filter flags narrow or widen the selection; see Using pnpm --filter for Targeted Builds.

Does the filter consider uncommitted changes? Yes. pnpm diffs the ref against the working tree, so local uncommitted edits count as changes. That is useful locally — run tests for what you are editing — and irrelevant in CI, where the working tree matches the commit.

How do I exclude a package from a changed-since selection? Add a negative filter: --filter "...[origin/main]" --filter "!@acme/docs". Exclusions apply after the positive selection is computed.

Related

pnpm Workspace Filtering