Skip to content

Commit f825541

Browse files
romtsnclaude
andauthored
feat(bundle-jvm): Automatically collect JVM source files with respecting excludes and gitignore (#3260)
## Summary - Filter collected sources by JVM file extensions (`java`, `kt`, `scala`, `groovy`) instead of collecting all files - Exclude common build output directories by default (`build`, `.gradle`, `.cxx`, `node_modules`) - Respect `.gitignore` / `.ignore` rules as an additional layer of defense - Add `--exclude` CLI arg for user-provided glob patterns on top of defaults This enables invoking `bundle-jvm` directly on a project root without needing to pre-copy sources into a separate directory (e.g. from the Android Gradle plugin's `CollectSourcesTask`). This also does not break the existing plugins' workflows, only in subtle cases where a non-JVM file has been included into the `src/main/java` source set, but this is likely not supported on the product side either. ## Test plan - [ ] Run `bundle-jvm` on an Android project root and verify only `.java`/`.kt` files are collected - [ ] Verify `build/` directory contents are excluded - [ ] Verify `.gitignore`d paths are respected - [ ] Test `--exclude` with custom glob patterns - [ ] Verify existing callers of `ReleaseFileSearch` are unaffected (default `respect_ignores: false`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 35edfb3 commit f825541

4 files changed

Lines changed: 176 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- (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))
8+
- (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))
9+
510
### Fixes
611

712
- 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)).

src/commands/debug_files/bundle_jvm.rs

Lines changed: 156 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,62 @@ use crate::utils::file_upload::SourceFile;
77
use crate::utils::fs::path_as_url;
88
use crate::utils::source_bundle::{self, BundleContext};
99
use anyhow::{bail, Context as _, Result};
10-
use clap::{Arg, ArgMatches, Command};
10+
use clap::{Arg, ArgAction, ArgMatches, Command};
11+
use log::debug;
1112
use sentry::types::DebugId;
1213
use std::collections::BTreeMap;
14+
use std::ffi::OsStr;
1315
use std::fs;
14-
use std::path::PathBuf;
16+
use std::path::{Path, PathBuf};
1517
use std::str::FromStr as _;
1618
use std::sync::Arc;
1719
use symbolic::debuginfo::sourcebundle::SourceFileType;
1820

21+
const JVM_EXTENSIONS: &[&str] = &[
22+
"java", "kt", "scala", "sc", "groovy", "gvy", "gy", "gsh", "clj", "cljc",
23+
];
24+
25+
/// Safe to exclude globally — can never be valid JVM package names.
26+
const SAFE_EXCLUDES: &[&str] = &[
27+
".cxx",
28+
".eclipse",
29+
".fleet",
30+
".gradle",
31+
".idea",
32+
".kotlin",
33+
".mvn",
34+
".settings",
35+
".vscode",
36+
"node_modules",
37+
];
38+
39+
/// Common build output dirs that could also be valid JVM package names
40+
/// (e.g. `com.example.build`). Only excluded outside of `src/` directories.
41+
const AMBIGUOUS_EXCLUDES: &[&str] = &["bin", "build", "out", "target"];
42+
43+
/// Checks *all* ambiguous directories in the path and excludes if any of them
44+
/// is not under a `src/` ancestor. Handles nested cases like
45+
/// `build/src/main/java/com/example/target/Foo.java` — inner `target` is under
46+
/// `src`, but outer `build` is not, so the file is excluded.
47+
fn is_in_ambiguous_build_dir(relative_path: &Path) -> bool {
48+
for ancestor in relative_path.ancestors() {
49+
let Some(name) = ancestor.file_name().and_then(|n| n.to_str()) else {
50+
continue;
51+
};
52+
if AMBIGUOUS_EXCLUDES.contains(&name) {
53+
// Check if any ancestor *above* this directory is named "src".
54+
let has_src_above = ancestor
55+
.ancestors()
56+
.skip(1) // skip the ambiguous dir itself
57+
.any(|a| a.file_name() == Some(OsStr::new("src")));
58+
if !has_src_above {
59+
return true;
60+
}
61+
}
62+
}
63+
false
64+
}
65+
1966
pub fn make_command(command: Command) -> Command {
2067
command
2168
.hide(true) // experimental for now
@@ -47,6 +94,17 @@ pub fn make_command(command: Command) -> Command {
4794
.value_parser(DebugId::from_str)
4895
.help("Debug ID (UUID) to use for the source bundle."),
4996
)
97+
.arg(
98+
Arg::new("exclude")
99+
.long("exclude")
100+
.value_name("PATTERN")
101+
.action(ArgAction::Append)
102+
.help(
103+
"Glob pattern to exclude files/directories. Can be repeated. \
104+
By default, common build output and IDE directories are excluded \
105+
(build, .gradle, target, .idea, .vscode, out, bin, etc.).",
106+
),
107+
)
50108
}
51109

52110
pub fn execute(matches: &ArgMatches) -> Result<()> {
@@ -75,21 +133,42 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
75133
))?;
76134
}
77135

78-
let sources = ReleaseFileSearch::new(path.clone()).collect_files()?;
79-
let files = sources.iter().map(|source| {
136+
let all_excludes = SAFE_EXCLUDES
137+
.iter()
138+
.copied()
139+
.chain(
140+
matches
141+
.get_many::<String>("exclude")
142+
.into_iter()
143+
.flatten()
144+
.map(|s| s.as_str()),
145+
)
146+
.map(|v| format!("!{v}"));
147+
148+
let sources = ReleaseFileSearch::new(path.clone())
149+
.extensions(JVM_EXTENSIONS.iter().copied())
150+
.ignores(all_excludes)
151+
.respect_ignores(true)
152+
.collect_files()?;
153+
154+
let files = sources.into_iter().filter_map(|source| {
80155
let local_path = source.path.strip_prefix(&source.base_path).unwrap();
156+
if is_in_ambiguous_build_dir(local_path) {
157+
debug!("excluding (build output): {}", source.path.display());
158+
return None;
159+
}
81160
let local_path_jvm_ext = local_path.with_extension("jvm");
82161
let url = format!("~/{}", path_as_url(&local_path_jvm_ext));
83162

84-
SourceFile {
163+
Some(SourceFile {
85164
url,
86-
path: source.path.clone(),
87-
contents: Arc::new(source.contents.clone()),
165+
path: source.path,
166+
contents: Arc::new(source.contents),
88167
ty: SourceFileType::Source,
89168
headers: BTreeMap::new(),
90169
messages: vec![],
91170
already_uploaded: false,
92-
}
171+
})
93172
});
94173

95174
let tempfile = source_bundle::build(context, files, Some(*debug_id))
@@ -100,3 +179,72 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
100179

101180
Ok(())
102181
}
182+
183+
#[cfg(test)]
184+
mod tests {
185+
use super::*;
186+
use std::path::Path;
187+
188+
#[test]
189+
fn test_excludes_build_output_at_module_root() {
190+
assert!(is_in_ambiguous_build_dir(Path::new(
191+
"app/build/generated/Foo.java"
192+
)));
193+
assert!(is_in_ambiguous_build_dir(Path::new(
194+
"build/generated/Foo.java"
195+
)));
196+
assert!(is_in_ambiguous_build_dir(Path::new(
197+
"module/target/classes/Foo.java"
198+
)));
199+
assert!(is_in_ambiguous_build_dir(Path::new("bin/Foo.class")));
200+
assert!(is_in_ambiguous_build_dir(Path::new(
201+
"out/production/Foo.java"
202+
)));
203+
}
204+
205+
#[test]
206+
fn test_keeps_source_packages_under_src() {
207+
assert!(!is_in_ambiguous_build_dir(Path::new(
208+
"src/main/java/com/example/build/Builder.java"
209+
)));
210+
assert!(!is_in_ambiguous_build_dir(Path::new(
211+
"app/src/main/java/com/example/target/Target.java"
212+
)));
213+
assert!(!is_in_ambiguous_build_dir(Path::new(
214+
"src/main/kotlin/com/example/out/Output.kt"
215+
)));
216+
}
217+
218+
#[test]
219+
fn test_excludes_build_dir_containing_src() {
220+
// build/src/... should still be excluded — src is *inside* build, not above it
221+
assert!(is_in_ambiguous_build_dir(Path::new(
222+
"build/src/main/java/Foo.java"
223+
)));
224+
assert!(is_in_ambiguous_build_dir(Path::new(
225+
"app/build/src/generated/Foo.java"
226+
)));
227+
}
228+
229+
#[test]
230+
fn test_excludes_nested_ambiguous_dirs_under_build() {
231+
// build/src/.../target/ — inner `target` is under src, but outer `build` is not
232+
assert!(is_in_ambiguous_build_dir(Path::new(
233+
"build/src/main/java/com/example/target/Foo.java"
234+
)));
235+
assert!(is_in_ambiguous_build_dir(Path::new(
236+
"target/src/main/java/com/example/out/Foo.java"
237+
)));
238+
}
239+
240+
#[test]
241+
fn test_keeps_files_without_ambiguous_dirs() {
242+
assert!(!is_in_ambiguous_build_dir(Path::new(
243+
"src/main/java/com/example/Foo.java"
244+
)));
245+
assert!(!is_in_ambiguous_build_dir(Path::new("Foo.java")));
246+
assert!(!is_in_ambiguous_build_dir(Path::new(
247+
"app/src/main/java/Foo.java"
248+
)));
249+
}
250+
}

src/utils/file_search.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub struct ReleaseFileSearch {
2020
ignores: BTreeSet<String>,
2121
ignore_file: Option<String>,
2222
decompress: bool,
23+
respect_ignores: bool,
2324
}
2425

2526
#[derive(Eq, PartialEq, Hash)]
@@ -37,6 +38,7 @@ impl ReleaseFileSearch {
3738
ignore_file: None,
3839
ignores: BTreeSet::new(),
3940
decompress: false,
41+
respect_ignores: false,
4042
}
4143
}
4244

@@ -78,6 +80,11 @@ impl ReleaseFileSearch {
7880
self
7981
}
8082

83+
pub fn respect_ignores(&mut self, respect: bool) -> &mut Self {
84+
self.respect_ignores = respect;
85+
self
86+
}
87+
8188
pub fn collect_file(path: PathBuf) -> Result<ReleaseFileMatch> {
8289
// NOTE: `collect_file` currently do not handle gzip decompression,
8390
// as its mostly used for 3rd tools like xcode or gradle.
@@ -105,11 +112,11 @@ impl ReleaseFileSearch {
105112
let mut collected = Vec::new();
106113

107114
let mut builder = WalkBuilder::new(&self.path);
108-
builder
109-
.follow_links(true)
110-
.git_exclude(false)
111-
.git_ignore(false)
112-
.ignore(false);
115+
builder.follow_links(true);
116+
117+
if !self.respect_ignores {
118+
builder.git_exclude(false).git_ignore(false).ignore(false);
119+
}
113120

114121
if !&self.extensions.is_empty() {
115122
let mut types_builder = TypesBuilder::new();

tests/integration/_cases/debug_files/debug_files-bundle-jvm-help.trycmd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ Options:
1818
--debug-id <UUID> Debug ID (UUID) to use for the source bundle.
1919
--log-level <LOG_LEVEL> Set the log output verbosity. [possible values: trace, debug, info,
2020
warn, error]
21+
--exclude <PATTERN> Glob pattern to exclude files/directories. Can be repeated. By
22+
default, common build output and IDE directories are excluded
23+
(build, .gradle, target, .idea, .vscode, out, bin, etc.).
2124
--quiet Do not print any output while preserving correct exit code. This
2225
flag is currently implemented only for selected subcommands.
2326
[aliases: --silent]

0 commit comments

Comments
 (0)