Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 293 additions & 2 deletions src/commands/code_mappings/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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'."),
)
}

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

let explicit_repo = matches.get_one::<String>("repo");
let explicit_branch = matches.get_one::<String>("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<String> {
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<String> {
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<String> {
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");
}
}
26 changes: 19 additions & 7 deletions src/utils/vcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,27 +301,39 @@ pub fn git_repo_base_ref(repo: &git2::Repository, remote_name: &str) -> Result<S
})
}

/// 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<Option<String>> {
/// 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<Option<String>> {
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"
} else {
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<Option<String>> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Arguments:
Options:
-o, --org <ORG> The organization ID or slug.
--repo <REPO> The repository name (e.g. owner/repo). Defaults to the git remote.
--default-branch <BRANCH> The default branch name. [default: main]
--default-branch <BRANCH> The default branch name. Defaults to the git remote HEAD or 'main'.
--header <KEY:VALUE> Custom headers that should be attached to all requests
in key:value format.
-p, --project <PROJECT> The project ID or slug.
Expand Down
Loading