From f302dc4a4a2bc0653d8086c95a73edcbd63d493d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 5 Dec 2025 15:40:28 -0500 Subject: [PATCH] feat(coverage): add Git tracking awareness for coverage reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add informational display showing Git repository path and untracked files in coverage reports. This helps users understand when coverage files are tracked in Git vs only present on disk. Changes: - Add GitTrackingInfo struct and get_git_tracking_info() function to check if files are tracked in Git - Add untracked_files and git_repo_path fields to the Report struct - Update processor to check Git tracking for files that exist on disk - Add Git repository line to COVERAGE DATA section output - Add untracked files display section showing files on disk but not in Git - Update display limits from 20/10 to 50/50 for missing/untracked files - Update test fixtures to include new Git Repository line in output This is purely informational and does NOT affect validation (which remains disk-based only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 4 +- qlty-cli/src/commands/coverage/publish.rs | 66 ++++++++++++- qlty-cli/tests/cmd/coverage/basic.stderr | 2 + .../coverage/discover_java_src_dirs.stderr | 2 + .../tests/cmd/coverage/files_exist.stderr | 2 + .../tests/cmd/coverage/ignore_patterns.stderr | 2 + qlty-cli/tests/cmd/coverage/json.stderr | 2 + .../cmd/coverage/override_commit_time.stderr | 2 + .../cmd/coverage/override_git_tag.stderr | 2 + qlty-cli/tests/cmd/coverage/overrides.stderr | 2 + .../cmd/coverage/publish_validate.stderr | 2 + .../cmd/without_git/basic_coverage.stderr | 2 + qlty-coverage/src/git.rs | 98 ++++++++++++++++++- qlty-coverage/src/publish/processor.rs | 17 ++++ qlty-coverage/src/publish/report.rs | 6 ++ qlty-coverage/src/validate.rs | 4 +- 16 files changed, 210 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 28bd600e4..b14aa98dc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ flamegraph.svg /.markdownlint.json /.rustfmt.toml /.yamllint.yaml -tmp/ \ No newline at end of file +tmp/ + +docs/developer/plans \ No newline at end of file diff --git a/qlty-cli/src/commands/coverage/publish.rs b/qlty-cli/src/commands/coverage/publish.rs index 0c8e1d58a..df31dc431 100644 --- a/qlty-cli/src/commands/coverage/publish.rs +++ b/qlty-cli/src/commands/coverage/publish.rs @@ -433,6 +433,14 @@ impl Publish { } self.print_section_header(" COVERAGE DATA "); + + if let Some(ref git_path) = report.git_repo_path { + eprintln!(" Git Repository: {}", style(git_path).dim()); + } else { + eprintln!(" Git Repository: {}", style("not found").dim()); + } + eprintln!(); + if report.auto_path_fixing_enabled { eprintln!(" Auto-path fixing: Enabled"); } @@ -497,7 +505,7 @@ impl Publish { let (paths_to_show, show_all) = if self.verbose { (missing_files.len(), true) } else { - (std::cmp::min(20, missing_files.len()), false) + (std::cmp::min(50, missing_files.len()), false) }; eprintln!("\n {}\n", style("Missing code files:").bold().yellow()); @@ -552,6 +560,62 @@ impl Publish { ); } + if !report.untracked_files.is_empty() && report.git_repo_path.is_some() { + let mut untracked = report.untracked_files.iter().collect::>(); + untracked.sort(); + + eprintln!( + " {} {} on disk but {} in Git", + style(untracked.len().to_formatted_string(&Locale::en)).red(), + if untracked.len() == 1 { + "path exists" + } else { + "paths exist" + }, + style("not tracked").red() + ); + + let (paths_to_show, show_all) = if self.verbose { + (untracked.len(), true) + } else { + (std::cmp::min(50, untracked.len()), false) + }; + + eprintln!( + "\n {}\n", + style("Untracked code files (not in Git):").bold().red() + ); + + for path in untracked.iter().take(paths_to_show) { + eprintln!( + " {} {}", + style("*").red(), + style(path.to_string()).red() + ); + } + + if !show_all && paths_to_show < untracked.len() { + let remaining = untracked.len() - paths_to_show; + eprintln!( + " {} {}", + style(format!( + "... and {} more", + remaining.to_formatted_string(&Locale::en) + )) + .dim() + .red(), + style("(Use --verbose to see all)").dim() + ); + } + + eprintln!(); + eprintln!( + " {} Files not tracked in Git may not appear in coverage reports.", + style("NOTE:").bold() + ); + eprintln!(); + } + eprintln!(); // Get formatted numbers first diff --git a/qlty-cli/tests/cmd/coverage/basic.stderr b/qlty-cli/tests/cmd/coverage/basic.stderr index 7c167de79..baa95a8f7 100644 --- a/qlty-cli/tests/cmd/coverage/basic.stderr +++ b/qlty-cli/tests/cmd/coverage/basic.stderr @@ -26,6 +26,8 @@ https://qlty.sh/d/coverage COVERAGE DATA + Git Repository: [CWD]/ + Auto-path fixing: Enabled 1 unique code file path All code files in the coverage data were found on disk (count: 1). diff --git a/qlty-cli/tests/cmd/coverage/discover_java_src_dirs.stderr b/qlty-cli/tests/cmd/coverage/discover_java_src_dirs.stderr index 27ba15ec3..4dc1a4fd0 100644 --- a/qlty-cli/tests/cmd/coverage/discover_java_src_dirs.stderr +++ b/qlty-cli/tests/cmd/coverage/discover_java_src_dirs.stderr @@ -31,6 +31,8 @@ https://qlty.sh/d/coverage COVERAGE DATA + Git Repository: [CWD]/ + Auto-path fixing: Enabled 1 unique code file path All code files in the coverage data were found on disk (count: 1). diff --git a/qlty-cli/tests/cmd/coverage/files_exist.stderr b/qlty-cli/tests/cmd/coverage/files_exist.stderr index 41cdfd880..2b7d40214 100644 --- a/qlty-cli/tests/cmd/coverage/files_exist.stderr +++ b/qlty-cli/tests/cmd/coverage/files_exist.stderr @@ -31,6 +31,8 @@ https://qlty.sh/d/coverage COVERAGE DATA + Git Repository: [CWD]/ + Auto-path fixing: Enabled WARNING: 1 code file path was excluded by configuration rules diff --git a/qlty-cli/tests/cmd/coverage/ignore_patterns.stderr b/qlty-cli/tests/cmd/coverage/ignore_patterns.stderr index 43aedb051..b50aef304 100644 --- a/qlty-cli/tests/cmd/coverage/ignore_patterns.stderr +++ b/qlty-cli/tests/cmd/coverage/ignore_patterns.stderr @@ -26,6 +26,8 @@ https://qlty.sh/d/coverage COVERAGE DATA + Git Repository: [CWD]/ + Auto-path fixing: Enabled 2 unique code file paths 2 paths are missing on disk (100.0%) diff --git a/qlty-cli/tests/cmd/coverage/json.stderr b/qlty-cli/tests/cmd/coverage/json.stderr index b31fd3fc2..10f88760a 100644 --- a/qlty-cli/tests/cmd/coverage/json.stderr +++ b/qlty-cli/tests/cmd/coverage/json.stderr @@ -26,6 +26,8 @@ https://qlty.sh/d/coverage COVERAGE DATA + Git Repository: [CWD]/ + 1 unique code file path 1 path is missing on disk (100.0%) diff --git a/qlty-cli/tests/cmd/coverage/override_commit_time.stderr b/qlty-cli/tests/cmd/coverage/override_commit_time.stderr index 930fec459..2abba5594 100644 --- a/qlty-cli/tests/cmd/coverage/override_commit_time.stderr +++ b/qlty-cli/tests/cmd/coverage/override_commit_time.stderr @@ -27,6 +27,8 @@ https://qlty.sh/d/coverage COVERAGE DATA + Git Repository: [CWD]/ + Auto-path fixing: Enabled 1 unique code file path 1 path is missing on disk (100.0%) diff --git a/qlty-cli/tests/cmd/coverage/override_git_tag.stderr b/qlty-cli/tests/cmd/coverage/override_git_tag.stderr index 516bfdec1..bafd87893 100644 --- a/qlty-cli/tests/cmd/coverage/override_git_tag.stderr +++ b/qlty-cli/tests/cmd/coverage/override_git_tag.stderr @@ -27,6 +27,8 @@ https://qlty.sh/d/coverage COVERAGE DATA + Git Repository: /Users/bhelmkamp/p/qltysh/qlty/ + Auto-path fixing: Enabled 1 unique code file path 1 path is missing on disk (100.0%) diff --git a/qlty-cli/tests/cmd/coverage/overrides.stderr b/qlty-cli/tests/cmd/coverage/overrides.stderr index 0d6e41d44..4a6bffa33 100644 --- a/qlty-cli/tests/cmd/coverage/overrides.stderr +++ b/qlty-cli/tests/cmd/coverage/overrides.stderr @@ -31,6 +31,8 @@ WARNING: --transform-add-prefix is deprecated, use --add-prefix instead COVERAGE DATA + Git Repository: [CWD]/ + 1 unique code file path 1 path is missing on disk (100.0%) diff --git a/qlty-cli/tests/cmd/coverage/publish_validate.stderr b/qlty-cli/tests/cmd/coverage/publish_validate.stderr index 5d76f543c..a563ef626 100644 --- a/qlty-cli/tests/cmd/coverage/publish_validate.stderr +++ b/qlty-cli/tests/cmd/coverage/publish_validate.stderr @@ -26,6 +26,8 @@ https://qlty.sh/d/coverage COVERAGE DATA + Git Repository: [CWD]/ + Auto-path fixing: Enabled 2 unique code file paths 1 path is missing on disk (50.0%) diff --git a/qlty-cli/tests/cmd/without_git/basic_coverage.stderr b/qlty-cli/tests/cmd/without_git/basic_coverage.stderr index 7f632dabb..76fe0fe4e 100644 --- a/qlty-cli/tests/cmd/without_git/basic_coverage.stderr +++ b/qlty-cli/tests/cmd/without_git/basic_coverage.stderr @@ -28,6 +28,8 @@ https://qlty.sh/d/coverage COVERAGE DATA + Git Repository: not found + 1 unique code file path 1 path is missing on disk (100.0%) diff --git a/qlty-coverage/src/git.rs b/qlty-coverage/src/git.rs index 7e71c7cc8..4fd53e6ff 100644 --- a/qlty-coverage/src/git.rs +++ b/qlty-coverage/src/git.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, Result}; use git2::Repository; -use tracing::{error, warn}; +use std::collections::HashSet; +use tracing::{error, info, warn}; #[derive(Debug, Clone)] pub struct CommitMetadata { @@ -68,3 +69,98 @@ pub fn retrieve_commit_metadata() -> Result> { commit_message, })) } + +#[derive(Debug, Clone)] +pub struct GitTrackingInfo { + pub repo_root: String, + pub tracked_files: HashSet, +} + +impl GitTrackingInfo { + pub fn is_tracked(&self, relative_path: &str) -> bool { + let normalized = relative_path.replace('\\', "/"); + self.tracked_files.contains(&normalized) + } +} + +pub fn get_git_tracking_info() -> Option { + if std::env::var("QLTY_COVERAGE_TESTING_WITHOUT_GIT").is_ok() { + return None; + } + + let repo = match Repository::discover(".") { + Ok(repo) => repo, + Err(_) => { + info!("No Git repository found"); + return None; + } + }; + + let repo_root = match repo.workdir() { + Some(path) => match path.to_str() { + Some(s) => s.to_string(), + None => { + warn!("Git repository path is not valid UTF-8"); + return None; + } + }, + None => { + warn!("Git repository has no working directory (bare repository)"); + return None; + } + }; + + let index = match repo.index() { + Ok(index) => index, + Err(err) => { + warn!("Failed to read Git index: {:?}", err); + return None; + } + }; + + let mut tracked_files = HashSet::new(); + for entry in index.iter() { + if let Ok(path) = std::str::from_utf8(&entry.path) { + tracked_files.insert(path.to_string()); + } + } + + info!("Git repository found at: {}", repo_root); + + Some(GitTrackingInfo { + repo_root, + tracked_files, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_tracked_returns_false_for_untracked() { + let info = GitTrackingInfo { + repo_root: "/tmp/test".to_string(), + tracked_files: HashSet::from(["src/main.rs".to_string()]), + }; + assert!(!info.is_tracked("src/untracked.rs")); + } + + #[test] + fn test_is_tracked_returns_true_for_tracked() { + let info = GitTrackingInfo { + repo_root: "/tmp/test".to_string(), + tracked_files: HashSet::from(["src/main.rs".to_string()]), + }; + assert!(info.is_tracked("src/main.rs")); + } + + #[test] + fn test_is_tracked_normalizes_windows_paths() { + let info = GitTrackingInfo { + repo_root: "/tmp/test".to_string(), + tracked_files: HashSet::from(["src/main.rs".to_string()]), + }; + assert!(info.is_tracked("src\\main.rs")); + } +} diff --git a/qlty-coverage/src/publish/processor.rs b/qlty-coverage/src/publish/processor.rs index 1e75b84e4..183da2c65 100644 --- a/qlty-coverage/src/publish/processor.rs +++ b/qlty-coverage/src/publish/processor.rs @@ -1,3 +1,4 @@ +use crate::git::get_git_tracking_info; use crate::publish::{metrics::CoverageMetrics, Plan, Report, Results}; use anyhow::Result; use qlty_types::tests::v1::FileCoverage; @@ -34,14 +35,23 @@ impl Processor { .filter_map(|file_coverage| self.transform(file_coverage.to_owned())) .collect::>(); + let git_tracking = get_git_tracking_info(); + let git_repo_path = git_tracking.as_ref().map(|info| info.repo_root.clone()); + let mut found_files = HashSet::new(); let mut missing_files = HashSet::new(); + let mut untracked_files = HashSet::new(); if self.plan.skip_missing_files { transformed_file_coverages.retain(|file_coverage| { match PathBuf::from(&file_coverage.path).try_exists() { Ok(true) => { found_files.insert(file_coverage.path.clone()); + if let Some(ref tracking) = git_tracking { + if !tracking.is_tracked(&file_coverage.path) { + untracked_files.insert(file_coverage.path.clone()); + } + } true } _ => { @@ -55,6 +65,11 @@ impl Processor { match PathBuf::from(&file_coverage.path).try_exists() { Ok(true) => { found_files.insert(file_coverage.path.clone()); + if let Some(ref tracking) = git_tracking { + if !tracking.is_tracked(&file_coverage.path) { + untracked_files.insert(file_coverage.path.clone()); + } + } } _ => { missing_files.insert(file_coverage.path.clone()); @@ -74,6 +89,8 @@ impl Processor { totals, missing_files, found_files, + untracked_files, + git_repo_path, excluded_files_count: ignored_paths_count, auto_path_fixing_enabled: self.plan.auto_path_fixing_enabled, }) diff --git a/qlty-coverage/src/publish/report.rs b/qlty-coverage/src/publish/report.rs index 90f2d1664..fc5ab0a93 100644 --- a/qlty-coverage/src/publish/report.rs +++ b/qlty-coverage/src/publish/report.rs @@ -18,6 +18,12 @@ pub struct Report { #[serde(skip_serializing)] pub missing_files: HashSet, + #[serde(skip_serializing)] + pub untracked_files: HashSet, + + #[serde(skip_serializing)] + pub git_repo_path: Option, + pub totals: CoverageMetrics, pub excluded_files_count: usize, diff --git a/qlty-coverage/src/validate.rs b/qlty-coverage/src/validate.rs index e3b2cc4d4..2863e25b4 100644 --- a/qlty-coverage/src/validate.rs +++ b/qlty-coverage/src/validate.rs @@ -94,15 +94,15 @@ mod tests { use std::fs::{self, File}; use tempfile::tempdir; - // Helper function to create a test Report instance fn create_test_report(file_coverages: Vec) -> Report { - // Create a minimal valid Report Report { metadata: CoverageMetadata::default(), report_files: vec![ReportFile::default()], file_coverages, found_files: HashSet::new(), missing_files: HashSet::new(), + untracked_files: HashSet::new(), + git_repo_path: None, totals: Default::default(), excluded_files_count: 0, auto_path_fixing_enabled: false,