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.
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 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.tsExpected 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 countIf 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.
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.
Related
- Incremental Builds & Affected Detection in Monorepos — the parent guide on incremental strategies.
- How to Configure Nx Affected Commands for Faster PR Checks — the same idea with a tool that owns the graph.
- Turborepo vs Nx Monorepo Build Tool Comparison — when adopting a tool becomes worth it.
- Build Optimization & Caching Strategies — the section overview.
← Back to Incremental Builds and Affected Detection in Monorepos