From a66aa34b3f73994321cdbc6e0f4a17f3b9c0ac4c Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Mon, 9 Mar 2026 09:21:07 +0000 Subject: [PATCH 01/15] feat: add LatestTag location resolver for init-time tag discovery --- README_ADVANCED.md | 2 + src/cli.rs | 22 +++-- src/commands/build/repository.rs | 3 + src/commands/init/config.rs | 13 ++- src/commands/init/latest_resolver.rs | 116 ++++++++++++++++++++++++++ src/commands/init/mod.rs | 1 + src/commands/init/repositories.rs | 3 + src/commands/status/git/formatters.rs | 9 ++ src/commands/status/git/repo_paths.rs | 2 +- src/config.rs | 38 ++++++--- src/main_app/version.rs | 3 + 11 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 src/commands/init/latest_resolver.rs diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 58375405..c4df9aa1 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -25,6 +25,7 @@ foc-devnet init [OPTIONS] - `--rand` - Use random mnemonic instead of deterministic one. Use this for unique test scenarios. **Source Format:** +- `latesttag:` - Newest git tag found on the given branch (resolved once at `init` time and pinned in config). Use this to always pull the latest stable release on a branch without hardcoding a version. Example: `latesttag:master` - `gittag:v1.0.0` - Specific git tag (uses default repo) - `gittag:https://github.com/user/repo.git:v1.0.0` - Tag from custom repo - `gitcommit:abc123` - Specific git commit @@ -812,6 +813,7 @@ port_range_count = 100 Default versions for these repositories are defined in code (see [`src/config.rs`](src/config.rs) `Config::default()`). **Version specification methods:** +- **Latest tag** (`LatestTag`, i.e. `latesttag:`): Resolves to the newest git tag on the given branch at `init` time and pins that exact tag in `config.toml`. Use this to automatically track the latest stable release without hardcoding a version. - **Git tags** (`GitTag`): Used for stable releases. Tags provide version pinning and stability. - **Git commits** (`GitCommit`): Used for repositories where specific commits are required and there isn't a corresponding tag yet. (Generally tags should be preferred over commits.) - **Git branches** (`GitBranch`): Used for development or when tracking latest changes. diff --git a/src/cli.rs b/src/cli.rs index 993301dc..3f1a80a5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,13 +23,25 @@ pub enum Commands { Stop, /// Initialize foc-devnet by building and caching Docker images Init { - /// Curio source location (e.g., 'gittag:tag', 'gittag:url:tag', 'gitcommit:commit', 'gitcommit:url:commit', 'gitbranch:branch', 'gitbranch:url:branch', 'local:/path/to/curio') + /// Curio source location. + /// Magic values: 'latesttag:' (newest tag on given branch). + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/curio'. #[arg(long)] curio: Option, - /// Lotus source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/lotus') + /// Lotus source location. + /// Magic values: 'latesttag:' (newest tag on given branch). + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/lotus'. #[arg(long)] lotus: Option, - /// Filecoin Services source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/filecoin-services') + /// Filecoin Services source location. + /// Magic values: 'latesttag:' (newest tag on given branch). + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/filecoin-services'. #[arg(long)] filecoin_services: Option, /// Yugabyte download URL @@ -82,12 +94,12 @@ pub enum BuildCommands { pub enum ConfigCommands { /// Configure Lotus source location Lotus { - /// Lotus source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/lotus') + /// Lotus source location (e.g., 'latesttag:master', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/lotus') source: String, }, /// Configure Curio source location Curio { - /// Curio source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/curio') + /// Curio source location (e.g., 'latesttag:pdpv0', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/curio') source: String, }, } diff --git a/src/commands/build/repository.rs b/src/commands/build/repository.rs index 7520f032..4d5c52fb 100644 --- a/src/commands/build/repository.rs +++ b/src/commands/build/repository.rs @@ -50,6 +50,9 @@ pub fn prepare_repository( prepare_git_repo(&repo_path, url)?; checkout_branch(&repo_path, branch)?; } + Location::LatestTag { .. } => { + return Err(format!("{}: LatestTag should have been resolved at init time", project).into()); + } } info!("Repository prepared successfully"); diff --git a/src/commands/init/config.rs b/src/commands/init/config.rs index 82966f3f..b90fa557 100644 --- a/src/commands/init/config.rs +++ b/src/commands/init/config.rs @@ -1,11 +1,15 @@ //! Configuration generation utilities for foc-devnet initialization. //! //! This module handles the generation of default configuration files -//! and application of location overrides. +//! and application of location overrides. Dynamic location variants +//! (`LatestTag`) is resolved to a concrete value at init +//! time via [`super::latest_resolver`], ensuring the stored config always +//! records the exact tag that was used. use std::fs; use tracing::{info, warn}; +use super::latest_resolver::resolve_location; use crate::config::{Config, Location}; use crate::paths::foc_devnet_config; @@ -67,6 +71,12 @@ pub fn generate_default_config( "https://github.com/FilOzone/filecoin-services.git", )?; + // Resolve any dynamic variants (LatestTag) by querying the remote. + // The resolved concrete tag is stored in config.toml for reproducibility. + config.lotus = resolve_location(config.lotus)?; + config.curio = resolve_location(config.curio)?; + config.filecoin_services = resolve_location(config.filecoin_services)?; + // Override yugabyte URL if provided if let Some(url) = yugabyte_url { config.yugabyte_download_url = url; @@ -103,6 +113,7 @@ pub fn apply_location_override( Location::GitTag { ref url, .. } => url.clone(), Location::GitCommit { ref url, .. } => url.clone(), Location::GitBranch { ref url, .. } => url.clone(), + Location::LatestTag { ref url, .. } => url.clone(), Location::LocalSource { .. } => default_url.to_string(), }; *location = Location::parse_with_default(&loc_str, &url) diff --git a/src/commands/init/latest_resolver.rs b/src/commands/init/latest_resolver.rs new file mode 100644 index 00000000..aa93334e --- /dev/null +++ b/src/commands/init/latest_resolver.rs @@ -0,0 +1,116 @@ +//! Resolves `LatestTag` → `GitTag` at init time. +//! +//! When a user specifies `--lotus latesttag:master` (or similar), we need to +//! figure out which concrete tag that maps to. This module: +//! +//! 1. Creates a temporary bare git repo (no working tree, no blobs). +//! 2. Fetches only the requested branch + tags from the remote. +//! 3. Picks the newest tag reachable from that branch. +//! 4. Returns a `GitTag` so the rest of the system works with a pinned version. +//! +//! The temp repo is automatically cleaned up when it goes out of scope. + +use crate::config::Location; +use std::process::Command; +use tracing::info; + +/// Temporary bare git repo that deletes itself on drop. +/// +/// We use a bare repo (no checkout) so we never download actual file content — +/// only refs and tag metadata. The `--filter=blob:none` fetch flag ensures +/// this stays lightweight even for large repositories. +struct TempBareRepo(std::path::PathBuf); + +impl TempBareRepo { + fn create() -> Result> { + let dir = std::env::temp_dir().join(format!( + "foc-devnet-tag-probe-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let status = Command::new("git") + .args(["init", "--bare", dir.to_str().unwrap()]) + .env("GIT_TERMINAL_PROMPT", "0") + .status()?; + if !status.success() { + return Err("git init --bare failed".into()); + } + Ok(Self(dir)) + } + + fn path(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TempBareRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// If `location` is `LatestTag`, resolve it to a concrete `GitTag`. +/// All other variants pass through unchanged. +/// +/// Example: `LatestTag { url: "…lotus.git", branch: "master" }` +/// → `GitTag { url: "…lotus.git", tag: "v1.35.0" }` +pub fn resolve_location(location: Location) -> Result> { + match location { + Location::LatestTag { url, branch } => { + let tag = fetch_latest_tag(&url, &branch)?; + info!("Resolved latesttag: {} (branch {}) → {}", url, branch, tag); + Ok(Location::GitTag { url, tag }) + } + other => Ok(other), + } +} + +/// Fetch the newest tag on `branch` from the remote at `url`. +/// +/// Steps: +/// 1. `git fetch --tags --filter=blob:none refs/heads/` +/// — pulls the branch ref and all tags without downloading any file blobs. +/// 2. `git tag --merged --sort=-creatordate` +/// — lists tags reachable from that branch, newest first. +/// 3. Take the first line → that's the latest tag. +fn fetch_latest_tag(url: &str, branch: &str) -> Result> { + info!("Fetching newest tag on branch '{}' from {}", branch, url); + + let repo = TempBareRepo::create()?; + let refspec = format!("refs/heads/{b}:refs/heads/{b}", b = branch); + + // Fetch branch + tags (no blobs — keeps it fast) + let fetch = Command::new("git") + .args(["fetch", "--tags", "--filter=blob:none", url, &refspec]) + .current_dir(repo.path()) + .env("GIT_TERMINAL_PROMPT", "0") + .status()?; + if !fetch.success() { + return Err(format!("git fetch failed for {} (branch {})", url, branch).into()); + } + + // List tags reachable from the branch, newest first + let tags = Command::new("git") + .args(["tag", "--merged", branch, "--sort=-creatordate"]) + .current_dir(repo.path()) + .output()?; + if !tags.status.success() { + return Err(format!( + "git tag --merged {} failed: {}", + branch, + String::from_utf8_lossy(&tags.stderr).trim() + ) + .into()); + } + + // First non-empty line is the newest tag + let stdout = String::from_utf8_lossy(&tags.stdout); + stdout + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("No tags found on branch '{}' for {}", branch, url).into()) +} diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index 25b0932e..e42338cf 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -13,6 +13,7 @@ pub mod artifacts; pub mod config; pub mod directories; pub mod keys; +pub mod latest_resolver; pub mod path_setup; pub mod repositories; diff --git a/src/commands/init/repositories.rs b/src/commands/init/repositories.rs index a23fa392..624e819a 100644 --- a/src/commands/init/repositories.rs +++ b/src/commands/init/repositories.rs @@ -99,6 +99,9 @@ fn download_repository(name: &str, location: &Location) -> Result<(), Box { clone_and_checkout(name, url, None, None, Some(branch)) } + Location::LatestTag { .. } => { + Err(format!("{}: LatestTag should have been resolved at init time", name).into()) + } } } diff --git a/src/commands/status/git/formatters.rs b/src/commands/status/git/formatters.rs index 20a92e58..7b016a77 100644 --- a/src/commands/status/git/formatters.rs +++ b/src/commands/status/git/formatters.rs @@ -78,6 +78,9 @@ pub fn format_location_info( ) if expected_branch == actual_branch => true, (Location::GitBranch { .. }, GitInfo::Tag(_) | GitInfo::Commit(_)) => true, // Assume it's ready if we have some valid state (Location::GitBranch { .. }, _) => false, + + // LatestTag is resolved at init time; treat as not ready if somehow present. + (Location::LatestTag { .. }, _) => false, }; let status = if is_ready { @@ -125,6 +128,12 @@ pub fn format_location_info( "Not found".to_string(), ), }, + // Resolved at init time; display as underlying type if somehow still present. + Location::LatestTag { .. } => ( + "Latest Tag".to_string(), + "(unresolved)".to_string(), + "".to_string(), + ), }; (source_type, version, commit, status) diff --git a/src/commands/status/git/repo_paths.rs b/src/commands/status/git/repo_paths.rs index b6fd59e3..aac16fde 100644 --- a/src/commands/status/git/repo_paths.rs +++ b/src/commands/status/git/repo_paths.rs @@ -34,7 +34,7 @@ pub fn get_repo_path_from_config(location: &Location, component: &str) -> std::p // For local sources, check the specified directory std::path::PathBuf::from(dir) } - Location::GitTag { .. } | Location::GitCommit { .. } | Location::GitBranch { .. } => { + Location::GitTag { .. } | Location::GitCommit { .. } | Location::GitBranch { .. } | Location::LatestTag { .. } => { // For git sources, check if it exists in the foc-devnet code directory foc_devnet_code().join(component) } diff --git a/src/config.rs b/src/config.rs index 85b3e822..f4a38f56 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,26 +36,35 @@ pub enum Location { /// The `url` field is the Git repository URL, and `branch` is the specific /// branch (e.g., "main", "develop") to check out. GitBranch { url: String, branch: String }, + + /// Resolve to the newest tag on a specific branch at init time. + /// + /// `url` is the Git repository URL. `branch` specifies the exact branch + /// to search for tags on. At init time this is immediately resolved to a + /// concrete `GitTag` so the stored config always records the exact tag used. + /// + /// Example CLI usage: `--curio latesttag:pdpv0` or `--lotus latesttag:master` + LatestTag { url: String, branch: String }, } impl Location { - /// Parse a location string in the format "type:value" or "type:url:value" + /// Parse a location string in the format "type" or "type:value". /// /// Supported formats: - /// - "gittag:tag" (uses default URL) - /// - "gitcommit:commit" (uses default URL) - /// - "gitbranch:branch" (uses default URL) - /// - "local:dir" - /// - "gittag:url:tag" - /// - "gitcommit:url:commit" - /// - "gitbranch:url:branch" - /// - /// Where url can contain colons (e.g., https://github.com/repo.git) + /// - `latesttag:` — newest tag on specified branch (e.g. `latesttag:main`) + /// - `gittag:` — (uses default URL) + /// - `gitcommit:` — (uses default URL) + /// - `gitbranch:` — (uses default URL) + /// - `local:` + /// - `gittag::` + /// - `gitcommit::` + /// - `gitbranch::` pub fn parse_with_default(s: &str, default_url: &str) -> Result { let parts: Vec<&str> = s.split(':').collect(); if parts.len() < 2 { return Err(format!( - "Invalid location format: {}. Expected 'type:value' or 'type:url:value'", + "Invalid location format: '{}'. Expected \ + 'latesttag:', or 'gittag/gitcommit/gitbranch/local:...'", s )); } @@ -64,6 +73,11 @@ impl Location { let remaining = &parts[1..].join(":"); match location_type { + // latesttag: — newest tag on specified branch + "latesttag" => Ok(Location::LatestTag { + url: default_url.to_string(), + branch: remaining.to_string(), + }), "local" => Ok(Location::LocalSource { dir: remaining.to_string(), }), @@ -107,7 +121,7 @@ impl Location { } } _ => Err(format!( - "Unknown location type: {}. Supported types: local, gittag, gitcommit, gitbranch", + "Unknown location type: {}. Supported types: latesttag, local, gittag, gitcommit, gitbranch", location_type )), } diff --git a/src/main_app/version.rs b/src/main_app/version.rs index a0c6a576..b8d47328 100644 --- a/src/main_app/version.rs +++ b/src/main_app/version.rs @@ -70,5 +70,8 @@ fn print_location_info(label: &str, location: &Location) { Location::GitBranch { url, branch } => { info!("{}: {}, branch {}", label, url, branch); } + Location::LatestTag { url, .. } => { + info!("{}: {}, latest tag (unresolved)", label, url); + } } } From a3613aa96bc27d165e71b6e0c4727d7d0b380f83 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Mon, 9 Mar 2026 11:56:52 +0000 Subject: [PATCH 02/15] makehappy: fmt --- src/commands/build/repository.rs | 6 +++++- src/commands/status/git/repo_paths.rs | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/commands/build/repository.rs b/src/commands/build/repository.rs index 4d5c52fb..019c5593 100644 --- a/src/commands/build/repository.rs +++ b/src/commands/build/repository.rs @@ -51,7 +51,11 @@ pub fn prepare_repository( checkout_branch(&repo_path, branch)?; } Location::LatestTag { .. } => { - return Err(format!("{}: LatestTag should have been resolved at init time", project).into()); + return Err(format!( + "{}: LatestTag should have been resolved at init time", + project + ) + .into()); } } diff --git a/src/commands/status/git/repo_paths.rs b/src/commands/status/git/repo_paths.rs index aac16fde..c99f4adf 100644 --- a/src/commands/status/git/repo_paths.rs +++ b/src/commands/status/git/repo_paths.rs @@ -34,7 +34,10 @@ pub fn get_repo_path_from_config(location: &Location, component: &str) -> std::p // For local sources, check the specified directory std::path::PathBuf::from(dir) } - Location::GitTag { .. } | Location::GitCommit { .. } | Location::GitBranch { .. } | Location::LatestTag { .. } => { + Location::GitTag { .. } + | Location::GitCommit { .. } + | Location::GitBranch { .. } + | Location::LatestTag { .. } => { // For git sources, check if it exists in the foc-devnet code directory foc_devnet_code().join(component) } From 57af83a18de65acc3166fb303349029c685bbc0d Mon Sep 17 00:00:00 2001 From: RedPanda Date: Mon, 9 Mar 2026 17:28:55 +0530 Subject: [PATCH 03/15] Update src/commands/init/latest_resolver.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/commands/init/latest_resolver.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/commands/init/latest_resolver.rs b/src/commands/init/latest_resolver.rs index aa93334e..19004b64 100644 --- a/src/commands/init/latest_resolver.rs +++ b/src/commands/init/latest_resolver.rs @@ -23,21 +23,19 @@ struct TempBareRepo(std::path::PathBuf); impl TempBareRepo { fn create() -> Result> { - let dir = std::env::temp_dir().join(format!( - "foc-devnet-tag-probe-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); + let temp_dir = tempfile::Builder::new() + .prefix("foc-devnet-tag-probe-") + .tempdir()?; + let path = temp_dir.into_path(); let status = Command::new("git") - .args(["init", "--bare", dir.to_str().unwrap()]) + .args(["init", "--bare"]) + .arg(&path) .env("GIT_TERMINAL_PROMPT", "0") .status()?; if !status.success() { return Err("git init --bare failed".into()); } - Ok(Self(dir)) + Ok(Self(path)) } fn path(&self) -> &std::path::Path { From 28211b8aeca5905e4aaa83727b77f252cd7499f3 Mon Sep 17 00:00:00 2001 From: RedPanda Date: Mon, 9 Mar 2026 17:30:33 +0530 Subject: [PATCH 04/15] Update src/commands/init/repositories.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/commands/init/repositories.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/commands/init/repositories.rs b/src/commands/init/repositories.rs index 624e819a..5e68ecc9 100644 --- a/src/commands/init/repositories.rs +++ b/src/commands/init/repositories.rs @@ -100,7 +100,22 @@ fn download_repository(name: &str, location: &Location) -> Result<(), Box { - Err(format!("{}: LatestTag should have been resolved at init time", name).into()) + let config_path = foc_devnet_config(); + let msg = format!( + concat!( + "{}: repository location is configured as 'latesttag', which is an internal placeholder\n", + "that should have been resolved during 'foc-devnet init'.\n", + "This usually means your config file at '{}' is out of date or was edited manually.\n", + "\n", + "To fix this, either:\n", + " - run 'foc-devnet init --force' to regenerate config.toml, or\n", + " - edit config.toml and replace 'latesttag' with a concrete 'gittag:', 'gitbranch:',\n", + " 'gitcommit:' or 'localsource:' entry for this repository.\n" + ), + name, + config_path.display() + ); + Err(msg.into()) } } } From 6049096555994a596df26ea78a531c70c1ee924d Mon Sep 17 00:00:00 2001 From: RedPanda Date: Mon, 9 Mar 2026 17:30:54 +0530 Subject: [PATCH 05/15] Update src/config.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index f4a38f56..3b3a266e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -48,7 +48,7 @@ pub enum Location { } impl Location { - /// Parse a location string in the format "type" or "type:value". + /// Parse a location string in the format "type:value" or "type:url:value". /// /// Supported formats: /// - `latesttag:` — newest tag on specified branch (e.g. `latesttag:main`) From 5894a6502bb0c45bf068821e19fa16fb2728f9e2 Mon Sep 17 00:00:00 2001 From: RedPanda Date: Mon, 9 Mar 2026 17:31:25 +0530 Subject: [PATCH 06/15] Update src/config.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/config.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index 3b3a266e..ff732621 100644 --- a/src/config.rs +++ b/src/config.rs @@ -74,10 +74,25 @@ impl Location { match location_type { // latesttag: — newest tag on specified branch - "latesttag" => Ok(Location::LatestTag { - url: default_url.to_string(), - branch: remaining.to_string(), - }), + "latesttag" => { + let branch = remaining.trim(); + if branch.is_empty() { + return Err(format!( + "Invalid location format: '{}'. 'latesttag' requires a non-empty branch name, e.g. 'latesttag:main'", + s + )); + } + if branch.starts_with('-') || branch.contains(':') { + return Err(format!( + "Invalid branch name '{}' in '{}'. Branch names for 'latesttag' must not start with '-' or contain ':'", + branch, s + )); + } + Ok(Location::LatestTag { + url: default_url.to_string(), + branch: branch.to_string(), + }) + } "local" => Ok(Location::LocalSource { dir: remaining.to_string(), }), From 967a3b73c08ceccf5805a2fe9839f3631e7b3e66 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:49:53 +0530 Subject: [PATCH 07/15] refactor: decompose `fetch_latest_tag` into focused helpers with module-level constant (#85) * Initial plan * refactor: decompose fetch_latest_tag into focused helpers with module-level constant Co-authored-by: redpanda-f <181817029+redpanda-f@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: redpanda-f <181817029+redpanda-f@users.noreply.github.com> --- src/commands/init/latest_resolver.rs | 62 +++++++++++++++++++--------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/src/commands/init/latest_resolver.rs b/src/commands/init/latest_resolver.rs index 19004b64..408b47bb 100644 --- a/src/commands/init/latest_resolver.rs +++ b/src/commands/init/latest_resolver.rs @@ -14,6 +14,9 @@ use crate::config::Location; use std::process::Command; use tracing::info; +/// Prefix for the temporary bare-repo directory used during tag probing. +const TEMP_DIR_PREFIX: &str = "foc-devnet-tag-probe-"; + /// Temporary bare git repo that deletes itself on drop. /// /// We use a bare repo (no checkout) so we never download actual file content — @@ -23,9 +26,7 @@ struct TempBareRepo(std::path::PathBuf); impl TempBareRepo { fn create() -> Result> { - let temp_dir = tempfile::Builder::new() - .prefix("foc-devnet-tag-probe-") - .tempdir()?; + let temp_dir = tempfile::Builder::new().prefix(TEMP_DIR_PREFIX).tempdir()?; let path = temp_dir.into_path(); let status = Command::new("git") .args(["init", "--bare"]) @@ -67,44 +68,65 @@ pub fn resolve_location(location: Location) -> Result refs/heads/` -/// — pulls the branch ref and all tags without downloading any file blobs. -/// 2. `git tag --merged --sort=-creatordate` -/// — lists tags reachable from that branch, newest first. -/// 3. Take the first line → that's the latest tag. +/// Orchestrates: repo creation → ref fetch → tag listing → tag selection. fn fetch_latest_tag(url: &str, branch: &str) -> Result> { info!("Fetching newest tag on branch '{}' from {}", branch, url); - let repo = TempBareRepo::create()?; - let refspec = format!("refs/heads/{b}:refs/heads/{b}", b = branch); + fetch_refs(&repo, url, branch)?; + let stdout = list_merged_tags(&repo, branch)?; + pick_first_tag(&stdout, branch, url) +} - // Fetch branch + tags (no blobs — keeps it fast) - let fetch = Command::new("git") +/// Run `git fetch --tags --filter=blob:none` to pull the branch ref and all +/// tags without downloading any file blobs. +fn fetch_refs( + repo: &TempBareRepo, + url: &str, + branch: &str, +) -> Result<(), Box> { + let refspec = format!("refs/heads/{b}:refs/heads/{b}", b = branch); + let status = Command::new("git") .args(["fetch", "--tags", "--filter=blob:none", url, &refspec]) .current_dir(repo.path()) .env("GIT_TERMINAL_PROMPT", "0") .status()?; - if !fetch.success() { + if !status.success() { return Err(format!("git fetch failed for {} (branch {})", url, branch).into()); } + Ok(()) +} - // List tags reachable from the branch, newest first - let tags = Command::new("git") +/// Run `git tag --merged --sort=-creatordate` and return stdout. +/// +/// The output is a newline-separated list of tag names reachable from `branch`, +/// ordered newest-first by creator date. +fn list_merged_tags( + repo: &TempBareRepo, + branch: &str, +) -> Result> { + let output = Command::new("git") .args(["tag", "--merged", branch, "--sort=-creatordate"]) .current_dir(repo.path()) .output()?; - if !tags.status.success() { + if !output.status.success() { return Err(format!( "git tag --merged {} failed: {}", branch, - String::from_utf8_lossy(&tags.stderr).trim() + String::from_utf8_lossy(&output.stderr).trim() ) .into()); } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} - // First non-empty line is the newest tag - let stdout = String::from_utf8_lossy(&tags.stdout); +/// Return the first non-empty line from `stdout` (the newest tag). +/// +/// Example: given `"v1.35.0\nv1.34.0\n"` this returns `"v1.35.0"`. +fn pick_first_tag( + stdout: &str, + branch: &str, + url: &str, +) -> Result> { stdout .lines() .map(str::trim) From 93c53328bd60b312d12c2ee97daab45fbf1d6208 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Mon, 9 Mar 2026 13:22:45 +0000 Subject: [PATCH 08/15] fix: remove usage of deprecated methods --- src/commands/init/latest_resolver.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/init/latest_resolver.rs b/src/commands/init/latest_resolver.rs index 408b47bb..d598110b 100644 --- a/src/commands/init/latest_resolver.rs +++ b/src/commands/init/latest_resolver.rs @@ -27,7 +27,7 @@ struct TempBareRepo(std::path::PathBuf); impl TempBareRepo { fn create() -> Result> { let temp_dir = tempfile::Builder::new().prefix(TEMP_DIR_PREFIX).tempdir()?; - let path = temp_dir.into_path(); + let path = temp_dir.keep(); let status = Command::new("git") .args(["init", "--bare"]) .arg(&path) From 7e562ab54d16592924abb561f6d4c1d636f09c73 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 10 Mar 2026 04:59:00 +0000 Subject: [PATCH 09/15] fix: new resolver --- src/commands/build/repository.rs | 7 - src/commands/config.rs | 2 +- src/commands/init/config.rs | 15 +- src/commands/init/latest_resolver.rs | 136 ------------ src/commands/init/mod.rs | 1 - src/commands/init/repositories.rs | 18 -- src/commands/status/git/formatters.rs | 9 - src/commands/status/git/repo_paths.rs | 5 +- src/config.rs | 304 ++++++++++++++++++-------- src/main_app/version.rs | 3 - 10 files changed, 220 insertions(+), 280 deletions(-) delete mode 100644 src/commands/init/latest_resolver.rs diff --git a/src/commands/build/repository.rs b/src/commands/build/repository.rs index 019c5593..7520f032 100644 --- a/src/commands/build/repository.rs +++ b/src/commands/build/repository.rs @@ -50,13 +50,6 @@ pub fn prepare_repository( prepare_git_repo(&repo_path, url)?; checkout_branch(&repo_path, branch)?; } - Location::LatestTag { .. } => { - return Err(format!( - "{}: LatestTag should have been resolved at init time", - project - ) - .into()); - } } info!("Repository prepared successfully"); diff --git a/src/commands/config.rs b/src/commands/config.rs index 4a370d17..d8deceea 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -59,7 +59,7 @@ fn update_config_location( .map_err(|e| format!("Failed to parse config file: {}", e))?; // Parse the source location - let location = Location::parse_with_default(&source, default_url) + let location = Location::resolve_with_default(&source, default_url) .map_err(|e| format!("Invalid {} source format: {}", field, e))?; // Update the appropriate field diff --git a/src/commands/init/config.rs b/src/commands/init/config.rs index b90fa557..75cdcde8 100644 --- a/src/commands/init/config.rs +++ b/src/commands/init/config.rs @@ -1,15 +1,11 @@ //! Configuration generation utilities for foc-devnet initialization. //! //! This module handles the generation of default configuration files -//! and application of location overrides. Dynamic location variants -//! (`LatestTag`) is resolved to a concrete value at init -//! time via [`super::latest_resolver`], ensuring the stored config always -//! records the exact tag that was used. +//! and application of location overrides. use std::fs; use tracing::{info, warn}; -use super::latest_resolver::resolve_location; use crate::config::{Config, Location}; use crate::paths::foc_devnet_config; @@ -71,12 +67,6 @@ pub fn generate_default_config( "https://github.com/FilOzone/filecoin-services.git", )?; - // Resolve any dynamic variants (LatestTag) by querying the remote. - // The resolved concrete tag is stored in config.toml for reproducibility. - config.lotus = resolve_location(config.lotus)?; - config.curio = resolve_location(config.curio)?; - config.filecoin_services = resolve_location(config.filecoin_services)?; - // Override yugabyte URL if provided if let Some(url) = yugabyte_url { config.yugabyte_download_url = url; @@ -113,10 +103,9 @@ pub fn apply_location_override( Location::GitTag { ref url, .. } => url.clone(), Location::GitCommit { ref url, .. } => url.clone(), Location::GitBranch { ref url, .. } => url.clone(), - Location::LatestTag { ref url, .. } => url.clone(), Location::LocalSource { .. } => default_url.to_string(), }; - *location = Location::parse_with_default(&loc_str, &url) + *location = Location::resolve_with_default(&loc_str, &url) .map_err(|e| format!("Invalid location: {}", e))?; } Ok(()) diff --git a/src/commands/init/latest_resolver.rs b/src/commands/init/latest_resolver.rs deleted file mode 100644 index d598110b..00000000 --- a/src/commands/init/latest_resolver.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Resolves `LatestTag` → `GitTag` at init time. -//! -//! When a user specifies `--lotus latesttag:master` (or similar), we need to -//! figure out which concrete tag that maps to. This module: -//! -//! 1. Creates a temporary bare git repo (no working tree, no blobs). -//! 2. Fetches only the requested branch + tags from the remote. -//! 3. Picks the newest tag reachable from that branch. -//! 4. Returns a `GitTag` so the rest of the system works with a pinned version. -//! -//! The temp repo is automatically cleaned up when it goes out of scope. - -use crate::config::Location; -use std::process::Command; -use tracing::info; - -/// Prefix for the temporary bare-repo directory used during tag probing. -const TEMP_DIR_PREFIX: &str = "foc-devnet-tag-probe-"; - -/// Temporary bare git repo that deletes itself on drop. -/// -/// We use a bare repo (no checkout) so we never download actual file content — -/// only refs and tag metadata. The `--filter=blob:none` fetch flag ensures -/// this stays lightweight even for large repositories. -struct TempBareRepo(std::path::PathBuf); - -impl TempBareRepo { - fn create() -> Result> { - let temp_dir = tempfile::Builder::new().prefix(TEMP_DIR_PREFIX).tempdir()?; - let path = temp_dir.keep(); - let status = Command::new("git") - .args(["init", "--bare"]) - .arg(&path) - .env("GIT_TERMINAL_PROMPT", "0") - .status()?; - if !status.success() { - return Err("git init --bare failed".into()); - } - Ok(Self(path)) - } - - fn path(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TempBareRepo { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -/// If `location` is `LatestTag`, resolve it to a concrete `GitTag`. -/// All other variants pass through unchanged. -/// -/// Example: `LatestTag { url: "…lotus.git", branch: "master" }` -/// → `GitTag { url: "…lotus.git", tag: "v1.35.0" }` -pub fn resolve_location(location: Location) -> Result> { - match location { - Location::LatestTag { url, branch } => { - let tag = fetch_latest_tag(&url, &branch)?; - info!("Resolved latesttag: {} (branch {}) → {}", url, branch, tag); - Ok(Location::GitTag { url, tag }) - } - other => Ok(other), - } -} - -/// Fetch the newest tag on `branch` from the remote at `url`. -/// -/// Orchestrates: repo creation → ref fetch → tag listing → tag selection. -fn fetch_latest_tag(url: &str, branch: &str) -> Result> { - info!("Fetching newest tag on branch '{}' from {}", branch, url); - let repo = TempBareRepo::create()?; - fetch_refs(&repo, url, branch)?; - let stdout = list_merged_tags(&repo, branch)?; - pick_first_tag(&stdout, branch, url) -} - -/// Run `git fetch --tags --filter=blob:none` to pull the branch ref and all -/// tags without downloading any file blobs. -fn fetch_refs( - repo: &TempBareRepo, - url: &str, - branch: &str, -) -> Result<(), Box> { - let refspec = format!("refs/heads/{b}:refs/heads/{b}", b = branch); - let status = Command::new("git") - .args(["fetch", "--tags", "--filter=blob:none", url, &refspec]) - .current_dir(repo.path()) - .env("GIT_TERMINAL_PROMPT", "0") - .status()?; - if !status.success() { - return Err(format!("git fetch failed for {} (branch {})", url, branch).into()); - } - Ok(()) -} - -/// Run `git tag --merged --sort=-creatordate` and return stdout. -/// -/// The output is a newline-separated list of tag names reachable from `branch`, -/// ordered newest-first by creator date. -fn list_merged_tags( - repo: &TempBareRepo, - branch: &str, -) -> Result> { - let output = Command::new("git") - .args(["tag", "--merged", branch, "--sort=-creatordate"]) - .current_dir(repo.path()) - .output()?; - if !output.status.success() { - return Err(format!( - "git tag --merged {} failed: {}", - branch, - String::from_utf8_lossy(&output.stderr).trim() - ) - .into()); - } - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) -} - -/// Return the first non-empty line from `stdout` (the newest tag). -/// -/// Example: given `"v1.35.0\nv1.34.0\n"` this returns `"v1.35.0"`. -fn pick_first_tag( - stdout: &str, - branch: &str, - url: &str, -) -> Result> { - stdout - .lines() - .map(str::trim) - .find(|l| !l.is_empty()) - .map(str::to_string) - .ok_or_else(|| format!("No tags found on branch '{}' for {}", branch, url).into()) -} diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index e42338cf..25b0932e 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -13,7 +13,6 @@ pub mod artifacts; pub mod config; pub mod directories; pub mod keys; -pub mod latest_resolver; pub mod path_setup; pub mod repositories; diff --git a/src/commands/init/repositories.rs b/src/commands/init/repositories.rs index 5e68ecc9..a23fa392 100644 --- a/src/commands/init/repositories.rs +++ b/src/commands/init/repositories.rs @@ -99,24 +99,6 @@ fn download_repository(name: &str, location: &Location) -> Result<(), Box { clone_and_checkout(name, url, None, None, Some(branch)) } - Location::LatestTag { .. } => { - let config_path = foc_devnet_config(); - let msg = format!( - concat!( - "{}: repository location is configured as 'latesttag', which is an internal placeholder\n", - "that should have been resolved during 'foc-devnet init'.\n", - "This usually means your config file at '{}' is out of date or was edited manually.\n", - "\n", - "To fix this, either:\n", - " - run 'foc-devnet init --force' to regenerate config.toml, or\n", - " - edit config.toml and replace 'latesttag' with a concrete 'gittag:', 'gitbranch:',\n", - " 'gitcommit:' or 'localsource:' entry for this repository.\n" - ), - name, - config_path.display() - ); - Err(msg.into()) - } } } diff --git a/src/commands/status/git/formatters.rs b/src/commands/status/git/formatters.rs index 7b016a77..20a92e58 100644 --- a/src/commands/status/git/formatters.rs +++ b/src/commands/status/git/formatters.rs @@ -78,9 +78,6 @@ pub fn format_location_info( ) if expected_branch == actual_branch => true, (Location::GitBranch { .. }, GitInfo::Tag(_) | GitInfo::Commit(_)) => true, // Assume it's ready if we have some valid state (Location::GitBranch { .. }, _) => false, - - // LatestTag is resolved at init time; treat as not ready if somehow present. - (Location::LatestTag { .. }, _) => false, }; let status = if is_ready { @@ -128,12 +125,6 @@ pub fn format_location_info( "Not found".to_string(), ), }, - // Resolved at init time; display as underlying type if somehow still present. - Location::LatestTag { .. } => ( - "Latest Tag".to_string(), - "(unresolved)".to_string(), - "".to_string(), - ), }; (source_type, version, commit, status) diff --git a/src/commands/status/git/repo_paths.rs b/src/commands/status/git/repo_paths.rs index c99f4adf..b6fd59e3 100644 --- a/src/commands/status/git/repo_paths.rs +++ b/src/commands/status/git/repo_paths.rs @@ -34,10 +34,7 @@ pub fn get_repo_path_from_config(location: &Location, component: &str) -> std::p // For local sources, check the specified directory std::path::PathBuf::from(dir) } - Location::GitTag { .. } - | Location::GitCommit { .. } - | Location::GitBranch { .. } - | Location::LatestTag { .. } => { + Location::GitTag { .. } | Location::GitCommit { .. } | Location::GitBranch { .. } => { // For git sources, check if it exists in the foc-devnet code directory foc_devnet_code().join(component) } diff --git a/src/config.rs b/src/config.rs index ff732621..76b4aded 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,108 +36,135 @@ pub enum Location { /// The `url` field is the Git repository URL, and `branch` is the specific /// branch (e.g., "main", "develop") to check out. GitBranch { url: String, branch: String }, +} - /// Resolve to the newest tag on a specific branch at init time. +impl Location { + /// Given a url and a selector, finds the latest tag given that selector. /// - /// `url` is the Git repository URL. `branch` specifies the exact branch - /// to search for tags on. At init time this is immediately resolved to a - /// concrete `GitTag` so the stored config always records the exact tag used. + /// Runs `git ls-remote --tags --sort=-version:refname ` and + /// returns the first tag name from the output (i.e. the lexicographically newest + /// version-sorted tag that matches `selector`). /// - /// Example CLI usage: `--curio latesttag:pdpv0` or `--lotus latesttag:master` - LatestTag { url: String, branch: String }, -} + /// Example: `resolveLatestTag("https://github.com/foo/bar.git", "v*")` might + /// return `"v1.2.3"`. + fn resolve_latest_tag(url: &str, selector: &str) -> Result { + let output = std::process::Command::new("git") + .args([ + "ls-remote", + "--tags", + "--sort=-version:refname", + url, + selector, + ]) + .output() + .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; -impl Location { - /// Parse a location string in the format "type:value" or "type:url:value". + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git ls-remote failed: {}", stderr.trim())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let first_line = stdout + .lines() + .next() + .ok_or_else(|| format!("No tags found for selector '{}' at '{}'", selector, url))?; + + // Output format: "\trefs/tags/" + let tag_ref = first_line + .split_whitespace() + .nth(1) + .ok_or_else(|| format!("Unexpected git ls-remote output: '{}'", first_line))?; + + tag_ref + .strip_prefix("refs/tags/") + .map(|t| t.to_string()) + .ok_or_else(|| format!("Unexpected tag ref format: '{}'", tag_ref)) + } + + /// Canonicalizes the location from a set of following variants: /// - /// Supported formats: - /// - `latesttag:` — newest tag on specified branch (e.g. `latesttag:main`) - /// - `gittag:` — (uses default URL) - /// - `gitcommit:` — (uses default URL) - /// - `gitbranch:` — (uses default URL) + /// - `latesttag:` — newest tag on specified selector (e.g. `latesttag:pdp/v*`) + /// - `latesttag:url:` — newest tag on specified selector (e.g. `latesttag::pdp/v*`) + /// - `gittag:` — (uses default URL) + /// - `gitcommit:` — (uses default URL) + /// - `gitbranch:` — (uses default URL) /// - `local:` /// - `gittag::` /// - `gitcommit::` /// - `gitbranch::` - pub fn parse_with_default(s: &str, default_url: &str) -> Result { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() < 2 { - return Err(format!( + /// + /// to a smaller subset: + /// + /// - `local:` -> `("local", "", "dir")` + /// - `gittag::` -> `("gittag", "url", "tag")` + /// - `gitcommit::` -> `("gitcommit", "url", "commit")` + /// - `gitbranch::` -> `("gitbranch", "url", "branch")` + fn canonicalize_location( + location: &str, + default_url: &str, + ) -> Result<(String, String, String), String> { + // We need to do this setup in two steps since otherwise + // "gittag:https://github.com/orgs/repo:v1.2.0" would not be parseable + // and split the string itself in two parts + let (mut location_type, remaining) = location.split_once(':').ok_or_else(|| { + format!( "Invalid location format: '{}'. Expected \ - 'latesttag:', or 'gittag/gitcommit/gitbranch/local:...'", - s - )); + 'latesttag:', or 'gittag/gitcommit/gitbranch/local:...'", + location + ) + })?; + + // If the remaining part contains another ':', the portion before the last ':' + // is the URL and everything after is the value (handles HTTPS URLs with colons). + let (url, mut selector) = if let Some(colon_pos) = remaining.rfind(':') { + (&remaining[..colon_pos], &remaining[colon_pos + 1..]) + } else { + (default_url, remaining) + }; + + // Special case: latesttag resolution into GitTag + let resolved_tag; // must outlive the match below + if location_type == "latesttag" { + resolved_tag = Self::resolve_latest_tag(url, selector)?; + selector = resolved_tag.as_str(); + location_type = "gittag"; } - let location_type = parts[0]; - let remaining = &parts[1..].join(":"); - - match location_type { - // latesttag: — newest tag on specified branch - "latesttag" => { - let branch = remaining.trim(); - if branch.is_empty() { - return Err(format!( - "Invalid location format: '{}'. 'latesttag' requires a non-empty branch name, e.g. 'latesttag:main'", - s - )); - } - if branch.starts_with('-') || branch.contains(':') { - return Err(format!( - "Invalid branch name '{}' in '{}'. Branch names for 'latesttag' must not start with '-' or contain ':'", - branch, s - )); - } - Ok(Location::LatestTag { - url: default_url.to_string(), - branch: branch.to_string(), - }) - } - "local" => Ok(Location::LocalSource { - dir: remaining.to_string(), + Ok((location_type.into(), url.into(), selector.into())) + } + + /// Parse a location string in the format "type:value" or "type:url:value". + /// May attempt to resolve `latesttag` if provided by reaching over the internet. + /// + /// Supported formats: + /// - `latesttag:` — newest tag on specified selector (e.g. `latesttag:pdp/v*`) + /// - `latesttag::` — newest tag on specified selector at a URL (e.g. `latesttag::pdp/v*`) + /// - `gittag:` — (uses default URL) + /// - `gitcommit:` — (uses default URL) + /// - `gitbranch:` — (uses default URL) + /// - `local:` + /// - `gittag::` + /// - `gitcommit::` + /// - `gitbranch::` + pub fn resolve_with_default(location: &str, default_url: &str) -> Result { + let canonical_location = Self::canonicalize_location(location, default_url)?; + let (_type, url, selector) = canonical_location; + + match _type.as_ref() { + "local" => Ok(Location::LocalSource { dir: selector }), + "gittag" => Ok(Location::GitTag { url, tag: selector }), + "gitcommit" => Ok(Location::GitCommit { + url, + commit: selector, + }), + "gitbranch" => Ok(Location::GitBranch { + url, + branch: selector, }), - "gittag" | "gitcommit" | "gitbranch" => { - // Check if remaining contains ':' (indicating url:value format) - if let Some(colon_pos) = remaining.rfind(':') { - let url = &remaining[..colon_pos]; - let value = &remaining[colon_pos + 1..]; - match location_type { - "gittag" => Ok(Location::GitTag { - url: url.to_string(), - tag: value.to_string(), - }), - "gitcommit" => Ok(Location::GitCommit { - url: url.to_string(), - commit: value.to_string(), - }), - "gitbranch" => Ok(Location::GitBranch { - url: url.to_string(), - branch: value.to_string(), - }), - _ => unreachable!(), - } - } else { - // No colon, so remaining is just the value, use default URL - match location_type { - "gittag" => Ok(Location::GitTag { - url: default_url.to_string(), - tag: remaining.to_string(), - }), - "gitcommit" => Ok(Location::GitCommit { - url: default_url.to_string(), - commit: remaining.to_string(), - }), - "gitbranch" => Ok(Location::GitBranch { - url: default_url.to_string(), - branch: remaining.to_string(), - }), - _ => unreachable!(), - } - } - } _ => Err(format!( - "Unknown location type: {}. Supported types: latesttag, local, gittag, gitcommit, gitbranch", - location_type + "Unknown location type: {}. Supported types: local, gittag, gitcommit, gitbranch", + _type )), } } @@ -316,3 +343,104 @@ impl Config { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::Location; + + const DEFAULT_URL: &str = "https://github.com/default/repo.git"; + + fn canonicalize(s: &str) -> Result<(String, String, String), String> { + Location::canonicalize_location(s, DEFAULT_URL) + } + + // --- happy-path tests --- + + #[test] + fn gittag_short_uses_default_url() { + assert_eq!( + canonicalize("gittag:v1.2.3").unwrap(), + ("gittag".into(), DEFAULT_URL.into(), "v1.2.3".into()) + ); + } + + #[test] + fn gittag_explicit_https_url() { + assert_eq!( + canonicalize("gittag:https://github.com/foo/bar.git:v1.2.3").unwrap(), + ( + "gittag".into(), + "https://github.com/foo/bar.git".into(), + "v1.2.3".into() + ) + ); + } + + #[test] + fn gitcommit_short_uses_default_url() { + assert_eq!( + canonicalize("gitcommit:abc123def456").unwrap(), + ( + "gitcommit".into(), + DEFAULT_URL.into(), + "abc123def456".into() + ) + ); + } + + #[test] + fn gitcommit_explicit_https_url() { + assert_eq!( + canonicalize("gitcommit:https://github.com/foo/bar.git:abc123def456").unwrap(), + ( + "gitcommit".into(), + "https://github.com/foo/bar.git".into(), + "abc123def456".into() + ) + ); + } + + #[test] + fn gitbranch_short_uses_default_url() { + assert_eq!( + canonicalize("gitbranch:main").unwrap(), + ("gitbranch".into(), DEFAULT_URL.into(), "main".into()) + ); + } + + #[test] + fn gitbranch_explicit_https_url() { + assert_eq!( + canonicalize("gitbranch:https://github.com/foo/bar.git:feature/my-branch").unwrap(), + ( + "gitbranch".into(), + "https://github.com/foo/bar.git".into(), + "feature/my-branch".into() + ) + ); + } + + #[test] + fn local_dir() { + assert_eq!( + canonicalize("local:/home/user/my-project").unwrap(), + ( + "local".into(), + DEFAULT_URL.into(), + "/home/user/my-project".into() + ) + ); + } + + // --- error-path tests --- + + #[test] + fn missing_colon_returns_error() { + assert!(canonicalize("gittag").is_err()); + } + + #[test] + fn empty_string_returns_error() { + assert!(canonicalize("").is_err()); + } +} diff --git a/src/main_app/version.rs b/src/main_app/version.rs index b8d47328..a0c6a576 100644 --- a/src/main_app/version.rs +++ b/src/main_app/version.rs @@ -70,8 +70,5 @@ fn print_location_info(label: &str, location: &Location) { Location::GitBranch { url, branch } => { info!("{}: {}, branch {}", label, url, branch); } - Location::LatestTag { url, .. } => { - info!("{}: {}, latest tag (unresolved)", label, url); - } } } From 83b2bd6c8400f455b4de02fc4dd94a7e98abd970 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 10 Mar 2026 05:25:24 +0000 Subject: [PATCH 10/15] fixup: latesttag --- README_ADVANCED.md | 18 +++++---- src/cli.rs | 13 ++++--- src/config.rs | 96 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 92 insertions(+), 35 deletions(-) diff --git a/README_ADVANCED.md b/README_ADVANCED.md index c4df9aa1..65e852d1 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -25,12 +25,16 @@ foc-devnet init [OPTIONS] - `--rand` - Use random mnemonic instead of deterministic one. Use this for unique test scenarios. **Source Format:** -- `latesttag:` - Newest git tag found on the given branch (resolved once at `init` time and pinned in config). Use this to always pull the latest stable release on a branch without hardcoding a version. Example: `latesttag:master` -- `gittag:v1.0.0` - Specific git tag (uses default repo) -- `gittag:https://github.com/user/repo.git:v1.0.0` - Tag from custom repo -- `gitcommit:abc123` - Specific git commit -- `gitbranch:main` - Specific git branch -- `local:/path/to/repo` - Local directory +- `latesttag` - Newest git tag in the default repo (resolved once at `init`). +- `latesttag:` - Newest git tag matching a glob selector, e.g. `latesttag:v/*` or `latesttag:pdp/v*`. +- `latesttag::` - Newest matching tag from a custom repo, e.g. `latesttag:https://github.com/org/repo.git:v/*`. +- `gittag:` - Specific git tag (uses default repo) +- `gittag::` - Tag from custom repo, e.g. `gittag:https://github.com/org/repo.git:v1.0.0` +- `gitcommit:` - Specific commit (uses default repo) +- `gitcommit::` - Commit from custom repo +- `gitbranch:` - Specific branch (uses default repo) +- `gitbranch::` - Branch from custom repo +- `local:` - Local directory **Example:** ```bash @@ -813,7 +817,7 @@ port_range_count = 100 Default versions for these repositories are defined in code (see [`src/config.rs`](src/config.rs) `Config::default()`). **Version specification methods:** -- **Latest tag** (`LatestTag`, i.e. `latesttag:`): Resolves to the newest git tag on the given branch at `init` time and pins that exact tag in `config.toml`. Use this to automatically track the latest stable release without hardcoding a version. +- **Latest tag** (`latesttag`, `latesttag:`, `latesttag::`): Resolved once at `init` time via `git ls-remote` and pinned as a concrete `GitTag` in `config.toml`. Use a glob selector to scope which tags are considered, e.g. `latesttag:v/*` or `latesttag:pdp/v*`. Bare `latesttag` matches all tags. - **Git tags** (`GitTag`): Used for stable releases. Tags provide version pinning and stability. - **Git commits** (`GitCommit`): Used for repositories where specific commits are required and there isn't a corresponding tag yet. (Generally tags should be preferred over commits.) - **Git branches** (`GitBranch`): Used for development or when tracking latest changes. diff --git a/src/cli.rs b/src/cli.rs index 3f1a80a5..a16dd1d3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -24,21 +24,24 @@ pub enum Commands { /// Initialize foc-devnet by building and caching Docker images Init { /// Curio source location. - /// Magic values: 'latesttag:' (newest tag on given branch). + /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:pdp/v*'), + /// 'latesttag::' (custom repo). Resolved once at init. /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', /// 'gitcommit::', 'gitbranch:', 'gitbranch::', /// 'local:/path/to/curio'. #[arg(long)] curio: Option, /// Lotus source location. - /// Magic values: 'latesttag:' (newest tag on given branch). + /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:v/*'), + /// 'latesttag::' (custom repo). Resolved once at init. /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', /// 'gitcommit::', 'gitbranch:', 'gitbranch::', /// 'local:/path/to/lotus'. #[arg(long)] lotus: Option, /// Filecoin Services source location. - /// Magic values: 'latesttag:' (newest tag on given branch). + /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:v/*'), + /// 'latesttag::' (custom repo). Resolved once at init. /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', /// 'gitcommit::', 'gitbranch:', 'gitbranch::', /// 'local:/path/to/filecoin-services'. @@ -94,12 +97,12 @@ pub enum BuildCommands { pub enum ConfigCommands { /// Configure Lotus source location Lotus { - /// Lotus source location (e.g., 'latesttag:master', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/lotus') + /// Lotus source location (e.g., 'latesttag', 'latesttag:v/*', 'latesttag::v/*', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/lotus') source: String, }, /// Configure Curio source location Curio { - /// Curio source location (e.g., 'latesttag:pdpv0', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/curio') + /// Curio source location (e.g., 'latesttag', 'latesttag:pdp/v*', 'latesttag::pdp/v*', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/curio') source: String, }, } diff --git a/src/config.rs b/src/config.rs index 76b4aded..bc402005 100644 --- a/src/config.rs +++ b/src/config.rs @@ -48,23 +48,34 @@ impl Location { /// Example: `resolveLatestTag("https://github.com/foo/bar.git", "v*")` might /// return `"v1.2.3"`. fn resolve_latest_tag(url: &str, selector: &str) -> Result { - let output = std::process::Command::new("git") - .args([ - "ls-remote", - "--tags", - "--sort=-version:refname", - url, - selector, - ]) - .output() - .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git ls-remote failed: {}", stderr.trim())); - } + let stdout = { + #[cfg(not(test))] + { + let output = std::process::Command::new("git") + .args([ + "ls-remote", + "--tags", + "--sort=-version:refname", + url, + selector, + ]) + .output() + .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git ls-remote failed: {}", stderr.trim())); + } + + String::from_utf8_lossy(&output.stdout).into_owned() + } + + #[cfg(test)] + { + String::from("0000000000000 refs/tags/v1.0.0") + } + }; - let stdout = String::from_utf8_lossy(&output.stdout); let first_line = stdout .lines() .next() @@ -84,8 +95,9 @@ impl Location { /// Canonicalizes the location from a set of following variants: /// - /// - `latesttag:` — newest tag on specified selector (e.g. `latesttag:pdp/v*`) - /// - `latesttag:url:` — newest tag on specified selector (e.g. `latesttag::pdp/v*`) + /// - `latesttag` — newest tag matching `*` (uses default URL) + /// - `latesttag:` — newest tag matching selector (e.g. `latesttag:pdp/v*`) + /// - `latesttag::` — newest tag matching selector at a custom URL /// - `gittag:` — (uses default URL) /// - `gitcommit:` — (uses default URL) /// - `gitbranch:` — (uses default URL) @@ -104,6 +116,12 @@ impl Location { location: &str, default_url: &str, ) -> Result<(String, String, String), String> { + // Special case: bare "latesttag" with no selector — implicitly matches all tags. + if location == "latesttag" { + let tag = Self::resolve_latest_tag(default_url, "*")?; + return Ok(("gittag".into(), default_url.into(), tag)); + } + // We need to do this setup in two steps since otherwise // "gittag:https://github.com/orgs/repo:v1.2.0" would not be parseable // and split the string itself in two parts @@ -138,11 +156,12 @@ impl Location { /// May attempt to resolve `latesttag` if provided by reaching over the internet. /// /// Supported formats: - /// - `latesttag:` — newest tag on specified selector (e.g. `latesttag:pdp/v*`) - /// - `latesttag::` — newest tag on specified selector at a URL (e.g. `latesttag::pdp/v*`) - /// - `gittag:` — (uses default URL) - /// - `gitcommit:` — (uses default URL) - /// - `gitbranch:` — (uses default URL) + /// - `latesttag` — newest tag (uses default URL, matches `*`) + /// - `latesttag:` — newest tag matching selector (e.g. `latesttag:pdp/v*`) + /// - `latesttag::` — newest tag matching selector at a custom URL + /// - `gittag:` — (uses default URL) + /// - `gitcommit:` — (uses default URL) + /// - `gitbranch:` — (uses default URL) /// - `local:` /// - `gittag::` /// - `gitcommit::` @@ -432,6 +451,37 @@ mod tests { ); } + #[test] + fn latesttag_with_url() { + assert_eq!( + canonicalize(&format!( + "latesttag:https://github.com/randomorg/randomrepo:v/*" + )) + .unwrap(), + ( + "gittag".into(), + "https://github.com/randomorg/randomrepo".into(), + "v1.0.0".into() + ) + ); + } + + #[test] + fn latesttag_without_url() { + assert_eq!( + canonicalize(&format!("latesttag:v/*")).unwrap(), + ("gittag".into(), DEFAULT_URL.into(), "v1.0.0".into()) + ); + } + + #[test] + fn latesttag_without_url_without_selector() { + assert_eq!( + canonicalize("latesttag").unwrap(), + ("gittag".into(), DEFAULT_URL.into(), "v1.0.0".into()) + ); + } + // --- error-path tests --- #[test] From 880c442eb52532cb68238fe92015e0d34a7681c0 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 10 Mar 2026 05:28:30 +0000 Subject: [PATCH 11/15] fixup: clippy issues --- src/config.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index bc402005..5fbcbfea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -454,10 +454,7 @@ mod tests { #[test] fn latesttag_with_url() { assert_eq!( - canonicalize(&format!( - "latesttag:https://github.com/randomorg/randomrepo:v/*" - )) - .unwrap(), + canonicalize("latesttag:https://github.com/randomorg/randomrepo:v/*").unwrap(), ( "gittag".into(), "https://github.com/randomorg/randomrepo".into(), @@ -469,7 +466,7 @@ mod tests { #[test] fn latesttag_without_url() { assert_eq!( - canonicalize(&format!("latesttag:v/*")).unwrap(), + canonicalize("latesttag:v/*").unwrap(), ("gittag".into(), DEFAULT_URL.into(), "v1.0.0".into()) ); } From e48ac15329e3c0c987c2ec3e922a9af10f8d0932 Mon Sep 17 00:00:00 2001 From: RedPanda Date: Wed, 11 Mar 2026 18:40:05 +0530 Subject: [PATCH 12/15] Update src/config.rs Co-authored-by: Rod Vagg --- src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 5fbcbfea..ba1a35b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -466,7 +466,7 @@ mod tests { #[test] fn latesttag_without_url() { assert_eq!( - canonicalize("latesttag:v/*").unwrap(), + canonicalize("latesttag:v*").unwrap(), ("gittag".into(), DEFAULT_URL.into(), "v1.0.0".into()) ); } From 56423bf829d1fc6376a3ab3acc06b943cc0381cb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:45:03 +0530 Subject: [PATCH 13/15] Rename `_type` to `loc_type` in `resolve_with_default` (#91) * Initial plan * rename _type to loc_type in resolve_with_default Co-authored-by: redpanda-f <181817029+redpanda-f@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: redpanda-f <181817029+redpanda-f@users.noreply.github.com> --- src/config.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index ba1a35b5..d3bd9142 100644 --- a/src/config.rs +++ b/src/config.rs @@ -168,9 +168,9 @@ impl Location { /// - `gitbranch::` pub fn resolve_with_default(location: &str, default_url: &str) -> Result { let canonical_location = Self::canonicalize_location(location, default_url)?; - let (_type, url, selector) = canonical_location; + let (loc_type, url, selector) = canonical_location; - match _type.as_ref() { + match loc_type.as_ref() { "local" => Ok(Location::LocalSource { dir: selector }), "gittag" => Ok(Location::GitTag { url, tag: selector }), "gitcommit" => Ok(Location::GitCommit { @@ -183,7 +183,7 @@ impl Location { }), _ => Err(format!( "Unknown location type: {}. Supported types: local, gittag, gitcommit, gitbranch", - _type + loc_type )), } } From 535fa10df215226000289287672de8f743cc6060 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:53:02 +0530 Subject: [PATCH 14/15] Simplify `latesttag` branch with early return, drop lifetime workaround (#93) * Initial plan * Simplify latesttag handling with early return, drop lifetimes dance Co-authored-by: redpanda-f <181817029+redpanda-f@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: redpanda-f <181817029+redpanda-f@users.noreply.github.com> --- src/config.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/config.rs b/src/config.rs index d3bd9142..a889c019 100644 --- a/src/config.rs +++ b/src/config.rs @@ -125,7 +125,7 @@ impl Location { // We need to do this setup in two steps since otherwise // "gittag:https://github.com/orgs/repo:v1.2.0" would not be parseable // and split the string itself in two parts - let (mut location_type, remaining) = location.split_once(':').ok_or_else(|| { + let (location_type, remaining) = location.split_once(':').ok_or_else(|| { format!( "Invalid location format: '{}'. Expected \ 'latesttag:', or 'gittag/gitcommit/gitbranch/local:...'", @@ -135,18 +135,16 @@ impl Location { // If the remaining part contains another ':', the portion before the last ':' // is the URL and everything after is the value (handles HTTPS URLs with colons). - let (url, mut selector) = if let Some(colon_pos) = remaining.rfind(':') { + let (url, selector) = if let Some(colon_pos) = remaining.rfind(':') { (&remaining[..colon_pos], &remaining[colon_pos + 1..]) } else { (default_url, remaining) }; // Special case: latesttag resolution into GitTag - let resolved_tag; // must outlive the match below if location_type == "latesttag" { - resolved_tag = Self::resolve_latest_tag(url, selector)?; - selector = resolved_tag.as_str(); - location_type = "gittag"; + let tag = Self::resolve_latest_tag(url, selector)?; + return Ok(("gittag".into(), url.into(), tag)); } Ok((location_type.into(), url.into(), selector.into())) From dbdb38dff5c62dd1b4aa8c383fc8b32c0c77de9b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:53:20 +0530 Subject: [PATCH 15/15] =?UTF-8?q?Fix=20invalid=20`v/*`=20glob=20example=20?= =?UTF-8?q?=E2=80=94=20replace=20with=20`v*`=20throughout=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Replace invalid v/* glob pattern with v* in docs and code Co-authored-by: redpanda-f <181817029+redpanda-f@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: redpanda-f <181817029+redpanda-f@users.noreply.github.com> --- README_ADVANCED.md | 6 +++--- src/cli.rs | 6 +++--- src/config.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 65e852d1..72f06f8f 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -26,8 +26,8 @@ foc-devnet init [OPTIONS] **Source Format:** - `latesttag` - Newest git tag in the default repo (resolved once at `init`). -- `latesttag:` - Newest git tag matching a glob selector, e.g. `latesttag:v/*` or `latesttag:pdp/v*`. -- `latesttag::` - Newest matching tag from a custom repo, e.g. `latesttag:https://github.com/org/repo.git:v/*`. +- `latesttag:` - Newest git tag matching a glob selector, e.g. `latesttag:v*` or `latesttag:pdp/v*`. +- `latesttag::` - Newest matching tag from a custom repo, e.g. `latesttag:https://github.com/org/repo.git:v*`. - `gittag:` - Specific git tag (uses default repo) - `gittag::` - Tag from custom repo, e.g. `gittag:https://github.com/org/repo.git:v1.0.0` - `gitcommit:` - Specific commit (uses default repo) @@ -817,7 +817,7 @@ port_range_count = 100 Default versions for these repositories are defined in code (see [`src/config.rs`](src/config.rs) `Config::default()`). **Version specification methods:** -- **Latest tag** (`latesttag`, `latesttag:`, `latesttag::`): Resolved once at `init` time via `git ls-remote` and pinned as a concrete `GitTag` in `config.toml`. Use a glob selector to scope which tags are considered, e.g. `latesttag:v/*` or `latesttag:pdp/v*`. Bare `latesttag` matches all tags. +- **Latest tag** (`latesttag`, `latesttag:`, `latesttag::`): Resolved once at `init` time via `git ls-remote` and pinned as a concrete `GitTag` in `config.toml`. Use a glob selector to scope which tags are considered, e.g. `latesttag:v*` or `latesttag:pdp/v*`. Bare `latesttag` matches all tags. - **Git tags** (`GitTag`): Used for stable releases. Tags provide version pinning and stability. - **Git commits** (`GitCommit`): Used for repositories where specific commits are required and there isn't a corresponding tag yet. (Generally tags should be preferred over commits.) - **Git branches** (`GitBranch`): Used for development or when tracking latest changes. diff --git a/src/cli.rs b/src/cli.rs index a16dd1d3..500f04ac 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -32,7 +32,7 @@ pub enum Commands { #[arg(long)] curio: Option, /// Lotus source location. - /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:v/*'), + /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:v*'), /// 'latesttag::' (custom repo). Resolved once at init. /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', /// 'gitcommit::', 'gitbranch:', 'gitbranch::', @@ -40,7 +40,7 @@ pub enum Commands { #[arg(long)] lotus: Option, /// Filecoin Services source location. - /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:v/*'), + /// Latest tag: 'latesttag' (newest), 'latesttag:' (e.g. 'latesttag:v*'), /// 'latesttag::' (custom repo). Resolved once at init. /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', /// 'gitcommit::', 'gitbranch:', 'gitbranch::', @@ -97,7 +97,7 @@ pub enum BuildCommands { pub enum ConfigCommands { /// Configure Lotus source location Lotus { - /// Lotus source location (e.g., 'latesttag', 'latesttag:v/*', 'latesttag::v/*', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/lotus') + /// Lotus source location (e.g., 'latesttag', 'latesttag:v*', 'latesttag::v*', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/lotus') source: String, }, /// Configure Curio source location diff --git a/src/config.rs b/src/config.rs index a889c019..27591abd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -452,7 +452,7 @@ mod tests { #[test] fn latesttag_with_url() { assert_eq!( - canonicalize("latesttag:https://github.com/randomorg/randomrepo:v/*").unwrap(), + canonicalize("latesttag:https://github.com/randomorg/randomrepo:v*").unwrap(), ( "gittag".into(), "https://github.com/randomorg/randomrepo".into(),