Skip to content

Commit bee85dc

Browse files
romtsnclaude
andauthored
feat(code-mappings): Add git inference for repo and default branch (#3208)
*#skip-changelog* When `--repo` or `--default-branch` are not provided, infer them from the<br>local git repository. Uses the configured VCS remote (SENTRY_VCS_REMOTE / ini)<br>first, then falls back to best-effort remote detection (upstream > origin > first). Also extracts `find_best_remote()` as a shared utility in `src/utils/vcs.rs`,<br>replacing the inline logic that was duplicated in `git_repo_base_repo_name_preserve_case`. Stack: #3207 → [#3208](<#3208>) → [#3209](<#3209>) → [#3210](<#3210>) Backend PRs: [getsentry/sentry#109783](<getsentry/sentry#109783>), [getsentry/sentry#109785](<getsentry/sentry#109785>), [getsentry/sentry#109786](<getsentry/sentry#109786>) Closes [GRADLE-79](https://linear.app/getsentry/issue/GRADLE-79/add-git-inference-for-repo-name-and-default-branch) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d893c9d commit bee85dc

File tree

3 files changed

+313
-10
lines changed

3 files changed

+313
-10
lines changed

src/commands/code_mappings/upload.rs

Lines changed: 293 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ use std::fs;
22

33
use anyhow::{bail, Context as _, Result};
44
use clap::{Arg, ArgMatches, Command};
5+
use log::debug;
56
use serde::Deserialize;
67

8+
use crate::config::Config;
9+
use crate::utils::vcs;
10+
711
#[derive(Debug, Deserialize)]
812
#[serde(rename_all = "camelCase")]
913
struct CodeMapping {
@@ -30,8 +34,7 @@ pub fn make_command(command: Command) -> Command {
3034
Arg::new("default_branch")
3135
.long("default-branch")
3236
.value_name("BRANCH")
33-
.default_value("main")
34-
.help("The default branch name."),
37+
.help("The default branch name. Defaults to the git remote HEAD or 'main'."),
3538
)
3639
}
3740

@@ -57,7 +60,295 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
5760
}
5861
}
5962

63+
let explicit_repo = matches.get_one::<String>("repo");
64+
let explicit_branch = matches.get_one::<String>("default_branch");
65+
66+
let git_repo = (explicit_repo.is_none() || explicit_branch.is_none())
67+
.then(|| git2::Repository::open_from_env().ok())
68+
.flatten();
69+
70+
let (repo_name, default_branch) = resolve_repo_and_branch(
71+
explicit_repo.map(|s| s.as_str()),
72+
explicit_branch.map(|s| s.as_str()),
73+
git_repo.as_ref(),
74+
)?;
75+
6076
println!("Found {} code mapping(s) in {path}", mappings.len());
77+
println!("Repository: {repo_name}");
78+
println!("Default branch: {default_branch}");
6179

6280
Ok(())
6381
}
82+
83+
/// Resolves the repository name and default branch from explicit args and git inference.
84+
fn resolve_repo_and_branch(
85+
explicit_repo: Option<&str>,
86+
explicit_branch: Option<&str>,
87+
git_repo: Option<&git2::Repository>,
88+
) -> Result<(String, String)> {
89+
let (repo_name, remote_name) = if let Some(r) = explicit_repo {
90+
// Try to find a local remote whose URL matches the explicit repo name,
91+
// so we can infer the default branch from it. Falls back to None (-> "main").
92+
let remote = git_repo.and_then(|repo| find_remote_for_repo(repo, r));
93+
(r.to_owned(), remote)
94+
} else {
95+
let remote = git_repo.and_then(resolve_git_remote);
96+
let name = infer_repo_name(git_repo, remote.as_deref())?;
97+
(name, remote)
98+
};
99+
100+
let default_branch = if let Some(b) = explicit_branch {
101+
b.to_owned()
102+
} else {
103+
infer_default_branch(git_repo, remote_name.as_deref())
104+
};
105+
106+
Ok((repo_name, default_branch))
107+
}
108+
109+
/// Finds the best git remote name. Prefers the configured VCS remote
110+
/// (SENTRY_VCS_REMOTE / ini), then falls back to upstream > origin > first.
111+
fn resolve_git_remote(repo: &git2::Repository) -> Option<String> {
112+
let config = Config::current();
113+
let configured_remote = config.get_cached_vcs_remote();
114+
if vcs::git_repo_remote_url(repo, &configured_remote).is_ok() {
115+
debug!("Using configured VCS remote: {configured_remote}");
116+
return Some(configured_remote);
117+
}
118+
match vcs::find_best_remote(repo) {
119+
Ok(Some(best)) => {
120+
debug!("Configured remote '{configured_remote}' not found, using: {best}");
121+
Some(best)
122+
}
123+
_ => None,
124+
}
125+
}
126+
127+
/// Finds the remote whose URL matches the given repository name (e.g. "owner/repo").
128+
fn find_remote_for_repo(repo: &git2::Repository, repo_name: &str) -> Option<String> {
129+
let remotes = repo.remotes().ok()?;
130+
let found = remotes.iter().flatten().find(|name| {
131+
vcs::git_repo_remote_url(repo, name)
132+
.map(|url| vcs::get_repo_from_remote_preserve_case(&url) == repo_name)
133+
.unwrap_or(false)
134+
})?;
135+
debug!("Found remote '{found}' matching repo '{repo_name}'");
136+
Some(found.to_owned())
137+
}
138+
139+
/// Infers the repository name (e.g. "owner/repo") from the git remote URL.
140+
fn infer_repo_name(
141+
git_repo: Option<&git2::Repository>,
142+
remote_name: Option<&str>,
143+
) -> Result<String> {
144+
let git_repo = git_repo.ok_or_else(|| {
145+
anyhow::anyhow!("Could not open git repository. Use --repo to specify manually.")
146+
})?;
147+
let remote_name = remote_name.ok_or_else(|| {
148+
anyhow::anyhow!("No remotes found in the git repository. Use --repo to specify manually.")
149+
})?;
150+
let remote_url = vcs::git_repo_remote_url(git_repo, remote_name)?;
151+
debug!("Found remote '{remote_name}': {remote_url}");
152+
let inferred = vcs::get_repo_from_remote_preserve_case(&remote_url);
153+
if inferred.is_empty() {
154+
bail!("Could not parse repository name from remote URL: {remote_url}");
155+
}
156+
Ok(inferred)
157+
}
158+
159+
/// Infers the default branch from the git remote HEAD, falling back to "main".
160+
fn infer_default_branch(git_repo: Option<&git2::Repository>, remote_name: Option<&str>) -> String {
161+
git_repo
162+
.zip(remote_name)
163+
.and_then(|(repo, name)| {
164+
vcs::git_repo_base_ref(repo, name)
165+
.map_err(|e| {
166+
debug!("Could not infer default branch from remote: {e}");
167+
e
168+
})
169+
.ok()
170+
})
171+
.unwrap_or_else(|| {
172+
debug!("No git repo or remote available, falling back to 'main'");
173+
"main".to_owned()
174+
})
175+
}
176+
177+
#[cfg(test)]
178+
mod tests {
179+
use super::*;
180+
use std::path::PathBuf;
181+
182+
use ini::Ini;
183+
use tempfile::tempdir;
184+
185+
use crate::config::Config;
186+
187+
fn init_git_repo_with_remotes(remotes: &[(&str, &str)]) -> tempfile::TempDir {
188+
let dir = tempdir().expect("temp dir");
189+
std::process::Command::new("git")
190+
.args(["init", "--quiet"])
191+
.current_dir(&dir)
192+
.env_remove("GIT_DIR")
193+
.output()
194+
.expect("git init");
195+
for (name, url) in remotes {
196+
std::process::Command::new("git")
197+
.args(["remote", "add", name, url])
198+
.current_dir(&dir)
199+
.output()
200+
.expect("git remote add");
201+
}
202+
dir
203+
}
204+
205+
/// Creates a commit and sets up remote HEAD refs so branch inference works.
206+
fn setup_remote_head_refs(
207+
repo: &git2::Repository,
208+
dir: &std::path::Path,
209+
branches: &[(&str, &str)],
210+
) {
211+
for (args, msg) in [
212+
(vec!["config", "--local", "user.name", "test"], "git config"),
213+
(
214+
vec!["config", "--local", "user.email", "test@test.com"],
215+
"git config",
216+
),
217+
(vec!["commit", "--allow-empty", "-m", "init"], "git commit"),
218+
] {
219+
std::process::Command::new("git")
220+
.args(&args)
221+
.current_dir(dir)
222+
.output()
223+
.expect(msg);
224+
}
225+
226+
let head_commit = repo.head().unwrap().peel_to_commit().unwrap().id();
227+
for (remote, branch) in branches {
228+
repo.reference(
229+
&format!("refs/remotes/{remote}/{branch}"),
230+
head_commit,
231+
false,
232+
"test",
233+
)
234+
.unwrap();
235+
repo.reference_symbolic(
236+
&format!("refs/remotes/{remote}/HEAD"),
237+
&format!("refs/remotes/{remote}/{branch}"),
238+
false,
239+
"test",
240+
)
241+
.unwrap();
242+
}
243+
}
244+
245+
/// Calls `resolve_repo_and_branch` with explicit args and a pre-opened git repo.
246+
fn run_resolve(
247+
git_repo: Option<&git2::Repository>,
248+
explicit_repo: Option<&str>,
249+
explicit_branch: Option<&str>,
250+
) -> Result<(String, String)> {
251+
// Bind a default Config so resolve_git_remote can call Config::current().
252+
Config::from_file(PathBuf::from("/dev/null"), Ini::new()).bind_to_process();
253+
254+
resolve_repo_and_branch(explicit_repo, explicit_branch, git_repo)
255+
}
256+
257+
#[test]
258+
fn find_remote_for_repo_matches_upstream() {
259+
let dir = init_git_repo_with_remotes(&[
260+
("origin", "https://github.com/my-fork/MyRepo"),
261+
("upstream", "https://github.com/MyOrg/MyRepo"),
262+
]);
263+
let repo = git2::Repository::open(dir.path()).unwrap();
264+
assert_eq!(
265+
find_remote_for_repo(&repo, "MyOrg/MyRepo"),
266+
Some("upstream".to_owned())
267+
);
268+
}
269+
270+
#[test]
271+
fn find_remote_for_repo_matches_origin() {
272+
let dir = init_git_repo_with_remotes(&[("origin", "https://github.com/MyOrg/MyRepo")]);
273+
let repo = git2::Repository::open(dir.path()).unwrap();
274+
assert_eq!(
275+
find_remote_for_repo(&repo, "MyOrg/MyRepo"),
276+
Some("origin".to_owned())
277+
);
278+
}
279+
280+
#[test]
281+
fn find_remote_for_repo_no_match() {
282+
let dir =
283+
init_git_repo_with_remotes(&[("origin", "https://github.com/other-org/other-repo")]);
284+
let repo = git2::Repository::open(dir.path()).unwrap();
285+
assert_eq!(find_remote_for_repo(&repo, "MyOrg/MyRepo"), None);
286+
}
287+
288+
#[test]
289+
fn find_remote_for_repo_preserves_case() {
290+
let dir = init_git_repo_with_remotes(&[("origin", "https://github.com/MyOrg/MyRepo")]);
291+
let repo = git2::Repository::open(dir.path()).unwrap();
292+
assert_eq!(find_remote_for_repo(&repo, "myorg/myrepo"), None);
293+
}
294+
295+
#[test]
296+
fn resolve_no_repo_no_branch_infers_both() {
297+
let dir = init_git_repo_with_remotes(&[("origin", "https://github.com/MyOrg/MyRepo")]);
298+
let repo = git2::Repository::open(dir.path()).unwrap();
299+
setup_remote_head_refs(&repo, dir.path(), &[("origin", "develop")]);
300+
301+
let (repo_name, branch) = run_resolve(Some(&repo), None, None).unwrap();
302+
assert_eq!(repo_name, "MyOrg/MyRepo");
303+
assert_eq!(branch, "develop");
304+
}
305+
306+
#[test]
307+
fn resolve_explicit_branch_no_repo_infers_repo() {
308+
let dir = init_git_repo_with_remotes(&[("origin", "https://github.com/MyOrg/MyRepo")]);
309+
let repo = git2::Repository::open(dir.path()).unwrap();
310+
311+
let (repo_name, branch) = run_resolve(Some(&repo), None, Some("release")).unwrap();
312+
assert_eq!(repo_name, "MyOrg/MyRepo");
313+
assert_eq!(branch, "release");
314+
}
315+
316+
#[test]
317+
fn resolve_both_explicit_skips_git() {
318+
let (repo_name, branch) = run_resolve(None, Some("MyOrg/MyRepo"), Some("release")).unwrap();
319+
assert_eq!(repo_name, "MyOrg/MyRepo");
320+
assert_eq!(branch, "release");
321+
}
322+
323+
#[test]
324+
fn resolve_explicit_repo_no_match_falls_back_to_main() {
325+
let dir =
326+
init_git_repo_with_remotes(&[("origin", "https://github.com/other-org/other-repo")]);
327+
let repo = git2::Repository::open(dir.path()).unwrap();
328+
329+
let (repo_name, branch) = run_resolve(Some(&repo), Some("MyOrg/MyRepo"), None).unwrap();
330+
assert_eq!(repo_name, "MyOrg/MyRepo");
331+
assert_eq!(branch, "main");
332+
}
333+
334+
#[test]
335+
fn resolve_explicit_repo_infers_branch_from_matching_remote() {
336+
// --repo matches "upstream", --default-branch omitted:
337+
// branch should be inferred from upstream's HEAD ("develop"),
338+
// not origin's ("master").
339+
let dir = init_git_repo_with_remotes(&[
340+
("origin", "https://github.com/my-fork/MyRepo"),
341+
("upstream", "https://github.com/MyOrg/MyRepo"),
342+
]);
343+
let repo = git2::Repository::open(dir.path()).unwrap();
344+
setup_remote_head_refs(
345+
&repo,
346+
dir.path(),
347+
&[("origin", "master"), ("upstream", "develop")],
348+
);
349+
350+
let (repo_name, branch) = run_resolve(Some(&repo), Some("MyOrg/MyRepo"), None).unwrap();
351+
assert_eq!(repo_name, "MyOrg/MyRepo");
352+
assert_eq!(branch, "develop");
353+
}
354+
}

src/utils/vcs.rs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -301,27 +301,39 @@ pub fn git_repo_base_ref(repo: &git2::Repository, remote_name: &str) -> Result<S
301301
})
302302
}
303303

304-
/// Like git_repo_base_repo_name but preserves the original case of the repository name.
305-
/// This is used specifically for build upload where case preservation is important.
306-
pub fn git_repo_base_repo_name_preserve_case(repo: &git2::Repository) -> Result<Option<String>> {
304+
/// Finds the best remote in a git repository.
305+
/// Prefers "upstream" if it exists, then "origin", otherwise uses the first remote.
306+
pub fn find_best_remote(repo: &git2::Repository) -> Result<Option<String>> {
307307
let remotes = repo.remotes()?;
308308
let remote_names: Vec<&str> = remotes.iter().flatten().collect();
309309

310310
if remote_names.is_empty() {
311-
warn!("No remotes found in repository");
312311
return Ok(None);
313312
}
314313

315-
// Prefer "upstream" if it exists, then "origin", otherwise use the first one
316-
let chosen_remote = if remote_names.contains(&"upstream") {
314+
let chosen = if remote_names.contains(&"upstream") {
317315
"upstream"
318316
} else if remote_names.contains(&"origin") {
319317
"origin"
320318
} else {
321319
remote_names[0]
322320
};
323321

324-
match git_repo_remote_url(repo, chosen_remote) {
322+
Ok(Some(chosen.to_owned()))
323+
}
324+
325+
/// Like git_repo_base_repo_name but preserves the original case of the repository name.
326+
/// This is used specifically for build upload where case preservation is important.
327+
pub fn git_repo_base_repo_name_preserve_case(repo: &git2::Repository) -> Result<Option<String>> {
328+
let chosen_remote = match find_best_remote(repo)? {
329+
Some(remote) => remote,
330+
None => {
331+
warn!("No remotes found in repository");
332+
return Ok(None);
333+
}
334+
};
335+
336+
match git_repo_remote_url(repo, &chosen_remote) {
325337
Ok(remote_url) => {
326338
debug!("Found remote '{chosen_remote}': {remote_url}");
327339
let repo_name = get_repo_from_remote_preserve_case(&remote_url);

tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Arguments:
1313
Options:
1414
-o, --org <ORG> The organization ID or slug.
1515
--repo <REPO> The repository name (e.g. owner/repo). Defaults to the git remote.
16-
--default-branch <BRANCH> The default branch name. [default: main]
16+
--default-branch <BRANCH> The default branch name. Defaults to the git remote HEAD or 'main'.
1717
--header <KEY:VALUE> Custom headers that should be attached to all requests
1818
in key:value format.
1919
-p, --project <PROJECT> The project ID or slug.

0 commit comments

Comments
 (0)