How nx affected uses git to find what changed
nx affected runs a target such as test, lint or build only for the projects a change touches, plus the projects that depend on them. Monorepo teams use it to keep CI time roughly proportional to the size of a change rather than the size of the repository. That only works if Nx can work out what changed, and it does that with git.
You give Nx two refs: a base and a head. Pass them with --base and --head, or set the NX_BASE and NX_HEAD environment variables. Nx finds the merge-base of the two refs, which is the most recent commit they share. It then diffs that commit against the head to get a list of changed files. It maps those files to projects and walks the project graph to add every dependent.
Both steps need data from your local clone. The base commit must exist locally, and so must enough history to connect it to HEAD. Without the history, git cannot compute a merge-base.
main: A---B---C---F
\
PR: D---E <- HEAD (checked out)
merge-base(main, HEAD) = B
Full clone: A B C D E F (B reachable from E)
Depth-1 clone: E (no parents, no B, no main)
A depth-1 clone contains only the checked-out commit, with its parent links cut off. Nx asks git for the merge-base of main and E. Git has no B and no path back to it, so the comparison has nothing to anchor to. The rest of this article covers how to fix that.
--base / NX_BASE --head / NX_HEAD
(e.g. main) (e.g. HEAD = E)
| |
+-------------+-------------+
|
v
+----------------------+ +---------------------------+
| git merge-base |<-----| local clone: commits + |
| -> commit B | | parent links |
+----------------------+ +---------------------------+
|
v
+----------------------+
| git diff B..E |
| -> changed files |
+----------------------+
|
v
+----------------------+
| map files to projects|
| + walk project graph |
+----------------------+
|
v
run target on affected projects
main: A---B---C---F
\
PR: D---E <- HEAD (checked out)
Full clone: A B C D E F -> merge-base B found
Depth-1 clone: E only -> no B, no path to it: comparison fails
nx affected resolves the merge-base of the base and head refs from local git history, diffs it against HEAD, and maps changed files to projects. A depth-1 clone contains only E, so it has no merge-base to find.
Why shallow clones produce base SHA errors
That missing anchor is not an edge case. Many CI providers' default checkout step fetches with depth 1 to keep clone times low. A depth-1 clone also implies --single-branch, so when CI checks out a feature branch, the runner has one commit and one ref. origin/main does not exist locally, and neither does the SHA of the previous successful build. Nx passes those refs straight to git, and git rejects them.
The errors take three common forms:
unknown revision or path not in the working treemeans the ref name, such asorigin/main, was never fetched, so git cannot resolve it.bad object <sha>orNot a valid object namemeans a raw SHA was supplied, usually from a previous pipeline run, but that commit object is not in the local object store.- No merge-base found means both refs resolve, but the shallow history stops before the two branches meet.
git merge-baseprints nothing and exits non-zero, and Nx reports that it could not find a merge-base.
You can reproduce all three without Nx. Clone any repository with --depth 1 and --branch set to a feature branch. If you clone from a local path, use a file:// URL, because git ignores --depth for plain local paths and prints a warning. In the clone, run git merge-base origin/main HEAD and you get the unknown-revision error. Run git diff against an older commit's SHA and you get bad object. If you fetch main at depth 1 and retry git merge-base, it finds no common ancestor. Every failure comes from git, before Nx computes anything.
#!/usr/bin/env bash
# Reproduce shallow-clone base SHA failures with plain git (no Nx involved).
set -u
export GIT_AUTHOR_NAME=ci GIT_AUTHOR_EMAIL=ci@example.com
export GIT_COMMITTER_NAME=ci GIT_COMMITTER_EMAIL=ci@example.com
work=$(mktemp -d) && cd "$work"
# Build a source repo where main and feature diverge after a shared commit.
git init -q src && cd src
git checkout -q -b main
git commit -q --allow-empty -m "base"
OLD_SHA=$(git rev-parse HEAD) # stands in for a previous successful build's SHA
git checkout -q -b feature
git commit -q --allow-empty -m "feature work"
git checkout -q main
git commit -q --allow-empty -m "main moves on"
cd ..
# Mimic a default CI checkout. file:// is required: git ignores --depth for plain paths.
git clone -q --depth 1 --branch feature "file://$work/src" ci && cd ci
# 1. origin/main was never fetched (depth 1 implies --single-branch).
git diff --name-only origin/main HEAD # fatal: ambiguous argument 'origin/main': unknown revision ...
git merge-base origin/main HEAD # fatal: Not a valid object name origin/main
# 2. The old SHA is not in the local object store.
git diff --name-only "$OLD_SHA" HEAD # fatal: bad object <sha>
# 3. Fetch main shallowly: both refs now resolve, but history stops before they meet.
git fetch -q --depth 1 origin main:refs/remotes/origin/main
git merge-base origin/main HEAD # prints nothing
echo "merge-base exit code: $?" # non-zero: no common ancestor in shallow history
A self-contained script that triggers the unknown-revision, bad-object and missing-merge-base errors using only git in a depth-1 clone, showing the failures happen before Nx is ever involved.
Three ways to get the base commit into the checkout
Each fix works by making the base commit, and the history linking it to HEAD, exist in the local object database before Nx runs.
Fix 1: Fetch full history
Set fetch-depth: 0 on actions/checkout. The runner then clones every commit and branch, so git merge-base can always walk back to the fork point. This is the simplest fix. Its cost grows with repository age and size, because every job downloads history it will never read.
Fix 2: Keep the clone shallow, then deepen it
Keep the default depth-1 checkout and run git fetch origin main so the base branch ref exists locally. On its own, this is not enough. A shallow fetch gives you only the tip of main, and your branch is also cut off at one commit. Neither side has the parents needed to reach the commit where they diverged, so merge-base finds nothing. Follow up with git fetch --deepen=<n> to extend both histories by a fixed number of commits, or git fetch --unshallow to fetch the rest. Choose n large enough to cover your longest-lived branches. If it is too small, the original failure comes back on old branches.
Fix 3: Use nx-set-shas
nx-set-shas computes the base for you. On pushes to main, it looks up the last commit that had a successful workflow run, so failed runs are covered by the next build. It exports that commit as NX_BASE and the current commit as NX_HEAD. It does not fetch that commit for you, so you still need enough history from Fix 1 or Fix 2. Input names and defaults vary between versions, so check the docs for the version you use.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
affected:
runs-on: ubuntu-latest
steps:
# Fix 1: set fetch-depth: 0 here to clone all history and skip
# the fetch step below. It is simplest, but slower on large repos.
- uses: actions/checkout@v4
with:
fetch-depth: 1
# Fix 2: get the main ref locally, then deepen past the fork point.
# A depth-1 fetch of main alone gives two unconnected tips with no
# common ancestor, so merge-base would still fail.
- name: Fetch base branch with enough history
run: |
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
# 200 is a placeholder. Size it to your longest-lived branch,
# or use 'git fetch --unshallow' to fetch everything.
git fetch --no-tags --deepen=200 origin +refs/heads/main:refs/remotes/origin/main
git merge-base origin/main HEAD # fails fast if still disconnected
# Fix 3: compute NX_BASE/NX_HEAD. On pushes to main, the base is the
# last commit with a successful run. That commit must already be
# in the local history from the steps above. Check your version's
# docs for input names.
- uses: nrwl/nx-set-shas@v4
- run: npm ci
- run: npx nx affected -t lint test build --base=$NX_BASE --head=$NX_HEAD
A GitHub Actions job that keeps a shallow checkout, fetches and deepens main so a merge base exists, uses nx-set-shas to export NX_BASE and NX_HEAD, and notes in a comment where fetch-depth: 0 would replace the fetch step.
Choosing base and head for each CI event
Pull requests
For a pull request, the base should be the merge-base between the PR branch and its target, such as git merge-base origin/main HEAD. Comparing against the tip of origin/main instead would pull in every change merged to main since the branch was created, so Nx would mark projects affected that the PR never touched. The merge-base isolates the PR's own commits. The head is the commit CI checked out, which may be the branch tip or a synthetic merge commit, depending on how your CI builds pull requests.
Pushes to main
On main, the base should be the last commit where the pipeline succeeded. nx-set-shas and similar helpers look that commit up for you. The previous commit also works, but only as a fallback.
Many pipelines hard-code HEAD~1 as the base, and it fails in two common cases:
- Several commits in one push. A fast-forward merge or a batch push can land several commits at once.
HEAD~1only covers the last one, so projects changed in the earlier commits are never tested. - Failed earlier runs. If the previous pipeline failed, its changes were never verified. The next run, comparing against
HEAD~1, skips them as well, and the broken project stays untested until someone touches it again.
Make it explicit
Pass both values on the command line: nx affected -t test --base=$NX_BASE --head=$NX_HEAD. Relying on defaults makes it easy for the CI environment to change the comparison without anyone noticing.
Before running Nx, add a verification step: git log --oneline "$NX_BASE..$NX_HEAD". This does two things:
- It catches missing history. It errors if the base commit isn't in the checkout, so you find the problem before Nx does.
- It shows the range. It prints the exact commits being compared in the job log, which makes "why did this run skip my project?" a quick question to answer.
#!/usr/bin/env bash
# Pick base/head per CI event, verify the range, then run Nx explicitly.
# Assumes full history (not a shallow clone) so merge-base and old SHAs resolve.
set -euo pipefail
CI_EVENT="${CI_EVENT:?set to pull_request or push}"
TARGET_BRANCH="${TARGET_BRANCH:-main}"
# SHA of the last green pipeline on main, supplied by your CI or a helper.
LAST_SUCCESSFUL_SHA="${LAST_SUCCESSFUL_SHA:-}"
NX_HEAD="$(git rev-parse HEAD)" # whatever CI checked out
if [ "$CI_EVENT" = "pull_request" ]; then
git fetch --no-tags origin "$TARGET_BRANCH"
# Only the PR's own commits, not everything merged to main since branching.
NX_BASE="$(git merge-base "origin/$TARGET_BRANCH" HEAD)"
elif [ -n "$LAST_SUCCESSFUL_SHA" ]; then
# Covers multi-commit pushes and changes from earlier failed runs.
NX_BASE="$LAST_SUCCESSFUL_SHA"
else
# Fallback only: misses earlier commits in a batch push.
echo "warning: no last successful SHA, falling back to HEAD~1" >&2
NX_BASE="$(git rev-parse HEAD~1)"
fi
echo "base=$NX_BASE head=$NX_HEAD"
# Fails if the base commit is missing from the checkout; prints the range otherwise.
git log --oneline "$NX_BASE..$NX_HEAD"
npx nx affected -t test --base="$NX_BASE" --head="$NX_HEAD"
A CI-agnostic shell step that chooses the merge-base for pull requests and the last successful SHA for pushes to main, verifies the range with git log, and passes --base and --head to nx affected explicitly.
Comparing the alternatives and when to avoid each
| Approach | Reliability | Clone cost | Failure modes | Best fit |
|---|---|---|---|---|
| Full history | High | Grows with repo history | Slow checkouts on large repos | Small to mid-sized repos |
| Targeted or deepened fetch | High if the base is fetched explicitly | Low | Fixed depth misses the base | Large repos with known base refs |
| nx-set-shas | High on main-branch pushes | Low to moderate | Stale or unreachable stored SHA | GitHub Actions push pipelines |
HEAD~1 |
Low | Minimal | Ignores all but the last commit | Single-commit merges only |
| Run all projects | Complete by design | Minimal | Wasted compute | Releases, nightly runs, fallback |
Fixed depths. A depth of 50 works until a long-lived branch or a large PR has more commits than that. When that happens, the merge base falls outside the fetched window. Fetch the base ref by name, or deepen the clone until the merge base resolves. A number picked once and never revisited will eventually be too small.
Last-successful bases. After a run of red builds, the last green commit can be far behind. The affected set then grows to cover every change since that commit. This is correct behaviour, but it can be slow. If you see a sudden jump in project count, check how old the base commit is.
Stored SHAs. Force-pushes, rebases and squash merges can remove a recorded commit from every branch. The stored SHA then points at something no fetch can retrieve. If the SHA is missing, fail the job or run all projects. Do not fall back to HEAD~1.
Remote caching reduces the cost of selecting too many projects, because cache hits replay quickly. It does nothing for a wrong base that selects too few. Projects that never run produce no cache misses and no failures, so nothing warns you they were skipped.
Debugging checklist and a loud-failure guardrail
That silence is what needs fixing, and the diagnosis starts with three facts printed at the top of the job.
What to log before running affected
git rev-parse --is-shallow-repository:truemeans history is truncated. The base may simply not have been fetched.- The resolved base and head SHAs: print the actual values of
NX_BASEandNX_HEAD(or whatever you pass to--baseand--head), not the expressions that produced them. An empty string, a branch name that never resolved, or a base equal to head each explain a different failure. git cat-file -t <sha>for both: it should printcommit. An error means the object is not in the local repository. Either the fetch was too shallow, or the commit no longer exists on any fetched ref.
If the base is missing but the clone is not shallow, check for a force-push or rebase. A PR base recorded before a rebase, or a "last successful main build" SHA that was rewritten away, can become unreachable. Deepening the fetch will not bring it back. git merge-base --is-ancestor <base> <head> confirms whether the base is still in head's history.
A guardrail that refuses to guess
Run a short check before nx affected. If the base is not a reachable commit, the check takes one of two explicit paths. It can exit non-zero with the diagnostics above. Or it can deliberately switch to nx run-many --all and log a warning that names the unresolved SHA. Choose fail-fast for pull requests, where a human can re-run the job. Choose fall back-to-all for main, where skipping verification is worse than paying for a full run.
Impact on CI cost and engineering time
Full history is often treated as expensive. Whether it is depends on your repository: its age, its binary history, and how close the runners are to the git host. Don't assume the cost. Run a branch with fetch-depth: 0 for a week, then compare checkout step durations against your current shallow configuration.
Illustrative estimate (assumed numbers, not a benchmark)
Formula: monthly extra cost = runs per month × extra checkout seconds ÷ 60 × price per runner-minute
Assumed inputs: 2,000 runs per month, 40 extra seconds per full clone, and $0.008 per runner-minute.
2,000 × 40 ÷ 60 ≈ 1,333 minutes, which comes to about $10.67 per month.
Compare that figure with the cost of the failures it prevents. Suppose one intermittent merge-base error per week takes an engineer 30 minutes to notice, diagnose and re-run. That engineer time almost certainly costs more than the clone minutes. Use your own measured inputs. A monorepo with large historical blobs can flip this result, and in that case a bounded deepen-and-retry fetch is the better trade.
The larger savings don't appear on the CI invoice:
- Intermittent failures stop. Merge-base errors that only appear when a branch falls behind main stop causing re-runs and Slack threads.
- Untested changes stop slipping through. A wrong base that quietly computes an empty or too-small affected set is the most costly failure here, because nobody notices it until production does.
- Fallbacks are visible. A logged "falling back to all projects" line tells you the configuration is drifting. A silent misconfiguration looks the same as a fast, green pipeline.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx affected base sha ci, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.