From 43b758063c2f9184811fb0e6b5862e303cb952ab Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Thu, 6 Aug 2026 14:44:28 +0300 Subject: [PATCH] COR-1766: report dirty worktree state with corgea scan uploads --- src/scanners/blast.rs | 18 ++++- src/utils/api.rs | 7 ++ src/utils/generic.rs | 92 ++++++++++++++++++++++++-- tests/cloud_commands_e2e/common/mod.rs | 20 ++++-- tests/cloud_commands_e2e/scan_list.rs | 27 ++++++++ 5 files changed, 151 insertions(+), 13 deletions(-) diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 4923f05..4e41614 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -64,7 +64,6 @@ pub fn run( fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let project_name = utils::generic::determine_project_name(project_name.as_deref()); let zip_path = format!("{}/{}.zip", temp_dir.display(), project_name); - let repo_info = utils::generic::get_repo_info("./").unwrap_or_default(); match utils::generic::create_path_if_not_exists(&temp_dir) { Ok(_) => (), Err(e) => { @@ -207,6 +206,23 @@ pub fn run( "\r{}Project packaged successfully.\n", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Green) ); + // Read dirty/sha after packaging so the flag matches the uploaded archive. + let repo_info = utils::generic::get_repo_info("./").unwrap_or_default(); + if let Some(ref info) = repo_info { + if info.dirty { + match info.sha.as_deref() { + Some(sha) => { + let short_sha = &sha[..sha.len().min(7)]; + println!( + "Working tree has uncommitted changes - scanning your local files, not commit {short_sha}." + ); + } + None => { + println!("Working tree has uncommitted changes - scanning your local files.") + } + } + } + } println!("\n\nSubmitting scan to Corgea:"); let upload_result = match utils::api::upload_zip( &zip_path, diff --git a/src/utils/api.rs b/src/utils/api.rs index 6bfc784..2b1cba8 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -18,6 +18,8 @@ use std::path::Path; const CHUNK_SIZE: usize = 50 * 1024 * 1024; // 50 MB const API_BASE: &str = "/api/v1"; +const DIRTY_TRUE: &str = "true"; +const DIRTY_FALSE: &str = "false"; fn auth_headers(token: &str) -> HeaderMap { let mut headers = HeaderMap::new(); @@ -336,6 +338,11 @@ pub fn upload_zip( if let Some(sha) = &info.sha { form = form.part("sha", multipart::Part::text(sha.to_string())); } + // Always send dirty: omitted field = old CLI; "false" = clean tree. + form = form.part( + "dirty", + multipart::Part::text(if info.dirty { DIRTY_TRUE } else { DIRTY_FALSE }), + ); } if let Some(scan_type) = scan_type.clone() { let scan_type = if scan_type.contains("blast") { diff --git a/src/utils/generic.rs b/src/utils/generic.rs index 3d28131..5e45e3d 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -1,5 +1,5 @@ use crate::utils::terminal::{set_text_color, TerminalColor}; -use git2::Repository; +use git2::{Repository, StatusOptions}; use globset::{Glob, GlobSetBuilder}; use ignore::WalkBuilder; use std::env; @@ -297,13 +297,33 @@ pub fn get_repo_info(dir: &str) -> Result, git2::Error> { .map(|commit| commit.id().to_string()) }); + let dirty = is_worktree_dirty(&repo); + Ok(Some(RepoInfo { branch, repo_url: origin_url(&repo), sha, + dirty, })) } +/// True when the worktree has modified, staged, or untracked files. +/// Gitignored paths alone do not count; submodules are excluded. +/// +/// Untracked paths that packaging would later drop via `DEFAULT_EXCLUDE_GLOBS` +/// still count as dirty (false-positive dirty costs a full scan, not a miss). +/// Status errors also treat the tree as dirty so we never claim clean HEAD. +fn is_worktree_dirty(repo: &Repository) -> bool { + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .include_ignored(false) + .exclude_submodules(true); + repo.statuses(Some(&mut opts)) + .map(|s| !s.is_empty()) + .unwrap_or(true) +} + /// `origin`'s URL, or None when the remote is missing or carries no URL. fn origin_url(repo: &Repository) -> Option { repo.find_remote("origin") @@ -412,6 +432,7 @@ pub struct RepoInfo { pub branch: Option, pub repo_url: Option, pub sha: Option, + pub dirty: bool, } #[cfg(test)] @@ -440,12 +461,7 @@ mod tests { fn get_repo_info_at_root_only_not_nested_cwd() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); - git(root, &["init"]); - git(root, &["config", "user.email", "test@example.com"]); - git(root, &["config", "user.name", "Test"]); - fs::write(root.join("README"), "hi").unwrap(); - git(root, &["add", "README"]); - git(root, &["commit", "-m", "init"]); + init_committed_repo(root); let root_s = root.to_str().unwrap(); let nested = root.join("pkg").join("inner"); @@ -456,6 +472,7 @@ mod tests { .unwrap() .expect("repo root should yield SHA metadata"); assert!(info.sha.is_some()); + assert!(!info.dirty, "clean commit should report dirty=false"); assert!(is_at_repo_root(root_s)); assert!( @@ -465,6 +482,67 @@ mod tests { assert!(!is_at_repo_root(nested_s)); } + fn init_committed_repo(root: &std::path::Path) { + git(root, &["init"]); + git(root, &["config", "user.email", "test@example.com"]); + git(root, &["config", "user.name", "Test"]); + fs::write(root.join("README"), "hi").unwrap(); + git(root, &["add", "README"]); + git(root, &["commit", "-m", "init"]); + } + + #[test] + fn get_repo_info_dirty_true_when_tracked_file_modified() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + init_committed_repo(root); + fs::write(root.join("README"), "changed").unwrap(); + let info = get_repo_info(root.to_str().unwrap()) + .unwrap() + .expect("repo info"); + assert!(info.dirty); + } + + #[test] + fn get_repo_info_dirty_true_when_change_staged() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + init_committed_repo(root); + fs::write(root.join("README"), "staged").unwrap(); + git(root, &["add", "README"]); + let info = get_repo_info(root.to_str().unwrap()) + .unwrap() + .expect("repo info"); + assert!(info.dirty); + } + + #[test] + fn get_repo_info_dirty_true_when_untracked_file() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + init_committed_repo(root); + fs::write(root.join("new.py"), "print(1)").unwrap(); + let info = get_repo_info(root.to_str().unwrap()) + .unwrap() + .expect("repo info"); + assert!(info.dirty); + } + + #[test] + fn get_repo_info_dirty_false_when_only_gitignored_file() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + init_committed_repo(root); + fs::write(root.join(".gitignore"), "ignored.txt\n").unwrap(); + git(root, &["add", ".gitignore"]); + git(root, &["commit", "-m", "ignore"]); + fs::write(root.join("ignored.txt"), "secret").unwrap(); + let info = get_repo_info(root.to_str().unwrap()) + .unwrap() + .expect("repo info"); + assert!(!info.dirty); + } + #[test] fn create_zip_from_target_excludes_default_globs() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/cloud_commands_e2e/common/mod.rs b/tests/cloud_commands_e2e/common/mod.rs index 3cd26a8..b475a28 100644 --- a/tests/cloud_commands_e2e/common/mod.rs +++ b/tests/cloud_commands_e2e/common/mod.rs @@ -767,12 +767,17 @@ pub(crate) fn assert_issue_summary(stdout: &str, context: &str) { } pub(crate) fn blast_plan(sha: &str) -> Vec { + blast_upload_plan(sha, false, true) +} + +/// BLAST upload contract. `include_sca` covers `--fail-on malicious` (SCA fetch). +pub(crate) fn blast_upload_plan(sha: &str, dirty: bool, include_sca: bool) -> Vec { let patch_sha = sha.to_string(); + let dirty_value = if dirty { "true" } else { "false" }.to_string(); let patch_path = "/api/v1/start-scan/transfer-123/".to_string(); let detail_path = "/api/v1/scan/blast-scan-123".to_string(); let issue_path = "/api/v1/scan/blast-scan-123/issues".to_string(); - let sca_path = "/api/v1/scan/blast-scan-123/issues/sca".to_string(); - vec![ + let mut plan = vec![ verify_request(), expected_request( "start BLAST upload", @@ -808,6 +813,7 @@ pub(crate) fn blast_plan(sha: &str) -> Vec { "https://github.com/corgea/cloud-e2e.git", )?; assert_multipart_text_field(request, "sha", &patch_sha)?; + assert_multipart_text_field(request, "dirty", &dirty_value)?; assert_body_contains(request, b"name=\"chunk_data\"") }, json_response(json!({ @@ -829,7 +835,10 @@ pub(crate) fn blast_plan(sha: &str) -> Vec { }, json_response(empty_issue_page()), ), - expected_request( + ]; + if include_sca { + let sca_path = "/api/v1/scan/blast-scan-123/issues/sca".to_string(); + plan.push(expected_request( "read malicious SCA issues", move |request| { assert_authenticated_request(request, Method::GET, &sca_path)?; @@ -837,6 +846,7 @@ pub(crate) fn blast_plan(sha: &str) -> Vec { assert_query(request, "page_size", "30") }, json_response(malicious_sca_issue_page()), - ), - ] + )); + } + plan } diff --git a/tests/cloud_commands_e2e/scan_list.rs b/tests/cloud_commands_e2e/scan_list.rs index 9632472..f210d48 100644 --- a/tests/cloud_commands_e2e/scan_list.rs +++ b/tests/cloud_commands_e2e/scan_list.rs @@ -26,6 +26,10 @@ fn scan_fail_on_malicious_sends_sha_and_list_renders_it() { scan_stdout.contains("matched --fail-on malicious"), "{scan_context}" ); + assert!( + !scan_stdout.contains("Working tree has uncommitted changes"), + "clean tree must not print dirty notice\n{scan_context}" + ); let list_response_sha = project.sha.clone(); let list_api = ApiStub::start(vec![ @@ -72,6 +76,29 @@ fn scan_fail_on_malicious_sends_sha_and_list_renders_it() { assert!(list_stdout.contains(&project.sha[..8]), "{list_context}"); } +#[test] +fn scan_dirty_worktree_sends_dirty_true_and_prints_notice() { + let project = git_project(); + std::fs::write(project.path().join("main.py"), "print('dirty')\n") + .expect("modify tracked file"); + let short_sha = &project.sha[..7]; + let scan_api = ApiStub::start(blast_upload_plan(&project.sha, true, false)); + let (mut scan_command, _scan_home) = cloud_command(&scan_api, project.path()); + scan_command.args(["scan", "blast", "--project-name", "cloud-e2e"]); + + let scan_output = run_with_timeout(scan_command, &scan_api); + let scan_transcript = scan_api.assert_finished(); + let scan_context = output_context(&scan_output, &scan_transcript); + assert_eq!(scan_output.status.code(), Some(0), "{scan_context}"); + let scan_stdout = String::from_utf8_lossy(&scan_output.stdout); + assert!( + scan_stdout.contains(&format!( + "Working tree has uncommitted changes - scanning your local files, not commit {short_sha}." + )), + "{scan_context}" + ); +} + #[test] fn list_json_returns_filtered_scan_contract() { let project = TempDir::new().expect("create list project");