Detecting Affected Workspaces with git diff Without Nx

You have an npm or pnpm workspace monorepo, no interest in adopting a task runner, and CI that rebuilds all fourteen packages when someone edits one. The affected set is computable in about sixty lines: read the dependency graph out of the manifests, attribute changed files to workspaces, and walk dependents until the set stops growing.

When to use this pattern

  • You use npm, pnpm or Yarn workspaces without Nx or Turborepo, and do not want to add one.
  • Most changes touch one or two packages but CI builds the whole repository.
  • You want the affected computation to be readable and debuggable rather than a black box.

Prerequisites

Why a directory diff is not enough

The naive version maps changed files to directories and stops. That produces a set that is correct only when packages are independent, which is exactly when a monorepo would not be worth having.

Transitive Propagation Through the Workspace Graph A change lands in the utils package. Direct dependents are ui and config. Their dependents are web, admin and api. A direct-only computation would build only utils, missing five packages that could break; the transitive walk reaches all six. CHANGED DIRECT DEPENDENTS TRANSITIVE DEPENDENTS utils 1 file changed ui config web admin api Directory-diff answer: build utils. Correct answer: build all six, because breakage travels along the arrows. The walk must repeat until the set stops growing — one level would still miss web, admin and api.

Complete working example

#!/usr/bin/env node
// scripts/affected.mjs — print the affected workspace names as JSON.
//   node scripts/affected.mjs origin/main
import { execSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { globSync } from 'node:fs';
import path from 'node:path';

const BASE = process.argv[2] ?? 'origin/main';

// Anything at the repository root that invalidates every workspace.
const GLOBAL_FILES = [/^package-lock\.json$/, /^pnpm-lock\.yaml$/, /^tsconfig\.base\.json$/,
                      /^\.github\/workflows\//, /^Dockerfile$/];

// 1. Read every workspace manifest into { name, dir, deps: Set<internal names> }.
const manifests = globSync('packages/*/package.json').map((p) => {
  const json = JSON.parse(readFileSync(p, 'utf8'));
  return { name: json.name, dir: path.dirname(p), pkg: json };
});
const names = new Set(manifests.map((m) => m.name));
const byName = new Map(manifests.map((m) => [m.name, m]));

for (const m of manifests) {
  // devDependencies count: a changed test helper can break a dependent's test run.
  const all = { ...m.pkg.dependencies, ...m.pkg.devDependencies, ...m.pkg.peerDependencies };
  m.deps = new Set(Object.keys(all ?? {}).filter((d) => names.has(d)));
}

// 2. Invert the graph once: name -> set of workspaces that depend on it.
const dependents = new Map(manifests.map((m) => [m.name, new Set()]));
for (const m of manifests) for (const d of m.deps) dependents.get(d).add(m.name);

// 3. Attribute changed files to workspaces; bail out early on a global change.
const changed = execSync(`git diff --name-only ${BASE}...`, { encoding: 'utf8' })
  .split('\n').filter(Boolean);

if (changed.some((f) => GLOBAL_FILES.some((re) => re.test(f)))) {
  console.log(JSON.stringify([...names].sort()));     // everything is affected — say so explicitly
  process.exit(0);
}

const seed = new Set();
for (const f of changed) {
  const owner = manifests.find((m) => f.startsWith(m.dir + '/'));
  if (owner) seed.add(owner.name);
}

// 4. Walk dependents until the set stops growing (breadth-first, cycle-safe).
const affected = new Set(seed);
const queue = [...seed];
while (queue.length) {
  for (const dep of dependents.get(queue.shift()) ?? []) {
    if (!affected.has(dep)) { affected.add(dep); queue.push(dep); }
  }
}

console.log(JSON.stringify([...affected].sort()));
# .github/workflows/build.yml — feed the result straight into a matrix.
jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:
      affected: ${{ steps.a.outputs.affected }}
      count:    ${{ steps.a.outputs.count }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }          # the diff needs a merge base
      - id: a
        run: |
          json=$(node scripts/affected.mjs "origin/${{ github.base_ref || 'main' }}")
          echo "affected=$json" >> "$GITHUB_OUTPUT"
          echo "count=$(echo "$json" | jq length)" >> "$GITHUB_OUTPUT"
          echo "affected: $json"

  build:
    needs: plan
    if: needs.plan.outputs.count != '0'
    strategy:
      fail-fast: false
      matrix:
        pkg: ${{ fromJSON(needs.plan.outputs.affected) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --ignore-scripts
      - run: npm run build --workspace "${{ matrix.pkg }}"

Step-by-step walkthrough

Invert the graph once, not per lookup. The manifests give you “what does X depend on”; the question you need answered is “who depends on X”. Building the reverse map up front turns the walk from quadratic into linear and makes the code read the way the problem does.

Include devDependencies and peerDependencies as edges. A workspace whose tests import a changed fixture package is affected even though the fixture never ships. Restricting to runtime dependencies produces a set that is smaller, faster, and wrong in precisely the cases that matter.

Attribute by directory prefix, carefully. f.startsWith(m.dir + '/') avoids the classic bug where packages/ui-kit/… is attributed to packages/ui. Getting this wrong produces a set that is subtly too small and fails intermittently.

Global files short-circuit to everything. A lockfile change can alter a transitive dependency’s version for every workspace, and nothing in the workspace graph records that. Returning the full set is the honest answer; the alternative is a green build that never compiled anything. The same escape hatch appears in the matrix approach described in generating a dynamic job matrix from JSON.

The Walk, Round by Round Round zero holds the seed set with one workspace. Round one adds its two direct dependents. Round two adds three further dependents. Round three adds nothing, which is the termination condition, leaving six workspaces in the affected set. round 0 — seed utils |set| = 1 round 1 + ui, config |set| = 3 round 2 + web, admin, api |set| = 6 round 3 nothing new stop — |set| = 6 Termination is the fixed point, not a fixed depth — a deeper graph simply takes more rounds. The seen-set guard makes a cyclic devDependency edge harmless instead of an infinite loop.

The walk must be cycle-safe. Workspace graphs are supposed to be acyclic, and in practice occasionally are not — a devDependency back-edge for testing is common. The if (!affected.has(dep)) guard makes a cycle harmless rather than an infinite loop.

Verification

# 1. Against a real branch — does the set match your expectation?
node scripts/affected.mjs origin/main | jq .

# 2. Prove transitivity: change a leaf package and confirm dependents appear.
echo "// probe" >> packages/utils/src/index.ts
node scripts/affected.mjs origin/main | jq .
git checkout -- packages/utils/src/index.ts

Expected from the second command — the seed plus everything downstream:

["@shop/admin","@shop/api","@shop/config","@shop/ui","@shop/utils","@shop/web"]
# 3. Prove the global short-circuit works.
touch package-lock.json && git add -N package-lock.json
node scripts/affected.mjs origin/main | jq length     # → the full workspace count

If the second command returns only ["@shop/utils"], the reverse graph is empty — usually because internal dependencies are declared with file paths or workspace:* protocol strings the name filter did not match. Print dependents and check the keys.

What this approach cannot see

File-level attribution has a hard ceiling, and it is worth knowing where it sits before trusting the output completely.

The script reasons about declared dependencies. A workspace that reaches into a sibling by relative path — import { x } from '../../utils/src/x' — has an undeclared edge that no manifest records, so a change to utils will not mark it affected. Similarly, a package that reads a JSON fixture from another workspace at runtime, or a build that globs across package boundaries, creates a dependency the graph does not contain. These are all technically bugs in the monorepo’s boundaries, but they exist in most real repositories, and the affected set is silently wrong wherever they do.

The practical mitigations are to lint against cross-workspace relative imports, and to run the full build on the main branch after merge even when the pull request built only a subset. That second run costs little — it is not blocking anyone — and it converts an incorrect affected set from a production incident into a red build on main with an obvious cause.

Declared Edges Are Visible; Relative-Path Edges Are Not A declared dependency from web to utils appears in web's package.json and is followed by the script. An undeclared relative-path import from admin into utils exists only in source code and is invisible to the graph, so admin is never marked affected. A full build on the main branch after merge is shown as the safety net that catches it. web "@shop/utils": "*" declared — visible admin import '../../utils/src/x' undeclared — invisible utils changed web is built on the pull request breakage caught before merge admin is skipped — caught only later by the full build on main after merge Lint against cross-workspace relative imports to remove the second row entirely.

Common pitfalls

Shallow checkout. Without fetch-depth: 0 there is no merge base, the diff returns everything or errors, and the affected set silently becomes “all” — which looks like the script working, only slower.

Comparing with two dots instead of three. git diff base..HEAD includes changes that landed on base since the branch started, inflating the set on every long-lived branch. base...HEAD compares against the merge base, which is what you want.

Forgetting that deleted files still count. --name-only lists deletions, and a deleted file in a workspace absolutely affects it. Do not filter to existing paths.

← Back to Incremental Builds and Affected Detection in Monorepos