diff --git a/Cargo.lock b/Cargo.lock index 68d51d3..01cfc31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,7 +369,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corgea" -version = "1.11.0" +version = "1.11.1" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 5dbc14a..9c74641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corgea" -version = "1.11.0" +version = "1.11.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index 5f050da..0adffc1 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -65,6 +65,7 @@ corgea scan --include-image myapp:1.2.3 --include-image ghcr.io/acme/api:latest corgea scan --project-name my-service # Override project name corgea scan --skip-if-commit-scanned-recently # Reuse a recent scan of this commit instead of scanning again corgea scan --skip-if-commit-scanned-recently --scanned-within 4h # Window for "recently" (default 24h) +corgea scan --skip-if-commit-scanned-recently --ignore-dirty-worktree # Reuse even if this tree or the prior scan is dirty ``` Scan types: `blast` (base AI), `policy` (PolicyIQ), `malicious`, `secrets`, `pii`. @@ -85,7 +86,7 @@ An included image is enough on its own: when it is combined with `--only-uncommi `--skip-if-commit-scanned-recently` reuses the project's most recent reusable scan of the current commit instead of starting a duplicate, when one ran inside the `--scanned-within` window (default `24h`; accepts `90s`, `30m`, `4h`, `7d`, and a bare number as hours). The reused scan takes the new scan's place for the rest of the command — results table, `--block-on` gate and its exit code, `--out-file` report — so the pipeline behaves the same either way. It prints `CORGEA_SCAN_SKIPPED=true` plus `CORGEA_SCAN_ID=` on a reuse and `CORGEA_SCAN_SKIPPED=false` when a scan runs, so a later step can branch on it. -Reuse requires a candidate that answers the same question: a completed `corgea-blast` scan of that commit, on a branch rather than a pull request, from an explicitly clean worktree, reporting no scanner problems. Anything else runs a real scan (nothing in the window, a failed or still-running scan, a worktree that does not match the commit including files hidden from `git status`, or a failed lookup). An unresolvable commit is a hard error (exit 1). Because the API exposes neither a scan's configured scan types and target policies nor whether it bundled a container image, a run that changes what gets scanned cannot be matched against a candidate, so the flag cannot be combined with `--scan-type`, `--policy`, `--include-image`, `--only-uncommitted`, or `--target`. `--exclude` is allowed but warns on a skip: what gets reused is a scan of the whole commit, so the results and the gate can cover files the run would have skipped (over-reporting, never under-reporting). +Reuse requires a candidate that answers the same question: a completed `corgea-blast` scan of that commit, on a branch rather than a pull request, from an explicitly clean worktree, reporting no scanner problems. Anything else runs a real scan (nothing in the window, a failed or still-running scan, a worktree that does not match the commit including files hidden from `git status`, or a failed lookup). `--ignore-dirty-worktree` (requires `--skip-if-commit-scanned-recently`) overrides the dirty-worktree half of that test: reuse proceeds even if this worktree is dirty or the prior scan recorded `worktree_dirty=true`. A prior scan that never reported the flag is still not reused. A new scan still reports the real dirty status. An unresolvable commit is a hard error (exit 1). Because the API exposes neither a scan's configured scan types and target policies nor whether it bundled a container image, a run that changes what gets scanned cannot be matched against a candidate, so the flag cannot be combined with `--scan-type`, `--policy`, `--include-image`, `--only-uncommitted`, or `--target`. `--exclude` is allowed but warns on a skip: what gets reused is a scan of the whole commit, so the results and the gate can cover files the run would have skipped (over-reporting, never under-reporting). ### Upload — `corgea upload [report]` diff --git a/src/main.rs b/src/main.rs index 3f3695e..e2920e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -185,6 +185,13 @@ enum Commands { help = "How recent a prior scan of the same commit must be for --skip-if-commit-scanned-recently to reuse it, e.g. 90s, 30m, 24h, 7d (a bare number means hours). Defaults to 24h, because unchanged code is still exposed to advisories published since it was last scanned." )] scanned_within: Option, + + #[arg( + long = "ignore-dirty-worktree", + requires = "skip_if_commit_scanned_recently", + help = "With --skip-if-commit-scanned-recently, reuse a recent scan of this commit even if this worktree is dirty or the prior scan recorded worktree_dirty. A new scan still reports the real dirty status." + )] + ignore_dirty_worktree: bool, }, /// Wait for the latest in progress scan Wait { @@ -674,6 +681,7 @@ fn main() { include_image, skip_if_commit_scanned_recently, scanned_within, + ignore_dirty_worktree, }) => { verify_token_and_exit_when_fail(&corgea_config); if let Some(level) = fail_on { @@ -849,6 +857,7 @@ fn main() { sbom.clone(), include_images, skip_recent, + ignore_dirty_worktree, ), } } diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 9841ba2..bbc5dc3 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -64,6 +64,7 @@ pub fn run( sbom: Option, include_images: Vec, skip_recent: Option, + ignore_dirty_worktree: &bool, ) { // Validate that only_uncommitted and target are not used together if *only_uncommitted && target.is_some() { @@ -93,7 +94,13 @@ pub fn run( // the results table, the blocking-rule gate, the report file — runs against // whichever scan id this resolves to. let reused_scan = skip_recent.as_ref().and_then(|skip| { - crate::skip_scan::resolve_reusable_scan(config, &project_name, skip, exclude.as_deref()) + crate::skip_scan::resolve_reusable_scan( + config, + &project_name, + skip, + exclude.as_deref(), + *ignore_dirty_worktree, + ) }); let (scan_id, project_id) = match reused_scan { diff --git a/src/skip_scan.rs b/src/skip_scan.rs index dc13218..7ec5ccd 100644 --- a/src/skip_scan.rs +++ b/src/skip_scan.rs @@ -10,7 +10,12 @@ //! //! Recency is a policy, not a technicality — the same commit scanned last week //! predates whatever advisories landed since, so a scan is only reusable -//! inside the window (24h by default). +//! inside the window (24h by default). `--ignore-dirty-worktree` is the +//! explicit override for reuse only: a dirty current tree, or a prior scan +//! that recorded `worktree_dirty=true`, can still stand in for this commit. +//! A prior scan that never reported the flag (`None`) is still rejected — +//! unknown scope is not dirtiness. A new scan still reports the real dirty +//! status. //! //! One scan may only stand in for another when it answers the same question, //! which is a stricter test than "same commit". Doghouse already settled what @@ -107,7 +112,10 @@ pub fn parse_window(raw: &str) -> Result { /// /// Every `None` is a decision to do the more expensive, more correct thing, so /// a lookup failure, an unreadable timestamp, or a dirty worktree all land -/// here rather than skipping a scan on incomplete information. The one hard +/// here rather than skipping a scan on incomplete information. `--ignore-dirty-worktree` +/// is the exception for known dirtiness: a dirty current tree, or a prior scan +/// that recorded `worktree_dirty=true`, can still be reused. `None` is still +/// rejected. The one hard /// failure is an unresolvable commit: the flag asks a question about the /// commit, and without one there is no question to answer. pub fn resolve_reusable_scan( @@ -115,6 +123,7 @@ pub fn resolve_reusable_scan( project_name: &str, skip: &SkipRecentScan, exclude: Option<&str>, + ignore_dirty_worktree: bool, ) -> Option { // `dirty`, not `status_dirty`: this asks whether the run would upload an // exact snapshot of the commit, and that is the flag the upload itself @@ -135,12 +144,19 @@ pub fn resolve_reusable_scan( let short = short_sha(&sha); if worktree_dirty { - println!( - "Working tree does not match commit {} exactly (uncommitted changes, or files the index hides from git status), so no scan of that commit describes what would be scanned here - running a new scan.", - short - ); - print_skipped_marker(None); - return None; + if ignore_dirty_worktree { + println!( + "Ignoring dirty worktree (--ignore-dirty-worktree); treating this as a scan of commit {}.", + short + ); + } else { + println!( + "Working tree does not match commit {} exactly (uncommitted changes, or files the index hides from git status), so no scan of that commit describes what would be scanned here - running a new scan.", + short + ); + print_skipped_marker(None); + return None; + } } println!( @@ -148,7 +164,14 @@ pub fn resolve_reusable_scan( short, project_name, skip.label ); - let found = match find_reusable_scan(config, project_name, &sha, skip.window, Utc::now()) { + let found = match find_reusable_scan( + config, + project_name, + &sha, + skip.window, + Utc::now(), + ignore_dirty_worktree, + ) { Ok(found) => found, Err(e) => { log::warn!( @@ -208,6 +231,7 @@ fn find_reusable_scan( sha: &str, window: Duration, now: DateTime, + ignore_dirty_worktree: bool, ) -> Result, String> { let mut page = 1; loop { @@ -223,7 +247,9 @@ fn find_reusable_scan( if scans.is_empty() { return Ok(None); } - if let Some(reusable) = select_reusable_scan(&scans, sha, now, window) { + if let Some(reusable) = + select_reusable_scan(&scans, sha, now, window, ignore_dirty_worktree) + { return Ok(Some((reusable.scan.clone(), reusable.age))); } // Newest first, so a page that ends outside the window is the end of the @@ -296,9 +322,10 @@ pub fn select_reusable_scan<'a>( sha: &str, now: DateTime, window: Duration, + ignore_dirty_worktree: bool, ) -> Option> { for scan in scans { - match scan_age_if_reusable(scan, sha, now, window) { + match scan_age_if_reusable(scan, sha, now, window, ignore_dirty_worktree) { Ok(age) => { return Some(ReusableScan { scan, @@ -324,6 +351,7 @@ fn scan_age_if_reusable( sha: &str, now: DateTime, window: Duration, + ignore_dirty_worktree: bool, ) -> Result { if !scan_matches_commit(scan, sha) { return match scan.git_sha.as_deref() { @@ -351,11 +379,17 @@ fn scan_age_if_reusable( // platform and scheduled scans do record `false`, and the scans that do not // include the partial `--target`/`--exclude` uploads of older CLIs — which // this run has no way to tell apart from whole-commit ones. - if scan.worktree_dirty != Some(false) { - return match scan.worktree_dirty { - Some(true) => Err("it scanned a worktree with uncommitted changes".to_string()), - _ => Err("it did not report whether its worktree was clean".to_string()), - }; + // `--ignore-dirty-worktree` may reuse a known-dirty scan (`Some(true)`), + // but not `None`: unknown scope is not dirtiness. + match scan.worktree_dirty { + Some(false) => {} + Some(true) if ignore_dirty_worktree => {} + Some(true) => { + return Err("it scanned a worktree with uncommitted changes".to_string()); + } + None => { + return Err("it did not report whether its worktree was clean".to_string()); + } } let created_at = parse_timestamp(&scan.created_at) .ok_or_else(|| format!("its timestamp '{}' could not be read", scan.created_at))?; @@ -485,7 +519,8 @@ mod tests { scan("newer", "complete", Some(SHA), "2026-01-01T21:00:00Z"), scan("older", "complete", Some(SHA), "2026-01-01T12:00:00Z"), ]; - let reusable = select_reusable_scan(&scans, SHA, now(), DAY).expect("expected a reuse"); + let reusable = + select_reusable_scan(&scans, SHA, now(), DAY, false).expect("expected a reuse"); assert_eq!(reusable.scan.id, "newer"); assert_eq!(reusable.age, "3h 0m"); } @@ -498,7 +533,8 @@ mod tests { scan("failed", "incomplete", Some(SHA), "2026-01-01T23:00:00Z"), scan("good", "complete", Some(SHA), "2026-01-01T22:00:00Z"), ]; - let reusable = select_reusable_scan(&scans, SHA, now(), DAY).expect("expected a reuse"); + let reusable = + select_reusable_scan(&scans, SHA, now(), DAY, false).expect("expected a reuse"); assert_eq!(reusable.scan.id, "good"); } @@ -507,7 +543,7 @@ mod tests { for status in ["processing", "scanning", "incomplete", "failed", ""] { let scans = vec![scan("s", status, Some(SHA), "2026-01-01T23:00:00Z")]; assert!( - select_reusable_scan(&scans, SHA, now(), DAY).is_none(), + select_reusable_scan(&scans, SHA, now(), DAY, false).is_none(), "status {status} must not be reused" ); } @@ -527,7 +563,7 @@ mod tests { ), scan("no-commit", "complete", None, "2026-01-01T23:00:00Z"), ]; - assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_none()); + assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_none()); } #[test] @@ -538,7 +574,7 @@ mod tests { Some(&SHA.to_uppercase()), "2026-01-01T23:00:00Z", )]; - assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_some()); + assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_some()); } #[test] @@ -546,7 +582,7 @@ mod tests { // The point of the window: the code is unchanged, but the advisories // it is scanned against are not. let scans = vec![scan("stale", "complete", Some(SHA), "2025-12-30T00:00:00Z")]; - assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_none()); + assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_none()); // A shorter window is what makes a scan from this morning stale. let scans = vec![scan( "morning", @@ -554,13 +590,15 @@ mod tests { Some(SHA), "2026-01-01T20:00:00Z", )]; - assert!(select_reusable_scan(&scans, SHA, now(), Duration::from_secs(3_600)).is_none()); + assert!( + select_reusable_scan(&scans, SHA, now(), Duration::from_secs(3_600), false).is_none() + ); } #[test] fn a_scan_exactly_at_the_window_edge_is_still_reusable() { let scans = vec![scan("edge", "complete", Some(SHA), "2026-01-01T00:00:00Z")]; - assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_some()); + assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_some()); } #[test] @@ -568,7 +606,22 @@ mod tests { // Those results describe someone's uncommitted edits, not this commit. let mut dirty = scan("dirty", "complete", Some(SHA), "2026-01-01T23:00:00Z"); dirty.worktree_dirty = Some(true); - assert!(select_reusable_scan(&[dirty], SHA, now(), DAY).is_none()); + assert!(select_reusable_scan(&[dirty], SHA, now(), DAY, false).is_none()); + } + + #[test] + fn ignore_dirty_worktree_reuses_a_dirty_prior_scan() { + let mut dirty = scan("dirty", "complete", Some(SHA), "2026-01-01T23:00:00Z"); + dirty.worktree_dirty = Some(true); + assert!(select_reusable_scan(&[dirty], SHA, now(), DAY, true).is_some()); + } + + #[test] + fn ignore_dirty_worktree_still_rejects_a_scan_that_never_reported_dirtiness() { + // `None` is unknown scope (legacy / partial uploads), not known dirty. + let mut unknown = scan("unknown", "complete", Some(SHA), "2026-01-01T23:00:00Z"); + unknown.worktree_dirty = None; + assert!(select_reusable_scan(&[unknown], SHA, now(), DAY, true).is_none()); } #[test] @@ -579,7 +632,7 @@ mod tests { // indistinguishable from whole-commit ones from here. let mut unknown = scan("unknown", "complete", Some(SHA), "2026-01-01T23:00:00Z"); unknown.worktree_dirty = None; - assert!(select_reusable_scan(&[unknown], SHA, now(), DAY).is_none()); + assert!(select_reusable_scan(&[unknown], SHA, now(), DAY, false).is_none()); } #[test] @@ -588,7 +641,7 @@ mod tests { // to the diff, so it cannot stand in for a branch build of the commit. let mut pr_scan = scan("pr", "complete", Some(SHA), "2026-01-01T23:00:00Z"); pr_scan.pull_request_id = Some("42".to_string()); - assert!(select_reusable_scan(&[pr_scan], SHA, now(), DAY).is_none()); + assert!(select_reusable_scan(&[pr_scan], SHA, now(), DAY, false).is_none()); } #[test] @@ -597,17 +650,17 @@ mod tests { // which is not what `corgea scan blast` was asked to produce. let mut semgrep = scan("semgrep", "complete", Some(SHA), "2026-01-01T23:00:00Z"); semgrep.engine = "semgrep".to_string(); - assert!(select_reusable_scan(&[semgrep], SHA, now(), DAY).is_none()); + assert!(select_reusable_scan(&[semgrep], SHA, now(), DAY, false).is_none()); // Every blast scan carries this engine, whoever started it. let mut blast = scan("blast", "complete", Some(SHA), "2026-01-01T23:00:00Z"); blast.engine = BLAST_ENGINE.to_uppercase(); - assert!(select_reusable_scan(&[blast], SHA, now(), DAY).is_some()); + assert!(select_reusable_scan(&[blast], SHA, now(), DAY, false).is_some()); } #[test] fn unreadable_timestamps_do_not_skip_the_scan() { let scans = vec![scan("bad-time", "complete", Some(SHA), "not a timestamp")]; - assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_none()); + assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_none()); } #[test] @@ -622,7 +675,7 @@ mod tests { ] { let scans = vec![scan("s", "complete", Some(SHA), raw)]; assert!( - select_reusable_scan(&scans, SHA, now(), DAY).is_some(), + select_reusable_scan(&scans, SHA, now(), DAY, false).is_some(), "{raw} should parse" ); } @@ -638,13 +691,14 @@ mod tests { Some(SHA), "2026-01-02T01:00:00Z", )]; - let reusable = select_reusable_scan(&scans, SHA, now(), DAY).expect("expected a reuse"); + let reusable = + select_reusable_scan(&scans, SHA, now(), DAY, false).expect("expected a reuse"); assert_eq!(reusable.age, "0s"); } #[test] fn empty_scan_list_reuses_nothing() { - assert!(select_reusable_scan(&[], SHA, now(), DAY).is_none()); + assert!(select_reusable_scan(&[], SHA, now(), DAY, false).is_none()); } #[test] diff --git a/tests/cloud_commands_e2e/scan_skip.rs b/tests/cloud_commands_e2e/scan_skip.rs index 7549306..0335a51 100644 --- a/tests/cloud_commands_e2e/scan_skip.rs +++ b/tests/cloud_commands_e2e/scan_skip.rs @@ -333,6 +333,171 @@ fn a_file_hidden_from_git_status_scans_instead_of_reusing() { ); } +/// `--ignore-dirty-worktree` lets `--skip-if-commit-scanned-recently` reuse a +/// prior scan even when this worktree is dirty. A new scan still reports dirty. +#[test] +fn ignore_dirty_worktree_reuses_a_scan_a_dirty_tree_would_otherwise_run() { + let project = git_project(); + std::fs::write(project.path().join("main.py"), "print('dirty')\n") + .expect("modify tracked file"); + let api = ApiStub::start(vec![ + verify_request(), + commit_lookup(&project.sha, vec![prior_scan(&project.sha, &ago(3))]), + clean_detail(&project.sha), + reused_scan_issues(), + reused_scan_blocking_rules(false), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--ignore-dirty-worktree", + "--block-on", + "criticals", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!( + stdout.contains("Ignoring dirty worktree (--ignore-dirty-worktree)"), + "{context}" + ); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=true"), "{context}"); + assert!(!stdout.contains("Scanning with BLAST"), "{context}"); + assert!( + !stdout.contains("Working tree does not match commit"), + "{context}" + ); +} + +/// Same override for index hide-bits: assume-unchanged is invisible to +/// `git status` but still blocks reuse unless ignored. +#[test] +fn ignore_dirty_worktree_reuses_when_a_file_is_hidden_from_git_status() { + let project = git_project(); + run_git( + project.path(), + &["update-index", "--assume-unchanged", "main.py"], + ); + std::fs::write(project.path().join("main.py"), "print('hidden change')\n") + .expect("modify assume-unchanged file"); + let api = ApiStub::start(vec![ + verify_request(), + commit_lookup(&project.sha, vec![prior_scan(&project.sha, &ago(3))]), + clean_detail(&project.sha), + reused_scan_issues(), + reused_scan_blocking_rules(false), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--ignore-dirty-worktree", + "--block-on", + "criticals", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=true"), "{context}"); + assert!( + stdout.contains("Ignoring dirty worktree (--ignore-dirty-worktree)"), + "{context}" + ); +} + +/// A prior scan that itself recorded `worktree_dirty` is also reusable when +/// the override is on — the customer case where the last scan of the commit +/// was marked dirty even though they consider the tree clean. +#[test] +fn ignore_dirty_worktree_reuses_a_prior_dirty_scan() { + let project = git_project(); + let mut prior = prior_scan(&project.sha, &ago(3)); + prior["worktree_dirty"] = json!(true); + let mut detail = prior.clone(); + detail["scan_errors"] = json!([]); + let path = format!("/api/v1/scan/{PRIOR_SCAN}"); + let api = ApiStub::start(vec![ + verify_request(), + commit_lookup(&project.sha, vec![prior]), + expected_request( + "confirm the dirty scan being reused", + move |request| assert_authenticated_request(request, Method::GET, &path), + json_response(detail), + ), + reused_scan_issues(), + reused_scan_blocking_rules(false), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--ignore-dirty-worktree", + "--block-on", + "criticals", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=true"), "{context}"); + assert!(!stdout.contains("Scanning with BLAST"), "{context}"); +} + +/// The override is only a reuse rule. When nothing can be reused, the new +/// scan still sends the real dirty status. +#[test] +fn ignore_dirty_worktree_still_uploads_dirty_when_nothing_is_reused() { + let project = git_project(); + std::fs::write(project.path().join("main.py"), "print('dirty')\n") + .expect("modify tracked file"); + let mut plan = blast_upload_plan(&project.sha, true, false); + plan.insert(1, commit_lookup(&project.sha, vec![])); + let api = ApiStub::start(plan); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--ignore-dirty-worktree", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=false"), "{context}"); + assert!(stdout.contains("Scanning with BLAST"), "{context}"); + assert!( + stdout.contains("Working tree has uncommitted changes"), + "{context}" + ); +} + /// A prior scan that finished with a scanner's results missing is not reused: a /// fresh scan says so out loud and may also clear a transient failure, while /// reusing it would gate silently on findings known to be incomplete. The scan @@ -532,6 +697,27 @@ fn the_window_cannot_be_set_without_the_skip_flag() { ); } +/// `--ignore-dirty-worktree` only changes reuse; it is meaningless without +/// `--skip-if-commit-scanned-recently`. +#[test] +fn ignore_dirty_worktree_cannot_be_set_without_the_skip_flag() { + let api = ApiStub::start(Vec::new()); + let project = git_project(); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["scan", "blast", "--ignore-dirty-worktree"]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(2), "{context}"); + assert!( + stderr.contains("--skip-if-commit-scanned-recently"), + "{context}" + ); +} + /// A backend that predates the `sha` filter answers with the project's scans /// at every commit; acting on that would skip this commit's scan because a /// different commit was scanned recently.