-
Notifications
You must be signed in to change notification settings - Fork 1
COR-1766: report dirty worktree state with corgea scan uploads #150
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
@@ -109,7 +109,9 @@ pub fn create_zip_from_target<P: AsRef<Path>>( | |
| let mut excluded_files = Vec::new(); | ||
|
|
||
| for (path, relative_path) in files_to_zip { | ||
| let is_excluded = glob_set.is_match(&path); | ||
| // Match against the repo-relative path. Absolute paths (target mode) | ||
| // can live under `/tmp/...` on Linux and would falsely hit `**/tmp/**`. | ||
| let is_excluded = glob_set.is_match(&relative_path); | ||
|
|
||
| if (path.is_file() || path.is_dir()) && !is_excluded { | ||
| if path.is_file() { | ||
|
|
@@ -297,13 +299,62 @@ pub fn get_repo_info(dir: &str) -> Result<Option<RepoInfo>, git2::Error> { | |
| .map(|commit| commit.id().to_string()) | ||
| }); | ||
|
|
||
| let dirty = is_worktree_dirty(&repo); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. get_repo_info now recursively walks untracked directories even for project-name lookups; should we separate lightweight repository identity from upload-only dirty sampling? |
||
|
|
||
| Ok(Some(RepoInfo { | ||
| branch, | ||
| repo_url: origin_url(&repo), | ||
| sha, | ||
| dirty, | ||
| })) | ||
| } | ||
|
|
||
| /// True when the worktree has modified, staged, or untracked files (including | ||
| /// dirty submodules). Gitignored paths alone do not count. | ||
| /// | ||
| /// 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(); | ||
| // Include submodule status: packaging walks into submodule dirs, so a | ||
| // modified checkout must not be advertised as clean parent HEAD. | ||
| opts.include_untracked(true) | ||
| .recurse_untracked_dirs(true) | ||
| .include_ignored(false); | ||
| repo.statuses(Some(&mut opts)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. external symlink targets can change archived bytes without affecting Git status; could we build clean archives from Git objects or preserve/reject symlinks? |
||
| .map(|s| !s.is_empty()) | ||
| .unwrap_or(true) | ||
| } | ||
|
|
||
| /// Merge before/after packaging samples into upload metadata. | ||
| /// | ||
| /// Only `dirty=false` when both samples exist, are clean, and share the same | ||
| /// SHA. Any missing sample, dirty sample, or SHA drift fails safe to dirty. | ||
| /// Prefer the post-packaging sample for branch/url/sha display. | ||
| pub fn reconcile_repo_info_for_upload( | ||
| before: Option<RepoInfo>, | ||
| after: Option<RepoInfo>, | ||
| ) -> Option<RepoInfo> { | ||
| match (before, after) { | ||
| (None, None) => None, | ||
| (Some(sample), None) | (None, Some(sample)) => Some(RepoInfo { | ||
| dirty: true, | ||
| ..sample | ||
| }), | ||
| (Some(before), Some(after)) => { | ||
| let stable_clean = | ||
| !before.dirty && !after.dirty && before.sha.is_some() && before.sha == after.sha; | ||
| Some(RepoInfo { | ||
| branch: after.branch.or(before.branch), | ||
| repo_url: after.repo_url.or(before.repo_url), | ||
| sha: after.sha.or(before.sha), | ||
| dirty: !stable_clean, | ||
| }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// `origin`'s URL, or None when the remote is missing or carries no URL. | ||
| fn origin_url(repo: &Repository) -> Option<String> { | ||
| repo.find_remote("origin") | ||
|
|
@@ -407,11 +458,13 @@ pub fn get_status(status: &str) -> &str { | |
| } | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| #[derive(Debug, Clone)] | ||
| pub struct RepoInfo { | ||
| pub branch: Option<String>, | ||
| pub repo_url: Option<String>, | ||
| pub sha: Option<String>, | ||
| /// True when the upload must not be treated as an exact clean HEAD snapshot. | ||
| pub dirty: bool, | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
|
|
@@ -440,12 +493,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 +504,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 +514,169 @@ 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); | ||
| } | ||
|
|
||
| fn sample_info(sha: &str, dirty: bool) -> RepoInfo { | ||
| RepoInfo { | ||
| branch: Some("main".into()), | ||
| repo_url: Some("https://github.com/org/repo.git".into()), | ||
| sha: Some(sha.into()), | ||
| dirty, | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn reconcile_clean_same_sha_stays_clean() { | ||
| let before = sample_info("aaa", false); | ||
| let after = sample_info("aaa", false); | ||
| let out = reconcile_repo_info_for_upload(Some(before), Some(after)).unwrap(); | ||
| assert!(!out.dirty); | ||
| assert_eq!(out.sha.as_deref(), Some("aaa")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn reconcile_sha_drift_marks_dirty() { | ||
| let before = sample_info("aaa", false); | ||
| let after = sample_info("bbb", false); | ||
| let out = reconcile_repo_info_for_upload(Some(before), Some(after)).unwrap(); | ||
| assert!(out.dirty); | ||
| assert_eq!(out.sha.as_deref(), Some("bbb")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn reconcile_either_dirty_marks_dirty() { | ||
| let before = sample_info("aaa", true); | ||
| let after = sample_info("aaa", false); | ||
| let out = reconcile_repo_info_for_upload(Some(before), Some(after)).unwrap(); | ||
| assert!(out.dirty); | ||
|
|
||
| let before = sample_info("aaa", false); | ||
| let after = sample_info("aaa", true); | ||
| let out = reconcile_repo_info_for_upload(Some(before), Some(after)).unwrap(); | ||
| assert!(out.dirty); | ||
| } | ||
|
|
||
| #[test] | ||
| fn reconcile_missing_sample_marks_dirty() { | ||
| let only = sample_info("aaa", false); | ||
| assert!( | ||
| reconcile_repo_info_for_upload(Some(only.clone()), None) | ||
| .unwrap() | ||
| .dirty | ||
| ); | ||
| assert!( | ||
| reconcile_repo_info_for_upload(None, Some(only)) | ||
| .unwrap() | ||
| .dirty | ||
| ); | ||
| assert!(reconcile_repo_info_for_upload(None, None).is_none()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn get_repo_info_dirty_when_submodule_content_modified() { | ||
| let parent_dir = tempfile::tempdir().unwrap(); | ||
| let parent = parent_dir.path(); | ||
| init_committed_repo(parent); | ||
|
|
||
| // Keep the submodule source outside the parent so it is not an | ||
| // untracked sibling that would itself mark the tree dirty. | ||
| let sub_dir = tempfile::tempdir().unwrap(); | ||
| let sub_src = sub_dir.path(); | ||
| git(sub_src, &["init"]); | ||
| git(sub_src, &["config", "user.email", "test@example.com"]); | ||
| git(sub_src, &["config", "user.name", "Test"]); | ||
| fs::write(sub_src.join("lib.py"), "v1\n").unwrap(); | ||
| git(sub_src, &["add", "lib.py"]); | ||
| git(sub_src, &["commit", "-m", "sub init"]); | ||
|
|
||
| // Modern git blocks file:// clones unless explicitly allowed. | ||
| git( | ||
| parent, | ||
| &[ | ||
| "-c", | ||
| "protocol.file.allow=always", | ||
| "submodule", | ||
| "add", | ||
| sub_src.to_str().unwrap(), | ||
| "vendor", | ||
| ], | ||
| ); | ||
| git(parent, &["commit", "-m", "add submodule"]); | ||
|
|
||
| let clean = get_repo_info(parent.to_str().unwrap()) | ||
| .unwrap() | ||
| .expect("repo info"); | ||
| assert!(!clean.dirty, "committed submodule should be clean"); | ||
|
|
||
| fs::write(parent.join("vendor").join("lib.py"), "v2\n").unwrap(); | ||
| let dirty = get_repo_info(parent.to_str().unwrap()) | ||
| .unwrap() | ||
| .expect("repo info"); | ||
| assert!( | ||
| dirty.dirty, | ||
| "modified submodule checkout must mark parent dirty" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn create_zip_from_target_excludes_default_globs() { | ||
| let dir = tempfile::tempdir().unwrap(); | ||
|
|
@@ -508,6 +720,26 @@ mod tests { | |
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn default_exclude_globs_match_abs_tmp_but_not_repo_relative_paths() { | ||
| // Linux CI tempdirs are `/tmp/...`. Matching DEFAULT_EXCLUDE_GLOBS on the | ||
| // absolute path made `--target` drop every file via `**/tmp/**`. | ||
| let mut builder = GlobSetBuilder::new(); | ||
| for &pattern in DEFAULT_EXCLUDE_GLOBS { | ||
| builder.add(Glob::new(pattern).unwrap()); | ||
| } | ||
| let set = builder.build().unwrap(); | ||
| assert!( | ||
| set.is_match(Path::new("/tmp/proj/app.py")), | ||
| "absolute /tmp paths hit **/tmp/** (the CI failure mode)" | ||
| ); | ||
| assert!( | ||
| !set.is_match(Path::new("app.py")), | ||
| "repo-relative paths must stay scannable" | ||
| ); | ||
| assert!(!set.is_match(Path::new("src/app.py"))); | ||
| } | ||
|
|
||
| #[test] | ||
| fn extract_repo_path_handles_common_remote_forms() { | ||
| for url in [ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.