diff --git a/e2e/cli/test_install_before_explicit_go_backend b/e2e/cli/test_install_before_explicit_go_backend new file mode 100644 index 0000000000..06e1b9abbe --- /dev/null +++ b/e2e/cli/test_install_before_explicit_go_backend @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +export GOPROXY="https://proxy.golang.org,direct" + +cat <<'EOF' >mise.toml +[settings] +install_before = "2100-01-01" + +[tools] +'go:github.com/roborev-dev/roborev/cmd/roborev' = { version = "latest", install_before = "2026-04-07" } +EOF + +assert_contains "mise install --dry-run 2>&1" "go:github.com/roborev-dev/roborev/cmd/roborev@0.50.0" + +cat <<'EOF' >mise.toml +[settings] +install_before = "2026-04-07" + +[tools] +'go:github.com/roborev-dev/roborev/cmd/roborev' = { version = "latest", install_before = "2100-01-01" } +EOF + +assert_contains "mise install --dry-run 2>&1" "go:github.com/roborev-dev/roborev/cmd/roborev@0.51.0" diff --git a/src/backend/go.rs b/src/backend/go.rs index 37b49c7eb1..64093543b7 100644 --- a/src/backend/go.rs +++ b/src/backend/go.rs @@ -4,7 +4,7 @@ use crate::backend::backend_type::BackendType; use crate::backend::platform_target::PlatformTarget; use crate::cache::{CacheManager, CacheManagerBuilder}; use crate::cli::args::BackendArg; -use crate::cmd::CmdLineRunner; +use crate::cmd::{CmdLineRunner, cmd}; use crate::config::Config; use crate::config::Settings; use crate::hash::hash_to_str; @@ -14,8 +14,11 @@ use crate::timeout; use crate::toolset::{ToolRequest, ToolVersion}; use async_trait::async_trait; use dashmap::DashMap; -use std::collections::BTreeMap; +use serde_json::Deserializer; +use std::collections::{BTreeMap, HashMap}; +use std::ffi::OsString; use std::{fmt::Debug, sync::Arc}; +use tokio::sync::Semaphore; use versions::Versioning; use xx::regex; @@ -161,6 +164,8 @@ pub fn install_time_option_keys() -> Vec { } const DEFAULT_GOPROXY: &str = "https://proxy.golang.org,direct"; +const GO_PROXY_VERSION_INFO_CONCURRENCY: usize = 20; +const GO_LIST_VERSION_INFO_BATCH_SIZE: usize = 50; impl GoBackend { pub fn from_arg(ba: BackendArg) -> Self { @@ -210,14 +215,23 @@ impl GoBackend { let path = &candidates[*idx]; match result { ProxyListResult::Versions(versions) if !versions.is_empty() => { - let mut version_infos: Vec = versions + let versions: Vec = versions .iter() - .map(|v| VersionInfo { - version: v.trim_start_matches('v').to_string(), - ..Default::default() - }) + .filter(|v| Versioning::new(v.trim_start_matches('v')).is_some()) + .cloned() .collect(); - version_infos.retain(|v| Versioning::new(&v.version).is_some()); + if versions.is_empty() { + let encoded = encode_module_path(path); + match query_proxy_latest(&proxies, &encoded).await { + ProxyVersionInfoResult::Found(info) => { + return Ok(Some(vec![version_info_from_metadata(info)])); + } + ProxyVersionInfoResult::NotFound => continue, + ProxyVersionInfoResult::Error => return Ok(None), + } + } + let mut version_infos = + fetch_proxy_version_infos(&proxies, path, &versions).await; version_infos.sort_by_cached_key(|v| Versioning::new(&v.version)); return Ok(Some(version_infos)); } @@ -225,9 +239,11 @@ impl GoBackend { // Check if @latest resolves (module using pseudo-versions) let encoded = encode_module_path(path); match query_proxy_latest(&proxies, &encoded).await { - ProxyListResult::Versions(_) => return Ok(Some(vec![])), - ProxyListResult::NotFound => continue, - ProxyListResult::Error => return Ok(None), + ProxyVersionInfoResult::Found(info) => { + return Ok(Some(vec![version_info_from_metadata(info)])); + } + ProxyVersionInfoResult::NotFound => continue, + ProxyVersionInfoResult::Error => return Ok(None), } } ProxyListResult::NotFound => continue, @@ -278,21 +294,71 @@ impl GoBackend { Err(_) => return Ok(None), }; - // remove the leading v from the versions - let versions = mod_info - .versions - .into_iter() - .map(|v| VersionInfo { - version: v.trim_start_matches('v').to_string(), - ..Default::default() - }) - .collect(); + let versions = self + .fetch_go_module_version_infos(config, mod_path, &mod_info.versions) + .await; Ok(Some(versions)) }) .await .cloned() } + + async fn fetch_go_module_version_infos( + &self, + config: &Arc, + mod_path: &str, + versions: &[String], + ) -> Vec { + let env = match self.dependency_env(config).await { + Ok(env) => env, + Err(_) => { + return versions + .iter() + .map(|version| VersionInfo { + version: version.trim_start_matches('v').to_string(), + ..Default::default() + }) + .collect(); + } + }; + + let mut metadata_by_version = HashMap::with_capacity(versions.len()); + for chunk in versions.chunks(GO_LIST_VERSION_INFO_BATCH_SIZE) { + let mut args = vec![ + OsString::from("list"), + OsString::from("-mod=readonly"), + OsString::from("-m"), + OsString::from("-json"), + ]; + for version in chunk { + args.push(format!("{mod_path}@{version}").into()); + } + let Ok(raw) = cmd("go", args).full_env(&env).read() else { + continue; + }; + let Ok(infos) = Deserializer::from_str(&raw) + .into_iter::() + .collect::, _>>() + else { + continue; + }; + for info in infos { + metadata_by_version.insert(info.version.clone(), info); + } + } + + versions + .iter() + .map(|version| match metadata_by_version.remove(version) { + Some(info) => version_info_from_metadata(info), + None => VersionInfo { + version: version.trim_start_matches('v').to_string(), + ..Default::default() + }, + }) + .collect() + } } enum ProxyListResult { @@ -301,6 +367,12 @@ enum ProxyListResult { Error, } +enum ProxyVersionInfoResult { + Found(GoModuleVersionMetadata), + NotFound, + Error, +} + #[derive(Clone, Debug, PartialEq)] enum FallThrough { OnNotFound, @@ -344,11 +416,21 @@ async fn query_proxy_list(proxies: &[GoProxy], encoded_path: &str) -> ProxyListR ProxyListResult::NotFound } -async fn query_proxy_latest(proxies: &[GoProxy], encoded_path: &str) -> ProxyListResult { +async fn query_proxy_latest(proxies: &[GoProxy], encoded_path: &str) -> ProxyVersionInfoResult { + query_proxy_version_metadata(proxies, &format!("{encoded_path}/@latest")).await +} + +async fn query_proxy_version_metadata( + proxies: &[GoProxy], + endpoint: &str, +) -> ProxyVersionInfoResult { for proxy in proxies { - let url = format!("{}/{}/@latest", proxy.url, encoded_path); + let url = format!("{}/{}", proxy.url, endpoint); match HTTP_FETCH.get_text(&url).await { - Ok(_) => return ProxyListResult::Versions(vec![]), + Ok(body) => match serde_json::from_str::(&body) { + Ok(info) => return ProxyVersionInfoResult::Found(info), + Err(_) => return ProxyVersionInfoResult::Error, + }, Err(e) => { let is_not_found_or_gone = e .downcast_ref::() @@ -360,11 +442,11 @@ async fn query_proxy_latest(proxies: &[GoProxy], encoded_path: &str) -> ProxyLis if is_not_found_or_gone || proxy.fall_through == FallThrough::OnAnyError { continue; } - return ProxyListResult::Error; + return ProxyVersionInfoResult::Error; } } } - ProxyListResult::NotFound + ProxyVersionInfoResult::NotFound } fn parse_goproxy() -> Vec { @@ -432,6 +514,68 @@ pub struct GoModInfo { versions: Vec, } +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "PascalCase")] +struct GoModuleVersionMetadata { + version: String, + #[serde(default)] + time: Option, +} + +fn version_info_from_metadata(info: GoModuleVersionMetadata) -> VersionInfo { + VersionInfo { + version: info.version.trim_start_matches('v').to_string(), + created_at: info.time, + ..Default::default() + } +} + +async fn fetch_proxy_version_infos( + proxies: &[GoProxy], + path: &str, + versions: &[String], +) -> Vec { + let encoded = Arc::new(encode_module_path(path)); + let proxies = Arc::new(proxies.to_vec()); + let sem = Arc::new(Semaphore::new(GO_PROXY_VERSION_INFO_CONCURRENCY)); + let mut join_set = tokio::task::JoinSet::new(); + + for version in versions { + let proxies = proxies.clone(); + let encoded = encoded.clone(); + let sem = sem.clone(); + let version = version.clone(); + join_set.spawn(async move { + let _permit = sem.acquire_owned().await.expect("semaphore closed"); + let endpoint = format!("{encoded}/@v/{version}.info"); + let info = query_proxy_version_metadata(proxies.as_slice(), &endpoint).await; + (version, info) + }); + } + + let mut times = BTreeMap::new(); + while let Some(result) = join_set.join_next().await { + match result { + Ok((version, ProxyVersionInfoResult::Found(info))) => { + times.insert(version, info.time); + } + Ok((version, ProxyVersionInfoResult::NotFound | ProxyVersionInfoResult::Error)) => { + times.insert(version, None); + } + Err(e) => warn!("proxy version info task panicked: {e}"), + } + } + + versions + .iter() + .map(|version| VersionInfo { + version: version.trim_start_matches('v').to_string(), + created_at: times.get(version).cloned().flatten(), + ..Default::default() + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -450,6 +594,14 @@ mod tests { assert_eq!(info.versions, vec!["v1.0.0", "v1.1.0"]); } + #[test] + fn parse_go_module_version_metadata() { + let raw = r#"{"Version":"v1.2.3","Time":"2026-04-08T12:56:30Z"}"#; + let info: GoModuleVersionMetadata = serde_json::from_str(raw).unwrap(); + assert_eq!(info.version, "v1.2.3"); + assert_eq!(info.time, Some("2026-04-08T12:56:30Z".to_string())); + } + #[test] fn encode_module_path_lowercase() { assert_eq!(