How nx affected decides what changed, and what a submodule bump looks like to it

nx affected asks Git which files changed between a base and a head commit, maps each changed path to the project whose root contains it, then walks the project graph to add every project that depends on those. --base defaults to the defaultBase in nx.json (commonly main). Nx uses the merge-base of that ref and your branch, not the tip of main. Without --head, Nx compares against the working tree, so uncommitted edits count. In CI you usually pass both refs explicitly.

Every one of those Git calls runs in the parent repository. Nx does not open the submodule's object store or read its history. From the parent's side, a submodule is one tree entry called a gitlink: a path with mode 160000 whose value is a commit SHA in another repository. When you bump a submodule, the only thing that changes in the parent is that SHA.

You can see this by diffing two parent commits. git diff --submodule=short prints the old and new Subproject commit lines. git diff --submodule=log lists the submodule's commit subjects between the two SHAs. Neither output names any file inside the submodule. The changed path is just the submodule root, such as libs/payments.

Parent commit tree → libs/payments (gitlink, mode 160000) → submodule commit def456

That entry is then processed as follows:

  1. Changed path: libs/payments
  2. Owning project: the project whose root is libs/payments, if one exists
  3. Result: that project and its dependents are marked affected

If no project root contains that path, the change maps to no project. The bump is then silently ignored.

# Run from the parent repo, after a commit that only bumped the submodule.

# 1. The parent stores the submodule as a gitlink: mode 160000, type commit.
git ls-tree HEAD libs/payments
# 160000 commit def4567...  libs/payments

# 2. The changed paths are what Nx maps to project roots.
#    Only the submodule root is listed, not files inside it.
git diff --name-only HEAD~1 HEAD
# libs/payments

# 3. short: shows the old and new SHAs recorded by the gitlink.
git diff --submodule=short HEAD~1 HEAD
# diff --git a/libs/payments b/libs/payments
# index abc1234..def4567 160000
# -Subproject commit abc1234...
# +Subproject commit def4567...

# 4. log: lists the submodule commit subjects between the two SHAs.
#    The submodule must be checked out for Git to read them.
git diff --submodule=log HEAD~1 HEAD
# Submodule libs/payments abc1234..def4567:
#   > Add refund endpoint
#   > Fix currency rounding

# 5. Pass both refs so Nx diffs the same two parent commits.
#    If a project's root is libs/payments, Nx lists that project and its dependents.
#    If no project root contains that path, the bump affects nothing.
npx nx show projects --affected --base=HEAD~1 --head=HEAD

From the parent repository, a submodule bump changes only the gitlink at libs/payments, so that path is the only input Nx can map to a project.

Who needs to model the submodule boundary, and when it matters

This applies to backend and platform engineers who run Nx in a parent repository that vendors shared code through submodules. Common examples are a common auth library, OpenAPI or JSON schemas, or a protobuf definitions repo consumed by several services. The parent repo owns the CI pipeline, but the code that changed lives in a history the parent only references by commit SHA.

The failure that matters is under-detection. Over-detection costs CI minutes. Under-detection lets a submodule bump merge without running the tests that would have caught a breaking change. It also lets a deploy pipeline ship an artifact built from the old submodule commit, because nothing told Nx to rebuild it.

Low-risk and high-risk layouts

Low risk: the submodule maps to exactly one project root and has one or two consumers that declare it as a dependency. Even coarse detection marks the right things affected, and a missed edge is easy to spot in review.

High risk: one of these applies:

  • A single submodule holds many projects, so a gitlink change can't be attributed to the specific project that changed.
  • The submodule feeds code generation. For example, protos are compiled into libs/generated by a script whose inputs Nx's project graph doesn't know about. The consumers of the generated code never appear affected.

Verify in your own workspace

Don't rely on documentation to tell you how your Nx setup treats gitlink paths or hashes nested repos. Behavior depends on your configuration and the commands involved. Test it directly:

  1. Make a controlled test commit that only bumps the submodule.
  2. Run nx show projects --affected with explicit --base and --head values.
  3. Compare the output against the projects you expect to be affected.
#!/usr/bin/env bash
# Controlled test: bump ONLY the submodule pointer, then ask Nx what it thinks is affected.
set -euo pipefail

SUBMODULE_PATH="libs/protos"     # placeholder: path of the vendored submodule
NEW_SHA="0123abcd"              # placeholder: a submodule commit with a known change

# Work on a throwaway branch so the test commit never reaches main.
git switch -c test/submodule-bump

# Move the submodule to the new commit and record only the gitlink change.
git -C "$SUBMODULE_PATH" fetch origin
git -C "$SUBMODULE_PATH" checkout "$NEW_SHA"
git add "$SUBMODULE_PATH"
git commit -m "test: bump $SUBMODULE_PATH only"

# Confirm the commit touches nothing but the gitlink path.
git diff --name-only HEAD~1 HEAD

# Ask Nx with explicit refs so the result does not depend on defaults.
npx nx show projects --affected --base=HEAD~1 --head=HEAD | sort > actual.txt

# Projects you expect to be affected, including consumers of generated code
# (for example, services that import from libs/generated).
printf '%s\n' protos orders-service billing-service | sort > expected.txt

# Lines prefixed with '<' are expected but missing: that is under-detection.
diff expected.txt actual.txt && echo "Nx affected matches expectations"

# Clean up: return to the previous branch and discard the test branch.
git switch -
git submodule update --init "$SUBMODULE_PATH"
git branch -D test/submodule-bump

A throwaway-branch test that commits only a submodule pointer bump, runs nx show projects --affected with explicit --base and --head, and diffs the result against the projects you expect so you can spot under-detection.

Declaring edges and inputs: implicitDependencies, namedInputs and a runtime SHA input

Each consuming project in the parent repo needs to say explicitly that it depends on the submodule, because the graph cannot infer that from imports.

Implicit edges

Suppose a billing-api project generates code from protos in a submodule at libs/protos. Give the submodule directory its own project definition, then list that project's name under implicitDependencies in billing-api/project.json. Nx treats this edge like an import edge. When any file attributed to the submodule project changes, billing-api shows up as affected. This handles the case where a service loads schemas at build time or copies generated files, and no TypeScript import points to them.

Inputs that include the submodule

Edges control what counts as affected. Inputs control what gets hashed for caching, so you need both. In nx.json, define a named input such as protoSources that globs {workspaceRoot}/libs/protos/**/*. Reference it from the inputs of the relevant targets, either in targetDefaults or per project.

Then add a runtime input, { "runtime": "git -C libs/protos rev-parse HEAD" }, to the same targets. This puts the pinned SHA into the computation hash. File globs alone may not be enough. Whether files inside a submodule get hashed reliably depends on how your Nx setup enumerates workspace files. The SHA covers that gap.

What it costs

  • Drift in hand-maintained edges. When someone adds a new consumer and forgets the edge, a real dependency goes untested. When a consumer stops using the submodule and nobody removes the edge, the graph keeps triggering that project for no reason. Review these edges with the same care as lockfile changes.
  • Runtime input overhead. The runtime input spawns one git process every time a hash is computed.
  • Fewer cross-branch cache hits. Branches that pin different SHAs no longer share cache entries.

What happens if you skip the runtime input

Leaving out the runtime input is worse than paying those costs. If the files you hash don't capture the SHA change, Nx can replay a cached build or test result that was produced against a different submodule commit. CI then reports green for code that was never actually run.

// nx.json: shared named input and target defaults
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    // Hash every file in the submodule checkout
    "protoSources": ["{workspaceRoot}/libs/protos/**/*"]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": [
        "default",
        "^default",
        "protoSources",
        // Pinned submodule SHA goes into the computation hash, so a
        // result built against another SHA is never replayed
        { "runtime": "git -C libs/protos rev-parse HEAD" }
      ]
    }
  }
}

// libs/protos/project.json: gives the submodule a node in the graph
{
  "name": "protos",
  "root": "libs/protos"
}

// billing-api/project.json: hand-maintained edge, because no import
// points at the protos. Remove it when billing-api stops using them.
{
  "name": "billing-api",
  "root": "billing-api",
  "implicitDependencies": ["protos"]
}

Three config files: a project for the libs/protos submodule, an implicit edge from billing-api to it, and build inputs that hash both the submodule's files and its pinned SHA.

Per-file detection inside a submodule with nx affected --files

When a single submodule holds a dozen projects, an implicitDependencies edge that marks all of them affected on every bump wastes CI time. The fix is to resolve the bump into the actual files that changed and hand those to Nx.

The script does four things. First, it reads the old and new gitlink SHAs from the parent repository. git rev-parse <base>:<submodule-path> and the same call against <head> return the commit each side points to. Second, it runs git diff --name-only <old> <new> inside the submodule. Third, it prefixes each path with the submodule's location, because Nx resolves file paths relative to the workspace root, not the submodule root. Finally, it joins the list and passes it to nx affected --files.

The most important part is failure handling. After an upstream force-push or history rewrite, the old SHA may no longer exist. In that case git diff returns an error, and a script that ignores the error produces an empty file list, which reports nothing affected. Check the old commit explicitly with git cat-file -e <old>^{commit} before diffing. If the check fails, either exit non-zero or fall back to marking every project under that submodule path as affected. Never treat a lookup failure as "no changes."

The diff also needs the submodule's history between the two SHAs to be present locally. Shallow submodule clones often lack the old commit, which triggers the same failure. If submodules are nested recursively, every level repeats both the history fetch and the diff.

#!/usr/bin/env bash
# Resolve a submodule bump into changed files and hand them to nx affected.
# Prerequisite: the submodule history between OLD and NEW is fetched locally
# (shallow submodule clones usually lack OLD and will hit the error path).
set -euo pipefail

BASE="${1:-origin/main}"
HEAD_REF="${2:-HEAD}"
SUB="libs/vendor-ui"                # submodule path, relative to workspace root
ON_MISSING="${ON_MISSING:-fail}"    # "fail" or "all"

# Gitlink SHAs recorded by the parent on each side of the range.
# This fails loudly if the submodule does not exist at BASE (newly added).
OLD=$(git rev-parse "$BASE:$SUB")
NEW=$(git rev-parse "$HEAD_REF:$SUB")
[ "$OLD" = "$NEW" ] && { echo "$SUB not bumped"; exit 0; }

# NEW must exist, or the checkout itself is broken.
git -C "$SUB" cat-file -e "$NEW^{commit}"

if git -C "$SUB" cat-file -e "$OLD^{commit}" 2>/dev/null; then
  CHANGED=$(git -C "$SUB" diff --name-only "$OLD" "$NEW")
elif [ "$ON_MISSING" = "all" ]; then
  # OLD unreachable (force-push, rewrite, shallow clone): treat every
  # tracked file as changed so every project in the submodule is affected.
  echo "warning: $OLD missing in $SUB, marking all projects affected" >&2
  CHANGED=$(git -C "$SUB" ls-tree -r --name-only "$NEW")
else
  echo "error: $OLD not found in $SUB; refusing to report no changes" >&2
  exit 1
fi

# Diff succeeded but listed nothing (e.g. empty commits): genuinely nothing.
[ -z "$CHANGED" ] && { echo "no file changes in $SUB"; exit 0; }

# Nx resolves paths from the workspace root, so prefix with the submodule path.
FILES=$(printf '%s\n' "$CHANGED" | sed "s|^|$SUB/|" | paste -sd, -)
npx nx affected -t test --files="$FILES"

A bash script that diffs the old and new gitlink SHAs inside a submodule, prefixes the changed paths with the submodule location, passes them to nx affected --files, and either fails or marks the whole submodule affected when the old SHA is unreachable.

CI checkout and base selection for submodule changes

CI has to satisfy three requirements, in this order, before any nx affected call runs.

Fetch enough parent history to contain the base. A depth-1 checkout has no ancestors, so git merge-base fails or returns nothing useful. Either fetch full history (git fetch --unshallow on an already-shallow clone) or deepen incrementally with git fetch --deepen=<n> until git merge-base origin/main HEAD succeeds.

Initialize submodules before Nx reads the workspace. Run git submodule update --init --recursive before any Nx command. Nx builds the project graph and computes hashes from files on disk. If a submodule directory is empty at that point, its projects are missing from the graph, or they hash as if they had no content. The result looks valid and is wrong.

Deepen submodule history when you diff inside it. The gitlink only records the new SHA. To list changed files between the old and new pointer, the old commit must exist in the submodule clone. Run git -C <path> fetch --deepen=<n>, or fetch that specific SHA if the remote allows it, and repeat this at each level of nesting.

Choosing the base. For pull requests, use the merge-base with main. That limits the comparison to changes the branch introduced. For builds on main, use the last commit on main whose CI run succeeded. Your pipeline has to record that SHA somewhere it can read back later. If you compare against HEAD~1 instead, a failed run's changes drop out of the affected set on the next build.

The trade-off. Shallow fetches keep checkout fast. They also break base resolution and inner diffs, and they fail silently. Deeper fetches cost time and bandwidth on every run, multiplied by the number of submodules.

Local developer pitfalls that make results differ from CI

Uncommitted work inside a submodule

Nx reads changes from the parent repository. The parent tracks a submodule only as a commit pointer, so edits inside it never appear as individual files. At most the parent reports the submodule path as "modified content", and file-level inputs cannot act on that. In the parent, git status shows the submodule as modified: libs/payments (modified content). Running git submodule foreach git status --short lists the actual files. To fix it, commit inside the submodule, then stage the new pointer in the parent. If you need a result before committing, pass the inner paths explicitly with --files.

Checked out at a different SHA than recorded

This happens after a git pull that moved the parent's pointer but did not update the submodule's working tree. The parent then sees a pointer change you never made. Nx treats it as a local change, marks the submodule's dependents affected, and computes hashes CI will never reproduce. git submodule status prefixes the entry with +, and git status reports (new commits). Run git submodule update --recursive. Setting submodule.recurse to true makes pull and checkout keep submodules in step.

Uninitialized submodule

A clone without --recurse-submodules leaves empty directories. Nx finds no project configuration there, so those projects are missing from the graph. Edges into them disappear, and the hashes of their dependents are computed from different inputs than in CI. git submodule status prefixes the entry with -. Run git submodule update --init --recursive, then reset the Nx daemon so the graph is rebuilt.

Alternatives, cost and when to stop using submodules

Each approach trades accuracy against code you have to own.

Approach Accuracy Maintenance burden CI cost Failure mode Fits when
Whole submodule as one unit Coarse: any bump affects every dependent Near zero High for large shared submodules Over-building Submodule is small or rarely bumped
implicitDependencies plus inputs Project-level, driven by the declared SHA Low: edges must track real usage Moderate Stale edges miss dependents Few consumers, stable boundaries
Per-file script File-level inside the submodule High: custom code you own Lowest Script bugs under-build silently Large shared submodule, frequent bumps
Fold into monorepo or versioned package Native graph, or explicit version bumps Migration cost once, then low Low Version drift (package route) Most changes cross the boundary

The coarse approach is cheap to set up and maintain, but it over-builds. When a submodule has many dependents, rebuilding all of them on every bump can erase most of what affected detection saves.

Illustrative estimate (assumed numbers, not a benchmark). Assume 20 bumps per month, 40 dependent projects, 5 actually touched per bump, and 3 CI minutes per project.

Monthly savings = bumps × (all dependents − touched dependents) × minutes per project = 20 × (40 − 5) × 3 = 2,100 minutes.

Compare that against the engineering hours you spend keeping the script correct. A script that under-builds costs more than the minutes it saves.

If most changes cross the submodule boundary, stop using submodules. Fold the code into the monorepo so Nx sees one graph and computes affected projects without workarounds. If the code needs its own release cycle, publish it as a versioned package instead.

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 git submodules, 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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.