diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d63809dc6..14e55b3b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Features + +- (bundle-jvm) Allow running directly on a project root (including multi-module repos) by automatically collecting only JVM source files (`.java`, `.kt`, `.scala`, `.groovy`), respecting `.gitignore`, and excluding common build output directories ([#3260](https://github.com/getsentry/sentry-cli/pull/3260)) +- (bundle-jvm) Add `--exclude` option for custom glob patterns to exclude files/directories from source collection ([#3260](https://github.com/getsentry/sentry-cli/pull/3260)) + ### Fixes - Replace `eprintln!` with `log::info!` for progress bar completion messages when the progress bar is disabled (e.g. in CI). This avoids spurious stderr output that some CI systems treat as errors ([#3223](https://github.com/getsentry/sentry-cli/pull/3223)). diff --git a/src/commands/debug_files/bundle_jvm.rs b/src/commands/debug_files/bundle_jvm.rs index 884c944e35..91f3454115 100644 --- a/src/commands/debug_files/bundle_jvm.rs +++ b/src/commands/debug_files/bundle_jvm.rs @@ -7,15 +7,62 @@ use crate::utils::file_upload::SourceFile; use crate::utils::fs::path_as_url; use crate::utils::source_bundle::{self, BundleContext}; use anyhow::{bail, Context as _, Result}; -use clap::{Arg, ArgMatches, Command}; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use log::debug; use sentry::types::DebugId; use std::collections::BTreeMap; +use std::ffi::OsStr; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr as _; use std::sync::Arc; use symbolic::debuginfo::sourcebundle::SourceFileType; +const JVM_EXTENSIONS: &[&str] = &[ + "java", "kt", "scala", "sc", "groovy", "gvy", "gy", "gsh", "clj", "cljc", +]; + +/// Safe to exclude globally — can never be valid JVM package names. +const SAFE_EXCLUDES: &[&str] = &[ + ".cxx", + ".eclipse", + ".fleet", + ".gradle", + ".idea", + ".kotlin", + ".mvn", + ".settings", + ".vscode", + "node_modules", +]; + +/// Common build output dirs that could also be valid JVM package names +/// (e.g. `com.example.build`). Only excluded outside of `src/` directories. +const AMBIGUOUS_EXCLUDES: &[&str] = &["bin", "build", "out", "target"]; + +/// Checks *all* ambiguous directories in the path and excludes if any of them +/// is not under a `src/` ancestor. Handles nested cases like +/// `build/src/main/java/com/example/target/Foo.java` — inner `target` is under +/// `src`, but outer `build` is not, so the file is excluded. +fn is_in_ambiguous_build_dir(relative_path: &Path) -> bool { + for ancestor in relative_path.ancestors() { + let Some(name) = ancestor.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if AMBIGUOUS_EXCLUDES.contains(&name) { + // Check if any ancestor *above* this directory is named "src". + let has_src_above = ancestor + .ancestors() + .skip(1) // skip the ambiguous dir itself + .any(|a| a.file_name() == Some(OsStr::new("src"))); + if !has_src_above { + return true; + } + } + } + false +} + pub fn make_command(command: Command) -> Command { command .hide(true) // experimental for now @@ -47,6 +94,17 @@ pub fn make_command(command: Command) -> Command { .value_parser(DebugId::from_str) .help("Debug ID (UUID) to use for the source bundle."), ) + .arg( + Arg::new("exclude") + .long("exclude") + .value_name("PATTERN") + .action(ArgAction::Append) + .help( + "Glob pattern to exclude files/directories. Can be repeated. \ + By default, common build output and IDE directories are excluded \ + (build, .gradle, target, .idea, .vscode, out, bin, etc.).", + ), + ) } pub fn execute(matches: &ArgMatches) -> Result<()> { @@ -75,21 +133,42 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { ))?; } - let sources = ReleaseFileSearch::new(path.clone()).collect_files()?; - let files = sources.iter().map(|source| { + let all_excludes = SAFE_EXCLUDES + .iter() + .copied() + .chain( + matches + .get_many::("exclude") + .into_iter() + .flatten() + .map(|s| s.as_str()), + ) + .map(|v| format!("!{v}")); + + let sources = ReleaseFileSearch::new(path.clone()) + .extensions(JVM_EXTENSIONS.iter().copied()) + .ignores(all_excludes) + .respect_ignores(true) + .collect_files()?; + + let files = sources.into_iter().filter_map(|source| { let local_path = source.path.strip_prefix(&source.base_path).unwrap(); + if is_in_ambiguous_build_dir(local_path) { + debug!("excluding (build output): {}", source.path.display()); + return None; + } let local_path_jvm_ext = local_path.with_extension("jvm"); let url = format!("~/{}", path_as_url(&local_path_jvm_ext)); - SourceFile { + Some(SourceFile { url, - path: source.path.clone(), - contents: Arc::new(source.contents.clone()), + path: source.path, + contents: Arc::new(source.contents), ty: SourceFileType::Source, headers: BTreeMap::new(), messages: vec![], already_uploaded: false, - } + }) }); let tempfile = source_bundle::build(context, files, Some(*debug_id)) @@ -100,3 +179,72 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_excludes_build_output_at_module_root() { + assert!(is_in_ambiguous_build_dir(Path::new( + "app/build/generated/Foo.java" + ))); + assert!(is_in_ambiguous_build_dir(Path::new( + "build/generated/Foo.java" + ))); + assert!(is_in_ambiguous_build_dir(Path::new( + "module/target/classes/Foo.java" + ))); + assert!(is_in_ambiguous_build_dir(Path::new("bin/Foo.class"))); + assert!(is_in_ambiguous_build_dir(Path::new( + "out/production/Foo.java" + ))); + } + + #[test] + fn test_keeps_source_packages_under_src() { + assert!(!is_in_ambiguous_build_dir(Path::new( + "src/main/java/com/example/build/Builder.java" + ))); + assert!(!is_in_ambiguous_build_dir(Path::new( + "app/src/main/java/com/example/target/Target.java" + ))); + assert!(!is_in_ambiguous_build_dir(Path::new( + "src/main/kotlin/com/example/out/Output.kt" + ))); + } + + #[test] + fn test_excludes_build_dir_containing_src() { + // build/src/... should still be excluded — src is *inside* build, not above it + assert!(is_in_ambiguous_build_dir(Path::new( + "build/src/main/java/Foo.java" + ))); + assert!(is_in_ambiguous_build_dir(Path::new( + "app/build/src/generated/Foo.java" + ))); + } + + #[test] + fn test_excludes_nested_ambiguous_dirs_under_build() { + // build/src/.../target/ — inner `target` is under src, but outer `build` is not + assert!(is_in_ambiguous_build_dir(Path::new( + "build/src/main/java/com/example/target/Foo.java" + ))); + assert!(is_in_ambiguous_build_dir(Path::new( + "target/src/main/java/com/example/out/Foo.java" + ))); + } + + #[test] + fn test_keeps_files_without_ambiguous_dirs() { + assert!(!is_in_ambiguous_build_dir(Path::new( + "src/main/java/com/example/Foo.java" + ))); + assert!(!is_in_ambiguous_build_dir(Path::new("Foo.java"))); + assert!(!is_in_ambiguous_build_dir(Path::new( + "app/src/main/java/Foo.java" + ))); + } +} diff --git a/src/utils/file_search.rs b/src/utils/file_search.rs index 6e11e5a63b..50138e1308 100644 --- a/src/utils/file_search.rs +++ b/src/utils/file_search.rs @@ -20,6 +20,7 @@ pub struct ReleaseFileSearch { ignores: BTreeSet, ignore_file: Option, decompress: bool, + respect_ignores: bool, } #[derive(Eq, PartialEq, Hash)] @@ -37,6 +38,7 @@ impl ReleaseFileSearch { ignore_file: None, ignores: BTreeSet::new(), decompress: false, + respect_ignores: false, } } @@ -78,6 +80,11 @@ impl ReleaseFileSearch { self } + pub fn respect_ignores(&mut self, respect: bool) -> &mut Self { + self.respect_ignores = respect; + self + } + pub fn collect_file(path: PathBuf) -> Result { // NOTE: `collect_file` currently do not handle gzip decompression, // as its mostly used for 3rd tools like xcode or gradle. @@ -105,11 +112,11 @@ impl ReleaseFileSearch { let mut collected = Vec::new(); let mut builder = WalkBuilder::new(&self.path); - builder - .follow_links(true) - .git_exclude(false) - .git_ignore(false) - .ignore(false); + builder.follow_links(true); + + if !self.respect_ignores { + builder.git_exclude(false).git_ignore(false).ignore(false); + } if !&self.extensions.is_empty() { let mut types_builder = TypesBuilder::new(); diff --git a/tests/integration/_cases/debug_files/debug_files-bundle-jvm-help.trycmd b/tests/integration/_cases/debug_files/debug_files-bundle-jvm-help.trycmd index 8f3b704e73..6dd75ce9d1 100644 --- a/tests/integration/_cases/debug_files/debug_files-bundle-jvm-help.trycmd +++ b/tests/integration/_cases/debug_files/debug_files-bundle-jvm-help.trycmd @@ -18,6 +18,9 @@ Options: --debug-id Debug ID (UUID) to use for the source bundle. --log-level Set the log output verbosity. [possible values: trace, debug, info, warn, error] + --exclude Glob pattern to exclude files/directories. Can be repeated. By + default, common build output and IDE directories are excluded + (build, .gradle, target, .idea, .vscode, out, bin, etc.). --quiet Do not print any output while preserving correct exit code. This flag is currently implemented only for selected subcommands. [aliases: --silent]