Skip to content

Scan incrementally by default: diff against the last scan locally and send the file list - #160

Open
Ibrahimrahhal wants to merge 7 commits into
mainfrom
cursor/cli-client-computed-incremental-scans-6d22
Open

Scan incrementally by default: diff against the last scan locally and send the file list#160
Ibrahimrahhal wants to merge 7 commits into
mainfrom
cursor/cli-client-computed-incremental-scans-6d22

Conversation

@Ibrahimrahhal

@Ibrahimrahhal Ibrahimrahhal commented Aug 23, 2026

Copy link
Copy Markdown
Member

What

Every corgea scan blast run now finds the project's newest completed scan of a clean worktree, diffs this commit against it with git2, and sends both the base commit and the changed-file list with the upload. Corgea analyzes only those files and carries existing findings forward for everything else. --disable-incremental forces every file to be analyzed.

Needs the doghouse side to land first: Corgea/doghouse#1939. Against an older server the two fields are ignored and the run is a normal full scan.

Why

Corgea already runs incremental scans, but doghouse works the diff out server-side by asking the project's SCM integration to compare two commits. Projects it cannot answer for — zip-only projects, self-hosted hosts it cannot reach, commits that were never pushed — pay for a full analysis on every run. At one enterprise customer that is 86% of eligible full scans, and no server-side change can fix it, because the diff simply is not available to the server. It is available right here in the clone the scan is already reading from.

It is on by default because an optimization nobody opts into does not run, and the projects this exists for are the ones least likely to hear that a new flag shipped.

What it does not do

The archive is unchanged: a full scan still uploads the full project. Fusion reads unchanged files for cross-file context even when it only analyzes the diff, and the server can only carry a finding forward for a file the archive still contains. What shrinks is the analysis, not the upload.

Every way it declines

Being the default means it has to be safe on a repository that was never set up for it. Each of these falls through to the full scan that run would have done anyway, and says why:

condition reason
no git repository, no commit, detached HEAD, or scan started below the repository root nothing to diff from
dirty worktree a commit-to-commit diff cannot see uncommitted edits
no earlier completed scan of a clean worktree nothing to diff against — every project's first scan
base commit missing from the clone shallow clone; the message names fetch-depth: 0
more changed files than the payload guard allows not worth an incremental scan

--only-uncommitted, --target and --exclude disable it silently rather than conflicting. Those runs upload a narrowed archive, so carrying findings forward for files the archive no longer contains would be wrong — but they are not scanning every file either, so "scanning every file" would be a lie.

The server-side company.incremental_scan_enabled flag remains the central kill switch, so an org can turn this off for everyone without a CLI rollback.

Design notes

The base commit travels with the list. The server carries findings forward for every file the list omits, so if it picked a different baseline than the one diffed here, findings in files that changed between the two would be reported as current when nothing had looked at them. Sending incremental_base_sha lets the server copy from exactly the scan this diff describes, or refuse. The two fields are attached to the form together or not at all.

The diff is not --target git:diff=. get_git_diff_files records only new_file().path() and only for Added/Copied/Modified/Renamed, because it is picking files that still exist on disk to put in an archive. This needs the opposite: both sides of every delta, no status filtered out. A deleted file left off the list keeps its old findings in a tree where the file no longer exists, and a rename is a delete plus an add whose old path needs the same treatment. Two-dot diff_tree_to_tree, not three-dot: the question is which files differ between the snapshot we are copying from and now, not what this branch introduced.

A moved submodule refuses the diff. A committed pointer bump is a single gitlink delta naming the submodule directory, while packaging walks into it and uploads the files inside — so those files would be missing from the list and keep findings nothing re-examined. Diffing the two submodule commits would mean opening a repository that may not be checked out, so a gitlink on either side fails closed.

Baseline selection mirrors the server's. Completed corgea-blast scan, not a pull request, worktree_dirty == Some(false) (None is unknown scope, not a clean tree, and the server would reject it anyway). Same branch preferred, falling back to the newest on any branch, so the first scan of a feature branch is incremental against the trunk rather than full.

Client-side caps are payload guards, not policy. The server applies the real ceiling (INCREMENTAL_SCAN_MAX_FILES, 300) so it can be tuned without a CLI release.

upload_zip grew a fourth optional setting, so scan_type/policy/metadata/incremental moved into an UploadOptions struct rather than adding an eighth argument and a too_many_arguments suppression.

Cost

One extra GET /api/v1/scans per clean-tree scan, before the upload. Runs that cannot use a baseline — no git, dirty tree, narrowed archive — skip the lookup entirely rather than making a call they cannot act on.

Known limitation

Files git ignores are in the archive but invisible to the diff, so a changed ignored file would keep its old findings. Untracked files are not affected — they make the worktree dirty, which already refuses. This matches the existing server-side incremental path exactly; repo.compare() only reports tracked changes too.

Testing

./harness check is green: clippy strict, format, 760 tests, no new suppressions. ./harness ci passes clippy, format and dep audit.

Unit tests in src/incremental.rs cover baseline acceptance and each rejection reason (running, third-party engine, PR scan, dirty, dirty-signal-less, no commit), newest-first selection, and the diff itself against real git2 repositories — added, edited and deleted files named while untouched ones are not, a commit diffed against itself reporting nothing, an unknown base commit reported rather than panicked, and a moved submodule pointer refused.

tests/cloud_commands_e2e/scan_incremental.rs asserts the wire contract end to end against the HTTP stub, one case per outcome: the upload carries incremental_base_sha and incremental_changed_files with the exact JSON list; a project with no baseline, a directory that is not a git repository, and a dirty worktree each upload with neither field; --disable-incremental and --target do not even look for a baseline. "Did not look" is proven by the stub's request sequence rather than by output.

blast_upload_plan, the shared upload contract used by the other e2e suites, now expects the baseline lookup on clean-tree scans and answers with no scans — which is what makes it the full-scan contract.

Unrelated fix included

cargo audit was failing the CI gate on h2 0.4.12 (RUSTSEC-2026-0258, published 2026-08-17). main carries the identical lockfile entry, so this predates the branch. Bumped to 0.4.18 in its own lockfile-only commit.

Open in Web Open in Cursor 

…le list

Corgea already runs incremental scans, but doghouse works the diff out
server-side through the project's SCM integration, so projects without one
-- zip-only projects, unreachable self-hosted hosts, unpushed commits --
pay for a full analysis every run. At one enterprise customer that is 86%
of full scans.

--incremental resolves the project's newest completed scan of a clean
worktree, diffs this commit against it with git2, and sends both the base
commit and the changed-file list with the upload. The archive is unchanged:
Fusion reads unchanged files for cross-file context, and the server can only
carry a finding forward for a file the archive still contains.

The base commit travels with the list because the server carries findings
forward for every file the list omits; diffing against one baseline while
the server copies from another would report stale findings as current.

Both sides of every delta are collected and no status is filtered out, so a
deleted file lands in the list and does not keep its findings in a tree that
no longer contains it. Every refusal -- dirty tree, no baseline, a base
commit missing from a shallow clone -- scans everything and says why.

Co-authored-by: ibrahim <ibrahim@corgea.com>
@Ibrahimrahhal
Ibrahimrahhal marked this pull request as ready for review August 23, 2026 10:33
Comment thread src/main.rs Outdated
Comment thread src/incremental.rs
@corgea-security corgea-security added the dennis-reviewed Dennis completed an automated review label Aug 23, 2026

@corgea-security corgea-security left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review risk: 4/5.

Incremental scans can incorrectly carry forward stale findings for changed container images and submodule contents. Both existing review comments are valid and require fixes.

Critical or high-priority changes must be addressed.

Automatic approval was not submitted: checks failed: rust-tests.

cursoragent and others added 3 commits August 23, 2026 10:51
A committed submodule bump is a single gitlink delta naming the submodule
directory, but packaging walks into that directory and uploads the files
inside it. The parent worktree is clean, so the incremental path stayed
enabled and those files -- absent from the changed-file list -- kept
findings nothing had re-examined.

Diffing the two submodule commits would mean opening a repository that may
not be checked out, so a gitlink on either side of a delta refuses the diff
and scans everything.

Co-authored-by: ibrahim <ibrahim@corgea.com>
cargo audit fails the CI gate on h2 0.4.12 (unbounded empty DATA frames,
advisory published 2026-08-17). Lockfile-only bump; the advisory predates
this branch and affects main identically.

Co-authored-by: ibrahim <ibrahim@corgea.com>
An optimization nobody opts into does not run. The projects this exists for
-- zip-only, self-hosted, unpushed commits -- are also the ones least likely
to hear that a new flag exists, so it now runs on every blast scan and
--disable-incremental forces every file to be analyzed.

Being the default means it has to be safe on a repository that was never set
up for it, so every way it declines is a fall-through to the full scan that
run would have done anyway: no git repository or commit, a dirty worktree,
no earlier clean scan, or a base commit missing from a shallow clone. Each
says why.

--only-uncommitted, --target and --exclude no longer conflict with it; they
disable it silently instead. Those runs upload a narrowed archive, so
carrying findings forward for files the archive no longer contains would be
wrong -- but they are not scanning every file either, so there is no honest
reason to print.

blast_upload_plan now expects the baseline lookup for clean-tree scans and
answers with no scans, which is what makes it the full-scan contract.

Co-authored-by: ibrahim <ibrahim@corgea.com>
@cursor cursor Bot changed the title Add --incremental: diff against the last scan locally and send the file list Scan incrementally by default: diff against the last scan locally and send the file list Aug 23, 2026
cursoragent and others added 2 commits August 23, 2026 11:24
The lookup wanted the newest completed blast scan of a clean,
non-pull-request commit, and none of that was expressible as a query, so it
read the project's history 30 scans at a time and sorted it out locally.
With enough pull-request traffic a page holds nothing usable, and once the
page budget runs out the run gives up and scans everything -- so a project
could be permanently unable to find a baseline it actually has.

query_baseline_scans sends those four filters, so the answer is normally the
first entry of the first page.

is_usable_baseline and the page walk stay. A backend that predates the
filters ignores unknown parameters and answers with scans of every kind, and
diffing against a dirty or pull-request scan's commit would compare against
the wrong tree -- the same reason query_scans_for_commit re-checks git_sha.

Co-authored-by: ibrahim <ibrahim@corgea.com>
Same content, fewer words. Also drops two stale --incremental references in
test docs left by the flip to opt-out.

Co-authored-by: ibrahim <ibrahim@corgea.com>
short_sha sliced &sha[..7]. git_sha comes from the API, so a non-ASCII value
would split a UTF-8 char and panic mid-scan -- for incremental, on a code
path every scan now runs. Same one-liner in skip_scan.rs, same input.

Co-authored-by: ibrahim <ibrahim@corgea.com>

@corgea-security corgea-security left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review risk: 3/5.

No actionable critical or high-priority defects remain. The submodule issue was fixed, and the image concern was adequately rebutted by the documented server behavior. Risk is moderate because incremental behavior is now enabled by default.

No critical or high-priority changes were found.

Automatic approval was not submitted: automated risk 3/5 exceeds approval threshold 2.

Comment thread src/incremental.rs
/// Payload guard, not policy. The server applies the real ceiling
/// (`INCREMENTAL_SCAN_MAX_FILES`, 300) and falls back to a full scan above it.
/// This only avoids building a multi-megabyte form field to be refused.
const MAX_CHANGED_FILES: usize = 5_000;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not match doghouse?

Comment thread src/incremental.rs

let Some(base_sha) = find_baseline_sha(config, project_name, branch) else {
explain_full_scan(&format!(
"no earlier completed scan of a clean worktree was found for project '{project_name}', \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when query_baseline_scans fails (auth, network, 5xx..), find_baseline_sha returns None and we print “no earlier completed scan of a clean worktree was found..” which is the same message as a project with no baseline

can we split lookup failure from “not found” so users see something more like “could not look up previous scans” instead of implying they have no scan history

Comment thread src/incremental.rs
Comment on lines +159 to +227
for page in 1..=SCAN_LOOKUP_MAX_PAGES {
let response = match api::query_baseline_scans(
&url,
project_name,
BLAST_ENGINE,
page,
SCAN_LOOKUP_PAGE_SIZE,
) {
Ok(response) => response,
Err(e) => {
// A failed lookup proves nothing about the project's history,
// so it means full scan, not error.
crate::log::debug(&format!("Baseline scan lookup failed: {e}"));
return any_branch_fallback;
}
};

let scans = response.scans.unwrap_or_default();
if scans.is_empty() {
break;
}

// Newest first, so the first same-branch match is the best available
// and no later page can improve on it.
if let Some(scan) = usable_baselines(&scans)
.find(|scan| scan.branch.as_deref().is_some_and(|b| b == branch))
{
return scan.git_sha.clone();
}
if any_branch_fallback.is_none() {
any_branch_fallback = usable_baselines(&scans)
.next()
.and_then(|s| s.git_sha.clone());
}

if response
.total_pages
.is_some_and(|total| u32::from(page) >= total)
{
break;
}
}

any_branch_fallback
}

/// Scans on one page that can serve as a baseline, newest first.
fn usable_baselines(scans: &[ScanResponse]) -> impl Iterator<Item = &ScanResponse> {
scans.iter().filter(|scan| is_usable_baseline(scan))
}

/// Whether `scan` may be diffed against.
///
/// Client-side half of the filter doghouse applies picking a baseline itself: a
/// completed blast scan of a whole, clean, non-pull-request commit.
/// `worktree_dirty` must be an explicit `false` — `None` means never reported,
/// and unknown scope is not clean, so the server rejects it as a baseline too.
///
/// `query_baseline_scans` asks the server for exactly these, which keeps the
/// page walk from iterating. This stays because a backend predating those
/// parameters ignores them, and a dirty or pull-request scan's commit would
/// diff against the wrong tree.
fn is_usable_baseline(scan: &ScanResponse) -> bool {
classify_scan_status(&scan.status) == ScanState::Completed
&& scan.engine.eq_ignore_ascii_case(BLAST_ENGINE)
&& scan.pull_request_id.is_none()
&& scan.worktree_dirty == Some(false)
&& scan.git_sha.as_deref().is_some_and(|sha| !sha.is_empty())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scan-list responses omit scan_errors, so a completed baseline can carry incomplete findings. could we fetch each candidate detail before selecting it, then fall back when it has warnings?

Comment thread src/main.rs
#[arg(long, help = "Only scan uncommitted changes.")]
only_uncommitted: bool,

#[arg(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the reuse path returns before start_new_scan, so this flag pair skips analysis. should these flags conflict?

Comment thread src/incremental.rs
Comment on lines +188 to +192
if any_branch_fallback.is_none() {
any_branch_fallback = usable_baselines(&scans)
.next()
.and_then(|s| s.git_sha.clone());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cross-branch fallback and baseline pagination/error behavior remain untested. could we add coverage for those paths?

@yhoztak

yhoztak commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Feedback from meeting. Whatever transunion is currently using, like --skip-duplicate --ignore-dirtyworktree,
those arguments should not suppress this incremental behavior.
I don't see anything that'd change the behvaior so it should be good but just noting.

@yhoztak yhoztak left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dennis-reviewed Dennis completed an automated review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants