Incremental scanning with --diff
Scan only the files that changed and the code connected to them, instead of the whole repository. On a large codebase this turns a multi-minute CI check into a few seconds, without missing cross-file vulnerabilities.
TL;DR — add --diff to any gadriel code scan. In CI, run --diff on
pull requests and a full scan on merges to main.
Why use it
A full gadriel code scan re-analyzes every file every time — its cost scales
with repo size, not change size. A one-line pull request pays the same
wall-clock as a from-scratch audit, and it repeats on every push.
--diff scopes the work to the changed files and their dependency closure
(the files they import and the files that import them), so cross-file taint is
still caught, and merges the result into your existing .security/findings.json.
Quick start
bash# Scan only what changed since the base branch (auto-detected)gadriel code scan --diff# Diff against a specific refgadriel code scan --diff origin/maingadriel code scan --diff HEAD~1
The first scan of a repo is always full — --diff is an update, not a
bootstrap. After that, incremental scans merge into the baseline.
How it decides what to scan
Given the changed files, --diff builds a scan set:
scan set = changed files
∪ files they import (forward closure)
∪ files that import them (reverse closure)
The closure is why --diff stays sound: if you change a request handler and the
vulnerable sink lives in an unchanged helper it calls, that helper is pulled into
the scan set and the finding still fires. Findings on files outside the scan
set are carried over unchanged from your last full scan.
Per scanner:
| Scanner | On a --diff scan |
|---|---|
| SAST | changed files + their import closure (cross-file taint preserved) |
| Secrets | the changed-file closure (per-file; no cross-file semantics) |
| SCA (dependencies) | skipped entirely if no manifest/lockfile changed — the biggest single speed-up for code-only PRs |
| Config / Container / API | full-tree (cheap; keeps the merge exact) |
Choosing the diff base
Bare --diff resolves the base automatically, most-specific first:
- An explicit ref you pass:
--diff <ref>or--diff-base <ref> - CI pull-request target —
GITHUB_BASE_REF(GitHub),CI_MERGE_REQUEST_TARGET_BRANCH_NAME(GitLab) → merge-base withHEAD - CI push event — the previous SHA (
GITHUB_EVENT_BEFORE/CI_COMMIT_BEFORE_SHA) - Local — merge-base with your upstream branch, else
HEAD~1
Every scan logs the base it chose, so you can always see what was compared.
Flags
| Flag | Effect |
|---|---|
--diff [<base>] | Turn on incremental scanning. Optional value pins the git base. |
--diff-base <ref> | Explicit base (alias for --diff <ref>, for scripts that pass other diff flags too). |
--full-closure | Expand the closure to the whole connected import component instead of the default 2-hop bound. Maximum soundness over speed. |
--diff-verify | Soundness canary: run both a diff scan and a full scan and fail if the diff scan missed anything the full scan found on the scanned files. For nightly CI, not the hot path. |
When --diff falls back to a full scan
--diff never silently under-scans. When it can't compute a sound incremental
scope it runs a full scan and records a coverage advisory in the report. It falls
back when:
| Condition | Advisory |
|---|---|
| Not inside a git repository | COVERAGE-DIFF-NOT-GIT |
| The base isn't in local history (shallow clone) | COVERAGE-DIFF-BASE-UNREACHABLE |
| No files changed vs the base | COVERAGE-DIFF-NO-CHANGES |
| The closure is ≥ 60 % of the repo (diffing isn't worth it) | COVERAGE-DIFF-CLOSURE-TOO-LARGE |
| A changed file is Rust or PHP | COVERAGE-DIFF-LANG-UNSUPPORTED |
Shallow clones are the #1 gotcha. CI checkouts default to fetch-depth: 1,
which hides the base commit and forces a full scan. Use fetch-depth: 0.
Language coverage
| Behavior | Languages |
|---|---|
| Full closure (cross-file taint + import resolution) | Python, JavaScript, TypeScript, Go, Java |
| Falls back to a full scan (cross-file taint exists but the import graph doesn't yet resolve it) | Rust, PHP |
| Single-file scope (no cross-file taint — sound as-is) | C, C++, Ruby, Kotlin, Swift |
The report tells you it was incremental
A --diff scan tags .security/findings.json so no downstream consumer mistakes
it for a full audit:
json{"scan_scope": "diff","diff_base": "9be40051…","files_scanned": 4}
A full scan (including a --diff that fell back) carries "scan_scope": "full".
CI recipe
Run --diff on pull requests (fast, per-push) and a full scan on merges to
main (authoritative — it refreshes the baseline the incremental scans merge
into).
yaml# .github/workflows/gadriel.ymlon: [pull_request, push]jobs:gadriel:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4with:fetch-depth: 0 # REQUIRED — --diff needs the base commit- name: Install gadrielrun: npm install -g gadriel@latest# Pull requests: fast incremental scan- name: Incremental scanif: github.event_name == 'pull_request'run: gadriel code scan --diff --fail-on high# Merge to main: full scan refreshes the baseline- name: Full scanif: github.ref == 'refs/heads/main'run: gadriel code scan --fail-on high
The invariant to remember: the first scan on main must be full, and every
merge to main re-runs a full scan. That keeps findings on files a PR never
touched accurate. --diff alone is forced to a full scan on the first run, so
you can't accidentally bootstrap from an incremental result.
Trust it: --diff-verify
To prove that incremental scans aren't hiding anything, run the canary on a schedule (e.g. nightly):
bashgadriel code scan --diff origin/main --diff-verify
It scans diff-scoped and full, then compares. It exits non-zero if any finding the full scan produced on the scanned files is missing from the diff scan. Green means your incremental scans are sound on your codebase.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
--diff always runs a full scan in CI | Shallow clone. Set fetch-depth: 0. Check the report for COVERAGE-DIFF-BASE-UNREACHABLE. |
scan_scope: full on a Rust/PHP change | Expected — those languages fall back for soundness (COVERAGE-DIFF-LANG-UNSUPPORTED). |
| Diff scan finds fewer files than expected | The closure is bounded to 2 import hops. Use --full-closure for the whole component. |
| First scan didn't use the diff | By design — the first scan is always full. |
| A finding on an unchanged file disappeared | It was fixed and the file was in the closure (re-scanned). If it's outside the closure, it persists from the last full scan — run a full scan to reconcile. |
See also
gadriel code scan --help— the full flag reference- ADR-184 — the incremental-diff design and soundness contract
