Vercel Logo

Filtering and git-based filtering

Your CI builds everything every time. If you change one line in apps/web, CI rebuilds packages/ui, packages/utils, and apps/snippet-manager too - wasting time. You need to run tasks only for changed packages and their dependents.

Turborepo's --filter flag lets you scope tasks to specific packages. Combined with git-based filtering (--filter=...[main]), CI detects which packages changed, plus everything that depends on them, and only runs those tasks.

Outcome

Use --filter to run selective tasks and enable git-based filtering in CI for incremental builds.

Fast track

  1. Learn --filter syntax for targeting packages
  2. Use --filter=...[main] for changed packages and their dependents
  3. Update CI to use git-based filtering
  4. See massive time savings on incremental changes

Hands-on exercise 7.2

Implement selective task execution with filtering.

Requirements:

  1. Run tasks for single package: --filter @geniusgarage/web
  2. Run tasks with dependencies: --filter=@geniusgarage/web...
  3. Use git-based filtering: --filter=...[main]
  4. Update GitHub Actions to run only affected packages with --affected
  5. Test with small change to verify selective builds

Implementation hints:

  • --filter selects specific packages
  • ... before the name (...pkg) includes dependents; ... after the name (pkg...) includes dependencies
  • ^ with ... (pkg^...) leaves the target package itself out of the selection
  • [main] selects only the packages changed since main. Put ... in front (...[main]) to add the packages that depend on them

Filter syntax

Filter single package

# Build only apps/web
turbo build --filter @geniusgarage/web

Output:

• Packages in scope: @geniusgarage/web
• Running build in 1 package

@geniusgarage/web:build: cache miss, executing

Tasks:    1 successful, 1 total

Only apps/web builds!

Filter with dependencies

# Build apps/web and everything it depends on
turbo build --filter @geniusgarage/web...

Output:

• Packages in scope: @geniusgarage/ui, @geniusgarage/web
• Running build in 2 packages

@geniusgarage/ui:build        ✓
@geniusgarage/web:build       ✓

Tasks:    3 successful, 3 total

Builds web + its dependencies (ui, config).

Filter dependents

# Build packages/ui and everything that depends on it
turbo build --filter ...@geniusgarage/ui

Output:

• Packages in scope: @geniusgarage/ui, @geniusgarage/web, @geniusgarage/snippet-manager
• Running build in 3 packages

Builds ui + apps that use it (web, app).

Git-based filtering

Filter changed packages

# Build packages changed since main
turbo build --filter=[main]

If you changed packages/ui:

• Packages in scope: @geniusgarage/ui

That looks efficient, and it's a trap. The Button in packages/ui is what apps/web and apps/snippet-manager render, and neither of them got rebuilt. A bare [main] selects changed packages only. Ship this to CI, and a broken Button sails through every check.

Put ... in front to include the dependents:

# Build changed packages plus everything that depends on them
turbo build --filter=...[main]
• Packages in scope: @geniusgarage/snippet-manager, @geniusgarage/ui, @geniusgarage/web
• Running build in 3 packages

That's the selection we want: ui changed, so the two apps that use it build too.

How it works

# What changed?
git diff main...HEAD --name-only
packages/ui/src/button.tsx
 
# With ...[main], Turbo includes:
# - packages/ui (changed)
# - apps/web (depends on ui)
# - apps/snippet-manager (depends on ui)

Try it

1. Test filtering locally

Make a change to packages/ui:

packages/ui/src/button.tsx
// Add comment
export function Button() {
  // Changed!

Run with git filter:

git add .
git commit -m "test: change button"
turbo build --filter=...[HEAD^]

Output:

• Packages in scope: @geniusgarage/ui, @geniusgarage/web, @geniusgarage/snippet-manager

Only ui + dependents build!

2. Update CI with git filtering

Update .github/workflows/ci.yml:

.github/workflows/ci.yml
name: CI
 
on:
  push:
    branches:
      - main
  pull_request:
 
jobs:
  build:
    runs-on: ubuntu-latest
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Fetch all history for git filtering
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 8
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
 
      - name: Cache Turborepo
        uses: actions/cache@v3
        with:
          path: .turbo/cache
          key: ${{ runner.os }}-turbo-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-turbo-
 
      - name: Install dependencies
        run: pnpm install
 
      - name: Run build, lint, and test (affected packages only)
        run: pnpm turbo build lint test --affected

Key changes:

  • fetch-depth: 0 - Fetch full git history (needed for comparison)
  • --affected - Shorthand for --filter=...[main...HEAD]: changed packages plus their dependents. In GitHub Actions it reads the PR's base branch on its own, and on a shallow clone it falls back to running everything (--affected reference)

3. Test incremental CI

Make a small change:

echo "# Update" >> apps/web/README.md
git add .
git commit -m "docs: update web readme"
git push

CI output:

• Packages in scope: @geniusgarage/web
• Running build in 1 package
• Skipped: @geniusgarage/snippet-manager, @geniusgarage/ui, etc.

Tasks:    1 successful, 1 total
Time:     23s (was 2m 45s!)

2m 45s → 23s on incremental change!

Filter patterns

Exact package:

--filter @geniusgarage/web

Package + dependencies:

--filter @geniusgarage/web...

Package + dependents:

--filter ...@geniusgarage/ui

Multiple packages:

--filter @geniusgarage/web --filter @geniusgarage/snippet-manager

All apps:

--filter "./apps/*"

All packages:

--filter "./packages/*"

Changed since main, plus dependents:

--filter=...[main]

Affected packages (the CI shorthand):

--affected

CI optimization strategy

Branch builds (PRs):

# Only affected packages
run: pnpm turbo build lint test --affected

Main branch builds:

# Full build (no filter)
run: pnpm turbo build lint test

Conditional example:

- name: Run tasks
  run: |
    if [ "${{ github.ref }}" == "refs/heads/main" ]; then
      pnpm turbo build lint test
    else
      pnpm turbo build lint test --affected
    fi

Commit

git add .github/workflows/ci.yml
git commit -m "ci: add git-based filtering for incremental builds"

Done-when

Verify filtering works:

  • Ran --filter @geniusgarage/web and saw only web build
  • Ran --filter @geniusgarage/web... and saw web + dependencies
  • Ran --filter ...@geniusgarage/ui and saw ui + dependents
  • Ran --filter=[main], saw it skip the apps, then ran --filter=...[main] and saw them included
  • Updated CI with fetch-depth: 0
  • Updated CI with --affected
  • Tested incremental change and saw selective builds
  • Understood ... suffix for dependencies and dependents
  • Understood [branch] syntax for git-based filtering

What's Next

Filtering speeds up CI, but cache is still local to each machine. Next lesson: Remote Caching - you'll configure Vercel remote cache so your team shares build artifacts across machines, enabling instant cache hits even on fresh clones.

Was this helpful?

supported.