From 2eee74ee7437317751f40fba53c860e758a9e1e3 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Mon, 9 Mar 2026 18:11:33 -0700 Subject: [PATCH 1/5] Add VCS parameters to snapshots upload command --- src/api/data_types/snapshots.rs | 22 +++- src/commands/build/mod.rs | 1 + src/commands/build/snapshots.rs | 81 ++++++++++++- src/commands/build/upload.rs | 199 +------------------------------ src/commands/build/vcs.rs | 205 ++++++++++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 201 deletions(-) create mode 100644 src/commands/build/vcs.rs diff --git a/src/api/data_types/snapshots.rs b/src/api/data_types/snapshots.rs index ffce5f572a..b52a2528ef 100644 --- a/src/api/data_types/snapshots.rs +++ b/src/api/data_types/snapshots.rs @@ -1,9 +1,10 @@ //! Data types for the snapshots API. -use std::collections::HashMap; +use std::{borrow::Cow, collections::HashMap}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha1_smol::Digest; const IMAGE_FILE_NAME_FIELD: &str = "image_file_name"; const WIDTH_FIELD: &str = "width"; @@ -21,9 +22,26 @@ pub struct CreateSnapshotResponse { // Keep in sync with https://github.com/getsentry/sentry/blob/master/src/sentry/preprod/snapshots/manifest.py /// Manifest describing a set of snapshot images for an app. #[derive(Debug, Serialize)] -pub struct SnapshotsManifest { +pub struct SnapshotsManifest<'a> { pub app_id: String, pub images: HashMap, + // VCS info + #[serde(skip_serializing_if = "Option::is_none")] + pub head_sha: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_sha: Option, + #[serde(skip_serializing_if = "str::is_empty", rename = "provider")] + pub vcs_provider: Cow<'a, str>, + #[serde(skip_serializing_if = "str::is_empty")] + pub head_repo_name: Cow<'a, str>, + #[serde(skip_serializing_if = "str::is_empty")] + pub base_repo_name: Cow<'a, str>, + #[serde(skip_serializing_if = "str::is_empty")] + pub head_ref: Cow<'a, str>, + #[serde(skip_serializing_if = "str::is_empty")] + pub base_ref: Cow<'a, str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_number: Option, } // Keep in sync with https://github.com/getsentry/sentry/blob/master/src/sentry/preprod/snapshots/manifest.py diff --git a/src/commands/build/mod.rs b/src/commands/build/mod.rs index d302aa0791..cb7c9fb488 100644 --- a/src/commands/build/mod.rs +++ b/src/commands/build/mod.rs @@ -5,6 +5,7 @@ use crate::utils::args::ArgExt as _; pub mod snapshots; pub mod upload; +pub mod vcs; macro_rules! each_subcommand { ($mac:ident) => { diff --git a/src/commands/build/snapshots.rs b/src/commands/build/snapshots.rs index 196ca51a27..980788db01 100644 --- a/src/commands/build/snapshots.rs +++ b/src/commands/build/snapshots.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr as _; use anyhow::{Context as _, Result}; -use clap::{Arg, ArgMatches, Command}; +use clap::{Arg, ArgAction, ArgMatches, Command}; use console::style; use itertools::Itertools as _; use log::{debug, info, warn}; @@ -15,9 +15,11 @@ use serde_json::Value; use sha2::{Digest as _, Sha256}; use walkdir::WalkDir; +use super::vcs::{collect_git_metadata, parse_sha_allow_empty}; use crate::api::{Api, CreateSnapshotResponse, ImageMetadata, SnapshotsManifest}; use crate::config::{Auth, Config}; use crate::utils::args::ArgExt as _; +use crate::utils::ci::is_ci; const EXPERIMENTAL_WARNING: &str = "[EXPERIMENTAL] The \"build snapshots\" command is experimental. \ @@ -47,6 +49,68 @@ pub fn make_command(command: Command) -> Command { .help("The application identifier.") .required(true), ) + + .arg( + Arg::new("head_sha") + .long("head-sha") + .value_parser(parse_sha_allow_empty) + .help("The VCS commit sha to use for the upload. If not provided, the current commit sha will be used.") + ) + .arg( + Arg::new("base_sha") + .long("base-sha") + .value_parser(parse_sha_allow_empty) + .help("The VCS commit's base sha to use for the upload. If not provided, the merge-base of the current and remote branch will be used.") + ) + .arg( + Arg::new("vcs_provider") + .long("vcs-provider") + .help("The VCS provider to use for the upload. If not provided, the current provider will be used.") + ) + .arg( + Arg::new("head_repo_name") + .long("head-repo-name") + .help("The name of the git repository to use for the upload (e.g. organization/repository). If not provided, the current repository will be used.") + ) + .arg( + Arg::new("base_repo_name") + .long("base-repo-name") + .help("The name of the git repository to use for the upload (e.g. organization/repository). If not provided, the current repository will be used.") + ) + .arg( + Arg::new("head_ref") + .long("head-ref") + .help("The reference (branch) to use for the upload. If not provided, the current reference will be used.") + ) + .arg( + Arg::new("base_ref") + .long("base-ref") + .help("The base reference (branch) to use for the upload. If not provided, the merge-base with the remote tracking branch will be used.") + ) + .arg( + Arg::new("pr_number") + .long("pr-number") + .value_parser(clap::value_parser!(u32)) + .help("The pull request number to use for the upload. If not provided and running \ + in a pull_request-triggered GitHub Actions workflow, the PR number will be automatically \ + detected from GitHub Actions environment variables.") + ) + .arg( + Arg::new("force_git_metadata") + .long("force-git-metadata") + .action(ArgAction::SetTrue) + .conflicts_with("no_git_metadata") + .help("Force collection and sending of git metadata (branch, commit, etc.). \ + If neither this nor --no-git-metadata is specified, git metadata is \ + automatically collected when running in most CI environments.") + ) + .arg( + Arg::new("no_git_metadata") + .long("no-git-metadata") + .action(ArgAction::SetTrue) + .conflicts_with("force_git_metadata") + .help("Disable collection and sending of git metadata.") + ) } struct ImageInfo { @@ -80,6 +144,13 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { anyhow::bail!("Path is not a directory: {}", dir_path.display()); } + // Collect git metadata if running in CI, unless explicitly enabled or disabled. + let should_collect_git_metadata = + matches.get_flag("force_git_metadata") || (!matches.get_flag("no_git_metadata") && is_ci()); + + // Always collect git metadata, but only perform automatic inference when enabled + let vcs_info = collect_git_metadata(matches, &config, should_collect_git_metadata); + debug!("Scanning for images in: {}", dir_path.display()); debug!("Organization: {org}"); debug!("Project: {project}"); @@ -114,6 +185,14 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { let manifest = SnapshotsManifest { app_id: app_id.clone(), images: manifest_entries, + head_sha: vcs_info.head_sha, + base_sha: vcs_info.base_sha, + vcs_provider: vcs_info.vcs_provider, + head_repo_name: vcs_info.head_repo_name, + base_repo_name: vcs_info.base_repo_name, + head_ref: vcs_info.head_ref, + base_ref: vcs_info.base_ref, + pr_number: vcs_info.pr_number, }; // POST manifest to API diff --git a/src/commands/build/upload.rs b/src/commands/build/upload.rs index 96701b74e5..a79dffa394 100644 --- a/src/commands/build/upload.rs +++ b/src/commands/build/upload.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::io::Write as _; use std::path::Path; use std::thread; @@ -8,11 +7,11 @@ use anyhow::{anyhow, bail, Context as _, Result}; use clap::{Arg, ArgAction, ArgMatches, Command}; use indicatif::ProgressStyle; use log::{debug, info, warn}; -use sha1_smol::Digest; use symbolic::common::ByteView; use zip::write::SimpleFileOptions; use zip::{DateTime, ZipWriter}; +use super::vcs::{collect_git_metadata, parse_sha_allow_empty}; use crate::api::{Api, AuthenticatedApi, ChunkedBuildRequest, ChunkedFileState, VcsInfo}; use crate::config::Config; use crate::constants::DEFAULT_MAX_WAIT; @@ -28,11 +27,6 @@ use crate::utils::fs::get_sha1_checksums; use crate::utils::fs::TempDir; use crate::utils::fs::TempFile; use crate::utils::progress::ProgressBar; -use crate::utils::vcs::{ - self, get_github_base_ref, get_github_head_ref, get_github_pr_number, get_provider_from_remote, - get_repo_from_remote_preserve_case, git_repo_base_ref, git_repo_base_repo_name_preserve_case, - git_repo_head_ref, git_repo_remote_url, -}; pub fn make_command(command: Command) -> Command { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] @@ -309,181 +303,6 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { Ok(()) } -/// Collects git metadata from arguments and VCS introspection. -/// -/// When `auto_collect` is false, only explicitly provided values are collected; -/// automatic inference from git repository and CI environment is skipped. -fn collect_git_metadata( - matches: &ArgMatches, - config: &Config, - auto_collect: bool, -) -> VcsInfo<'static> { - let head_sha = matches - .get_one::>("head_sha") - .map(|d| d.as_ref().cloned()) - .or_else(|| auto_collect.then(|| vcs::find_head_sha().ok())) - .flatten(); - - let cached_remote = config.get_cached_vcs_remote(); - let (vcs_provider, head_repo_name, head_ref, base_ref, base_repo_name) = { - let repo = if auto_collect { - git2::Repository::open_from_env().ok() - } else { - None - }; - let repo_ref = repo.as_ref(); - let remote_url = repo_ref.and_then(|repo| git_repo_remote_url(repo, &cached_remote).ok()); - - let vcs_provider = matches - .get_one("vcs_provider") - .cloned() - .or_else(|| { - auto_collect - .then(|| remote_url.as_ref().map(|url| get_provider_from_remote(url)))? - }) - .unwrap_or_default(); - - let head_repo_name = matches - .get_one("head_repo_name") - .cloned() - .or_else(|| { - auto_collect.then(|| { - remote_url - .as_ref() - .map(|url| get_repo_from_remote_preserve_case(url)) - })? - }) - .unwrap_or_default(); - - let head_ref = matches - .get_one("head_ref") - .cloned() - .or_else(|| auto_collect.then(get_github_head_ref)?) - .or_else(|| { - auto_collect.then(|| { - repo_ref.and_then(|r| match git_repo_head_ref(r) { - Ok(ref_name) => { - debug!("Found current branch reference: {ref_name}"); - Some(ref_name) - } - Err(e) => { - debug!("No valid branch reference found (likely detached HEAD): {e}"); - None - } - }) - })? - }) - .unwrap_or_default(); - - let base_ref = matches - .get_one("base_ref") - .cloned() - .or_else(|| auto_collect.then(get_github_base_ref)?) - .or_else(|| { - auto_collect.then(|| { - repo_ref.and_then(|r| match git_repo_base_ref(r, &cached_remote) { - Ok(base_ref_name) => { - debug!("Found base reference: {base_ref_name}"); - Some(base_ref_name) - } - Err(e) => { - info!("Could not detect base branch reference: {e}"); - None - } - }) - })? - }) - .unwrap_or_default(); - - let base_repo_name = matches - .get_one("base_repo_name") - .cloned() - .or_else(|| { - auto_collect.then(|| { - repo_ref.and_then(|r| match git_repo_base_repo_name_preserve_case(r) { - Ok(Some(base_repo_name)) => { - debug!("Found base repository name: {base_repo_name}"); - Some(base_repo_name) - } - Ok(None) => { - debug!("No base repository found - not a fork"); - None - } - Err(e) => { - warn!("Could not detect base repository name: {e}"); - None - } - }) - })? - }) - .unwrap_or_default(); - - ( - vcs_provider, - head_repo_name, - head_ref, - base_ref, - base_repo_name, - ) - }; - - let base_sha_from_user = matches.get_one::>("base_sha").is_some(); - let base_ref_from_user = matches.get_one::("base_ref").is_some(); - - let mut base_sha = matches - .get_one::>("base_sha") - .map(|d| d.as_ref().cloned()) - .or_else(|| { - if auto_collect { - Some( - vcs::find_base_sha(&cached_remote) - .inspect_err(|e| debug!("Error finding base SHA: {e}")) - .ok() - .flatten(), - ) - } else { - None - } - }) - .flatten(); - - let mut base_ref = base_ref; - - // If base_sha equals head_sha and both were auto-inferred, skip setting base_sha and base_ref - if !base_sha_from_user - && !base_ref_from_user - && base_sha.is_some() - && head_sha.is_some() - && base_sha == head_sha - { - debug!( - "Base SHA equals head SHA ({}), and both were auto-inferred. Skipping base_sha and base_ref, but keeping head_sha.", - base_sha.expect("base_sha is Some at this point") - ); - base_sha = None; - base_ref = "".into(); - } - - let pr_number = matches.get_one("pr_number").copied().or_else(|| { - if auto_collect { - get_github_pr_number() - } else { - None - } - }); - - VcsInfo { - head_sha, - base_sha, - vcs_provider: Cow::Owned(vcs_provider), - head_repo_name: Cow::Owned(head_repo_name), - base_repo_name: Cow::Owned(base_repo_name), - head_ref: Cow::Owned(head_ref), - base_ref: Cow::Owned(base_ref), - pr_number, - } -} - fn handle_file( path: &Path, byteview: &ByteView, @@ -709,22 +528,6 @@ fn upload_file( } } -/// Utility function to parse a SHA1 digest, allowing empty strings. -/// -/// Empty strings result in Ok(None), otherwise we return the parsed digest -/// or an error if the SHA is invalid. -fn parse_sha_allow_empty(sha: &str) -> Result> { - if sha.is_empty() { - return Ok(None); - } - - let digest = sha - .parse() - .with_context(|| format!("{sha} is not a valid SHA1 digest"))?; - - Ok(Some(digest)) -} - #[cfg(not(windows))] #[cfg(test)] mod tests { diff --git a/src/commands/build/vcs.rs b/src/commands/build/vcs.rs new file mode 100644 index 0000000000..99a403d81f --- /dev/null +++ b/src/commands/build/vcs.rs @@ -0,0 +1,205 @@ +use std::borrow::Cow; + +use anyhow::{Context as _, Result}; +use clap::ArgMatches; +use log::{debug, info, warn}; +use sha1_smol::Digest; + +use crate::api::VcsInfo; +use crate::config::Config; +use crate::utils::vcs::{ + self, get_github_base_ref, get_github_head_ref, get_github_pr_number, get_provider_from_remote, + get_repo_from_remote_preserve_case, git_repo_base_ref, git_repo_base_repo_name_preserve_case, + git_repo_head_ref, git_repo_remote_url, +}; + +/// Collects git metadata from arguments and VCS introspection. +/// +/// When `auto_collect` is false, only explicitly provided values are collected; +/// automatic inference from git repository and CI environment is skipped. +pub fn collect_git_metadata( + matches: &ArgMatches, + config: &Config, + auto_collect: bool, +) -> VcsInfo<'static> { + let head_sha = matches + .get_one::>("head_sha") + .map(|d| d.as_ref().cloned()) + .or_else(|| auto_collect.then(|| vcs::find_head_sha().ok())) + .flatten(); + + let cached_remote = config.get_cached_vcs_remote(); + let (vcs_provider, head_repo_name, head_ref, base_ref, base_repo_name) = { + let repo = if auto_collect { + git2::Repository::open_from_env().ok() + } else { + None + }; + let repo_ref = repo.as_ref(); + let remote_url = repo_ref.and_then(|repo| git_repo_remote_url(repo, &cached_remote).ok()); + + let vcs_provider = matches + .get_one("vcs_provider") + .cloned() + .or_else(|| { + auto_collect + .then(|| remote_url.as_ref().map(|url| get_provider_from_remote(url)))? + }) + .unwrap_or_default(); + + let head_repo_name = matches + .get_one("head_repo_name") + .cloned() + .or_else(|| { + auto_collect.then(|| { + remote_url + .as_ref() + .map(|url| get_repo_from_remote_preserve_case(url)) + })? + }) + .unwrap_or_default(); + + let head_ref = matches + .get_one("head_ref") + .cloned() + .or_else(|| auto_collect.then(get_github_head_ref)?) + .or_else(|| { + auto_collect.then(|| { + repo_ref.and_then(|r| match git_repo_head_ref(r) { + Ok(ref_name) => { + debug!("Found current branch reference: {ref_name}"); + Some(ref_name) + } + Err(e) => { + debug!("No valid branch reference found (likely detached HEAD): {e}"); + None + } + }) + })? + }) + .unwrap_or_default(); + + let base_ref = matches + .get_one("base_ref") + .cloned() + .or_else(|| auto_collect.then(get_github_base_ref)?) + .or_else(|| { + auto_collect.then(|| { + repo_ref.and_then(|r| match git_repo_base_ref(r, &cached_remote) { + Ok(base_ref_name) => { + debug!("Found base reference: {base_ref_name}"); + Some(base_ref_name) + } + Err(e) => { + info!("Could not detect base branch reference: {e}"); + None + } + }) + })? + }) + .unwrap_or_default(); + + let base_repo_name = matches + .get_one("base_repo_name") + .cloned() + .or_else(|| { + auto_collect.then(|| { + repo_ref.and_then(|r| match git_repo_base_repo_name_preserve_case(r) { + Ok(Some(base_repo_name)) => { + debug!("Found base repository name: {base_repo_name}"); + Some(base_repo_name) + } + Ok(None) => { + debug!("No base repository found - not a fork"); + None + } + Err(e) => { + warn!("Could not detect base repository name: {e}"); + None + } + }) + })? + }) + .unwrap_or_default(); + + ( + vcs_provider, + head_repo_name, + head_ref, + base_ref, + base_repo_name, + ) + }; + + let base_sha_from_user = matches.get_one::>("base_sha").is_some(); + let base_ref_from_user = matches.get_one::("base_ref").is_some(); + + let mut base_sha = matches + .get_one::>("base_sha") + .map(|d| d.as_ref().cloned()) + .or_else(|| { + if auto_collect { + Some( + vcs::find_base_sha(&cached_remote) + .inspect_err(|e| debug!("Error finding base SHA: {e}")) + .ok() + .flatten(), + ) + } else { + None + } + }) + .flatten(); + + let mut base_ref = base_ref; + + // If base_sha equals head_sha and both were auto-inferred, skip setting base_sha and base_ref + if !base_sha_from_user + && !base_ref_from_user + && base_sha.is_some() + && head_sha.is_some() + && base_sha == head_sha + { + debug!( + "Base SHA equals head SHA ({}), and both were auto-inferred. Skipping base_sha and base_ref, but keeping head_sha.", + base_sha.expect("base_sha is Some at this point") + ); + base_sha = None; + base_ref = "".into(); + } + + let pr_number = matches.get_one("pr_number").copied().or_else(|| { + if auto_collect { + get_github_pr_number() + } else { + None + } + }); + + VcsInfo { + head_sha, + base_sha, + vcs_provider: Cow::Owned(vcs_provider), + head_repo_name: Cow::Owned(head_repo_name), + base_repo_name: Cow::Owned(base_repo_name), + head_ref: Cow::Owned(head_ref), + base_ref: Cow::Owned(base_ref), + pr_number, + } +} + +/// Utility function to parse a SHA1 digest, allowing empty strings. +/// +/// Empty strings result in Ok(None), otherwise we return the parsed digest +/// or an error if the SHA is invalid. +pub fn parse_sha_allow_empty(sha: &str) -> Result> { + if sha.is_empty() { + return Ok(None); + } + + let digest = sha + .parse() + .with_context(|| format!("{sha} is not a valid SHA1 digest"))?; + + Ok(Some(digest)) +} From be4044c283f31b26f3554688bbbf88465f52e450 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Mon, 9 Mar 2026 18:18:05 -0700 Subject: [PATCH 2/5] Fixes --- CHANGELOG.md | 1 + .../_cases/build/build-snapshots-help.trycmd | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce3b4f655..78db5052c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Experimental Feature 🧑‍🔬 (internal-only) - Pipe snapshot sidecar metadata into upload as part of `sentry-cli build snapshots` command ([#3163](https://github.com/getsentry/sentry-cli/pull/3163)). +- Add VCS metadata parameters to `sentry-cli build snapshots` command ([#3200](https://github.com/getsentry/sentry-cli/pull/3200)). ## 3.3.0 diff --git a/tests/integration/_cases/build/build-snapshots-help.trycmd b/tests/integration/_cases/build/build-snapshots-help.trycmd index 4eda1521de..56a7937694 100644 --- a/tests/integration/_cases/build/build-snapshots-help.trycmd +++ b/tests/integration/_cases/build/build-snapshots-help.trycmd @@ -29,15 +29,56 @@ Options: --auth-token Use the given Sentry auth token. + --head-sha + The VCS commit sha to use for the upload. If not provided, the current commit sha will be + used. + --log-level Set the log output verbosity. [possible values: trace, debug, info, warn, error] + --base-sha + The VCS commit's base sha to use for the upload. If not provided, the merge-base of the + current and remote branch will be used. + --quiet Do not print any output while preserving correct exit code. This flag is currently implemented only for selected subcommands. [aliases: --silent] + --vcs-provider + The VCS provider to use for the upload. If not provided, the current provider will be + used. + + --head-repo-name + The name of the git repository to use for the upload (e.g. organization/repository). If + not provided, the current repository will be used. + + --base-repo-name + The name of the git repository to use for the upload (e.g. organization/repository). If + not provided, the current repository will be used. + + --head-ref + The reference (branch) to use for the upload. If not provided, the current reference will + be used. + + --base-ref + The base reference (branch) to use for the upload. If not provided, the merge-base with + the remote tracking branch will be used. + + --pr-number + The pull request number to use for the upload. If not provided and running in a + pull_request-triggered GitHub Actions workflow, the PR number will be automatically + detected from GitHub Actions environment variables. + + --force-git-metadata + Force collection and sending of git metadata (branch, commit, etc.). If neither this nor + --no-git-metadata is specified, git metadata is automatically collected when running in + most CI environments. + + --no-git-metadata + Disable collection and sending of git metadata. + -h, --help Print help (see a summary with '-h') From e356dc9f2d8f2e9eb4825c3e425b52cc0f0b2d40 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Tue, 10 Mar 2026 09:40:47 -0700 Subject: [PATCH 3/5] PR feedback --- CHANGELOG.md | 1 - src/api/data_types/snapshots.rs | 24 ++---- src/commands/build/mod.rs | 1 - src/commands/build/snapshots.rs | 76 +------------------ src/commands/build/upload.rs | 64 +--------------- src/utils/args.rs | 67 ++++++++++++++++ .../build/vcs.rs => utils/build_vcs.rs} | 0 src/utils/mod.rs | 1 + .../build/build-upload-help-macos.trycmd | 16 ++-- 9 files changed, 87 insertions(+), 163 deletions(-) rename src/{commands/build/vcs.rs => utils/build_vcs.rs} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78db5052c6..1ce3b4f655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ ### Experimental Feature 🧑‍🔬 (internal-only) - Pipe snapshot sidecar metadata into upload as part of `sentry-cli build snapshots` command ([#3163](https://github.com/getsentry/sentry-cli/pull/3163)). -- Add VCS metadata parameters to `sentry-cli build snapshots` command ([#3200](https://github.com/getsentry/sentry-cli/pull/3200)). ## 3.3.0 diff --git a/src/api/data_types/snapshots.rs b/src/api/data_types/snapshots.rs index b52a2528ef..698c45e4a0 100644 --- a/src/api/data_types/snapshots.rs +++ b/src/api/data_types/snapshots.rs @@ -1,10 +1,11 @@ //! Data types for the snapshots API. -use std::{borrow::Cow, collections::HashMap}; +use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; -use sha1_smol::Digest; + +use super::VcsInfo; const IMAGE_FILE_NAME_FIELD: &str = "image_file_name"; const WIDTH_FIELD: &str = "width"; @@ -25,23 +26,8 @@ pub struct CreateSnapshotResponse { pub struct SnapshotsManifest<'a> { pub app_id: String, pub images: HashMap, - // VCS info - #[serde(skip_serializing_if = "Option::is_none")] - pub head_sha: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub base_sha: Option, - #[serde(skip_serializing_if = "str::is_empty", rename = "provider")] - pub vcs_provider: Cow<'a, str>, - #[serde(skip_serializing_if = "str::is_empty")] - pub head_repo_name: Cow<'a, str>, - #[serde(skip_serializing_if = "str::is_empty")] - pub base_repo_name: Cow<'a, str>, - #[serde(skip_serializing_if = "str::is_empty")] - pub head_ref: Cow<'a, str>, - #[serde(skip_serializing_if = "str::is_empty")] - pub base_ref: Cow<'a, str>, - #[serde(skip_serializing_if = "Option::is_none")] - pub pr_number: Option, + #[serde(flatten)] + pub vcs_info: VcsInfo<'a>, } // Keep in sync with https://github.com/getsentry/sentry/blob/master/src/sentry/preprod/snapshots/manifest.py diff --git a/src/commands/build/mod.rs b/src/commands/build/mod.rs index cb7c9fb488..d302aa0791 100644 --- a/src/commands/build/mod.rs +++ b/src/commands/build/mod.rs @@ -5,7 +5,6 @@ use crate::utils::args::ArgExt as _; pub mod snapshots; pub mod upload; -pub mod vcs; macro_rules! each_subcommand { ($mac:ident) => { diff --git a/src/commands/build/snapshots.rs b/src/commands/build/snapshots.rs index 980788db01..1d626d1852 100644 --- a/src/commands/build/snapshots.rs +++ b/src/commands/build/snapshots.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr as _; use anyhow::{Context as _, Result}; -use clap::{Arg, ArgAction, ArgMatches, Command}; +use clap::{Arg, ArgMatches, Command}; use console::style; use itertools::Itertools as _; use log::{debug, info, warn}; @@ -15,10 +15,10 @@ use serde_json::Value; use sha2::{Digest as _, Sha256}; use walkdir::WalkDir; -use super::vcs::{collect_git_metadata, parse_sha_allow_empty}; use crate::api::{Api, CreateSnapshotResponse, ImageMetadata, SnapshotsManifest}; use crate::config::{Auth, Config}; use crate::utils::args::ArgExt as _; +use crate::utils::build_vcs::collect_git_metadata; use crate::utils::ci::is_ci; const EXPERIMENTAL_WARNING: &str = @@ -49,68 +49,7 @@ pub fn make_command(command: Command) -> Command { .help("The application identifier.") .required(true), ) - - .arg( - Arg::new("head_sha") - .long("head-sha") - .value_parser(parse_sha_allow_empty) - .help("The VCS commit sha to use for the upload. If not provided, the current commit sha will be used.") - ) - .arg( - Arg::new("base_sha") - .long("base-sha") - .value_parser(parse_sha_allow_empty) - .help("The VCS commit's base sha to use for the upload. If not provided, the merge-base of the current and remote branch will be used.") - ) - .arg( - Arg::new("vcs_provider") - .long("vcs-provider") - .help("The VCS provider to use for the upload. If not provided, the current provider will be used.") - ) - .arg( - Arg::new("head_repo_name") - .long("head-repo-name") - .help("The name of the git repository to use for the upload (e.g. organization/repository). If not provided, the current repository will be used.") - ) - .arg( - Arg::new("base_repo_name") - .long("base-repo-name") - .help("The name of the git repository to use for the upload (e.g. organization/repository). If not provided, the current repository will be used.") - ) - .arg( - Arg::new("head_ref") - .long("head-ref") - .help("The reference (branch) to use for the upload. If not provided, the current reference will be used.") - ) - .arg( - Arg::new("base_ref") - .long("base-ref") - .help("The base reference (branch) to use for the upload. If not provided, the merge-base with the remote tracking branch will be used.") - ) - .arg( - Arg::new("pr_number") - .long("pr-number") - .value_parser(clap::value_parser!(u32)) - .help("The pull request number to use for the upload. If not provided and running \ - in a pull_request-triggered GitHub Actions workflow, the PR number will be automatically \ - detected from GitHub Actions environment variables.") - ) - .arg( - Arg::new("force_git_metadata") - .long("force-git-metadata") - .action(ArgAction::SetTrue) - .conflicts_with("no_git_metadata") - .help("Force collection and sending of git metadata (branch, commit, etc.). \ - If neither this nor --no-git-metadata is specified, git metadata is \ - automatically collected when running in most CI environments.") - ) - .arg( - Arg::new("no_git_metadata") - .long("no-git-metadata") - .action(ArgAction::SetTrue) - .conflicts_with("force_git_metadata") - .help("Disable collection and sending of git metadata.") - ) + .git_metadata_args() } struct ImageInfo { @@ -185,14 +124,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { let manifest = SnapshotsManifest { app_id: app_id.clone(), images: manifest_entries, - head_sha: vcs_info.head_sha, - base_sha: vcs_info.base_sha, - vcs_provider: vcs_info.vcs_provider, - head_repo_name: vcs_info.head_repo_name, - base_repo_name: vcs_info.base_repo_name, - head_ref: vcs_info.head_ref, - base_ref: vcs_info.base_ref, - pr_number: vcs_info.pr_number, + vcs_info, }; // POST manifest to API diff --git a/src/commands/build/upload.rs b/src/commands/build/upload.rs index a79dffa394..54cc253c48 100644 --- a/src/commands/build/upload.rs +++ b/src/commands/build/upload.rs @@ -11,7 +11,6 @@ use symbolic::common::ByteView; use zip::write::SimpleFileOptions; use zip::{DateTime, ZipWriter}; -use super::vcs::{collect_git_metadata, parse_sha_allow_empty}; use crate::api::{Api, AuthenticatedApi, ChunkedBuildRequest, ChunkedFileState, VcsInfo}; use crate::config::Config; use crate::constants::DEFAULT_MAX_WAIT; @@ -21,6 +20,7 @@ use crate::utils::build::{handle_asset_catalogs, ipa_to_xcarchive, is_apple_app, use crate::utils::build::{ is_aab_file, is_apk_file, is_zip_file, normalize_directory, write_version_metadata, }; +use crate::utils::build_vcs::collect_git_metadata; use crate::utils::chunks::{upload_chunks, Chunk, ASSEMBLE_POLL_INTERVAL}; use crate::utils::ci::is_ci; use crate::utils::fs::get_sha1_checksums; @@ -48,51 +48,7 @@ pub fn make_command(command: Command) -> Command { .action(ArgAction::Append) .required(true), ) - .arg( - Arg::new("head_sha") - .long("head-sha") - .value_parser(parse_sha_allow_empty) - .help("The VCS commit sha to use for the upload. If not provided, the current commit sha will be used.") - ) - .arg( - Arg::new("base_sha") - .long("base-sha") - .value_parser(parse_sha_allow_empty) - .help("The VCS commit's base sha to use for the upload. If not provided, the merge-base of the current and remote branch will be used.") - ) - .arg( - Arg::new("vcs_provider") - .long("vcs-provider") - .help("The VCS provider to use for the upload. If not provided, the current provider will be used.") - ) - .arg( - Arg::new("head_repo_name") - .long("head-repo-name") - .help("The name of the git repository to use for the upload (e.g. organization/repository). If not provided, the current repository will be used.") - ) - .arg( - Arg::new("base_repo_name") - .long("base-repo-name") - .help("The name of the git repository to use for the upload (e.g. organization/repository). If not provided, the current repository will be used.") - ) - .arg( - Arg::new("head_ref") - .long("head-ref") - .help("The reference (branch) to use for the upload. If not provided, the current reference will be used.") - ) - .arg( - Arg::new("base_ref") - .long("base-ref") - .help("The base reference (branch) to use for the upload. If not provided, the merge-base with the remote tracking branch will be used.") - ) - .arg( - Arg::new("pr_number") - .long("pr-number") - .value_parser(clap::value_parser!(u32)) - .help("The pull request number to use for the upload. If not provided and running \ - in a pull_request-triggered GitHub Actions workflow, the PR number will be automatically \ - detected from GitHub Actions environment variables.") - ) + .git_metadata_args() .arg( Arg::new("build_configuration") .long("build-configuration") @@ -113,22 +69,6 @@ pub fn make_command(command: Command) -> Command { for each other.", ) ) - .arg( - Arg::new("force_git_metadata") - .long("force-git-metadata") - .action(ArgAction::SetTrue) - .conflicts_with("no_git_metadata") - .help("Force collection and sending of git metadata (branch, commit, etc.). \ - If neither this nor --no-git-metadata is specified, git metadata is \ - automatically collected when running in most CI environments.") - ) - .arg( - Arg::new("no_git_metadata") - .long("no-git-metadata") - .action(ArgAction::SetTrue) - .conflicts_with("force_git_metadata") - .help("Disable collection and sending of git metadata.") - ) } /// Parse plugin info from SENTRY_PIPELINE environment variable. diff --git a/src/utils/args.rs b/src/utils/args.rs index b8de609270..de983da7be 100644 --- a/src/utils/args.rs +++ b/src/utils/args.rs @@ -86,6 +86,7 @@ pub trait ArgExt: Sized { fn project_arg(self, multiple: bool) -> Self; fn release_arg(self) -> Self; fn version_arg(self, global: bool) -> Self; + fn git_metadata_args(self) -> Self; } impl ArgExt for Command { @@ -142,4 +143,70 @@ impl ArgExt for Command { .help("The version of the release"), ) } + + fn git_metadata_args(self) -> Command { + use crate::utils::build_vcs::parse_sha_allow_empty; + + self.arg( + Arg::new("head_sha") + .long("head-sha") + .value_parser(parse_sha_allow_empty) + .help("The VCS commit sha to use for the upload. If not provided, the current commit sha will be used.") + ) + .arg( + Arg::new("base_sha") + .long("base-sha") + .value_parser(parse_sha_allow_empty) + .help("The VCS commit's base sha to use for the upload. If not provided, the merge-base of the current and remote branch will be used.") + ) + .arg( + Arg::new("vcs_provider") + .long("vcs-provider") + .help("The VCS provider to use for the upload. If not provided, the current provider will be used.") + ) + .arg( + Arg::new("head_repo_name") + .long("head-repo-name") + .help("The name of the git repository to use for the upload (e.g. organization/repository). If not provided, the current repository will be used.") + ) + .arg( + Arg::new("base_repo_name") + .long("base-repo-name") + .help("The name of the git repository to use for the upload (e.g. organization/repository). If not provided, the current repository will be used.") + ) + .arg( + Arg::new("head_ref") + .long("head-ref") + .help("The reference (branch) to use for the upload. If not provided, the current reference will be used.") + ) + .arg( + Arg::new("base_ref") + .long("base-ref") + .help("The base reference (branch) to use for the upload. If not provided, the merge-base with the remote tracking branch will be used.") + ) + .arg( + Arg::new("pr_number") + .long("pr-number") + .value_parser(clap::value_parser!(u32)) + .help("The pull request number to use for the upload. If not provided and running \ + in a pull_request-triggered GitHub Actions workflow, the PR number will be automatically \ + detected from GitHub Actions environment variables.") + ) + .arg( + Arg::new("force_git_metadata") + .long("force-git-metadata") + .action(ArgAction::SetTrue) + .conflicts_with("no_git_metadata") + .help("Force collection and sending of git metadata (branch, commit, etc.). \ + If neither this nor --no-git-metadata is specified, git metadata is \ + automatically collected when running in most CI environments.") + ) + .arg( + Arg::new("no_git_metadata") + .long("no-git-metadata") + .action(ArgAction::SetTrue) + .conflicts_with("force_git_metadata") + .help("Disable collection and sending of git metadata.") + ) + } } diff --git a/src/commands/build/vcs.rs b/src/utils/build_vcs.rs similarity index 100% rename from src/commands/build/vcs.rs rename to src/utils/build_vcs.rs diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 9f47f2a88a..69856ab420 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -3,6 +3,7 @@ pub mod android; pub mod args; pub mod auth_token; pub mod build; +pub mod build_vcs; pub mod chunks; pub mod ci; pub mod cordova; diff --git a/tests/integration/_cases/build/build-upload-help-macos.trycmd b/tests/integration/_cases/build/build-upload-help-macos.trycmd index 2b5d6e35f7..f51697cbec 100644 --- a/tests/integration/_cases/build/build-upload-help-macos.trycmd +++ b/tests/integration/_cases/build/build-upload-help-macos.trycmd @@ -67,6 +67,14 @@ Options: pull_request-triggered GitHub Actions workflow, the PR number will be automatically detected from GitHub Actions environment variables. + --force-git-metadata + Force collection and sending of git metadata (branch, commit, etc.). If neither this nor + --no-git-metadata is specified, git metadata is automatically collected when running in + most CI environments. + + --no-git-metadata + Disable collection and sending of git metadata. + --build-configuration The build configuration to use for the upload. If not provided, the current version will be used. @@ -78,14 +86,6 @@ Options: The install group(s) for this build. Can be specified multiple times. Builds with at least one matching install group will be shown updates for each other. - --force-git-metadata - Force collection and sending of git metadata (branch, commit, etc.). If neither this nor - --no-git-metadata is specified, git metadata is automatically collected when running in - most CI environments. - - --no-git-metadata - Disable collection and sending of git metadata. - -h, --help Print help (see a summary with '-h') From 08a89581f54d49b1f79bf6be26865fa7c67d8fb9 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Tue, 10 Mar 2026 09:49:08 -0700 Subject: [PATCH 4/5] Fix tests --- .../build/build-upload-help-not-macos.trycmd | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/integration/_cases/build/build-upload-help-not-macos.trycmd b/tests/integration/_cases/build/build-upload-help-not-macos.trycmd index e9d9461e89..84cd603175 100644 --- a/tests/integration/_cases/build/build-upload-help-not-macos.trycmd +++ b/tests/integration/_cases/build/build-upload-help-not-macos.trycmd @@ -1,5 +1,6 @@ ``` $ sentry-cli build upload --help +? success Upload builds to a project. This feature only works with Sentry SaaS. @@ -38,7 +39,7 @@ Options: --quiet Do not print any output while preserving correct exit code. This flag is currently implemented only for selected subcommands. - + [aliases: --silent] --vcs-provider @@ -66,6 +67,14 @@ Options: pull_request-triggered GitHub Actions workflow, the PR number will be automatically detected from GitHub Actions environment variables. + --force-git-metadata + Force collection and sending of git metadata (branch, commit, etc.). If neither this nor + --no-git-metadata is specified, git metadata is automatically collected when running in + most CI environments. + + --no-git-metadata + Disable collection and sending of git metadata. + --build-configuration The build configuration to use for the upload. If not provided, the current version will be used. @@ -77,14 +86,6 @@ Options: The install group(s) for this build. Can be specified multiple times. Builds with at least one matching install group will be shown updates for each other. - --force-git-metadata - Force collection and sending of git metadata (branch, commit, etc.). If neither this nor - --no-git-metadata is specified, git metadata is automatically collected when running in - most CI environments. - - --no-git-metadata - Disable collection and sending of git metadata. - -h, --help Print help (see a summary with '-h') From 99fb9c05783f1a49f95faf3ba55a1ab35cb47c31 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Tue, 10 Mar 2026 09:59:38 -0700 Subject: [PATCH 5/5] Fix --- .../integration/_cases/build/build-upload-help-not-macos.trycmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/_cases/build/build-upload-help-not-macos.trycmd b/tests/integration/_cases/build/build-upload-help-not-macos.trycmd index 84cd603175..e387416b00 100644 --- a/tests/integration/_cases/build/build-upload-help-not-macos.trycmd +++ b/tests/integration/_cases/build/build-upload-help-not-macos.trycmd @@ -39,7 +39,7 @@ Options: --quiet Do not print any output while preserving correct exit code. This flag is currently implemented only for selected subcommands. - + [aliases: --silent] --vcs-provider