diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs index d40d6427c3..3bd9f2fef9 100644 --- a/src/commands/code_mappings/upload.rs +++ b/src/commands/code_mappings/upload.rs @@ -2,8 +2,12 @@ use std::fs; use anyhow::{bail, Context as _, Result}; use clap::{Arg, ArgMatches, Command}; +use log::debug; use serde::Deserialize; +use crate::config::Config; +use crate::utils::vcs; + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CodeMapping { @@ -30,8 +34,7 @@ pub fn make_command(command: Command) -> Command { Arg::new("default_branch") .long("default-branch") .value_name("BRANCH") - .default_value("main") - .help("The default branch name."), + .help("The default branch name. Defaults to the git remote HEAD or 'main'."), ) } @@ -57,7 +60,295 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { } } + let explicit_repo = matches.get_one::("repo"); + let explicit_branch = matches.get_one::("default_branch"); + + let git_repo = (explicit_repo.is_none() || explicit_branch.is_none()) + .then(|| git2::Repository::open_from_env().ok()) + .flatten(); + + let (repo_name, default_branch) = resolve_repo_and_branch( + explicit_repo.map(|s| s.as_str()), + explicit_branch.map(|s| s.as_str()), + git_repo.as_ref(), + )?; + println!("Found {} code mapping(s) in {path}", mappings.len()); + println!("Repository: {repo_name}"); + println!("Default branch: {default_branch}"); Ok(()) } + +/// Resolves the repository name and default branch from explicit args and git inference. +fn resolve_repo_and_branch( + explicit_repo: Option<&str>, + explicit_branch: Option<&str>, + git_repo: Option<&git2::Repository>, +) -> Result<(String, String)> { + let (repo_name, remote_name) = if let Some(r) = explicit_repo { + // Try to find a local remote whose URL matches the explicit repo name, + // so we can infer the default branch from it. Falls back to None (-> "main"). + let remote = git_repo.and_then(|repo| find_remote_for_repo(repo, r)); + (r.to_owned(), remote) + } else { + let remote = git_repo.and_then(resolve_git_remote); + let name = infer_repo_name(git_repo, remote.as_deref())?; + (name, remote) + }; + + let default_branch = if let Some(b) = explicit_branch { + b.to_owned() + } else { + infer_default_branch(git_repo, remote_name.as_deref()) + }; + + Ok((repo_name, default_branch)) +} + +/// Finds the best git remote name. Prefers the configured VCS remote +/// (SENTRY_VCS_REMOTE / ini), then falls back to upstream > origin > first. +fn resolve_git_remote(repo: &git2::Repository) -> Option { + let config = Config::current(); + let configured_remote = config.get_cached_vcs_remote(); + if vcs::git_repo_remote_url(repo, &configured_remote).is_ok() { + debug!("Using configured VCS remote: {configured_remote}"); + return Some(configured_remote); + } + match vcs::find_best_remote(repo) { + Ok(Some(best)) => { + debug!("Configured remote '{configured_remote}' not found, using: {best}"); + Some(best) + } + _ => None, + } +} + +/// Finds the remote whose URL matches the given repository name (e.g. "owner/repo"). +fn find_remote_for_repo(repo: &git2::Repository, repo_name: &str) -> Option { + let remotes = repo.remotes().ok()?; + let found = remotes.iter().flatten().find(|name| { + vcs::git_repo_remote_url(repo, name) + .map(|url| vcs::get_repo_from_remote_preserve_case(&url) == repo_name) + .unwrap_or(false) + })?; + debug!("Found remote '{found}' matching repo '{repo_name}'"); + Some(found.to_owned()) +} + +/// Infers the repository name (e.g. "owner/repo") from the git remote URL. +fn infer_repo_name( + git_repo: Option<&git2::Repository>, + remote_name: Option<&str>, +) -> Result { + let git_repo = git_repo.ok_or_else(|| { + anyhow::anyhow!("Could not open git repository. Use --repo to specify manually.") + })?; + let remote_name = remote_name.ok_or_else(|| { + anyhow::anyhow!("No remotes found in the git repository. Use --repo to specify manually.") + })?; + let remote_url = vcs::git_repo_remote_url(git_repo, remote_name)?; + debug!("Found remote '{remote_name}': {remote_url}"); + let inferred = vcs::get_repo_from_remote_preserve_case(&remote_url); + if inferred.is_empty() { + bail!("Could not parse repository name from remote URL: {remote_url}"); + } + Ok(inferred) +} + +/// Infers the default branch from the git remote HEAD, falling back to "main". +fn infer_default_branch(git_repo: Option<&git2::Repository>, remote_name: Option<&str>) -> String { + git_repo + .zip(remote_name) + .and_then(|(repo, name)| { + vcs::git_repo_base_ref(repo, name) + .map_err(|e| { + debug!("Could not infer default branch from remote: {e}"); + e + }) + .ok() + }) + .unwrap_or_else(|| { + debug!("No git repo or remote available, falling back to 'main'"); + "main".to_owned() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + use ini::Ini; + use tempfile::tempdir; + + use crate::config::Config; + + fn init_git_repo_with_remotes(remotes: &[(&str, &str)]) -> tempfile::TempDir { + let dir = tempdir().expect("temp dir"); + std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(&dir) + .env_remove("GIT_DIR") + .output() + .expect("git init"); + for (name, url) in remotes { + std::process::Command::new("git") + .args(["remote", "add", name, url]) + .current_dir(&dir) + .output() + .expect("git remote add"); + } + dir + } + + /// Creates a commit and sets up remote HEAD refs so branch inference works. + fn setup_remote_head_refs( + repo: &git2::Repository, + dir: &std::path::Path, + branches: &[(&str, &str)], + ) { + for (args, msg) in [ + (vec!["config", "--local", "user.name", "test"], "git config"), + ( + vec!["config", "--local", "user.email", "test@test.com"], + "git config", + ), + (vec!["commit", "--allow-empty", "-m", "init"], "git commit"), + ] { + std::process::Command::new("git") + .args(&args) + .current_dir(dir) + .output() + .expect(msg); + } + + let head_commit = repo.head().unwrap().peel_to_commit().unwrap().id(); + for (remote, branch) in branches { + repo.reference( + &format!("refs/remotes/{remote}/{branch}"), + head_commit, + false, + "test", + ) + .unwrap(); + repo.reference_symbolic( + &format!("refs/remotes/{remote}/HEAD"), + &format!("refs/remotes/{remote}/{branch}"), + false, + "test", + ) + .unwrap(); + } + } + + /// Calls `resolve_repo_and_branch` with explicit args and a pre-opened git repo. + fn run_resolve( + git_repo: Option<&git2::Repository>, + explicit_repo: Option<&str>, + explicit_branch: Option<&str>, + ) -> Result<(String, String)> { + // Bind a default Config so resolve_git_remote can call Config::current(). + Config::from_file(PathBuf::from("/dev/null"), Ini::new()).bind_to_process(); + + resolve_repo_and_branch(explicit_repo, explicit_branch, git_repo) + } + + #[test] + fn find_remote_for_repo_matches_upstream() { + let dir = init_git_repo_with_remotes(&[ + ("origin", "https://github.com/my-fork/MyRepo"), + ("upstream", "https://github.com/MyOrg/MyRepo"), + ]); + let repo = git2::Repository::open(dir.path()).unwrap(); + assert_eq!( + find_remote_for_repo(&repo, "MyOrg/MyRepo"), + Some("upstream".to_owned()) + ); + } + + #[test] + fn find_remote_for_repo_matches_origin() { + let dir = init_git_repo_with_remotes(&[("origin", "https://github.com/MyOrg/MyRepo")]); + let repo = git2::Repository::open(dir.path()).unwrap(); + assert_eq!( + find_remote_for_repo(&repo, "MyOrg/MyRepo"), + Some("origin".to_owned()) + ); + } + + #[test] + fn find_remote_for_repo_no_match() { + let dir = + init_git_repo_with_remotes(&[("origin", "https://github.com/other-org/other-repo")]); + let repo = git2::Repository::open(dir.path()).unwrap(); + assert_eq!(find_remote_for_repo(&repo, "MyOrg/MyRepo"), None); + } + + #[test] + fn find_remote_for_repo_preserves_case() { + let dir = init_git_repo_with_remotes(&[("origin", "https://github.com/MyOrg/MyRepo")]); + let repo = git2::Repository::open(dir.path()).unwrap(); + assert_eq!(find_remote_for_repo(&repo, "myorg/myrepo"), None); + } + + #[test] + fn resolve_no_repo_no_branch_infers_both() { + let dir = init_git_repo_with_remotes(&[("origin", "https://github.com/MyOrg/MyRepo")]); + let repo = git2::Repository::open(dir.path()).unwrap(); + setup_remote_head_refs(&repo, dir.path(), &[("origin", "develop")]); + + let (repo_name, branch) = run_resolve(Some(&repo), None, None).unwrap(); + assert_eq!(repo_name, "MyOrg/MyRepo"); + assert_eq!(branch, "develop"); + } + + #[test] + fn resolve_explicit_branch_no_repo_infers_repo() { + let dir = init_git_repo_with_remotes(&[("origin", "https://github.com/MyOrg/MyRepo")]); + let repo = git2::Repository::open(dir.path()).unwrap(); + + let (repo_name, branch) = run_resolve(Some(&repo), None, Some("release")).unwrap(); + assert_eq!(repo_name, "MyOrg/MyRepo"); + assert_eq!(branch, "release"); + } + + #[test] + fn resolve_both_explicit_skips_git() { + let (repo_name, branch) = run_resolve(None, Some("MyOrg/MyRepo"), Some("release")).unwrap(); + assert_eq!(repo_name, "MyOrg/MyRepo"); + assert_eq!(branch, "release"); + } + + #[test] + fn resolve_explicit_repo_no_match_falls_back_to_main() { + let dir = + init_git_repo_with_remotes(&[("origin", "https://github.com/other-org/other-repo")]); + let repo = git2::Repository::open(dir.path()).unwrap(); + + let (repo_name, branch) = run_resolve(Some(&repo), Some("MyOrg/MyRepo"), None).unwrap(); + assert_eq!(repo_name, "MyOrg/MyRepo"); + assert_eq!(branch, "main"); + } + + #[test] + fn resolve_explicit_repo_infers_branch_from_matching_remote() { + // --repo matches "upstream", --default-branch omitted: + // branch should be inferred from upstream's HEAD ("develop"), + // not origin's ("master"). + let dir = init_git_repo_with_remotes(&[ + ("origin", "https://github.com/my-fork/MyRepo"), + ("upstream", "https://github.com/MyOrg/MyRepo"), + ]); + let repo = git2::Repository::open(dir.path()).unwrap(); + setup_remote_head_refs( + &repo, + dir.path(), + &[("origin", "master"), ("upstream", "develop")], + ); + + let (repo_name, branch) = run_resolve(Some(&repo), Some("MyOrg/MyRepo"), None).unwrap(); + assert_eq!(repo_name, "MyOrg/MyRepo"); + assert_eq!(branch, "develop"); + } +} diff --git a/src/utils/vcs.rs b/src/utils/vcs.rs index 948c35a0f9..2fd89b372d 100644 --- a/src/utils/vcs.rs +++ b/src/utils/vcs.rs @@ -301,19 +301,17 @@ pub fn git_repo_base_ref(repo: &git2::Repository, remote_name: &str) -> Result Result> { +/// Finds the best remote in a git repository. +/// Prefers "upstream" if it exists, then "origin", otherwise uses the first remote. +pub fn find_best_remote(repo: &git2::Repository) -> Result> { let remotes = repo.remotes()?; let remote_names: Vec<&str> = remotes.iter().flatten().collect(); if remote_names.is_empty() { - warn!("No remotes found in repository"); return Ok(None); } - // Prefer "upstream" if it exists, then "origin", otherwise use the first one - let chosen_remote = if remote_names.contains(&"upstream") { + let chosen = if remote_names.contains(&"upstream") { "upstream" } else if remote_names.contains(&"origin") { "origin" @@ -321,7 +319,21 @@ pub fn git_repo_base_repo_name_preserve_case(repo: &git2::Repository) -> Result< remote_names[0] }; - match git_repo_remote_url(repo, chosen_remote) { + Ok(Some(chosen.to_owned())) +} + +/// Like git_repo_base_repo_name but preserves the original case of the repository name. +/// This is used specifically for build upload where case preservation is important. +pub fn git_repo_base_repo_name_preserve_case(repo: &git2::Repository) -> Result> { + let chosen_remote = match find_best_remote(repo)? { + Some(remote) => remote, + None => { + warn!("No remotes found in repository"); + return Ok(None); + } + }; + + match git_repo_remote_url(repo, &chosen_remote) { Ok(remote_url) => { debug!("Found remote '{chosen_remote}': {remote_url}"); let repo_name = get_repo_from_remote_preserve_case(&remote_url); diff --git a/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd b/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd index 01033d6915..7eec72d0c2 100644 --- a/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd +++ b/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd @@ -13,7 +13,7 @@ Arguments: Options: -o, --org The organization ID or slug. --repo The repository name (e.g. owner/repo). Defaults to the git remote. - --default-branch The default branch name. [default: main] + --default-branch The default branch name. Defaults to the git remote HEAD or 'main'. --header Custom headers that should be attached to all requests in key:value format. -p, --project The project ID or slug.