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.
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.
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
zodchangespnpm-lock.yamland maybe onepackage.json. Packages that usezodthrough 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
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 ... listso reviewers can see what ran. - Keep a scheduled full run on
mainas 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 covers the full filter language.
- Running a Script Only in Changed Packages shows the same idea from the scripts side.
- Fixing pnpm --filter Matching No Projects debugs selectors that come back empty.
- CI/CD Pipeline Optimization for Monorepos puts affected testing in the wider CI picture.