NxtSoftLabs
← All writing

Deterministic code-graph diffing on every pull request

July 12, 2026·6 min read

Because CGraph's extraction is deterministic, the same source tree always produces the same graph. That single property is what makes a code graph usable in CI: build the graph on two commits, diff the two graph.json files, and the difference is a structural changelog of your codebase — new functions, deleted call edges, changed imports — that you can gate a pull request on with tools you already have.

Why determinism is the whole trick

Most static analysis is hard to run in CI not because it is slow but because it is noisy. Re-run it and you get a slightly different answer — reordered results, non-stable IDs, incidental churn — so a diff between two runs is dominated by artifacts, not by the change you care about.

CGraph is built the other way around. Deterministic extraction is a stated design decision: "the same source tree always produces the same graph." The indexing pipeline makes the same point at the scan stage — "the same tree yields the same node set every time" — and it is the property the daemon relies on internally to fold incremental edits into a previous graph rather than rebuild from scratch.

The docs are explicit about what that determinism buys you: "a deterministic graph can be diffed." The daemon uses that diffability at runtime for incremental updates. In CI, you get the same lever manually — two reproducible graphs, one meaningful diff.

What is documented today

Be precise about the surface that exists. CGraph does not ship a cgraph diff subcommand — the documented CLI is the one-shot cgraph, the graphd daemon, the cgraph-client thin client, and the cgraph-mcp server. So a CI workflow is built from two documented pieces:

  1. The one-shot CLI, which is designed for exactly this: "scan a tree, build the graph, write exports, exit." The design decisions call this out directly — "the one-shot cgraph stays available for CI and scripts where a resident process isn't wanted."
  2. The exported artifacts. Extraction writes graph.json, the canonical node-link form of the graph, to the --out directory (see the graph model).

Everything below is those two pieces plus a diff tool. No invented commands.

A one-shot build in CI

The CLI reference gives the shape:

cgraph --root . --out cgraph-out
FlagMeaning
--rootSource tree to scan (defaults to the current directory).
--outDirectory for the graph and exports.

That is the whole invocation. It scans the tree (.gitignore-aware, skipping generated and dependency directories), extracts, resolves, and writes graph.json and the other exports, then exits. No daemon to manage, no process to reap — which is what you want in an ephemeral CI runner.

Diffing two commits

Because the output is deterministic, a graph built on the base branch and a graph built on the PR branch differ only where the code differs. A GitHub Actions job can build both and compare the canonical graph.json:

# .github/workflows/graph-diff.yml
name: code-graph-diff
on: pull_request

jobs:
  graph-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # Build the graph for the base branch.
      - run: git checkout ${{ github.event.pull_request.base.sha }}
      - run: cgraph --root . --out base-out

      # Build the graph for the PR head.
      - run: git checkout ${{ github.sha }}
      - run: cgraph --root . --out head-out

      # Compare the canonical node-link graphs.
      - run: diff base-out/graph.json head-out/graph.json || true
build @ base
cgraph --root . --out base-out
build @ head
cgraph --root . --out head-out
diff graph.json
structural changes, zero noise
The whole CI gate: two deterministic builds and a plain text diff.

The last step is deliberately a plain diff. graph.json is JSON, so you can just as well normalize both files through jq first and diff the sorted output, or compute a checksum to assert "the graph did not change" on a docs-only PR. The point is that the artifact is stable enough that ordinary text tooling produces a signal instead of noise — that is the payoff of the determinism.

(This example assumes the cgraph binary is on the runner. CGraph is a native C++ engine built with CMake and vcpkg, so a real workflow would build or cache the binary first; see installation for the build.)

What a graph diff tells you that a text diff doesn't

A git diff shows you which lines changed. A graph diff shows you what those lines did:

  • A new calls edge means a function started depending on another one.
  • A removed edge means a call path disappeared — possibly the one a downstream module relied on.
  • A new node under a symbol you thought you deleted means a reference survived.

These are the links CGraph resolves — imports, calls, and relations — surfacing as additions and deletions in the diff. It is the structural review that a line-based diff can't give you, computed once and comparable across commits.

If you want the same questions answered interactively rather than in a gate, that is the daemon's job — the impact query returns everything a change touches in about ten milliseconds against a warm graph. CI diffing and warm impact queries are two views of the same deterministic graph: one runs per PR, one runs while you edit.

Honest limits

Determinism is about reproducibility, not omniscience. The same design decision that guarantees a stable graph also notes the tradeoff: "resolution is static. Dynamic dispatch, reflection, and runtime wiring can't always be resolved from source, so some edges are approximate." A graph diff will faithfully show you the resolved structure changing; it will not invent an edge for a call dispatched through reflection. Read a graph diff as "here is what changed in the statically resolvable structure," not "here is every possible runtime effect."

Two more practical notes. First, no diff subcommand exists today, so the comparison step is yours to write — that is a feature for CI (use whatever diff or checksum tooling your pipeline already trusts) but it means there is no built-in "graph changed" exit code to key off. Second, the one-shot build is a full build, not an incremental fold-in; on a large repository budget for that. The published benchmark post measured a 0.42s full build, but on that repository's own 141 files in a Debug (unoptimized) build — treat it as a data point on one corpus, not a guarantee for yours.

Try it

Build a graph on two commits of your own repository and diff the graph.json files by hand before you wire up a workflow — it is the fastest way to see whether a graph diff tells you something a git diff didn't. Start from the CLI reference, then read how CGraph exports the same graph in six shapes if you'd rather diff a Cypher dump or eyeball an SVG. The engine is open source at github.com/taylor009/CGraph.